summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-17 22:30:37 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-17 22:30:37 +0200
commit24d61c40786497d864c7c8843e68cf2af880e5f7 (patch)
tree17f9247d251ad513687c169237b79b78302bd094
parent1374ab51138a00024b0fe148394dbe4ea7f91578 (diff)
Bootstrap the Trash node: reserved slug, identity, guards
-rw-r--r--app/models/node.rb62
-rw-r--r--lib/ccc_conventions.rb5
-rw-r--r--test/models/node_test.rb54
3 files changed, 119 insertions, 2 deletions
diff --git a/app/models/node.rb b/app/models/node.rb
index dc69508..a2e9981 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -19,6 +19,7 @@ class Node < ApplicationRecord
19 before_save :check_for_changed_slug 19 before_save :check_for_changed_slug
20 after_save :update_unique_names_of_children 20 after_save :update_unique_names_of_children
21 before_destroy :refuse_destroy_with_children 21 before_destroy :refuse_destroy_with_children
22 before_destroy :refuse_destroying_trash_node
22 23
23 # Validations 24 # Validations
24 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } 25 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
@@ -26,6 +27,8 @@ class Node < ApplicationRecord
26 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } 27 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
27 validates_presence_of :parent_id, :unless => -> { Node.root.nil? } 28 validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
28 29
30 validate :reserved_slug_stays_reserved
31 validate :no_head_inside_trash
29 validates :default_template_name, 32 validates :default_template_name,
30 :inclusion => { :in => ->(_) { Page.custom_templates } }, 33 :inclusion => { :in => ->(_) { Page.custom_templates } },
31 :allow_nil => true, 34 :allow_nil => true,
@@ -57,6 +60,15 @@ class Node < ApplicationRecord
57 nil 60 nil
58 end 61 end
59 62
63 # The Trash container node. Lazily self-creating and idempotent, so
64 # every environment acquires it on first touch.
65 # Never call from validations. the positional predicates below
66 # exist for that.
67 def self.trash
68 root.children.find_by(:slug => CccConventions::TRASH_SLUG) ||
69 root.children.create!(:slug => CccConventions::TRASH_SLUG)
70 end
71
60 # Instance Methods 72 # Instance Methods
61 73
62 # Acquires (or reaffirms) the editing lock without creating a draft or 74 # Acquires (or reaffirms) the editing lock without creating a draft or
@@ -203,6 +215,10 @@ class Node < ApplicationRecord
203 # Return nil if nothing to publish and no staged changes 215 # Return nil if nothing to publish and no staged changes
204 return nil unless self.draft || staged_slug || staged_parent_id 216 return nil unless self.draft || staged_slug || staged_parent_id
205 217
218 if in_trash? || trash_node?
219 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
220 end
221
206 path_before = self.unique_name 222 path_before = self.unique_name
207 223
208 ActiveRecord::Base.transaction do 224 ActiveRecord::Base.transaction do
@@ -322,6 +338,20 @@ class Node < ApplicationRecord
322 autosave || draft || head 338 autosave || draft || head
323 end 339 end
324 340
341 def trash_node?
342 parent&.root? && slug == CccConventions::TRASH_SLUG
343 end
344
345 # Inside the Trash subtree. False for the Trash node itself.
346 def in_trash?
347 current = parent
348 while current
349 return true if current.trash_node?
350 current = current.parent
351 end
352 false
353 end
354
325 # Returns immutable node id for all new nodes so that the atom feed entry ids 355 # Returns immutable node id for all new nodes so that the atom feed entry ids
326 # stay the same eventhough the slug or positions changes. 356 # stay the same eventhough the slug or positions changes.
327 # Can be removed after a year or so ;) 357 # Can be removed after a year or so ;)
@@ -435,4 +465,36 @@ class Node < ApplicationRecord
435 end 465 end
436 end 466 end
437 end 467 end
468
469 private
470
471 def reserved_slug_stays_reserved
472 if parent&.root? && !trash_node_already_me?
473 errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG
474 errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG
475 end
476
477 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node?
478 errors.add(:slug, "of the Trash node cannot change") if slug_changed?
479 errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed?
480 errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present?
481 errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present?
482 end
483 end
484
485 def trash_node_already_me?
486 existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG)
487 existing.nil? || existing.id == id
488 end
489
490 def no_head_inside_trash
491 return unless head_id.present?
492 errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node?
493 end
494
495 def refuse_destroying_trash_node
496 return unless trash_node?
497 errors.add(:base, "The Trash node cannot be destroyed")
498 throw :abort
499 end
438end 500end
diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb
index 352dd3c..7ff5a3a 100644
--- a/lib/ccc_conventions.rb
+++ b/lib/ccc_conventions.rb
@@ -1,6 +1,7 @@
1module CccConventions 1module CccConventions
2 ERFA_PARENT_NAME = "club/erfas" 2 TRASH_SLUG = "trash"
3 CHAOSTREFF_PARENT_NAME = "club/chaostreffs" 3 ERFA_PARENT_NAME = "club/erfas"
4 CHAOSTREFF_PARENT_NAME = "club/chaostreffs"
4 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze 5 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze
5 6
6 NODE_KINDS = { 7 NODE_KINDS = {
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index 9c4834e..3fb2d23 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -807,4 +807,58 @@ class NodeTest < ActiveSupport::TestCase
807 807
808 assert_equal 0, Page.where(:id => autosave_id).count 808 assert_equal 0, Page.where(:id => autosave_id).count
809 end 809 end
810
811 test "Node.trash lazily creates the container exactly once" do
812 assert_difference "Node.count", 1 do
813 Node.trash
814 end
815 assert_no_difference "Node.count" do
816 assert_equal Node.trash, Node.trash
817 end
818 assert Node.trash.trash_node?
819 assert_not Node.trash.in_trash?
820 end
821
822 test "in_trash? walks the whole parent chain" do
823 child = Node.trash.children.create!(:slug => "trashed_thing")
824 grandchild = child.children.create!(:slug => "trashed_deeper")
825
826 assert child.in_trash?
827 assert grandchild.in_trash?
828 assert_not Node.root.children.create!(:slug => "living_thing").in_trash?
829 end
830
831 test "the reserved slug is refused for other root children, live and staged" do
832 Node.trash
833
834 assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid?
835 assert_not Node.root.children.build(:slug => "fine", :staged_slug => CccConventions::TRASH_SLUG).valid?
836 assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid?
837 end
838
839 test "the Trash node refuses rename, move, and destroy" do
840 trash = Node.trash
841 other = Node.root.children.create!(:slug => "not_the_trash")
842
843 trash.slug = "recycling"
844 assert_not trash.valid?
845
846 trash.reload.parent_id = other.id
847 assert_not trash.valid?
848
849 assert_no_difference "Node.count" do
850 assert_not trash.reload.destroy
851 end
852 end
853
854 test "a head cannot exist inside the Trash, and publishing there is refused" do
855 node = Node.trash.children.create!(:slug => "no_publish_here")
856 page = node.pages.create!
857
858 node.head = page
859 assert_not node.valid?
860
861 node.reload
862 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft! }
863 end
810end 864end