test: cover OrmErrorsJsonapiTest, test_empty_errors_produce_empty_array
This commit is contained in:
1 parent
d862a5e973
commit
49c7152790
2 files changed
+181
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user