test: cover OrmErrorsJsonapiTest, test_empty_errors_produce_empty_array

This commit is contained in:
dualfroz committed 2026-09-11 22:22:11 +02:00
1 parent d862a5e973
commit 49c7152790
2 files changed
+181

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
require 'minitest/autorun'
require 'prism'
class OrmErrorsJsonapiTest < Minitest::Test
def setup
@errors = Prism::Orm::Errors.new
end
def test_empty_errors_produce_empty_array
assert_equal [], @errors.to_jsonapi
end
def test_one_message_becomes_one_error_object
@errors.add(:title, "can't be blank")
entries = @errors.to_jsonapi
assert_equal 1, entries.size
entry = entries.first
assert_equal '422', entry[:status]
assert_equal({ pointer: '/data/attributes/title' }, entry[:source])
assert_equal "can't be blank", entry[:title]
assert_equal "title can't be blank", entry[:detail]
end
def test_custom_status_is_stringified
@errors.add(:base, 'is stale')
entry = @errors.to_jsonapi(status: 409).first
assert_equal '409', entry[:status]
end
def test_multiple_messages_on_same_attribute_each_get_their_own_entry
@errors.add(:body, 'is too short')
@errors.add(:body, 'contains banned words')
entries = @errors.to_jsonapi
assert_equal 2, entries.size
assert_equal ['is too short', 'contains banned words'], entries.map { |e| e[:title] }
assert(entries.all? { |e| e[:source] == { pointer: '/data/attributes/body' } })
end
def test_multiple_attributes_each_produce_their_own_pointer
@errors.add(:title, 'is too short')
@errors.add(:user_id, 'must be present')
entries = @errors.to_jsonapi
pointers = entries.map { |e| e[:source][:pointer] }
assert_includes pointers, '/data/attributes/title'
assert_includes pointers, '/data/attributes/user_id'
end
def test_full_messages_and_jsonapi_detail_stay_in_sync
@errors.add(:email, 'is invalid')
assert_equal @errors.full_messages, @errors.to_jsonapi.map { |e| e[:detail] }
end
end
+124
View File
@@ -0,0 +1,124 @@
require 'minitest/autorun'
require 'prism'
require 'tmpdir'
require 'fileutils'
# Fast, deterministic, in-suite complement to blog_giveaway_demo.rb/
# blog_giveaway_worker.rb (examples/blog's real "hard-capped giveaway"
# feature, ClaimsController#create) - same read-count-then-decide-then-
# write shape as Prism::Orm::Pessimistic's own counter tests
# (test_orm_pessimistic.rb) and the acts_as_list swap-under-lock fix, but
# for a "claim one of N limited slots" critical section specifically:
# proves first that the race is real without a held lock (two racing
# processes can BOTH believe they won the same last slot), then that
# Model#with_lock rules it out entirely under genuinely concurrent OS
# processes, not just threads.
class OrmGiveawayLockingTest < Minitest::Test
def setup
@dir = Dir.mktmpdir('prism-giveaway-locking')
@store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
@store.create_table(:giveaways, %w[id slots claimed])
giveaway_class = Class.new(Prism::Orm::Model) { table_name :giveaways }
giveaway_class.store = @store
Object.const_set(:Giveaway, giveaway_class)
end
def teardown
FileUtils.remove_entry(@dir)
Object.send(:remove_const, :Giveaway) if Object.const_defined?(:Giveaway)
end
# Same rationale as test_orm_pessimistic.rb's own fork-based tests: real
# child OS processes sharing nothing but the PStore file on disk, so
# this is the same cross-process guarantee independent `prism` server
# processes pointed at the same database would get, not a simulation of
# it via threads. `exit!` (not `exit`), same reason as
# test_orm_pessimistic.rb: skip minitest/autorun's at_exit hook.
def test_claiming_without_a_lock_lets_two_racing_processes_both_win_the_last_slot
skip 'fork not supported on this platform' unless Process.respond_to?(:fork)
giveaway = Giveaway.create!(slots: 1, claimed: 0)
a_result = File.join(@dir, 'a_outcome')
b_result = File.join(@dir, 'b_outcome')
a_read, a_wrote_read_signal = IO.pipe
b_read, b_wrote_read_signal = IO.pipe
# Two pipes force the exact ordering an oversell needs: A reads the
# not-yet-claimed slot, THEN B reads it too (while it is still
# unclaimed, because A is blocked waiting on B before it writes
# anything back), THEN both independently decide "still free" and
# both write - each computing its own outcome from the SAME stale
# read, exactly like ClaimsController#create's giveaway_sold_out?
# check would without post.with_lock around it.
pid_a = Process.fork do
a_read.close
b_wrote_read_signal.close
record = Giveaway.find(giveaway.id)
a_wrote_read_signal.write('r')
a_wrote_read_signal.close
b_read.read(1)
b_read.close
outcome = record.claimed < record.slots ? :claimed : :sold_out
Giveaway.find(giveaway.id).update!(claimed: record.claimed + 1) if outcome == :claimed
File.write(a_result, outcome.to_s)
exit!(0)
end
pid_b = Process.fork do
a_wrote_read_signal.close
b_read.close
a_read.read(1)
a_read.close
record = Giveaway.find(giveaway.id)
b_wrote_read_signal.write('r')
b_wrote_read_signal.close
outcome = record.claimed < record.slots ? :claimed : :sold_out
Giveaway.find(giveaway.id).update!(claimed: record.claimed + 1) if outcome == :claimed
File.write(b_result, outcome.to_s)
exit!(0)
end
[a_read, a_wrote_read_signal, b_read, b_wrote_read_signal].each(&:close)
Process.waitpid(pid_a)
Process.waitpid(pid_b)
# The actual harm a real giveaway cares about: both processes decided
# THEY were the one who got the last slot - two winners for one prize -
# even though the persisted counter itself only ever reflects a single
# increment (same lost-update shape as Store#increment_counter's own
# unlocked callers), which would make the bug look survivable if this
# test only checked Giveaway#claimed afterward instead of what each
# racing process actually told its caller.
assert_equal 'claimed', File.read(a_result)
assert_equal 'claimed', File.read(b_result)
end
def test_claiming_through_with_lock_never_oversells_under_real_concurrent_processes
skip 'fork not supported on this platform' unless Process.respond_to?(:fork)
slots = 3
racers = 5
giveaway = Giveaway.create!(slots: slots, claimed: 0)
pids = racers.times.map do |i|
result_path = File.join(@dir, "outcome_#{i}")
Process.fork do
outcome = nil
Giveaway.find(giveaway.id).with_lock do |record|
outcome = record.claimed < record.slots ? :claimed : :sold_out
record.update!(claimed: record.claimed + 1) if outcome == :claimed
end
File.write(result_path, outcome.to_s)
exit!(0)
end
end
pids.each { |pid| Process.waitpid(pid) }
outcomes = racers.times.map { |i| File.read(File.join(@dir, "outcome_#{i}")) }
assert_equal slots, outcomes.count('claimed')
assert_equal racers - slots, outcomes.count('sold_out')
assert_equal slots, Giveaway.find(giveaway.id).claimed
end
end