summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-09 03:33:55 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-09 03:33:55 +0200
commit20c735b0c5a2db9b8984848253ad99332d6211a8 (patch)
treea4ae71c53c403c1a7740db624e43fd3163e37ab7
parent5203b80f47786b8adc169e80d89cd69159c44b62 (diff)
Attach assets to the draft instead of to every lifecycle row
Node#attach_asset! writes to the node's draft alone, creating one from the head when none is pending. It refuses when another user holds the lock, and when an autosave already exists The asset_attach verb goes with it. create_new_draft names the editor on the draft it creates and the publish entry's asset delta reports the attachment.
-rw-r--r--app/controllers/assets_controller.rb7
-rw-r--r--app/helpers/node_actions_helper.rb1
-rw-r--r--app/models/node.rb107
-rw-r--r--app/views/layouts/_flash.html.erb5
-rw-r--r--config/locales/de.yml6
-rw-r--r--config/locales/en.yml4
-rw-r--r--test/controllers/assets_controller_test.rb34
-rw-r--r--test/controllers/content_controller_test.rb2
-rw-r--r--test/models/node_attach_asset_test.rb83
9 files changed, 149 insertions, 100 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index f8a7d1e4..d86370f4 100644
--- a/app/controllers/assets_controller.rb
+++ b/app/controllers/assets_controller.rb
@@ -111,8 +111,10 @@ class AssetsController < ApplicationController
111 flash[:notice] = 111 flash[:notice] =
112 if result[:attached].zero? 112 if result[:attached].zero?
113 t("flash.assets.already_attached", :title => node.title) 113 t("flash.assets.already_attached", :title => node.title)
114 elsif result[:draft_created]
115 t("flash.assets.attached_new_draft", :title => node.title)
114 else 116 else
115 t("flash.assets.attached", :title => node.title) 117 t("flash.assets.attached_to_draft", :title => node.title)
116 end 118 end
117 case result[:headline] 119 case result[:headline]
118 when :set then flash[:notice] += " " + t("flash.common.now_headline") 120 when :set then flash[:notice] += " " + t("flash.common.now_headline")
@@ -122,5 +124,8 @@ class AssetsController < ApplicationController
122 rescue LockedByAnotherUser 124 rescue LockedByAnotherUser
123 flash[:locked_by] = node.lock_owner&.login 125 flash[:locked_by] = node.lock_owner&.login
124 flash[:locked_node_path] = node_path(node) 126 flash[:locked_node_path] = node_path(node)
127 rescue ActiveRecord::RecordInvalid => e
128 flash[:error] = e.record.errors.full_messages.to_sentence
129 flash[:resolve_node_path] = node_path(node)
125 end 130 end
126end 131end
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index f21fee61..960e28b3 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -14,7 +14,6 @@ module NodeActionsHelper
14 "discard_autosave" => "eraser", 14 "discard_autosave" => "eraser",
15 "destroy_draft" => "eraser", 15 "destroy_draft" => "eraser",
16 "asset_create" => "upload", 16 "asset_create" => "upload",
17 "asset_attach" => "paperclip",
18 "asset_destroy" => "file-x", 17 "asset_destroy" => "file-x",
19 "otp_enroll" => "shield-lock", 18 "otp_enroll" => "shield-lock",
20 "otp_disable" => "shield-off", 19 "otp_disable" => "shield-off",
diff --git a/app/models/node.rb b/app/models/node.rb
index f5b76b45..a401ed69 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -6,8 +6,8 @@ class Node < ApplicationRecord
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 7
8 # Entries where this node is the primary subject (fast-path column). 8 # Entries where this node is the primary subject (fast-path column).
9 # For a *complete* history -- including subtree trash/destroy entries 9 # For a *complete* history, including subtree trash/destroy entries
10 # recorded at an ancestor -- use participated_actions instead. 10 # recorded at an ancestor, use participated_actions instead.
11 has_many :node_actions, :dependent => :nullify 11 has_many :node_actions, :dependent => :nullify
12 has_many :action_participations, :class_name => "ActionParticipant", :as => :subject 12 has_many :action_participations, :class_name => "ActionParticipant", :as => :subject
13 has_many :participated_actions, :through => :action_participations, :source => :node_action 13 has_many :participated_actions, :through => :action_participations, :source => :node_action
@@ -15,7 +15,7 @@ class Node < ApplicationRecord
15 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true 15 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
16 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true 16 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
17 # Autosave pages carry no node_id, so has_many :pages does not cover 17 # Autosave pages carry no node_id, so has_many :pages does not cover
18 # them -- this dependent: :destroy is their only cleanup on node destroy. 18 # them. This dependent: :destroy is their only cleanup on node destroy.
19 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true 19 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
20 20
21 has_many :events, :dependent => :destroy 21 has_many :events, :dependent => :destroy
@@ -44,8 +44,8 @@ class Node < ApplicationRecord
44 :if => :default_template_name_changed? 44 :if => :default_template_name_changed?
45 45
46 # Everything outside the Trash subtree, the Trash node included. 46 # Everything outside the Trash subtree, the Trash node included.
47 # Relies on unique_name being authoritative for tree position -- 47 # Relies on unique_name being authoritative for tree position.
48 # the same trust public routing places in it. 48 # The same trust public routing places in it.
49 scope :not_in_trash, -> { 49 scope :not_in_trash, -> {
50 where.not(:unique_name => CccConventions::TRASH_SLUG) 50 where.not(:unique_name => CccConventions::TRASH_SLUG)
51 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%") 51 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%")
@@ -93,7 +93,7 @@ class Node < ApplicationRecord
93 # Instance Methods 93 # Instance Methods
94 94
95 # Acquires (or reaffirms) the editing lock without creating a draft or 95 # Acquires (or reaffirms) the editing lock without creating a draft or
96 # an autosave -- both are now deferred until there is real content to 96 # an autosave, both are now deferred until there is real content to
97 # hold. 97 # hold.
98 def lock_for_editing! current_user 98 def lock_for_editing! current_user
99 if self.lock_owner.nil? || self.lock_owner == current_user 99 if self.lock_owner.nil? || self.lock_owner == current_user
@@ -133,11 +133,11 @@ class Node < ApplicationRecord
133 133
134 # Promotes the current autosave into the draft (creating the draft if 134 # Promotes the current autosave into the draft (creating the draft if
135 # none exists yet) and destroys the autosave afterward. This is what 135 # none exists yet) and destroys the autosave afterward. This is what
136 # the explicit "Save" action does; it never creates a new revision -- 136 # the explicit "Save" action does; it never creates a new revision,
137 # same as any other in-place draft edit. The new draft is created via 137 # same as any other in-place draft edit. The new draft is created via
138 # self.pages.create! rather than by repointing the autosave's own 138 # self.pages.create! rather than by repointing the autosave's own
139 # node_id, because acts_as_list assigns the revision number at create 139 # node_id, because acts_as_list assigns the revision number at create
140 # time, scoped to node_id -- a page created with node_id nil and 140 # time, scoped to node_id, a page created with node_id nil and
141 # reassigned afterward would carry a wrong or missing revision number. 141 # reassigned afterward would carry a wrong or missing revision number.
142 def save_draft! current_user 142 def save_draft! current_user
143 assert_locked_by! current_user 143 assert_locked_by! current_user
@@ -178,7 +178,7 @@ class Node < ApplicationRecord
178 178
179 # Which layer-pairs are meaningful to compare right now, given this 179 # Which layer-pairs are meaningful to compare right now, given this
180 # node's actual state. Head vs autosave only shows up when no draft 180 # node's actual state. Head vs autosave only shows up when no draft
181 # sits between them -- with a draft present, autosave is compared 181 # sits between them, with a draft present, autosave is compared
182 # against the draft, never past it straight to head. 182 # against the draft, never past it straight to head.
183 def available_layer_pairs 183 def available_layer_pairs
184 pairs = [] 184 pairs = []
@@ -201,8 +201,8 @@ class Node < ApplicationRecord
201 self.draft.reload 201 self.draft.reload
202 end 202 end
203 203
204 # Discards exactly the topmost non-empty layer -- autosave if present, 204 # Discards exactly the topmost non-empty layer: autosave if present,
205 # else draft -- and reveals whatever's beneath it. Releases the lock 205 # else draft, and reveals whatever's beneath it. Releases the lock
206 # only once nothing is left to protect (no draft survives); leaves it 206 # only once nothing is left to protect (no draft survives); leaves it
207 # alone whenever a draft remains, since #edit still has real content 207 # alone whenever a draft remains, since #edit still has real content
208 # open. 208 # open.
@@ -313,7 +313,7 @@ class Node < ApplicationRecord
313 # Moves this node and its subtree into the Trash. Demotes every head 313 # Moves this node and its subtree into the Trash. Demotes every head
314 # in the subtree first (aggregators and search operate on heads 314 # in the subtree first (aggregators and search operate on heads
315 # regardless of tree position); where a node has no draft, the former 315 # regardless of tree position); where a node has no draft, the former
316 # head becomes its draft so content stays editable and restorable -- 316 # head becomes its draft so content stays editable and restorable,
317 # otherwise the former head remains a plain revision. One log entry, 317 # otherwise the former head remains a plain revision. One log entry,
318 # at the root, carrying the leaving-public-view snapshot. 318 # at the root, carrying the leaving-public-view snapshot.
319 def trash! current_user = nil 319 def trash! current_user = nil
@@ -475,28 +475,17 @@ class Node < ApplicationRecord
475 end 475 end
476 end 476 end
477 477
478 # Attaches an asset to every current lifecycle row -- head, draft and 478 # Attaches an asset to the node's draft, creating one from the head if
479 # autosave -- that does not already carry it. Attachments are page- 479 # none exists.
480 # scoped content (RelatedAsset belongs_to :page; drafts and autosaves
481 # are wholesale clones), so attaching to a single layer is how an
482 # attachment gets silently lost when another layer replaces it at
483 # publish or save. This is the out-of-band counterpart to the
484 # in-editor attach UI; it refuses when someone else holds the editing
485 # lock. Attaching to a head row changes the public page immediately,
486 # by design -- same reasoning as formalizing an already-existing
487 # editorial link.
488 # 480 #
489 # headline is a node-level decision: the flag is set on the newly 481 # Refuses when an autosave exists: it predates this attach and would
490 # created joins only when no current row has a headline yet and the 482 # overwrite the draft at save_draft!, silently dropping the join.
491 # asset is eligible; otherwise the asset is attached plain and the 483 # Refuses when someone else holds the lock, so an editor's work is not
492 # result says why, so the caller can point the editor at the star in 484 # altered under them.
493 # the editor instead.
494 # 485 #
495 # Returns { :attached => n, :already => n, 486 # Returns { :attached => n, :already => n, :draft_created => bool,
496 # :headline => nil | :set | :kept_existing | :not_eligible } 487 # :headline => nil | :set | :kept_existing | :not_eligible }
497 def attach_asset! asset, user:, headline: false 488 def attach_asset! asset, user:, headline: false
498 guard_live_change!(user)
499
500 if in_trash? || trash_node? 489 if in_trash? || trash_node?
501 errors.add(:base, :attach_in_trash) 490 errors.add(:base, :attach_in_trash)
502 raise ActiveRecord::RecordInvalid.new(self) 491 raise ActiveRecord::RecordInvalid.new(self)
@@ -510,37 +499,43 @@ class Node < ApplicationRecord
510 ) 499 )
511 end 500 end
512 501
513 rows = [head, draft, autosave].compact 502 if autosave
514 to_attach = rows.reject { |row| row.related_assets.exists?(:asset_id => asset.id) } 503 errors.add(:base, :attach_with_autosave)
504 raise ActiveRecord::RecordInvalid.new(self)
505 end
515 506
516 headline_state = 507 source = draft || head
517 if headline && to_attach.any? 508 if source && source.related_assets.exists?(:asset_id => asset.id)
518 if !(asset.image? || asset.pdf?) 509 return { :attached => 0,
519 :not_eligible 510 :already => 1,
520 elsif rows.any? { |row| row.headline_asset.present? } 511 :draft_created => false,
521 :kept_existing 512 :headline => nil }
522 else 513 end
523 :set 514
524 end 515 draft_created = draft.nil?
525 end
526 516
527 ActiveRecord::Base.transaction do 517 ActiveRecord::Base.transaction do
528 to_attach.each do |row| 518 create_new_draft(user) if draft_created
529 row.related_assets.create!(:asset => asset, :headline => headline_state == :set) 519 page = draft.reload
530 end
531 520
532 if to_attach.any? 521 headline_state =
533 metadata = { :asset_name => asset.name, 522 if headline
534 :path => asset.upload.url.sub(/\?\d+$/, "") } 523 if !(asset.image? || asset.pdf?)
535 metadata[:headline] = true if headline_state == :set 524 :not_eligible
536 NodeAction.record!(:node => self, :participants => [self, asset], 525 elsif page.headline_asset.present?
537 :user => user, :action => "asset_attach", **metadata) 526 :kept_existing
538 end 527 else
539 end 528 :set
529 end
530 end
531
532 page.related_assets.create!(:asset => asset, :headline => headline_state == :set)
540 533
541 { :attached => to_attach.size, 534 { :attached => 1,
542 :already => rows.size - to_attach.size, 535 :already => 0,
543 :headline => headline_state } 536 :draft_created => draft_created,
537 :headline => headline_state }
538 end
544 end 539 end
545 540
546 def title 541 def title
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb
index b72c377f..2cc7a5ab 100644
--- a/app/views/layouts/_flash.html.erb
+++ b/app/views/layouts/_flash.html.erb
@@ -13,6 +13,11 @@
13 <%= link_to t(".locked_link"), flash[:locked_node_path] %><%= t(".locked_suffix") %> 13 <%= link_to t(".locked_link"), flash[:locked_node_path] %><%= t(".locked_suffix") %>
14 </span> 14 </span>
15 <% end %> 15 <% end %>
16 <% if flash[:resolve_node_path] %>
17 <span class="warning">
18 <%= link_to t(".resolve_link"), flash[:resolve_node_path] %>
19 </span>
20 <% end %>
16 <% if flash[:headline_kept_path] %> 21 <% if flash[:headline_kept_path] %>
17 <span class="warning"><%= t(".headline_prefix") %> 22 <span class="warning"><%= t(".headline_prefix") %>
18 <%= link_to t(".headline_link"), flash[:headline_kept_path] %><%= t(".headline_suffix") %> 23 <%= link_to t(".headline_link"), flash[:headline_kept_path] %><%= t(".headline_suffix") %>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index c6dc17b7..58367aaf 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -136,6 +136,7 @@ de:
136 destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden" 136 destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden"
137 attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" 137 attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden"
138 not_permitted: "In diesem Bereich dürfen nur Mitglieder der Redaktion veröffentlichte Inhalte ändern" 138 not_permitted: "In diesem Bereich dürfen nur Mitglieder der Redaktion veröffentlichte Inhalte ändern"
139 attach_with_autosave: "An einen Node mit ungespeicherten Änderungen im Editor können keine Assets angehängt werden"
139 page: 140 page:
140 attributes: 141 attributes:
141 slug: 142 slug:
@@ -659,8 +660,9 @@ de:
659 assets: 660 assets:
660 created: "Asset wurde angelegt." 661 created: "Asset wurde angelegt."
661 updated: "Asset wurde aktualisiert." 662 updated: "Asset wurde aktualisiert."
662 attached: "Asset wurde angelegt und an „%{title}“ angehängt." 663 already_attached: "Asset wurde gespeichert. Es war bereits an „%{title}“ angehängt."
663 already_attached: "Asset gespeichert — es war bereits an „%{title}“ angehängt." 664 attached_to_draft: "Zum Entwurf von %{title} hinzugefügt. Zum Veröffentlichen den Entwurf freigeben."
665 attached_new_draft: "Entwurf von %{title} mit diesem Anhang angelegt. Zum Veröffentlichen den Entwurf freigeben."
664 events: 666 events:
665 created: "Termin wurde angelegt." 667 created: "Termin wurde angelegt."
666 updated: "Termin wurde aktualisiert." 668 updated: "Termin wurde aktualisiert."
diff --git a/config/locales/en.yml b/config/locales/en.yml
index bd339cc9..4bdca31b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -87,6 +87,7 @@ en:
87 destroy_outside_trash: "Nodes are only destroyed from the Trash" 87 destroy_outside_trash: "Nodes are only destroyed from the Trash"
88 attach_in_trash: "Cannot attach assets to a node in the Trash" 88 attach_in_trash: "Cannot attach assets to a node in the Trash"
89 not_permitted: "Only Redaktion members may change published content in this section" 89 not_permitted: "Only Redaktion members may change published content in this section"
90 attach_with_autosave: "Cannot attach assets to a node with unsaved changes in the editor"
90 page: 91 page:
91 attributes: 92 attributes:
92 slug: 93 slug:
@@ -627,8 +628,9 @@ en:
627 assets: 628 assets:
628 created: "Asset was successfully created." 629 created: "Asset was successfully created."
629 updated: "Asset was successfully updated." 630 updated: "Asset was successfully updated."
630 attached: "Asset was successfully created and attached to “%{title}”."
631 already_attached: "Asset saved — it was already attached to “%{title}”." 631 already_attached: "Asset saved — it was already attached to “%{title}”."
632 attached_to_draft: "Attached to the draft of %{title}. Publish it to make the change live."
633 attached_new_draft: "Created a draft of %{title} with this attachment. Publish it to make the change live."
632 events: 634 events:
633 created: "Event was successfully created." 635 created: "Event was successfully created."
634 updated: "Event was successfully updated." 636 updated: "Event was successfully updated."
diff --git a/test/controllers/assets_controller_test.rb b/test/controllers/assets_controller_test.rb
index 467e1a68..aa3e00e6 100644
--- a/test/controllers/assets_controller_test.rb
+++ b/test/controllers/assets_controller_test.rb
@@ -94,7 +94,7 @@ class AssetsControllerTest < ActionController::TestCase
94 94
95 # --- create with attach --- 95 # --- create with attach ---
96 96
97 test "create with node_id attaches the asset to the node's draft" do 97 test "create with node_id attaches the asset to an existing draft" do
98 node = Node.root.children.create!(:slug => "asset_attach_target") 98 node = Node.root.children.create!(:slug => "asset_attach_target")
99 99
100 post :create, params: { asset: { name: 'Attach me' }, node_id: node.id } 100 post :create, params: { asset: { name: 'Attach me' }, node_id: node.id }
@@ -102,7 +102,21 @@ class AssetsControllerTest < ActionController::TestCase
102 assert_response :redirect 102 assert_response :redirect
103 asset = Asset.last 103 asset = Asset.last
104 assert_includes node.draft.assets.reload, asset 104 assert_includes node.draft.assets.reload, asset
105 assert_equal I18n.t("flash.assets.attached", :title => node.title), flash[:notice] 105 assert_equal I18n.t("flash.assets.attached_to_draft", :title => node.title), flash[:notice]
106 end
107
108 test "create with node_id creates a draft when none is pending" do
109 node = Node.root.children.create!(:slug => "asset_attach_no_draft")
110 node.publish_draft!(users(:quentin))
111 assert_nil node.reload.draft
112
113 post :create, params: { asset: { name: 'Attach me too' }, node_id: node.id }
114
115 node.reload
116 assert_includes node.draft.assets.reload, Asset.last
117 assert_empty node.head.assets.reload
118 assert_equal users(:quentin), node.draft.editor
119 assert_equal I18n.t("flash.assets.attached_new_draft", :title => node.title), flash[:notice]
106 end 120 end
107 121
108 test "create against a foreign-locked node keeps the asset but refuses the attach" do 122 test "create against a foreign-locked node keeps the asset but refuses the attach" do
@@ -133,14 +147,16 @@ class AssetsControllerTest < ActionController::TestCase
133 assert_equal node_path(node), flash[:headline_kept_path] 147 assert_equal node_path(node), flash[:headline_kept_path]
134 end 148 end
135 149
136 test "create with node_id writes an asset_create and an asset_attach entry" do 150 test "create with node_id writes only an asset_create entry" do
137 node = Node.root.children.create!(:slug => "asset_log_pair") 151 node = Node.root.children.create!(:slug => "asset_log_single")
138 assert_difference 'NodeAction.where(:action => "asset_create").count' do 152
139 assert_difference 'NodeAction.where(:action => "asset_attach").count' do 153 assert_difference 'NodeAction.count', 1 do
140 post :create, params: { asset: { name: 'Logged twice' }, node_id: node.id } 154 post :create, params: { asset: { name: 'Logged once' }, node_id: node.id }
141 end
142 end 155 end
143 assert_equal users(:quentin), NodeAction.last.user 156
157 action = NodeAction.last
158 assert_equal "asset_create", action.action
159 assert_equal users(:quentin), action.user
144 end 160 end
145 161
146 # --- edit --- 162 # --- edit ---
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb
index 39fe276b..482c1ddf 100644
--- a/test/controllers/content_controller_test.rb
+++ b/test/controllers/content_controller_test.rb
@@ -170,6 +170,8 @@ class ContentControllerTest < ActionController::TestCase
170 :upload_content_type => "image/png", 170 :upload_content_type => "image/png",
171 :upload_updated_at => Time.at(1_700_000_000)) 171 :upload_updated_at => Time.at(1_700_000_000))
172 node.attach_asset!(asset, :user => @user1, :headline => true) 172 node.attach_asset!(asset, :user => @user1, :headline => true)
173 node.publish_draft!
174 node.reload
173 175
174 # has_variant? only tests File.exist?, so touching the path is enough 176 # has_variant? only tests File.exist?, so touching the path is enough
175 # and no ImageMagick runs in the suite. image/png takes .jpg for the 177 # and no ImageMagick runs in the suite. image/png takes .jpg for the
diff --git a/test/models/node_attach_asset_test.rb b/test/models/node_attach_asset_test.rb
index 2df2cfbb..9ab38158 100644
--- a/test/models/node_attach_asset_test.rb
+++ b/test/models/node_attach_asset_test.rb
@@ -9,49 +9,67 @@ class NodeAttachAssetTest < ActiveSupport::TestCase
9 @image = create_image_asset 9 @image = create_image_asset
10 end 10 end
11 11
12 test "attaches to a draft-only node" do 12 test "attaches to an existing draft" do
13 result = @node.attach_asset!(@image, :user => @user) 13 result = @node.attach_asset!(@image, :user => @user)
14 assert_equal 1, result[:attached] 14 assert_equal 1, result[:attached]
15 assert_not result[:draft_created]
15 assert_includes @node.draft.assets, @image 16 assert_includes @node.draft.assets, @image
16 end 17 end
17 18
18 test "attaches to head when no draft is pending" do 19 test "creates a draft when none is pending and leaves head untouched" do
19 @node.publish_draft!(@user) 20 @node.publish_draft!(@user)
20 result = @node.attach_asset!(@image, :user => @user) 21 result = @node.attach_asset!(@image, :user => @user)
21 assert_equal 1, result[:attached] 22 assert_equal 1, result[:attached]
22 assert_includes @node.head.assets, @image 23 assert result[:draft_created]
24 assert_includes @node.draft.assets, @image
25 assert_empty @node.head.assets.reload
23 end 26 end
24 27
25 test "attaches to head and pending draft alike" do 28 test "attaches to a pending draft and leaves head untouched" do
26 @node.publish_draft!(@user) 29 @node.publish_draft!(@user)
27 @node.lock_for_editing!(@user) 30 @node.lock_for_editing!(@user)
28 @node.create_new_draft(@user) 31 @node.create_new_draft(@user)
29 result = @node.attach_asset!(@image, :user => @user) 32 result = @node.attach_asset!(@image, :user => @user)
30 assert_equal 2, result[:attached] 33 assert_equal 1, result[:attached]
31 assert_includes @node.head.assets, @image 34 assert_not result[:draft_created]
32 assert_includes @node.draft.assets, @image 35 assert_includes @node.draft.assets, @image
36 assert_empty @node.head.assets.reload
33 end 37 end
34 38
35 test "attaches to all three lifecycle rows" do 39 test "refuses when an autosave exists and writes nothing" do
36 @node.publish_draft!(@user) 40 @node.publish_draft!(@user)
37 @node.lock_for_editing!(@user) 41 @node.lock_for_editing!(@user)
38 @node.create_new_draft(@user) 42 @node.create_new_draft(@user)
39 @node.autosave!({ :title => "wip" }, @user) 43 @node.autosave!({ :title => "wip" }, @user)
44 assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) }
45 assert_empty @node.draft.assets.reload
46 assert_empty @node.autosave.assets.reload
47 end
48
49 test "reports an asset the draft already carries without duplicating it" do
50 @node.draft.related_assets.create!(:asset => @image)
40 result = @node.attach_asset!(@image, :user => @user) 51 result = @node.attach_asset!(@image, :user => @user)
41 assert_equal 3, result[:attached] 52 assert_equal 0, result[:attached]
42 [@node.head, @node.draft, @node.autosave].each do |row| 53 assert_equal 1, result[:already]
43 assert_includes row.assets, @image 54 assert_not result[:draft_created]
44 end 55 assert_equal 1, @node.draft.related_assets.where(:asset_id => @image.id).count
45 end 56 end
46 57
47 test "skips rows that already carry the asset" do 58 test "creates no draft when head already carries the asset" do
48 @node.draft.related_assets.create!(:asset => @image) 59 @node.draft.related_assets.create!(:asset => @image)
49 @node.publish_draft!(@user) 60 @node.publish_draft!(@user)
50 @node.lock_for_editing!(@user) 61 assert_nil @node.draft
51 @node.create_new_draft(@user) 62
63 result = @node.attach_asset!(@image, :user => @user)
64 assert_equal 0, result[:attached]
65 assert_not result[:draft_created]
66 assert_nil @node.reload.draft
67 end
68
69 test "attaching twice leaves one join row" do
70 @node.attach_asset!(@image, :user => @user)
52 result = @node.attach_asset!(@image, :user => @user) 71 result = @node.attach_asset!(@image, :user => @user)
53 assert_equal 0, result[:attached] 72 assert_equal 0, result[:attached]
54 assert_equal 2, result[:already]
55 assert_equal 1, @node.draft.related_assets.where(:asset_id => @image.id).count 73 assert_equal 1, @node.draft.related_assets.where(:asset_id => @image.id).count
56 end 74 end
57 75
@@ -82,6 +100,17 @@ class NodeAttachAssetTest < ActiveSupport::TestCase
82 assert_includes @node.draft.assets, @image 100 assert_includes @node.draft.assets, @image
83 end 101 end
84 102
103 test "keeps a headline the new draft inherited from head" do
104 incumbent = create_image_asset
105 @node.draft.related_assets.create!(:asset => incumbent, :headline => true)
106 @node.publish_draft!(@user)
107
108 result = @node.attach_asset!(@image, :user => @user, :headline => true)
109 assert result[:draft_created]
110 assert_equal :kept_existing, result[:headline]
111 assert_equal incumbent, @node.draft.reload.headline_asset
112 end
113
85 test "declines the headline flag for ineligible asset types" do 114 test "declines the headline flag for ineligible asset types" do
86 plain = create_plain_asset 115 plain = create_plain_asset
87 result = @node.attach_asset!(plain, :user => @user, :headline => true) 116 result = @node.attach_asset!(plain, :user => @user, :headline => true)
@@ -95,25 +124,19 @@ class NodeAttachAssetTest < ActiveSupport::TestCase
95 assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) } 124 assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) }
96 end 125 end
97 126
98 test "attaching writes an asset_attach entry with node and asset participants" do 127 test "attaching writes no log entry -- publish carries the witnessing" do
99 result = @node.attach_asset!(@image, :user => @user, :headline => true) 128 assert_no_difference "NodeAction.count" do
100 assert_equal :set, result[:headline]
101
102 action = NodeAction.where(:action => "asset_attach").last
103 assert_equal @node, action.node
104 subjects = action.action_participants.map { |p| [p.subject_type, p.subject_id] }
105 assert_includes subjects, ["Node", @node.id]
106 assert_includes subjects, ["Asset", @image.id]
107 assert action.metadata["headline"]
108 end
109
110 test "a fully redundant attach writes no entry" do
111 @node.attach_asset!(@image, :user => @user)
112 assert_no_difference 'NodeAction.count' do
113 @node.attach_asset!(@image, :user => @user) 129 @node.attach_asset!(@image, :user => @user)
114 end 130 end
115 end 131 end
116 132
133 test "attaching under a restricted surface needs no redaktion role" do
134 updates = Node.root.children.create!(:slug => "updates")
135 node = updates.children.create!(:slug => "gated-attachment")
136 result = node.reload.attach_asset!(@image, :user => @user)
137 assert_equal 1, result[:attached]
138 end
139
117 private 140 private
118 141
119 def create_image_asset 142 def create_image_asset