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