From 995eac6bea82ae6717b046c10d8293d321f5252e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:24:51 +0200 Subject: [PATCH] test: cover OrmCounterCacheCascadeDestroyTest, teardown --- .../test_orm_counter_cache_cascade_destroy.rb | 91 +++++++++++++++++++ test/test_orm_dependent_destroy.rb | 71 +++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 test/test_orm_counter_cache_cascade_destroy.rb create mode 100644 test/test_orm_dependent_destroy.rb diff --git a/test/test_orm_counter_cache_cascade_destroy.rb b/test/test_orm_counter_cache_cascade_destroy.rb new file mode 100644 index 0000000..a287006 --- /dev/null +++ b/test/test_orm_counter_cache_cascade_destroy.rb @@ -0,0 +1,91 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +# Regression test for a real bug found through examples/blog integration +# (blog_demo.rb liking a post AND one of its comments, then trashing and +# restoring the post): a three-level paranoid dependent: :destroy cascade +# where the MIDDLE record (Comment) is both a counter_cache CHILD (of Post) +# and a counter_cache OWNER (of its own Like) used to lose counter updates. +# +# Model#save/Paranoia#soft_delete_row used to write the record's FULL +# @attributes hash on every update, including the soft-delete stamp. When +# Comment's own dependent: :destroy cascade (its Like) bumped Comment's +# likes_count via Store#increment_counter (a raw row mutation bypassing +# Comment's in-memory @attributes) and THEN Comment's own soft_delete_row +# wrote its stale in-memory @attributes back over the row, the fresh +# counter mutation was clobbered. The mirror bug existed on Paranoia#restore +# too, since it calls the ordinary #save (full-attribute overwrite) BEFORE +# cascade_restore_dependent_associations increments the counter again, +# corrupting the count from the other direction (double count instead of +# lost count). Both are fixed by only ever writing changed attributes +# (Model#dirty_attributes) instead of the full @attributes hash. +class OrmCounterCacheCascadeDestroyTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-counter-cache-cascade-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title comments_count likes_count deleted_at]) + @store.create_table(:comments, %w[id post_id body likes_count deleted_at]) + @store.create_table(:likes, %w[id likeable_id likeable_type deleted_at]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, foreign_key: 'post_id', dependent: :destroy + has_many :likes, as: :likeable, dependent: :destroy + acts_as_paranoid + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id', counter_cache: true + has_many :likes, as: :likeable, dependent: :destroy + acts_as_paranoid + end + like_class = Class.new(Prism::Orm::Model) do + table_name :likes + belongs_to :likeable, polymorphic: true, counter_cache: true + acts_as_paranoid + end + [post_class, comment_class, like_class].each { |k| k.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Comment, comment_class) + Object.const_set(:Like, like_class) + + @post = Post.create!(title: 'hi') + @comment = Comment.create!(post_id: @post.id, body: 'yo') + @like_on_post = Like.create!(likeable_id: @post.id, likeable_type: 'Post') + @like_on_comment = Like.create!(likeable_id: @comment.id, likeable_type: 'Comment') + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Comment Like].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_counters_start_correct + assert_equal 1, @post.reload.comments_count + assert_equal 1, @post.reload.likes_count + assert_equal 1, @comment.reload.likes_count + end + + def test_cascading_soft_destroy_does_not_clobber_a_counter_bumped_by_its_own_nested_cascade + @post.destroy + + # Comment's own Like was destroyed as part of Comment's own cascade + # (triggered by Post's cascade destroying Comment), decrementing + # Comment#likes_count to 0 - and Comment's subsequent soft-delete stamp + # must not silently revert that back to the stale in-memory value (1). + assert_equal 0, @store.find(:comments, @comment.id)['likes_count'] + assert_equal 0, @store.find(:posts, @post.id)['comments_count'] + assert_equal 0, @store.find(:posts, @post.id)['likes_count'] + end + + def test_recursive_restore_brings_every_counter_back_exactly_once + @post.destroy + @post.restore(recursive: true) + + assert_equal 1, @post.reload.comments_count + assert_equal 1, @post.reload.likes_count + assert_equal 1, Comment.find(@comment.id).likes_count + end +end diff --git a/test/test_orm_dependent_destroy.rb b/test/test_orm_dependent_destroy.rb new file mode 100644 index 0000000..bd5ed17 --- /dev/null +++ b/test/test_orm_dependent_destroy.rb @@ -0,0 +1,71 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmDependentDestroyTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-dependent-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + @store.create_table(:profiles, %w[id author_id bio]) + + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id', dependent: :destroy + has_one :profile, class_name: 'Profile', foreign_key: 'author_id', dependent: :nullify + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + profile_class = Class.new(Prism::Orm::Model) do + table_name :profiles + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [author_class, post_class, profile_class].each { |klass| klass.store = @store } + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + Object.const_set(:Profile, profile_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Author Post Profile].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_dependent_destroy_removes_has_many_children + author = Author.create!(name: 'foksiu') + p1 = Post.create!(title: 'a', author_id: author.id) + p2 = Post.create!(title: 'b', author_id: author.id) + + author.destroy + + assert_raises(Prism::Orm::RecordNotFound) { Post.find(p1.id) } + assert_raises(Prism::Orm::RecordNotFound) { Post.find(p2.id) } + end + + def test_dependent_nullify_clears_foreign_key_on_has_one + author = Author.create!(name: 'foksiu') + profile = Profile.create!(bio: 'hello', author_id: author.id) + + author.destroy + + reloaded = Profile.find(profile.id) + assert_nil reloaded.author_id + end + + def test_destroying_childless_author_does_not_raise + author = Author.create!(name: 'lonely') + assert author.destroy.destroyed? + end + + def test_unsupported_dependent_option_raises_at_declaration_time + assert_raises(ArgumentError) do + Class.new(Prism::Orm::Model) do + table_name :posts + has_many :whatever, class_name: 'Post', foreign_key: 'author_id', dependent: :delete_all + end + end + end +end