diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-17 22:30:37 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-17 22:30:37 +0200 |
| commit | 24d61c40786497d864c7c8843e68cf2af880e5f7 (patch) | |
| tree | 17f9247d251ad513687c169237b79b78302bd094 | |
| parent | 1374ab51138a00024b0fe148394dbe4ea7f91578 (diff) | |
Bootstrap the Trash node: reserved slug, identity, guards
| -rw-r--r-- | app/models/node.rb | 62 | ||||
| -rw-r--r-- | lib/ccc_conventions.rb | 5 | ||||
| -rw-r--r-- | 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 | |||
| 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 | ||
| 438 | end | 500 | 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 @@ | |||
| 1 | module CccConventions | 1 | module 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 | ||
| 810 | end | 864 | end |
