test: cover RateLimiterPStoreStoreTest, teardown and RenderErrorsGadget

This commit is contained in:
dualfroz committed 2026-09-13 19:17:32 +02:00
1 parent c81a6bee5f
commit 2329d73e63
2 files changed
+192

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
require 'minitest/autorun'
require 'prism'
require 'tmpdir'
require 'fileutils'
class RateLimiterPStoreStoreTest < Minitest::Test
def setup
@dir = Dir.mktmpdir('prism-rate-limiter')
@path = File.join(@dir, 'nested', 'rate_limits.pstore')
@store = Prism::RateLimiter::PStoreStore.new(@path)
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_creates_parent_directories
assert File.directory?(File.dirname(@path))
end
def test_count_increments_within_the_same_window
assert_equal 1, @store.count('ip:1', 60)
assert_equal 2, @store.count('ip:1', 60)
end
def test_count_resets_after_the_window_elapses
assert_equal 1, @store.count('ip:1', 0.05)
sleep 0.08
assert_equal 1, @store.count('ip:1', 0.05)
end
def test_ban_makes_banned_true_until_it_expires
@store.ban!('ip:1', 0.05)
assert @store.banned?('ip:1')
sleep 0.08
refute @store.banned?('ip:1')
end
def test_state_survives_reopening_the_same_file
@store.count('ip:1', 60)
@store.count('ip:1', 60)
@store.ban!('ip:2', 60)
reopened = Prism::RateLimiter::PStoreStore.new(@path)
assert_equal 3, reopened.count('ip:1', 60)
assert reopened.banned?('ip:2')
end
def test_reset_clears_windows_and_bans
@store.count('ip:1', 60)
@store.ban!('ip:2', 60)
@store.reset!
assert_equal 1, @store.count('ip:1', 60)
refute @store.banned?('ip:2')
end
# Real cross-process race: #count reads the current window, bumps it, and
# writes it back inside a single PStore#transaction, which flocks the file
# for the duration. If that were split into separate read/write steps (the
# exact bug found and fixed in Jobs::Queue#claim_next! two sessions ago),
# several OS processes hammering the same key would stomp on each other's
# increments and the final count would fall short of the true total.
def test_count_is_safe_across_real_concurrent_processes
store_path = File.join(@dir, 'shared.pstore')
process_count = 4
increments_per_process = 25
counter_script = File.join(@dir, 'counter.rb')
File.write(counter_script, <<~RUBY)
require 'prism'
store = Prism::RateLimiter::PStoreStore.new(#{store_path.inspect})
results = Array.new(#{increments_per_process}) { store.count('shared-key', 3600) }
File.write(File.join(#{@dir.inspect}, "\#{Process.pid}.result"), results.join(','))
RUBY
pids = process_count.times.map do
Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), counter_script,
out: File::NULL, err: File::NULL)
end
pids.each { |pid| Process.waitpid(pid) }
observed = pids.flat_map do |pid|
path = File.join(@dir, "#{pid}.result")
File.read(path).split(',').map(&:to_i)
end
expected_total = process_count * increments_per_process
assert_equal expected_total, observed.size
assert_equal (1..expected_total).to_a.sort, observed.sort,
'every value from 1..total should appear exactly once - a lost update would ' \
'produce a duplicate and leave a gap'
final_store = Prism::RateLimiter::PStoreStore.new(store_path)
assert_equal expected_total + 1, final_store.count('shared-key', 3600)
end
end
+96
View File
@@ -0,0 +1,96 @@
require 'minitest/autorun'
require 'prism'
require 'tmpdir'
require 'support'
class RenderErrorsGadget < Prism::Orm::Model
table_name :gadgets
validates :name, presence: true, length: { minimum: 3 }
end
class GadgetsController < Prism::Controller
def create
gadget = RenderErrorsGadget.new(name: params['name'])
if gadget.save
respond_to do |format|
format.json { json(id: gadget.id, name: gadget.name) }
end
else
respond_to do |format|
format.json { render_errors(gadget) }
end
end
end
def show
gadget = RenderErrorsGadget.find(params['id'])
respond_to do |format|
format.json { json(id: gadget.id, name: gadget.name) }
end
rescue Prism::Orm::RecordNotFound
respond_to do |format|
format.json { render_not_found('gadget not found') }
end
end
end
class GadgetsApp < Prism::Application
post '/gadgets', to: 'gadgets#create'
get '/gadgets/:id', to: 'gadgets#show'
end
class RenderErrorsTest < Minitest::Test
include TestSupport
def setup
@dir = Dir.mktmpdir('prism-render-errors-store')
store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
store.create_table(:gadgets, %w[id name])
RenderErrorsGadget.store = store
end
def teardown
FileUtils.remove_entry(@dir)
end
def call(method, path, form: {}, **opts)
body = form.map { |k, v| "#{k}=#{v}" }.join('&')
headers = { 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json' }.merge(opts.delete(:headers) || {})
GadgetsApp.call(env_for(method, path, body: body, headers: headers, **opts))
end
def test_valid_record_responds_with_201_style_json_payload
status, headers, body = call('POST', '/gadgets', form: { name: 'wrench' })
assert_equal 200, status
assert_equal 'application/json', headers['Content-Type']
payload = JSON.parse(body.join)
assert_equal 'wrench', payload['name']
end
def test_invalid_record_responds_with_422_and_jsonapi_error_array
status, headers, body = call('POST', '/gadgets', form: { name: '' })
assert_equal 422, status
assert_equal 'application/json', headers['Content-Type']
payload = JSON.parse(body.join)
errors = payload['errors']
refute_nil errors
assert(errors.any? { |e| e['source']['pointer'] == '/data/attributes/name' })
assert(errors.all? { |e| e['status'] == '422' })
end
def test_missing_record_responds_with_404_and_jsonapi_error_object
status, headers, body = call('GET', '/gadgets/999999')
assert_equal 404, status
assert_equal 'application/json', headers['Content-Type']
payload = JSON.parse(body.join)
errors = payload['errors']
refute_nil errors
assert_equal 1, errors.size
assert_equal '404', errors.first['status']
assert_nil errors.first['source']
assert_equal 'gadget not found', errors.first['detail']
end
end