summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/models/node.rb81
-rw-r--r--app/models/node_action.rb109
-rw-r--r--test/models/node_trash_test.rb127
3 files changed, 261 insertions, 56 deletions
diff --git a/app/models/node.rb b/app/models/node.rb
index a2e9981..4dae287 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -283,6 +283,87 @@ class Node < ApplicationRecord
283 end 283 end
284 end 284 end
285 285
286
287 # Moves this node and its subtree into the Trash. Demotes every head
288 # in the subtree first (aggregators and search operate on heads
289 # regardless of tree position); where a node has no draft, the former
290 # head becomes its draft so content stays editable and restorable --
291 # otherwise the former head remains a plain revision. One log entry,
292 # at the root, carrying the leaving-public-view snapshot.
293 def trash! current_user = nil
294 return nil if in_trash?
295 raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node?
296
297 ActiveRecord::Base.transaction do
298 path_before = unique_name
299 was_published = head_id.present?
300 final_published_at = head&.published_at
301
302 demoted = 0
303 ([self] + descendants.to_a).each do |node|
304 next unless node.head_id
305 former = node.head
306 node.head = nil
307 node.draft_id = former.id if node.draft_id.nil?
308 node.save!
309 demoted += 1
310 end
311
312 self.reload
313 move_to_child_of(Node.trash)
314 self.reload
315 update_unique_name
316 send(:update_unique_names_of_children)
317 unlock!
318
319 metadata = { :path => { "from" => path_before, "to" => unique_name } }
320 metadata[:was_published] = true if was_published
321 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
322 metadata[:demoted_heads] = demoted if demoted > 0
323
324 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata)
325 self
326 end
327 end
328
329 # Returns the node to the living tree under a chosen parent. The
330 # subtree comes back exactly as it sits in the Trash: all drafts,
331 # nothing published. Republication is a separate, witnessed act
332 # per node.
333 def restore_from_trash! new_parent, current_user = nil
334 return nil unless in_trash?
335
336 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) ||
337 new_parent.trash_node? || new_parent.in_trash?
338 raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node"
339 end
340
341 ActiveRecord::Base.transaction do
342 path_before = unique_name
343 move_to_child_of(new_parent)
344 self.reload
345 update_unique_name
346 send(:update_unique_names_of_children)
347
348 NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash",
349 :path => { "from" => path_before, "to" => unique_name })
350 self
351 end
352 end
353
354 # Final deletion. Only from inside the Trash, never with children.
355 # The log entry is written first, in the same transaction; node_id
356 # nullifies when the row dies, and the metadata carries what remains.
357 def destroy_from_trash! current_user = nil
358 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash?
359
360 ActiveRecord::Base.transaction do
361 NodeAction.record!(:node => self, :user => current_user, :action => "destroy",
362 :path => unique_name)
363 destroy!
364 end
365 end
366
286 # returns an array with all parts of a unique_name rather than a string 367 # returns an array with all parts of a unique_name rather than a string
287 def unique_path 368 def unique_path
288 unique_name.to_s.split("/") 369 unique_name.to_s.split("/")
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 792ae1e..6e09b5b 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -8,79 +8,76 @@ class NodeAction < ApplicationRecord
8 8
9 # == Metadata contract == 9 # == Metadata contract ==
10 # 10 #
11 # metadata is written once at creation and never updated. It is the 11 # metadata is written once at creation, never updated. It is the
12 # single place anything that must survive deletion of the referenced 12 # single place anything that must survive deletion of the referenced
13 # rows lives; node_id/page_id/user_id are lookup and ordering only. 13 # rows lives; node_id/page_id/user_id are lookup and ordering only.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y} -- 14 # All keys are strings. Pairs are always {"from" => x, "to" => y}.
15 # never _old/_new suffixes. Optional pairs are written only when the 15 # Optional pairs only when the sides differ; booleans only when
16 # two sides differ; required keys are always present. All titles and 16 # true; required keys always present. Titles and names are read
17 # names are read pinned to I18n.default_locale unless inside a 17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 # locale-keyed block.
19 # 18 #
20 # Baseline, every entry (written here, not by call sites): 19 # Baseline, every entry (written here, not by call sites):
21 # "username" -- actor's login at write time 20 # "username" -- actor's login at write time
22 # "human_readable_node_name" -- node title, default locale 21 # "human_readable_node_name" -- node title, default locale
23 # 22 #
24 # "create": 23 # "create":
25 # "title" -- initial title, default locale 24 # "title" -- initial title, flat string (NOT a pair)
26 # "path" -- unique path at creation time. Historical value only: 25 # "path" -- unique path at creation, flat string. Historical
27 # later renames/moves do NOT update old entries. Never 26 # value only; never a join key.
28 # use as a join key; node_id is the join key while the
29 # node lives.
30 # 27 #
31 # "publish" (any promotion of a page to head; the diff against the 28 # "publish" (any promotion to head; diff computed BEFORE head is
32 # outgoing head is computed BEFORE head is re-pointed, over the 29 # re-pointed, over the union of both pages' locales, by head_diff --
33 # union of both pages' locales, by the shared diff function also 30 # shared with the backfill):
34 # used by the backfill): 31 # "via" -- "draft" | "revision" (rollback). Always written;
35 # "via" -- "draft" (ordinary publish) | "revision" (rollback 32 # absent means a pre-contract entry.
36 # via restore_revision!). Always written; absent 33 # "title" -- pair, always; "from" null on first publish
37 # means a pre-contract entry. 34 # "author" -- pair, when the byline changed (incl. first publish)
38 # "title" -- pair, always present. "from" is null on a first 35 # "tags" -- pair of arrays, when changed
39 # publish (no outgoing head). 36 # "assets_changed", "template_changed",
40 # "author" -- pair, only when the byline (page.user) changed
41 # "tags" -- pair of arrays, only when changed
42 # "assets_changed", "template_changed"
43 # -- booleans, only when true; page_id links to the
44 # revision for the real diff (never stored here)
45 # "abstract_changed", "body_changed" 37 # "abstract_changed", "body_changed"
46 # -- booleans for the default locale, only when true 38 # -- the last two for the default locale; page_id links
47 # "translation_diff" -- only when any non-default locale differs: 39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
48 # { "<locale>" => { 41 # { "<locale>" => {
49 # "status" -- "added" | "removed" | "changed" 42 # "status" -- "added" | "removed" | "changed"
50 # "title" -- pair; "from" null when added, 43 # "title" -- pair, only when it differs; "from" null when
51 # "to" null when removed 44 # added, "to" null when removed
52 # "abstract_changed", "body_changed" -- booleans, only when 45 # "abstract_changed", "body_changed" -- status "changed" only
53 # true, only for status "changed"
54 # } } 46 # } }
55 # 47 #
56 # "move" (reparenting and/or unique-path change; one entry at the 48 # "move" (reparent and/or path change; one entry at the subtree
57 # subtree root, descendants get none -- a descendant's own zoomed 49 # root, descendants get none):
58 # view will not show path history): 50 # "path" -- pair
59 # "path" -- pair
60 # 51 #
61 # Reserved for the Trash feature, final shape decided there: 52 # "trash" (subtree into the Trash; every head in the subtree is
62 # "demote" (via "trash" | "depublish"; carries the leaving-public- 53 # demoted first; one entry at the root; snapshots the
63 # view snapshot: head presence, final published_at), "trash", 54 # leaving-public-view state, since Trash holds no heads and destroy
64 # "restore_from_trash", "destroy" (final path only; publish-state 55 # can no longer know):
65 # snapshot lives on the entry that removed it from public view). 56 # "path" -- pair; "from" doubles as the restore hint
66 # Whether trash logs one entry or a trash+demote pair is decided 57 # "was_published" -- boolean
67 # with that feature. 58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
68 # 60 #
69 # Backfilled entries mirror this vocabulary exactly. Their diff 61 # "restore_from_trash" (reparent back to a living node; returns as
70 # content is computed, not guessed (consecutive revisions still 62 # drafts, republication is a separate witnessed act):
71 # exist); only actor (from page.editor) and occurred_at are 63 # "path" -- pair
72 # inferred, and inferred_from names the heuristic per entry
73 # ("from_page_revision", "from_published_at_heuristic").
74 # inferred_from null = witnessed live.
75 # 64 #
76 # The "locale" column is currently written by no verb (it belonged 65 # "destroy" (only from inside the Trash, never with children; the
77 # to the retired translation_destroy) and is retained for backfill 66 # entry is written in the same transaction before the row dies):
78 # or future draft-scoped verbs. 67 # "path" -- final path, flat string (create-symmetric)
79 # 68 #
80 # This log records; it does not undo. Reversibility stays in 69 # Reserved: "demote" (via "trash" | "depublish") for an explicit
81 # restore_revision! and the revisions system. No IP, session, or 70 # depublish workflow, if ever built.
82 # user agent, ever. Success only -- rejected attempts are not 71 #
83 # logged. 72 # Backfilled entries mirror this vocabulary; diff content is
73 # computed, only actor (page.editor) and occurred_at are inferred,
74 # inferred_from names the heuristic ("from_node_created_at",
75 # "from_page_revision"). Null = witnessed live.
76 #
77 # The "locale" column is written by no verb; retained.
78 #
79 # This log records; it does not undo. No IP, session, or user
80 # agent, ever. Success only.
84 81
85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, 82 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
86 occurred_at: nil, inferred_from: nil, **extra) 83 occurred_at: nil, inferred_from: nil, **extra)
diff --git a/test/models/node_trash_test.rb b/test/models/node_trash_test.rb
new file mode 100644
index 0000000..b72770b
--- /dev/null
+++ b/test/models/node_trash_test.rb
@@ -0,0 +1,127 @@
1require "test_helper"
2
3class NodeTrashTest < ActiveSupport::TestCase
4
5 def setup
6 @user1 = User.create!(:login => "trasher", :email => "t@example.com",
7 :password => "foobar", :password_confirmation => "foobar")
8 end
9
10 test "trash! demotes the head into the draft slot when no draft exists" do
11 node = create_node_with_published_page
12 former_head = node.head
13
14 node.trash!(@user1)
15 node.reload
16
17 assert_nil node.head_id
18 assert_equal former_head, node.draft
19 assert node.in_trash?
20 end
21
22 test "trash! keeps an existing draft; the former head stays a plain revision" do
23 node = create_node_with_published_page
24 draft = find_or_create_draft(node, @user1)
25 former_head = node.head
26
27 node.trash!(@user1)
28 node.reload
29
30 assert_nil node.head_id
31 assert_equal draft, node.draft
32 assert_includes node.pages, former_head
33 end
34
35 test "trash! demotes descendant heads and counts them in the log entry" do
36 parent = create_node_with_published_page
37 child = parent.children.create!(:slug => "trashed_child")
38 child.draft.update!(:title => "Child")
39 child.publish_draft!(@user1)
40
41 parent.trash!(@user1)
42
43 action = NodeAction.where(:action => "trash").last
44 assert_equal parent, action.node
45 assert_equal 2, action.metadata["demoted_heads"]
46 assert action.metadata["was_published"]
47 assert_nil child.reload.head_id
48 assert child.in_trash?
49 end
50
51 test "trash! logs one entry with the path pair and updates unique names" do
52 node = create_node_with_published_page
53 path_before = node.unique_name
54
55 assert_difference "NodeAction.count", 1 do
56 node.trash!(@user1)
57 end
58
59 action = NodeAction.last
60 assert_equal path_before, action.metadata.dig("path", "from")
61 assert_equal node.reload.unique_name, action.metadata.dig("path", "to")
62 assert action.metadata.dig("path", "to").start_with?(CccConventions::TRASH_SLUG)
63 end
64
65 test "trash! on an already trashed node is a logged-nothing no-op" do
66 node = create_node_with_published_page
67 node.trash!(@user1)
68
69 assert_no_difference "NodeAction.count" do
70 assert_nil node.reload.trash!(@user1)
71 end
72 end
73
74 test "restore_from_trash! reparents as drafts without republishing" do
75 node = create_node_with_published_page
76 node.trash!(@user1)
77 target = Node.root.children.create!(:slug => "restore_target")
78
79 node.reload.restore_from_trash!(target, @user1)
80 node.reload
81
82 assert_equal target, node.parent
83 assert_not node.in_trash?
84 assert_nil node.head_id
85 assert_not_nil node.draft_id
86 assert_equal "restore_from_trash", NodeAction.last.action
87 end
88
89 test "restore_from_trash! refuses targets inside the Trash and the Trash itself" do
90 node = create_node_with_published_page
91 node.trash!(@user1)
92 other_trashed = Node.trash.children.create!(:slug => "also_trashed")
93
94 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(Node.trash, @user1) }
95 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(other_trashed, @user1) }
96 end
97
98 test "destroy_from_trash! refuses nodes outside the Trash" do
99 node = create_node_with_published_page
100
101 assert_raises(ActiveRecord::RecordInvalid) { node.destroy_from_trash!(@user1) }
102 assert Node.exists?(node.id)
103 end
104
105 test "destroy_from_trash! refuses nodes with children" do
106 node = create_node_with_published_page
107 node.children.create!(:slug => "still_here")
108 node.trash!(@user1)
109
110 assert_raises(ActiveRecord::RecordNotDestroyed) { node.reload.destroy_from_trash!(@user1) }
111 end
112
113 test "destroy_from_trash! logs an entry that survives the node" do
114 node = create_node_with_published_page
115 Globalize.with_locale(I18n.default_locale) { node.head.update!(:title => "Doomed") }
116 node.trash!(@user1)
117 final_path = node.reload.unique_name
118
119 node.destroy_from_trash!(@user1)
120
121 action = NodeAction.where(:action => "destroy").last
122 assert_nil action.node_id
123 assert_equal final_path, action.metadata["path"]
124 assert_equal "Doomed", action.subject_name
125 assert_equal @user1, action.user
126 end
127end