diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-09 03:33:55 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-09 03:33:55 +0200 |
| commit | 20c735b0c5a2db9b8984848253ad99332d6211a8 (patch) | |
| tree | a4ae71c53c403c1a7740db624e43fd3163e37ab7 | |
| parent | 5203b80f47786b8adc169e80d89cd69159c44b62 (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.rb | 7 | ||||
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 1 | ||||
| -rw-r--r-- | app/models/node.rb | 107 | ||||
| -rw-r--r-- | app/views/layouts/_flash.html.erb | 5 | ||||
| -rw-r--r-- | config/locales/de.yml | 6 | ||||
| -rw-r--r-- | config/locales/en.yml | 4 | ||||
| -rw-r--r-- | test/controllers/assets_controller_test.rb | 34 | ||||
| -rw-r--r-- | test/controllers/content_controller_test.rb | 2 | ||||
| -rw-r--r-- | test/models/node_attach_asset_test.rb | 83 |
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 |
| 126 | end | 131 | end |
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 |
