From f929caffdaeac836c32819e9d5fcf2433b4a797a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 22 Jul 2026 18:44:57 +0200 Subject: Add Node#attach_asset! attaching across all lifecycle rows --- app/models/node.rb | 57 ++++++++++++++++++ test/models/node_attach_asset_test.rb | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 test/models/node_attach_asset_test.rb diff --git a/app/models/node.rb b/app/models/node.rb index 746b7c7..a5a40d3 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -442,6 +442,63 @@ class Node < ApplicationRecord end end + # Attaches an asset to every current lifecycle row -- head, draft and + # autosave -- that does not already carry it. Attachments are page- + # scoped content (RelatedAsset belongs_to :page; drafts and autosaves + # are wholesale clones), so attaching to a single layer is how an + # attachment gets silently lost when another layer replaces it at + # publish or save. This is the out-of-band counterpart to the + # in-editor attach UI; it refuses when someone else holds the editing + # lock. Attaching to a head row changes the public page immediately, + # by design -- same reasoning as formalizing an already-existing + # editorial link. + # + # headline is a node-level decision: the flag is set on the newly + # created joins only when no current row has a headline yet and the + # asset is eligible; otherwise the asset is attached plain and the + # result says why, so the caller can point the editor at the star in + # the editor instead. + # + # Returns { :attached => n, :already => n, + # :headline => nil | :set | :kept_existing | :not_eligible } + def attach_asset! asset, user:, headline: false + if in_trash? || trash_node? + raise ActiveRecord::RecordInvalid.new(self), "Cannot attach assets to a node in the Trash" + end + + if lock_owner && lock_owner != user + raise( + LockedByAnotherUser, + "Page is locked by another user who is working on it! " \ + "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}" + ) + end + + rows = [head, draft, autosave].compact + to_attach = rows.reject { |row| row.related_assets.exists?(:asset_id => asset.id) } + + headline_state = + if headline && to_attach.any? + if !(asset.image? || asset.pdf?) + :not_eligible + elsif rows.any? { |row| row.headline_asset.present? } + :kept_existing + else + :set + end + end + + ActiveRecord::Base.transaction do + to_attach.each do |row| + row.related_assets.create!(:asset => asset, :headline => headline_state == :set) + end + end + + { :attached => to_attach.size, + :already => rows.size - to_attach.size, + :headline => headline_state } + end + def title editable_page&.title end diff --git a/test/models/node_attach_asset_test.rb b/test/models/node_attach_asset_test.rb new file mode 100644 index 0000000..cb5b60f --- /dev/null +++ b/test/models/node_attach_asset_test.rb @@ -0,0 +1,107 @@ +require "test_helper" + +class NodeAttachAssetTest < ActiveSupport::TestCase + + def setup + @user = users(:quentin) + @other = users(:aaron) + @node = Node.root.children.create!(:slug => "attach_asset_test") + @image = create_image_asset + end + + test "attaches to a draft-only node" do + result = @node.attach_asset!(@image, :user => @user) + assert_equal 1, result[:attached] + assert_includes @node.draft.assets, @image + end + + test "attaches to head when no draft is pending" do + @node.publish_draft!(@user) + result = @node.attach_asset!(@image, :user => @user) + assert_equal 1, result[:attached] + assert_includes @node.head.assets, @image + end + + test "attaches to head and pending draft alike" do + @node.publish_draft!(@user) + @node.lock_for_editing!(@user) + @node.create_new_draft(@user) + result = @node.attach_asset!(@image, :user => @user) + assert_equal 2, result[:attached] + assert_includes @node.head.assets, @image + assert_includes @node.draft.assets, @image + end + + test "attaches to all three lifecycle rows" do + @node.publish_draft!(@user) + @node.lock_for_editing!(@user) + @node.create_new_draft(@user) + @node.autosave!({ :title => "wip" }, @user) + result = @node.attach_asset!(@image, :user => @user) + assert_equal 3, result[:attached] + [@node.head, @node.draft, @node.autosave].each do |row| + assert_includes row.assets, @image + end + end + + test "skips rows that already carry the asset" do + @node.draft.related_assets.create!(:asset => @image) + @node.publish_draft!(@user) + @node.lock_for_editing!(@user) + @node.create_new_draft(@user) + result = @node.attach_asset!(@image, :user => @user) + assert_equal 0, result[:attached] + assert_equal 2, result[:already] + assert_equal 1, @node.draft.related_assets.where(:asset_id => @image.id).count + end + + test "refuses when another user holds the lock and writes nothing" do + @node.lock_for_editing!(@other) + assert_raises(LockedByAnotherUser) { @node.attach_asset!(@image, :user => @user) } + assert_empty @node.draft.assets.reload + end + + test "proceeds when the attaching user holds the lock" do + @node.lock_for_editing!(@user) + result = @node.attach_asset!(@image, :user => @user) + assert_equal 1, result[:attached] + end + + test "sets the headline when none exists" do + result = @node.attach_asset!(@image, :user => @user, :headline => true) + assert_equal :set, result[:headline] + assert_equal @image, @node.draft.headline_asset + end + + test "keeps an existing headline and reports it" do + incumbent = create_image_asset + @node.draft.related_assets.create!(:asset => incumbent, :headline => true) + result = @node.attach_asset!(@image, :user => @user, :headline => true) + assert_equal :kept_existing, result[:headline] + assert_equal incumbent, @node.draft.reload.headline_asset + assert_includes @node.draft.assets, @image + end + + test "declines the headline flag for ineligible asset types" do + plain = create_plain_asset + result = @node.attach_asset!(plain, :user => @user, :headline => true) + assert_equal :not_eligible, result[:headline] + assert_includes @node.draft.assets.reload, plain + assert_nil @node.draft.headline_asset + end + + test "refuses nodes in the Trash" do + @node.trash!(@user) + assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) } + end + + private + + def create_image_asset + Asset.create!(:name => "attach test image", :upload_content_type => "image/png") + end + + def create_plain_asset + Asset.create!(:name => "attach test note", :upload_content_type => "text/plain") + end +end -- cgit v1.3