From 726e1cd9089c349a43d4a4be62457a46246265d4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:15:57 +0200 Subject: [PATCH] test: cover PreviewFixtures and PreviewPost --- test/test_integration_preview.rb | 128 +++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 test/test_integration_preview.rb diff --git a/test/test_integration_preview.rb b/test/test_integration_preview.rb new file mode 100644 index 0000000..6ef2a7d --- /dev/null +++ b/test/test_integration_preview.rb @@ -0,0 +1,128 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Fixtures built once at file-load time (same shared-once idiom as +# TransactionalFixtures in test_testing_transactional.rb) instead of the +# per-test Dir.mktmpdir/FileUtils.remove_entry setup/teardown every other +# *_orm test file repeats - safe here because `use_transactional_tests` +# rolls each test's writes back, so nothing needs re-seeding between runs. +# +# PreviewApp mirrors PostsController#preview/#update from examples/blog +# closely enough to exercise the same assign -> valid? -> changes -> +# restore_attributes sequence, and the same optimistic-locking +# StaleObjectError path, through a REAL HTTP request cycle - something only +# ever exercised end-to-end via blog_demo.rb until now. It does not require +# examples/blog/app.rb itself: that file calls `Prism::Orm.connect` against +# a real on-disk dev database as a process-wide global, which would leak +# into every other test in this suite that relies on `Prism::Orm.store`. +module PreviewFixtures + DIR = Dir.mktmpdir('prism-preview-integration') + STORE = Prism::Orm::Store.new(File.join(DIR, 'test.pstore')) + STORE.create_table(:preview_posts, %w[id title body lock_version]) + + class PreviewPost < Prism::Orm::Model + self.store = STORE + table_name :preview_posts + validates :title, presence: true + end + + class PreviewApp < Prism::Application + get('/posts/:id') do + post = PreviewPost.find(params['id']) + json(id: post.id, title: post.title, body: post.body, lock_version: post.lock_version) + end + + # Same shape as PostsController#preview: dirty-track a proposed edit, + # capture the diff and any validation errors, then restore the record + # to exactly what was persisted before responding - nothing here ever + # calls #save. + patch('/posts/:id/preview') do + post = PreviewPost.find(params['id']) + attempted = params.require('post').permit(:title, :body, :lock_version).to_h + post.assign(attempted) + post.valid? + diff = post.changes.reject { |field, _| field == 'lock_version' } + errors = post.errors.full_messages + post.restore_attributes + json(diff: diff, errors: errors, current_title: post.title, current_body: post.body) + end + + patch('/posts/:id') do + post = PreviewPost.find(params['id']) + post.assign(params.require('post').permit(:title, :body, :lock_version).to_h) + begin + post.save! + json(id: post.id, title: post.title, lock_version: post.lock_version) + rescue Prism::Orm::StaleObjectError + halt(409, 'stale object') + end + end + end +end + +class IntegrationPreviewTest < Minitest::Test + include Prism::Testing::IntegrationTest + integration_app PreviewFixtures::PreviewApp + use_transactional_tests(store: PreviewFixtures::STORE) + + def test_preview_returns_a_diff_without_persisting_the_change + post = PreviewFixtures::PreviewPost.create!(title: 'Original title', body: 'original body') + + patch("/posts/#{post.id}/preview", params: { post: { title: 'Proposed title', body: 'original body' } }) + assert_response :success + body = response.json + assert_equal ['Original title', 'Proposed title'], body.dig('diff', 'title') + assert_empty body['errors'] + assert_equal 'Original title', body['current_title'], 'the response must show the record as still unsaved' + + get("/posts/#{post.id}") + assert_equal 'Original title', response.json['title'], 'the store itself must be untouched by the preview' + end + + def test_preview_reports_validation_errors_without_persisting_anything + post = PreviewFixtures::PreviewPost.create!(title: 'Keep me', body: 'keep me too') + + patch("/posts/#{post.id}/preview", params: { post: { title: '', body: 'still keep me too' } }) + assert_response :success + body = response.json + refute_empty body['errors'] + assert_equal 'Keep me', body['current_title'] + + get("/posts/#{post.id}") + assert_equal 'Keep me', response.json['title'] + end + + def test_preview_is_unaffected_by_a_stale_lock_version_because_it_never_saves + post = PreviewFixtures::PreviewPost.create!(title: 'Racing edit', body: 'body') + stale_lock_version = post.lock_version + post.update!(body: 'a concurrent edit landed first') + refute_equal stale_lock_version, post.lock_version + + patch("/posts/#{post.id}/preview", + params: { post: { title: 'Whatever I typed', body: 'body', lock_version: stale_lock_version } }) + assert_response :success # must not raise StaleObjectError - it only diffs, it never saves + assert_equal 'a concurrent edit landed first', response.json['current_body'] + end + + def test_real_update_with_a_stale_lock_version_is_rejected_with_409 + post = PreviewFixtures::PreviewPost.create!(title: 'Racing edit', body: 'body') + stale_lock_version = post.lock_version + post.update!(body: 'a concurrent edit landed first') + + patch("/posts/#{post.id}", params: { post: { title: 'Racing edit', body: 'my edit', lock_version: stale_lock_version } }) + assert_response 409 + + get("/posts/#{post.id}") + assert_equal 'a concurrent edit landed first', response.json['body'], + 'the losing, stale update must never have been applied' + end + + def test_real_update_with_a_fresh_lock_version_succeeds + post = PreviewFixtures::PreviewPost.create!(title: 'Racing edit', body: 'body') + + patch("/posts/#{post.id}", params: { post: { title: 'Racing edit', body: 'my edit', lock_version: post.lock_version } }) + assert_response :success + assert_equal 1, response.json['lock_version'] + end +end