From 24d61c40786497d864c7c8843e68cf2af880e5f7 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 17 Jul 2026 22:30:37 +0200 Subject: Bootstrap the Trash node: reserved slug, identity, guards --- app/models/node.rb | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/ccc_conventions.rb | 5 ++-- test/models/node_test.rb | 54 +++++++++++++++++++++++++++++++++++++++++ 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 before_save :check_for_changed_slug after_save :update_unique_names_of_children before_destroy :refuse_destroy_with_children + before_destroy :refuse_destroying_trash_node # Validations validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } @@ -26,6 +27,8 @@ class Node < ApplicationRecord validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } validates_presence_of :parent_id, :unless => -> { Node.root.nil? } + validate :reserved_slug_stays_reserved + validate :no_head_inside_trash validates :default_template_name, :inclusion => { :in => ->(_) { Page.custom_templates } }, :allow_nil => true, @@ -57,6 +60,15 @@ class Node < ApplicationRecord nil end + # The Trash container node. Lazily self-creating and idempotent, so + # every environment acquires it on first touch. + # Never call from validations. the positional predicates below + # exist for that. + def self.trash + root.children.find_by(:slug => CccConventions::TRASH_SLUG) || + root.children.create!(:slug => CccConventions::TRASH_SLUG) + end + # Instance Methods # Acquires (or reaffirms) the editing lock without creating a draft or @@ -203,6 +215,10 @@ class Node < ApplicationRecord # Return nil if nothing to publish and no staged changes return nil unless self.draft || staged_slug || staged_parent_id + if in_trash? || trash_node? + raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash" + end + path_before = self.unique_name ActiveRecord::Base.transaction do @@ -322,6 +338,20 @@ class Node < ApplicationRecord autosave || draft || head end + def trash_node? + parent&.root? && slug == CccConventions::TRASH_SLUG + end + + # Inside the Trash subtree. False for the Trash node itself. + def in_trash? + current = parent + while current + return true if current.trash_node? + current = current.parent + end + false + end + # Returns immutable node id for all new nodes so that the atom feed entry ids # stay the same eventhough the slug or positions changes. # Can be removed after a year or so ;) @@ -435,4 +465,36 @@ class Node < ApplicationRecord end end end + + private + + def reserved_slug_stays_reserved + if parent&.root? && !trash_node_already_me? + errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG + errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG + end + + if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node? + errors.add(:slug, "of the Trash node cannot change") if slug_changed? + errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed? + errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present? + errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present? + end + end + + def trash_node_already_me? + existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG) + existing.nil? || existing.id == id + end + + def no_head_inside_trash + return unless head_id.present? + errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node? + end + + def refuse_destroying_trash_node + return unless trash_node? + errors.add(:base, "The Trash node cannot be destroyed") + throw :abort + end end 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 @@ module CccConventions - ERFA_PARENT_NAME = "club/erfas" - CHAOSTREFF_PARENT_NAME = "club/chaostreffs" + TRASH_SLUG = "trash" + ERFA_PARENT_NAME = "club/erfas" + CHAOSTREFF_PARENT_NAME = "club/chaostreffs" SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze 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 assert_equal 0, Page.where(:id => autosave_id).count end + + test "Node.trash lazily creates the container exactly once" do + assert_difference "Node.count", 1 do + Node.trash + end + assert_no_difference "Node.count" do + assert_equal Node.trash, Node.trash + end + assert Node.trash.trash_node? + assert_not Node.trash.in_trash? + end + + test "in_trash? walks the whole parent chain" do + child = Node.trash.children.create!(:slug => "trashed_thing") + grandchild = child.children.create!(:slug => "trashed_deeper") + + assert child.in_trash? + assert grandchild.in_trash? + assert_not Node.root.children.create!(:slug => "living_thing").in_trash? + end + + test "the reserved slug is refused for other root children, live and staged" do + Node.trash + + assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid? + assert_not Node.root.children.build(:slug => "fine", :staged_slug => CccConventions::TRASH_SLUG).valid? + assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid? + end + + test "the Trash node refuses rename, move, and destroy" do + trash = Node.trash + other = Node.root.children.create!(:slug => "not_the_trash") + + trash.slug = "recycling" + assert_not trash.valid? + + trash.reload.parent_id = other.id + assert_not trash.valid? + + assert_no_difference "Node.count" do + assert_not trash.reload.destroy + end + end + + test "a head cannot exist inside the Trash, and publishing there is refused" do + node = Node.trash.children.create!(:slug => "no_publish_here") + page = node.pages.create! + + node.head = page + assert_not node.valid? + + node.reload + assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft! } + end end -- cgit v1.3