From 7c3d06a8ebc2a4d8ecbafb5dd79acf1dfed69a7e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 12:13:37 +0200 Subject: [PATCH 001/245] chore: update .gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index f3bc000..0becc6e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ vendor/bundle/ tmp/ *.log +# demo user uploads +examples/blog/public/uploads/* +!examples/blog/public/uploads/.gitkeep + +# compiled asset pipeline output (regenerated by Application#enable_assets on boot) +examples/blog/public/assets/ + # os / editor / env .DS_Store .env From 56385c1f2e425600112a0624980f5c9ef7155f8c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 13:10:19 +0200 Subject: [PATCH 002/245] feat: extend blog_demo with cookie_from and cookie_value_from --- blog_demo.rb | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 3 deletions(-) diff --git a/blog_demo.rb b/blog_demo.rb index b2585c5..a56f24a 100644 --- a/blog_demo.rb +++ b/blog_demo.rb @@ -1,21 +1,168 @@ $LOAD_PATH.unshift(File.expand_path('lib', __dir__)) require 'prism' require 'net/http' +require 'zlib' +require 'stringio' +require 'csv' require_relative 'examples/blog/app' server = Prism::Server.new(Blog, port: 0).start port = server.port puts "blog demo server on port #{port}" puts "database file: #{File.join(__dir__, 'examples/blog/db/development.pstore')}" +log_path = File.join(__dir__, 'examples/blog/log/development.log') +log_offset_before_requests = File.exist?(log_path) ? File.size(log_path) : 0 puts '-' * 40 +def cookie_from(response) + lines = response.get_fields('set-cookie') || [] + lines.map { |line| line.split(';').first }.join('; ') +end + +def cookie_value_from(response, name) + lines = response.get_fields('set-cookie') || [] + line = lines.find { |l| l.start_with?("#{name}=") } + line&.split(';')&.first +end + +def csrf_token_from(html) + html[/name="csrf-token" content="([^"]+)"/, 1] +end + +def multipart_body(boundary, fields:, files: {}) + parts = fields.map do |name, value| + "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n" + end + parts += files.map do |name, (filename, content_type, content)| + "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: #{content_type}\r\n\r\n#{content}\r\n" + end + parts.join + "--#{boundary}--\r\n" +end + +# Signs in as the writer from a brand new, entirely independent cookie jar +# (its own session cookie, its own CSRF token fetched fresh from the +# sign-in page) - a second real HTTP client would look exactly like this, +# unlike reusing the caller's own `cookie`/`token`. Returns the +# remember_session_token cookie this "browser" got back, proving +# Prism::Authenticatable's remember_sessions: hands out one independent +# LoginSession row per device instead of one shared column. +def sign_in_new_device(http, label, user_agent:) + new_session_page = http.get('/session/new', 'User-Agent' => user_agent) + device_cookie = cookie_from(new_session_page) + device_token = csrf_token_from(new_session_page.body) + body = URI.encode_www_form( + 'user[email]' => 'writer@example.com', 'user[password]' => 'correct horse battery staple', + 'user[remember_me]' => '1', 'authenticity_token' => device_token + ) + signed_in = http.post('/session.json', body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => device_cookie, 'User-Agent' => user_agent) + raise "expected #{label} to sign in, got #{signed_in.code}" unless signed_in.code == '200' + remember_cookie = cookie_value_from(signed_in, 'remember_session_token') + raise "expected #{label}'s sign-in to set its own remember_session_token cookie" unless remember_cookie + puts " #{label} signed in from its own independent cookie jar -> #{signed_in.code}, own remember-me cookie set" + remember_cookie +end + Net::HTTP.start('127.0.0.1', port) do |http| + home = http.get('/') + cookie = cookie_from(home) + token = csrf_token_from(home.body) + puts "GET / -> #{home.code} (session cookie set, csrf token captured)" + + css_href = home.body[//, 1] + js_src = home.body[/', + @context.javascript_include_tag('application.js') + end + + def test_image_tag_with_extra_attributes + assert_equal 'Logo', + @context.image_tag('logo.png', alt: 'Logo') + end +end From ac1bd7164d38ac905837ae514db5612737850a68 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 16:06:55 +0200 Subject: [PATCH 135/245] test: cover AssetsCompilerTest --- test/test_assets_compiler.rb | 115 +++++++++++++++++++++++++++++++++++ test/test_assets_manifest.rb | 43 +++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 test/test_assets_compiler.rb create mode 100644 test/test_assets_manifest.rb diff --git a/test/test_assets_compiler.rb b/test/test_assets_compiler.rb new file mode 100644 index 0000000..c1806c8 --- /dev/null +++ b/test/test_assets_compiler.rb @@ -0,0 +1,115 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class AssetsCompilerTest < Minitest::Test + def test_compiles_files_into_content_addressed_names + with_source do |source, output| + write(source, 'application.css', 'body { color: red; }') + + manifest = Prism::Assets::Compiler.new(source: source, output: output).compile! + + fingerprinted = manifest.lookup('application.css') + assert_match(/\Aapplication-[0-9a-f]{8}\.css\z/, fingerprinted) + assert_equal 'body { color: red; }', File.read(File.join(output, fingerprinted)) + end + end + + def test_same_content_produces_the_same_fingerprint_across_compiles + with_source do |source, output| + write(source, 'app.js', 'console.log(1);') + compiler = Prism::Assets::Compiler.new(source: source, output: output) + + first = compiler.compile!.lookup('app.js') + second = compiler.compile!.lookup('app.js') + + assert_equal first, second + end + end + + def test_changed_content_produces_a_different_fingerprint + with_source do |source, output| + write(source, 'app.js', 'console.log(1);') + compiler = Prism::Assets::Compiler.new(source: source, output: output) + first = compiler.compile!.lookup('app.js') + + write(source, 'app.js', 'console.log(2);') + second = compiler.compile!.lookup('app.js') + + refute_equal first, second + end + end + + def test_stale_fingerprinted_files_are_removed_on_recompile + with_source do |source, output| + write(source, 'app.js', 'console.log(1);') + compiler = Prism::Assets::Compiler.new(source: source, output: output) + old_fingerprinted = compiler.compile!.lookup('app.js') + old_path = File.join(output, old_fingerprinted) + assert File.file?(old_path) + + write(source, 'app.js', 'console.log(2);') + compiler.compile! + + refute File.file?(old_path), 'stale fingerprinted file should have been cleaned up' + end + end + + def test_unchanged_files_are_left_alone_when_a_sibling_changes + with_source do |source, output| + write(source, 'a.css', 'a') + write(source, 'b.css', 'b') + compiler = Prism::Assets::Compiler.new(source: source, output: output) + first = compiler.compile! + a_fingerprint = first.lookup('a.css') + + write(source, 'b.css', 'b-changed') + second = compiler.compile! + + assert_equal a_fingerprint, second.lookup('a.css') + assert File.file?(File.join(output, a_fingerprint)) + end + end + + def test_preserves_subdirectory_structure_as_logical_paths + with_source do |source, output| + write(source, 'css/nested/theme.css', '.x{}') + + manifest = Prism::Assets::Compiler.new(source: source, output: output).compile! + + fingerprinted = manifest.lookup('css/nested/theme.css') + assert_match(%r{\Acss/nested/theme-[0-9a-f]{8}\.css\z}, fingerprinted) + assert File.file?(File.join(output, fingerprinted)) + end + end + + def test_writes_manifest_json_to_the_output_directory + with_source do |source, output| + write(source, 'a.css', 'a') + Prism::Assets::Compiler.new(source: source, output: output).compile! + + manifest_path = File.join(output, 'manifest.json') + assert File.file?(manifest_path) + parsed = JSON.parse(File.read(manifest_path)) + assert_match(/\Aa-[0-9a-f]{8}\.css\z/, parsed['a.css']) + end + end + + private + + def with_source + Dir.mktmpdir do |root| + source = File.join(root, 'source') + output = File.join(root, 'output') + FileUtils.mkdir_p(source) + yield source, output + end + end + + def write(source, relative, content) + path = File.join(source, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end +end diff --git a/test/test_assets_manifest.rb b/test/test_assets_manifest.rb new file mode 100644 index 0000000..15ea035 --- /dev/null +++ b/test/test_assets_manifest.rb @@ -0,0 +1,43 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class AssetsManifestTest < Minitest::Test + def test_lookup_returns_the_fingerprinted_path + manifest = Prism::Assets::Manifest.new('application.css' => 'application-deadbeef.css') + assert_equal 'application-deadbeef.css', manifest.lookup('application.css') + end + + def test_lookup_accepts_symbol_or_string + manifest = Prism::Assets::Manifest.new('application.css' => 'application-deadbeef.css') + assert_equal 'application-deadbeef.css', manifest.lookup(:'application.css') + end + + def test_lookup_raises_on_unknown_asset + manifest = Prism::Assets::Manifest.new + error = assert_raises(Prism::Assets::Manifest::AssetNotFound) { manifest.lookup('missing.css') } + assert_match(/missing\.css/, error.message) + end + + def test_key_predicate + manifest = Prism::Assets::Manifest.new('a.js' => 'a-11111111.js') + assert manifest.key?('a.js') + refute manifest.key?('b.js') + end + + def test_save_and_load_round_trip + Dir.mktmpdir do |dir| + path = File.join(dir, 'manifest.json') + original = Prism::Assets::Manifest.new('a.css' => 'a-cafef00d.css', 'b.js' => 'b-0ff1ce00.js') + original.save(path) + + loaded = Prism::Assets::Manifest.load(path) + assert_equal original.to_h, loaded.to_h + end + end + + def test_load_of_missing_file_returns_an_empty_manifest + manifest = Prism::Assets::Manifest.load('/no/such/manifest.json') + refute manifest.key?('anything.css') + end +end From 3163f1966de6fc8289cb5d389428128ce6470b25 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 16:07:56 +0200 Subject: [PATCH 136/245] test: cover AuthSessionsController and create --- test/test_authenticatable.rb | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/test_authenticatable.rb diff --git a/test/test_authenticatable.rb b/test/test_authenticatable.rb new file mode 100644 index 0000000..ed4f3e2 --- /dev/null +++ b/test/test_authenticatable.rb @@ -0,0 +1,161 @@ +require 'minitest/autorun' +require 'prism' +require 'support' +require 'tmpdir' + +class AuthSessionsController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser + + def create + user = AuthUser.find_by(email: params['email']) + if user && user.authenticate(params['password']) + sign_in(user) + halt(200, after_authentication_url) + else + halt(401, 'invalid credentials') + end + end + + def destroy + sign_out + halt(200, 'signed out') + end +end + +class AuthDashboardController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in' + before_action :require_authentication + + def index + halt(200, "hello #{current_user.email}") + end +end + +class AuthApiController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser + before_action :require_authentication + + def index + halt(200, "hello #{current_user.email}") + end +end + +class AuthRememberSessionsController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, remember_cookie: :remember_token + + def create + user = AuthUser.find_by(email: params['email']) + if user && user.authenticate(params['password']) + sign_in(user, remember: params['remember'] == '1') + halt(200, 'signed in') + else + halt(401, 'invalid credentials') + end + end + + def destroy + sign_out + halt(200, 'signed out') + end +end + +class AuthRememberDashboardController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_cookie: :remember_token + before_action :require_authentication + + def index + halt(200, "hello #{current_user.email}") + end +end + +# `authenticate_with` is declared only on the base class here, the way a +# real app declares it once on ApplicationController - proving the setting +# (and its unauthenticated_redirect_path) reaches a subclass that never +# calls `authenticate_with` itself. +class AuthBaseController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in' +end + +class AuthInheritedController < AuthBaseController + before_action :require_authentication + + def index + halt(200, "hello #{current_user.email}") + end +end + +class AuthMultiSessionsController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession + + def create + user = AuthUser.find_by(email: params['email']) + if user && user.authenticate(params['password']) + sign_in(user, remember: params['remember'] == '1') + halt(200, 'signed in') + else + halt(401, 'invalid credentials') + end + end + + def destroy + sign_out + halt(200, 'signed out') + end +end + +class AuthMultiDashboardController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_sessions: :AuthLoginSession + before_action :require_authentication + + def index + halt(200, "hello #{current_user.email}") + end + + def other_devices + halt(200, other_login_sessions.count.to_s) + end + + def sign_out_others + sign_out_all_other_sessions + halt(200, other_login_sessions.count.to_s) + end + + def revoke + # Deliberately the raw String params['id'] gives, not params['id'].to_i - + # a real controller (see examples/blog's LoginSessionsController) has no + # reason to convert it itself; revoke_login_session! must do that on its + # own or every real HTTP request would silently no-op (see its comment). + ok = revoke_login_session!(params['id']) + halt(ok ? 200 : 404, ok ? 'revoked' : 'not found') + end +end + +class AuthCappedSessionsController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession, max_remember_sessions: 2 + + def create + user = AuthUser.find_by(email: params['email']) + if user && user.authenticate(params['password']) + sign_in(user, remember: true) + halt(200, 'signed in') + else + halt(401, 'invalid credentials') + end + end +end + +class AuthCappedDashboardController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', + remember_sessions: :AuthLoginSession, max_remember_sessions: 2 + before_action :require_authentication + From 344df3fca22aa239543516ccfca76e5cbf44fb01 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 16:32:50 +0200 Subject: [PATCH 137/245] test: cover index and AuthZeroCappedSessionsController --- test/test_authenticatable.rb | 158 +++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/test/test_authenticatable.rb b/test/test_authenticatable.rb index ed4f3e2..a386b78 100644 --- a/test/test_authenticatable.rb +++ b/test/test_authenticatable.rb @@ -159,3 +159,161 @@ class AuthCappedDashboardController < Prism::Controller remember_sessions: :AuthLoginSession, max_remember_sessions: 2 before_action :require_authentication + def index + halt(200, "hello #{current_user.email}") + end +end + +class AuthZeroCappedSessionsController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession, max_remember_sessions: 0 + + def create + user = AuthUser.find_by(email: params['email']) + if user && user.authenticate(params['password']) + sign_in(user, remember: true) + halt(200, 'signed in') + else + halt(401, 'invalid credentials') + end + end +end + +class AuthZeroCappedDashboardController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', + remember_sessions: :AuthLoginSession, max_remember_sessions: 0 + before_action :require_authentication + + def index + halt(200, "hello #{current_user.email}") + end +end + +class AuthenticatableApp < Prism::Application + enable_sessions(secret: 'auth-secret') + post '/sign_in', to: 'auth_sessions#create' + post '/sign_out', to: 'auth_sessions#destroy' + get '/dashboard', to: 'auth_dashboard#index' + get '/api/me', to: 'auth_api#index' + get '/inherited/me', to: 'auth_inherited#index' + post '/remember/sign_in', to: 'auth_remember_sessions#create' + post '/remember/sign_out', to: 'auth_remember_sessions#destroy' + get '/remember/dashboard', to: 'auth_remember_dashboard#index' + post '/multi/sign_in', to: 'auth_multi_sessions#create' + post '/multi/sign_out', to: 'auth_multi_sessions#destroy' + get '/multi/dashboard', to: 'auth_multi_dashboard#index' + get '/multi/other_devices', to: 'auth_multi_dashboard#other_devices' + post '/multi/sign_out_others', to: 'auth_multi_dashboard#sign_out_others' + post '/multi/revoke/:id', to: 'auth_multi_dashboard#revoke' + post '/capped/sign_in', to: 'auth_capped_sessions#create' + get '/capped/dashboard', to: 'auth_capped_dashboard#index' + post '/zero-capped/sign_in', to: 'auth_zero_capped_sessions#create' + get '/zero-capped/dashboard', to: 'auth_zero_capped_dashboard#index' +end + +class AuthenticatableTest < Minitest::Test + include TestSupport + + def setup + @dir = Dir.mktmpdir('prism-authenticatable') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:auth_users, %w[id email password_digest remember_token]) + @store.create_table(:auth_login_sessions, %w[id user_id token user_agent ip_address]) + user_class = Class.new(Prism::Orm::Model) do + table_name :auth_users + has_secure_password + has_secure_token :remember_token + end + user_class.store = @store + Object.const_set(:AuthUser, user_class) + + login_session_class = Class.new(Prism::Orm::Model) do + table_name :auth_login_sessions + has_secure_token :token + end + login_session_class.store = @store + Object.const_set(:AuthLoginSession, login_session_class) + + AuthUser.create!(email: 'foksiu@example.com', password: 'hunter2') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :AuthUser) if Object.const_defined?(:AuthUser) + Object.send(:remove_const, :AuthLoginSession) if Object.const_defined?(:AuthLoginSession) + end + + def call(method, path, **opts) + AuthenticatableApp.call(env_for(method, path, **opts)) + end + + def session_cookie_from(headers) + cookie_named(headers, 'prism.session') + end + + def cookie_named(headers, name) + line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?("#{name}=") } + line&.split(';')&.first + end + + def sign_in_cookie + _, headers, = call('POST', '/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2') + session_cookie_from(headers) + end + + def test_wrong_password_is_rejected + status, = call('POST', '/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=wrong') + assert_equal 401, status + end + + def test_dashboard_without_session_redirects_to_configured_path + status, headers, = call('GET', '/dashboard') + assert_equal 302, status + assert_equal '/sign_in', headers['Location'] + end + + def test_api_without_session_halts_with_401 + status, = call('GET', '/api/me') + assert_equal 401, status + end + + def test_sign_in_then_dashboard_sees_current_user + cookie = sign_in_cookie + status, _, body = call('GET', '/dashboard', cookies: cookie) + assert_equal 200, status + assert_equal 'hello foksiu@example.com', body.join + end + + def test_sign_out_forgets_current_user + cookie = sign_in_cookie + _, sign_out_headers, = call('POST', '/sign_out', cookies: cookie) + fresh_cookie = session_cookie_from(sign_out_headers) + + status, = call('GET', '/dashboard', cookies: fresh_cookie) + assert_equal 302, status + end + + def test_return_to_url_is_remembered_across_the_redirect + _, headers, = call('GET', '/dashboard') + refute_nil headers['Location'] + cookie = session_cookie_from(headers) + + _, sign_in_headers, body = call('POST', '/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + cookies: cookie, + body: 'email=foksiu@example.com&password=hunter2') + assert_equal '/dashboard', body.join + refute_nil sign_in_headers['Set-Cookie'] + end + + def test_authenticate_with_declared_on_a_base_controller_redirects_a_subclass_without_a_session + status, headers, = call('GET', '/inherited/me') + assert_equal 302, status + assert_equal '/sign_in', headers['Location'] + end + From a04596c9256df9687e613c42decb619b229b7b01 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 16:34:46 +0200 Subject: [PATCH 138/245] test: cover test_authenticate_with_declared_on_a_base_controller_sees... --- test/test_authenticatable.rb | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/test/test_authenticatable.rb b/test/test_authenticatable.rb index a386b78..0816ee5 100644 --- a/test/test_authenticatable.rb +++ b/test/test_authenticatable.rb @@ -317,3 +317,162 @@ class AuthenticatableTest < Minitest::Test assert_equal '/sign_in', headers['Location'] end + def test_authenticate_with_declared_on_a_base_controller_sees_current_user_in_a_subclass + cookie = sign_in_cookie + status, _, body = call('GET', '/inherited/me', cookies: cookie) + assert_equal 200, status + assert_equal 'hello foksiu@example.com', body.join + end + + def test_signing_in_without_remember_sets_no_remember_cookie + _, headers, = call('POST', '/remember/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2') + assert_nil cookie_named(headers, 'remember_token') + end + + def test_remember_cookie_alone_survives_a_lost_session_cookie + _, sign_in_headers, = call('POST', '/remember/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2&remember=1') + remember_cookie = cookie_named(sign_in_headers, 'remember_token') + refute_nil remember_cookie + + # No session cookie at all here - simulates a brand new browser session + # (browser restart) that only still has the persistent cookie. + status, dashboard_headers, body = call('GET', '/remember/dashboard', cookies: remember_cookie) + assert_equal 200, status + assert_equal 'hello foksiu@example.com', body.join + # The fast path (session) is repopulated for the next request on this + # same browser, so it does not need to re-verify the cookie every time. + refute_nil session_cookie_from(dashboard_headers) + end + + def test_dashboard_rejects_a_request_with_no_cookies_at_all + status, = call('GET', '/remember/dashboard') + assert_equal 302, status + end + + def test_dashboard_rejects_a_tampered_remember_cookie + _, sign_in_headers, = call('POST', '/remember/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2&remember=1') + remember_cookie = cookie_named(sign_in_headers, 'remember_token') + tampered = remember_cookie.sub('remember_token=', 'remember_token=tampered') + + status, = call('GET', '/remember/dashboard', cookies: tampered) + assert_equal 302, status + end + + def test_sign_out_rotates_the_token_so_the_old_remember_cookie_stops_working + _, sign_in_headers, = call('POST', '/remember/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2&remember=1') + remember_cookie = cookie_named(sign_in_headers, 'remember_token') + session_cookie = session_cookie_from(sign_in_headers) + + call('POST', '/remember/sign_out', cookies: session_cookie) + + status, = call('GET', '/remember/dashboard', cookies: remember_cookie) + assert_equal 302, status + end + + def multi_sign_in(remember: true) + _, headers, = call('POST', '/multi/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: "email=foksiu@example.com&password=hunter2&remember=#{remember ? '1' : '0'}") + headers + end + + def test_remember_sessions_rejects_both_options_at_once + error = assert_raises(ArgumentError) do + Class.new(Prism::Controller) do + include Prism::Authenticatable + authenticate_with model: :AuthUser, remember_cookie: :remember_token, remember_sessions: :AuthLoginSession + end + end + assert_match(/either remember_cookie: or remember_sessions:/, error.message) + end + + def test_remember_sessions_creates_its_own_row_per_sign_in + assert_equal 0, AuthLoginSession.count + multi_sign_in + assert_equal 1, AuthLoginSession.count + assert_equal AuthUser.find_by(email: 'foksiu@example.com').id, AuthLoginSession.first.user_id + end + + def test_remember_sessions_captures_user_agent_and_ip_when_the_model_has_those_columns + env = env_for('POST', '/multi/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded', 'User-Agent' => 'DeviceScanner/9.0' }, + body: 'email=foksiu@example.com&password=hunter2&remember=1') + env['REMOTE_ADDR'] = '198.51.100.42' + AuthenticatableApp.call(env) + + login_session = AuthLoginSession.first + assert_equal 'DeviceScanner/9.0', login_session.user_agent + assert_equal '198.51.100.42', login_session.ip_address + end + + def test_remember_session_cookie_alone_survives_a_lost_session_cookie + headers = multi_sign_in + remember_cookie = cookie_named(headers, 'remember_session_token') + refute_nil remember_cookie + + status, dashboard_headers, body = call('GET', '/multi/dashboard', cookies: remember_cookie) + assert_equal 200, status + assert_equal 'hello foksiu@example.com', body.join + refute_nil session_cookie_from(dashboard_headers) + end + + def test_two_remembered_browsers_are_independent_login_sessions + browser_a = cookie_named(multi_sign_in, 'remember_session_token') + browser_b = cookie_named(multi_sign_in, 'remember_session_token') + refute_equal browser_a, browser_b + assert_equal 2, AuthLoginSession.count + + status_a, = call('GET', '/multi/dashboard', cookies: browser_a) + status_b, = call('GET', '/multi/dashboard', cookies: browser_b) + assert_equal 200, status_a + assert_equal 200, status_b + end + + def test_signing_out_of_one_browser_leaves_the_other_remembered_browser_signed_in + headers_a = multi_sign_in + browser_a_remember = cookie_named(headers_a, 'remember_session_token') + browser_a_session = session_cookie_from(headers_a) + browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token') + + call('POST', '/multi/sign_out', cookies: browser_a_session) + + status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember) + assert_equal 302, status_a + + status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember) + assert_equal 200, status_b + end + + def test_other_login_sessions_excludes_the_current_browser + browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') + cookie_named(multi_sign_in, 'remember_session_token') + cookie_named(multi_sign_in, 'remember_session_token') + + status, _, body = call('GET', '/multi/other_devices', cookies: browser_a_remember) + assert_equal 200, status + assert_equal '2', body.join + end + + def test_sign_out_all_other_sessions_leaves_only_the_current_browser + browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') + browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token') + + status, _, body = call('POST', '/multi/sign_out_others', cookies: browser_a_remember) + assert_equal 200, status + assert_equal '0', body.join + assert_equal 1, AuthLoginSession.count + + status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember) + assert_equal 302, status_b + status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember) + assert_equal 200, status_a + end + From f0803046dda33e0973f8b6a154cc7d2bbd04d1a1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 16:59:24 +0200 Subject: [PATCH 139/245] test: cover test_revoke_login_session_by_id_signs_out_only_that_device --- test/test_authenticatable.rb | 122 +++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/test/test_authenticatable.rb b/test/test_authenticatable.rb index 0816ee5..7a6ff04 100644 --- a/test/test_authenticatable.rb +++ b/test/test_authenticatable.rb @@ -476,3 +476,125 @@ class AuthenticatableTest < Minitest::Test assert_equal 200, status_a end + def test_revoke_login_session_by_id_signs_out_only_that_device + browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') + browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token') + browser_b_id = AuthLoginSession.order(:id, :desc).first.id + + status, _, body = call('POST', "/multi/revoke/#{browser_b_id}", cookies: browser_a_remember) + assert_equal 200, status + assert_equal 'revoked', body.join + + status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember) + assert_equal 302, status_b + status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember) + assert_equal 200, status_a + end + + def test_revoke_login_session_refuses_a_session_belonging_to_another_user + AuthUser.create!(email: 'other@example.com', password: 'hunter2') + other_login = AuthLoginSession.create!(user_id: AuthUser.find_by(email: 'other@example.com').id) + browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') + + status, _, body = call('POST', "/multi/revoke/#{other_login.id}", cookies: browser_a_remember) + assert_equal 404, status + assert_equal 'not found', body.join + refute_nil AuthLoginSession.find_by(id: other_login.id) + end + + def test_revoking_the_current_login_session_signs_that_browser_out_too + browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') + current_id = AuthLoginSession.first.id + + call('POST', "/multi/revoke/#{current_id}", cookies: browser_a_remember) + + status, = call('GET', '/multi/dashboard', cookies: browser_a_remember) + assert_equal 302, status + end + + def test_signing_in_without_remember_does_not_set_a_remember_session_cookie + headers = multi_sign_in(remember: false) + assert_nil cookie_named(headers, 'remember_session_token') + assert_equal 0, AuthLoginSession.count + end + + def capped_sign_in + _, headers, = call('POST', '/capped/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2') + headers + end + + def test_max_remember_sessions_requires_remember_sessions + error = assert_raises(ArgumentError) do + Class.new(Prism::Controller) do + include Prism::Authenticatable + authenticate_with model: :AuthUser, max_remember_sessions: 2 + end + end + assert_match(/max_remember_sessions: requires remember_sessions:/, error.message) + end + + def test_under_the_limit_keeps_every_browser + browser_a = cookie_named(capped_sign_in, 'remember_session_token') + browser_b = cookie_named(capped_sign_in, 'remember_session_token') + assert_equal 2, AuthLoginSession.count + + status_a, = call('GET', '/capped/dashboard', cookies: browser_a) + status_b, = call('GET', '/capped/dashboard', cookies: browser_b) + assert_equal 200, status_a + assert_equal 200, status_b + end + + def test_exceeding_the_limit_signs_out_the_oldest_browser_first + browser_a = cookie_named(capped_sign_in, 'remember_session_token') + browser_b = cookie_named(capped_sign_in, 'remember_session_token') + browser_c = cookie_named(capped_sign_in, 'remember_session_token') + + assert_equal 2, AuthLoginSession.count + + status_a, = call('GET', '/capped/dashboard', cookies: browser_a) + assert_equal 302, status_a, 'the oldest (first) browser should have been pruned' + + status_b, = call('GET', '/capped/dashboard', cookies: browser_b) + status_c, = call('GET', '/capped/dashboard', cookies: browser_c) + assert_equal 200, status_b + assert_equal 200, status_c + end + + def test_repeatedly_exceeding_the_limit_keeps_it_pinned_at_the_max + 5.times { capped_sign_in } + assert_equal 2, AuthLoginSession.count + end + + def zero_capped_sign_in + call('POST', '/zero-capped/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'email=foksiu@example.com&password=hunter2') + end + + # `max_remember_sessions: 0` is a deliberately-set "never actually + # remembered" limit (see the comment on prune_oldest_login_sessions) - + # unlike every positive limit above, it must prune the row `sign_in` + # itself just created, not just older ones. + def test_max_remember_sessions_of_zero_prunes_the_row_it_just_created + status, headers, = zero_capped_sign_in + assert_equal 200, status + assert_equal 0, AuthLoginSession.count, + 'a limit of 0 must prune the just-created row too, not only rows older than it' + + session_cookie = session_cookie_from(headers) + refute_nil session_cookie, 'sign_in always sets the plain session cookie regardless of the remember-row prune' + status, = call('GET', '/zero-capped/dashboard', cookies: session_cookie) + assert_equal 200, status, 'the session cookie set by this very sign_in must still authenticate the request' + end + + def test_max_remember_sessions_of_zero_makes_the_remember_cookie_dead_on_arrival + _, headers, = zero_capped_sign_in + remember_cookie = cookie_named(headers, 'remember_session_token') + refute_nil remember_cookie, 'sign_in(remember: true) sets the cookie unconditionally, before the prune runs' + + status, = call('GET', '/zero-capped/dashboard', cookies: remember_cookie) + assert_equal 302, status, 'the remember cookie token matches no row once the limit-0 prune destroyed it' + end +end From 730c66caf0917abe4d798434ac43d80fd2354c4e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 17:30:12 +0200 Subject: [PATCH 140/245] test: cover AuthzArticlePolicy, destroy? and CacheHelpersTest --- test/test_authorizable.rb | 113 +++++++++++++++++++++++++++++++++++++ test/test_cache_helpers.rb | 67 ++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 test/test_authorizable.rb create mode 100644 test/test_cache_helpers.rb diff --git a/test/test_authorizable.rb b/test/test_authorizable.rb new file mode 100644 index 0000000..2ebdf3d --- /dev/null +++ b/test/test_authorizable.rb @@ -0,0 +1,113 @@ +require 'minitest/autorun' +require 'prism' +require 'support' +require 'tmpdir' + +class AuthzArticlePolicy < Prism::Policy + def destroy? + user && record.user_id == user.id + end +end + +class AuthzSessionsController < Prism::Controller + include Prism::Authenticatable + authenticate_with model: :AuthzUser + + def create + user = AuthzUser.find_by(email: params['email']) + if user + sign_in(user) + halt(200, 'signed in') + else + halt(401, 'invalid credentials') + end + end +end + +class ArticlesController < Prism::Controller + include Prism::Authenticatable + include Prism::Authorizable + authenticate_with model: :AuthzUser + before_action :require_authentication, only: %i[destroy] + rescue_from Prism::Policy::NotAuthorizedError, with: :forbidden + + def destroy + article = AuthzArticle.find(params['id']) + authorize(article) + article.destroy + halt(200, 'deleted') + rescue Prism::Orm::RecordNotFound + halt(404, 'not found') + end + + private + + def forbidden(_exception) + halt(403, 'forbidden') + end +end + +class AuthorizableApp < Prism::Application + enable_sessions(secret: 'authz-secret') + post '/sign_in', to: 'authz_sessions#create' + delete '/articles/:id', to: 'articles#destroy' +end + +class AuthorizableTest < Minitest::Test + include TestSupport + + def setup + @dir = Dir.mktmpdir('prism-authorizable') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authz_users, %w[id email]) + @store.create_table(:authz_articles, %w[id title user_id]) + + user_class = Class.new(Prism::Orm::Model) { table_name :authz_users } + user_class.store = @store + Object.const_set(:AuthzUser, user_class) + + article_class = Class.new(Prism::Orm::Model) { table_name :authz_articles } + article_class.store = @store + Object.const_set(:AuthzArticle, article_class) + + @owner = AuthzUser.create!(email: 'owner@example.com') + @stranger = AuthzUser.create!(email: 'stranger@example.com') + @article = AuthzArticle.create!(title: 'mine', user_id: @owner.id) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :AuthzUser) if Object.const_defined?(:AuthzUser) + Object.send(:remove_const, :AuthzArticle) if Object.const_defined?(:AuthzArticle) + end + + def call(method, path, **opts) + AuthorizableApp.call(env_for(method, path, **opts)) + end + + def cookie_for(email) + _, headers, = call('POST', '/sign_in', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: URI.encode_www_form('email' => email)) + lines = headers['Set-Cookie'].split("\n") + lines.find { |line| line.start_with?('prism.session=') }.split(';').first + end + + def test_owner_may_destroy_their_own_article + status, _, body = call('DELETE', "/articles/#{@article.id}", cookies: cookie_for('owner@example.com')) + assert_equal 200, status + assert_equal 'deleted', body.join + end + + def test_stranger_gets_403_instead_of_destroying_someone_elses_article + status, _, body = call('DELETE', "/articles/#{@article.id}", cookies: cookie_for('stranger@example.com')) + assert_equal 403, status + assert_equal 'forbidden', body.join + assert AuthzArticle.find_by(id: @article.id), 'the article must survive a denied authorization' + end + + def test_signed_out_request_never_reaches_the_policy_at_all + status, = call('DELETE', "/articles/#{@article.id}") + assert_equal 401, status + end +end diff --git a/test/test_cache_helpers.rb b/test/test_cache_helpers.rb new file mode 100644 index 0000000..437bf31 --- /dev/null +++ b/test/test_cache_helpers.rb @@ -0,0 +1,67 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class CacheHelpersTest < Minitest::Test + def setup + Prism::Cache.store.clear + @dir = Dir.mktmpdir('prism-cache-helpers') + FileUtils.mkdir_p(File.join(@dir, 'pages')) + @view = Prism::View.new(@dir) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Cache.store.clear + end + + def write(relative, source) + File.write(File.join(@dir, relative), source) + end + + def test_cache_block_renders_and_stores_the_fragment + write('pages/show.erb', '<% cache("greeting") do %><%= greeting %><% end %>') + result = @view.render('pages/show', locals: { greeting: 'hi' }) + assert_equal 'hi', result + assert_equal 'hi', Prism::Cache.store.read('greeting') + end + + def test_cache_block_is_not_reevaluated_on_a_hit + calls = [] + write('pages/show.erb', '<% cache("counter") do %><%= track! %><% end %>') + @view = Prism::View.new(@dir, helpers: CounterHelper.new(calls)) + + first = @view.render('pages/show') + second = @view.render('pages/show') + + assert_equal first, second + assert_equal 1, calls.size + end + + def test_cache_key_can_be_an_array_mixing_scope_and_a_model + model = Struct.new(:cache_key).new('posts/7-42') + write('pages/show.erb', '<% cache([:sidebar, model]) do %>rendered<% end %>') + @view.render('pages/show', locals: { model: model }) + assert @view.expand_cache_key([:sidebar, model]) == 'sidebar/posts/7-42' + assert_equal 'rendered', Prism::Cache.store.read('sidebar/posts/7-42') + end + + def test_expires_in_evicts_the_entry_after_the_given_time + write('pages/show.erb', '<% cache("temp", expires_in: 0) do %>value<% end %>') + @view.render('pages/show') + sleep 0.01 + assert_nil Prism::Cache.store.read('temp') + end + + class CounterHelper + def initialize(calls) + @calls = calls + end + + def track! + @calls << true + 'x' + end + end +end From 9d3d74ae61da6a8445bfd5ff16dcfffa696d5677 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 17:37:07 +0200 Subject: [PATCH 141/245] test: cover CacheStoreTest, test_write_then_read_round_trips --- test/test_cache_store.rb | 116 +++++++++++++++++++++++++++++ test/test_cli_assets_precompile.rb | 58 +++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 test/test_cache_store.rb create mode 100644 test/test_cli_assets_precompile.rb diff --git a/test/test_cache_store.rb b/test/test_cache_store.rb new file mode 100644 index 0000000..11884a3 --- /dev/null +++ b/test/test_cache_store.rb @@ -0,0 +1,116 @@ +require 'minitest/autorun' +require 'prism' + +class CacheStoreTest < Minitest::Test + def setup + @store = Prism::Cache::MemoryStore.new + end + + def test_write_then_read_round_trips + @store.write('key', 'value') + assert_equal 'value', @store.read('key') + end + + def test_read_of_missing_key_is_nil + assert_nil @store.read('missing') + end + + def test_keys_are_coerced_to_string + @store.write(:sym_key, 'value') + assert_equal 'value', @store.read('sym_key') + end + + def test_fetch_computes_and_stores_on_miss + calls = 0 + 3.times { @store.fetch('k') { calls += 1; 'computed' } } + assert_equal 1, calls + end + + def test_fetch_returns_existing_value_without_yielding_on_hit + @store.write('k', 'preset') + result = @store.fetch('k') { raise 'must not be called' } + assert_equal 'preset', result + end + + def test_delete_removes_the_entry + @store.write('k', 'v') + assert @store.delete('k') + assert_nil @store.read('k') + end + + def test_delete_of_missing_key_returns_falsey + refute @store.delete('missing') + end + + def test_exist_reflects_presence + refute @store.exist?('k') + @store.write('k', 'v') + assert @store.exist?('k') + end + + def test_clear_empties_the_store + @store.write('a', 1) + @store.write('b', 2) + @store.clear + assert_equal 0, @store.size + end + + def test_expires_in_makes_the_entry_unreadable_afterwards + @store.write('k', 'v', expires_in: 0) + sleep 0.01 + assert_nil @store.read('k') + end + + def test_delete_matched_removes_keys_matching_a_regexp + @store.write('posts/1', 'a') + @store.write('posts/2', 'b') + @store.write('users/1', 'c') + @store.delete_matched(/\Aposts\//) + assert_nil @store.read('posts/1') + assert_nil @store.read('posts/2') + assert_equal 'c', @store.read('users/1') + end + + def test_default_store_is_a_memory_store_singleton + assert_same Prism::Cache.store, Prism::Cache.store + assert_instance_of Prism::Cache::MemoryStore, Prism::Cache.store + end + + def test_unbounded_store_never_evicts + store = Prism::Cache::MemoryStore.new + 50.times { |i| store.write("k#{i}", i) } + assert_equal 50, store.size + end + + def test_max_size_evicts_the_least_recently_used_entry_on_write + store = Prism::Cache::MemoryStore.new(max_size: 2) + store.write('a', 1) + store.write('b', 2) + store.write('c', 3) + assert_equal 2, store.size + assert_nil store.read('a') + assert_equal 2, store.read('b') + assert_equal 3, store.read('c') + end + + def test_reading_a_key_protects_it_from_eviction + store = Prism::Cache::MemoryStore.new(max_size: 2) + store.write('a', 1) + store.write('b', 2) + store.read('a') # bump 'a' to most-recently-used, 'b' is now the LRU one + store.write('c', 3) + assert_equal 1, store.read('a') + assert_nil store.read('b') + assert_equal 3, store.read('c') + end + + def test_rewriting_an_existing_key_does_not_grow_the_store + store = Prism::Cache::MemoryStore.new(max_size: 2) + store.write('a', 1) + store.write('b', 2) + store.write('a', 'updated') + assert_equal 2, store.size + assert_equal 'updated', store.read('a') + assert_equal 2, store.read('b') + end +end diff --git a/test/test_cli_assets_precompile.rb b/test/test_cli_assets_precompile.rb new file mode 100644 index 0000000..2b2be2e --- /dev/null +++ b/test/test_cli_assets_precompile.rb @@ -0,0 +1,58 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' +require 'stringio' + +class CliAssetsPrecompileTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-cli-assets') + @cli = Prism::Cli.new(@dir) + @source = File.join(@dir, 'app', 'assets') + @output = File.join(@dir, 'public', 'assets') + FileUtils.mkdir_p(File.join(@source, 'stylesheets')) + File.write(File.join(@source, 'stylesheets', 'application.css'), 'body { color: red; }') + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_precompiles_without_booting_an_application + manifest = @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: StringIO.new) + logical = manifest.to_h.keys + assert_equal ['stylesheets/application.css'], logical + fingerprinted = manifest.lookup('stylesheets/application.css') + assert_match(/\Astylesheets\/application-[0-9a-f]{8}\.css\z/, fingerprinted) + assert File.exist?(File.join(@output, fingerprinted)) + assert File.exist?(File.join(@output, 'manifest.json')) + end + + def test_prints_a_summary_to_the_given_output + out = StringIO.new + @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: out) + assert_match(/compiled 1 file\(s\) into/, out.string) + end + + def test_recompiling_unchanged_content_reproduces_the_same_fingerprint_and_removes_nothing_stale + first = @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: StringIO.new) + second = @cli.assets_precompile(['--source=app/assets', '--output=public/assets'], out: StringIO.new) + assert_equal first.to_h, second.to_h + end + + def test_raises_usage_error_without_required_options + assert_raises(Prism::Cli::UsageError) { @cli.assets_precompile([]) } + assert_raises(Prism::Cli::UsageError) { @cli.assets_precompile(['--source=app/assets']) } + end + + def test_run_dispatches_the_assets_colon_precompile_command + original_stdout = $stdout + captured = StringIO.new + $stdout = captured + @cli.run(['assets:precompile', '--source=app/assets', '--output=public/assets']) + ensure + $stdout = original_stdout + assert_match(/compiled 1 file\(s\)/, captured.string) + assert File.exist?(File.join(@output, 'manifest.json')) + end +end From 6aa39c05d81d374a1efc5bb35e38a0ca96855536 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 19:05:00 +0200 Subject: [PATCH 142/245] test: cover CliConsoleTest and teardown --- test/test_cli_console.rb | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 test/test_cli_console.rb diff --git a/test/test_cli_console.rb b/test/test_cli_console.rb new file mode 100644 index 0000000..a692d60 --- /dev/null +++ b/test/test_cli_console.rb @@ -0,0 +1,98 @@ +require 'minitest/autorun' +require 'prism' +require 'stringio' +require 'tmpdir' +require 'fileutils' + +class CliConsoleTest < Minitest::Test + def setup + @workdir = Dir.mktmpdir('prism-cli-console') + @cli = Prism::Cli.new(@workdir) + end + + def teardown + FileUtils.remove_entry(@workdir) + end + + def run_console(script, extra_args = []) + input = StringIO.new(script) + out = StringIO.new + @cli.console(extra_args, input: input, out: out) + out.string + end + + def test_evaluates_a_simple_expression + output = run_console("1 + 1\n") + assert_includes output, '=> 2' + end + + def test_prints_prompt_before_each_line_and_at_the_end + output = run_console("1\n2\n") + assert_equal 3, output.scan('prism> ').size + end + + def test_local_variables_persist_across_lines + output = run_console("x = 5\nx * 2\n") + assert_includes output, '=> 5' + assert_includes output, '=> 10' + end + + def test_custom_prompt_option + output = run_console("1\n", ['--prompt=blog> ']) + assert_includes output, 'blog> ' + refute_includes output, 'prism> ' + end + + def test_syntax_error_is_reported_without_aborting_the_session + output = run_console("def broken(\n1 + 1\n") + assert_includes output, 'Error:' + assert_includes output, '=> 2' + end + + def test_runtime_error_is_reported_without_aborting_the_session + output = run_console("raise ArgumentError, 'boom'\n1 + 1\n") + assert_includes output, 'Error: ArgumentError: boom' + assert_includes output, '=> 2' + end + + def test_exit_stops_the_session_before_reading_further_lines + output = run_console("exit\n1 + 1\n") + refute_includes output, '=> 2' + end + + def test_quit_stops_the_session + output = run_console("quit\n") + refute_includes output, 'Error' + end + + def test_blank_line_is_skipped_without_error + output = run_console("\n1 + 1\n") + refute_includes output, 'Error' + assert_includes output, '=> 2' + end + + def test_unknown_option_raises_usage_error + assert_raises(Prism::Cli::UsageError) { @cli.console(['--bogus'], input: StringIO.new, out: StringIO.new) } + end + + def test_require_option_loads_a_file_before_the_session_starts + fixture = File.join(@workdir, 'greeting.rb') + File.write(fixture, "CONSOLE_TEST_GREETING = 'hello from fixture'\n") + + output = run_console("CONSOLE_TEST_GREETING\n", ['--require=greeting.rb']) + assert_includes output, '=> "hello from fixture"' + ensure + Object.send(:remove_const, :CONSOLE_TEST_GREETING) if Object.const_defined?(:CONSOLE_TEST_GREETING) + end + + def test_run_dispatches_console_command + output = StringIO.new + input = StringIO.new("1 + 1\n") + @cli.console([], input: input, out: output) + assert_includes output.string, '=> 2' + end + + def test_c_alias_is_recognized_by_run_dispatch + assert_raises(Prism::Cli::UsageError) { @cli.run(%w[c --bogus]) } + end +end From d7476e239ac7d97fdcfb37c9c6cec861fe47e07a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 20:10:53 +0200 Subject: [PATCH 143/245] test: cover CliDbTest and teardown --- test/test_cli_db.rb | 174 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 test/test_cli_db.rb diff --git a/test/test_cli_db.rb b/test/test_cli_db.rb new file mode 100644 index 0000000..49a14a9 --- /dev/null +++ b/test/test_cli_db.rb @@ -0,0 +1,174 @@ +require 'minitest/autorun' +require 'prism' +require 'stringio' +require 'tmpdir' +require 'fileutils' + +class CliDbTest < Minitest::Test + def setup + @workdir = Dir.mktmpdir('prism-cli-db') + @cli = Prism::Cli.new(@workdir) + @database = 'db/test.pstore' + FileUtils.mkdir_p(File.join(@workdir, 'db', 'migrate')) + end + + def teardown + FileUtils.remove_entry(@workdir) + end + + def write_migration(version, class_name, body) + path = File.join(@workdir, 'db', 'migrate', "#{version}_#{Prism::Inflector.underscore(class_name)}.rb") + File.write(path, <<~RUBY) + class #{class_name} < Prism::Orm::Migration + #{body} + end + RUBY + path + end + + def widgets_migration(version = '20260101000001') + write_migration(version, 'CreateWidgetsForCliDbTest', <<~RUBY) + def up + create_table :widgets_for_cli_db_test do |t| + t.string :name + end + end + + def down + drop_table :widgets_for_cli_db_test + end + RUBY + end + + def gadgets_migration(version = '20260101000002') + write_migration(version, 'CreateGadgetsForCliDbTest', <<~RUBY) + def up + create_table :gadgets_for_cli_db_test do |t| + t.string :label + end + end + + def down + drop_table :gadgets_for_cli_db_test + end + RUBY + end + + def store + Prism::Orm::Store.new(File.join(@workdir, @database)) + end + + def test_db_migrate_applies_pending_migrations_and_reports_them + widgets_migration + out = StringIO.new + applied = @cli.db_migrate(["--database=#{@database}"], out: out) + + assert_equal ['20260101000001'], applied + assert_includes out.string, 'migrated 20260101000001' + assert store.table_exists?(:widgets_for_cli_db_test) + end + + def test_db_migrate_with_nothing_pending_reports_that_instead_of_erroring + widgets_migration + @cli.db_migrate(["--database=#{@database}"], out: StringIO.new) + + out = StringIO.new + applied = @cli.db_migrate(["--database=#{@database}"], out: out) + + assert_empty applied + assert_includes out.string, 'no pending migrations' + end + + def test_db_migrate_dispatches_through_run + widgets_migration + capture_io { @cli.run(['db:migrate', "--database=#{@database}"]) } + assert store.table_exists?(:widgets_for_cli_db_test) + end + + def test_db_rollback_defaults_to_one_step + widgets_migration + gadgets_migration + @cli.db_migrate(["--database=#{@database}"], out: StringIO.new) + assert store.table_exists?(:gadgets_for_cli_db_test) + + out = StringIO.new + reverted = @cli.db_rollback(["--database=#{@database}"], out: out) + + assert_equal ['20260101000002'], reverted + assert_includes out.string, 'rolled back 20260101000002' + refute store.table_exists?(:gadgets_for_cli_db_test) + assert store.table_exists?(:widgets_for_cli_db_test), 'earlier migration must survive a single-step rollback' + end + + def test_db_rollback_honors_explicit_step_count + widgets_migration + gadgets_migration + @cli.db_migrate(["--database=#{@database}"], out: StringIO.new) + + reverted = @cli.db_rollback(["--database=#{@database}", '--step=2'], out: StringIO.new) + + assert_equal ['20260101000002', '20260101000001'], reverted + refute store.table_exists?(:gadgets_for_cli_db_test) + refute store.table_exists?(:widgets_for_cli_db_test) + end + + def test_db_rollback_with_nothing_applied_reports_that_instead_of_erroring + widgets_migration + out = StringIO.new + reverted = @cli.db_rollback(["--database=#{@database}"], out: out) + + assert_empty reverted + assert_includes out.string, 'nothing to roll back' + end + + def test_db_status_marks_applied_migrations_up_and_the_rest_down + widgets_migration + gadgets_migration + @cli.db_migrate(["--database=#{@database}"], out: StringIO.new) + @cli.db_rollback(["--database=#{@database}"], out: StringIO.new) + + out = StringIO.new + status = @cli.db_status(["--database=#{@database}"], out: out) + + assert_equal [['20260101000001', true], ['20260101000002', false]], + status.map { |entry, applied| [entry.version, applied] } + assert_match(/up\s+20260101000001\s+create_widgets_for_cli_db_test/, out.string) + assert_match(/down\s+20260101000002\s+create_gadgets_for_cli_db_test/, out.string) + end + + def test_db_status_with_no_migration_files_prints_only_the_header + out = StringIO.new + status = @cli.db_status(["--database=#{@database}"], out: out) + + assert_empty status + assert_equal "STATUS VERSION NAME\n", out.string + end + + def test_db_commands_use_the_migrations_flag_to_find_a_custom_directory + FileUtils.mkdir_p(File.join(@workdir, 'other_migrations')) + File.write(File.join(@workdir, 'other_migrations', '20260101000003_create_thingies_for_cli_db_test.rb'), <<~RUBY) + class CreateThingiesForCliDbTest < Prism::Orm::Migration + def up + create_table :thingies_for_cli_db_test do |t| + t.string :name + end + end + + def down + drop_table :thingies_for_cli_db_test + end + end + RUBY + + applied = @cli.db_migrate(["--database=#{@database}", '--migrations=other_migrations'], out: StringIO.new) + + assert_equal ['20260101000003'], applied + assert store.table_exists?(:thingies_for_cli_db_test) + end + + def test_db_options_reject_unknown_flags + assert_raises(Prism::Cli::UsageError) { @cli.db_migrate(['--bogus=1']) } + assert_raises(Prism::Cli::UsageError) { @cli.db_rollback(['--bogus=1']) } + assert_raises(Prism::Cli::UsageError) { @cli.db_status(['--bogus=1']) } + end +end From 285382c3fc0374e6f7484bfe018f88c1427bcc1a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 20:31:45 +0200 Subject: [PATCH 144/245] test: cover CliDbSeedTestWidget and CliDbSeedTest --- test/test_cli_db_seed.rb | 106 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 test/test_cli_db_seed.rb diff --git a/test/test_cli_db_seed.rb b/test/test_cli_db_seed.rb new file mode 100644 index 0000000..c5688fb --- /dev/null +++ b/test/test_cli_db_seed.rb @@ -0,0 +1,106 @@ +require 'minitest/autorun' +require 'prism' +require 'stringio' +require 'tmpdir' +require 'fileutils' + +class CliDbSeedTestWidget < Prism::Orm::Model + table_name 'cli_db_seed_test_widgets' +end + +class CliDbSeedTest < Minitest::Test + def setup + @workdir = Dir.mktmpdir('prism-cli-db-seed') + @cli = Prism::Cli.new(@workdir) + @database = File.join(@workdir, 'db', 'test.pstore') + FileUtils.mkdir_p(File.join(@workdir, 'db')) + Prism::Orm.connect(@database) + store.create_table('cli_db_seed_test_widgets', %w[id name]) + end + + def teardown + FileUtils.remove_entry(@workdir) + Object.send(:remove_const, :CliDbSeedTestRequiredApp) if Object.const_defined?(:CliDbSeedTestRequiredApp) + end + + def store + Prism::Orm::Store.new(@database) + end + + def write_seeds(content, filename = 'db/seeds.rb') + path = File.join(@workdir, filename) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + filename + end + + def test_db_seed_runs_the_seeds_file_against_the_already_loaded_environment + write_seeds("CliDbSeedTestWidget.create!(name: 'sprocket')\n") + + out = StringIO.new + result = @cli.db_seed([], out: out) + + assert_equal true, result + assert_includes out.string, 'seeded from db/seeds.rb' + assert_equal ['sprocket'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] } + end + + def test_db_seed_with_no_seeds_file_reports_that_instead_of_erroring + out = StringIO.new + result = @cli.db_seed([], out: out) + + assert_equal false, result + assert_includes out.string, 'no seeds file at db/seeds.rb' + end + + def test_db_seed_dispatches_through_run + write_seeds("CliDbSeedTestWidget.create!(name: 'via-run')\n") + + capture_io { @cli.run(['db:seed']) } + + assert_equal ['via-run'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] } + end + + def test_db_seed_honors_a_custom_file_path + write_seeds("CliDbSeedTestWidget.create!(name: 'custom')\n", 'db/custom_seeds.rb') + + out = StringIO.new + result = @cli.db_seed(['--file=db/custom_seeds.rb'], out: out) + + assert_equal true, result + assert_includes out.string, 'seeded from db/custom_seeds.rb' + assert_equal ['custom'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] } + end + + def test_db_seed_requires_the_environment_before_running_the_seeds_file + File.write(File.join(@workdir, 'app.rb'), <<~RUBY) + class CliDbSeedTestRequiredApp + def self.slogan + 'from --require' + end + end + RUBY + write_seeds("CliDbSeedTestWidget.create!(name: CliDbSeedTestRequiredApp.slogan)\n") + + @cli.db_seed(['--require=app.rb'], out: StringIO.new) + + assert_equal ['from --require'], store.all('cli_db_seed_test_widgets').map { |row| row['name'] } + end + + # `load`, not `require`: a seeds file is a sequence of side effects, not a + # class/module definition with meaningful "already loaded" state, so a + # second db:seed run must re-execute it rather than silently no-op the way + # `require` would once that exact path had already been loaded once. + def test_db_seed_reruns_the_file_on_every_call_instead_of_requiring_it_once + write_seeds("CliDbSeedTestWidget.create!(name: 'again')\n") + + @cli.db_seed([], out: StringIO.new) + @cli.db_seed([], out: StringIO.new) + + assert_equal %w[again again], store.all('cli_db_seed_test_widgets').map { |row| row['name'] } + end + + def test_db_seed_rejects_unknown_options + assert_raises(Prism::Cli::UsageError) { @cli.db_seed(['--bogus=1']) } + end +end From 399e2a5b6626731489633f131426f6a68888c2ce Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 21:01:39 +0200 Subject: [PATCH 145/245] test: cover CliDbSubprocessTest and teardown --- test/test_cli_db_subprocess.rb | 149 +++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 test/test_cli_db_subprocess.rb diff --git a/test/test_cli_db_subprocess.rb b/test/test_cli_db_subprocess.rb new file mode 100644 index 0000000..fe3c058 --- /dev/null +++ b/test/test_cli_db_subprocess.rb @@ -0,0 +1,149 @@ +require 'minitest/autorun' +require 'open3' +require 'tmpdir' +require 'fileutils' +require 'prism' + +# test_cli_db.rb exercises Prism::Cli#db_migrate/#db_rollback/#db_status +# in-process (a bare Ruby method call against an already-loaded library) - +# useful, but it never proves the *executable* works: it does not touch +# `bin/prism`'s shebang, its $LOAD_PATH setup, ARGV parsing off a real +# argv, or a real process exit code. This file spawns `bin/prism` as an +# actual child process (Open3.capture3, a fresh `ruby` interpreter, +# nothing shared with the test's own Ruby VM beyond the filesystem) so a +# regression in any of that - e.g. a typo in bin/prism's require, a +# load-path assumption that only happens to hold when required from +# test/ - would fail here even though test_cli_db.rb stayed green. +class CliDbSubprocessTest < Minitest::Test + BIN_PRISM = File.expand_path('../bin/prism', __dir__) + + def setup + @workdir = Dir.mktmpdir('prism-cli-db-subprocess') + FileUtils.mkdir_p(File.join(@workdir, 'db', 'migrate')) + end + + def teardown + FileUtils.remove_entry(@workdir) + end + + def write_migration(version, class_name, body) + path = File.join(@workdir, 'db', 'migrate', "#{version}_#{Prism::Inflector.underscore(class_name)}.rb") + File.write(path, <<~RUBY) + class #{class_name} < Prism::Orm::Migration + #{body} + end + RUBY + path + end + + def widgets_migration(version = '20260101000001') + write_migration(version, 'CreateWidgetsForCliDbSubprocessTest', <<~RUBY) + def up + create_table :widgets_for_cli_db_subprocess_test do |t| + t.string :name + end + end + + def down + drop_table :widgets_for_cli_db_subprocess_test + end + RUBY + end + + def gadgets_migration(version = '20260101000002') + write_migration(version, 'CreateGadgetsForCliDbSubprocessTest', <<~RUBY) + def up + create_table :gadgets_for_cli_db_subprocess_test do |t| + t.string :label + end + end + + def down + drop_table :gadgets_for_cli_db_subprocess_test + end + RUBY + end + + # Runs `bin/prism ` as a real child process, chdir'd into the + # scratch workdir the way a user's own shell would be when running + # `prism db:migrate` inside their app directory - `Cli.new` defaults its + # root to `Dir.pwd`, so this is the only way to exercise that default + # rather than the explicit root test_cli_db.rb passes to `Cli.new`. + def run_prism(*args) + Open3.capture3(RbConfig.ruby, BIN_PRISM, *args, chdir: @workdir) + end + + def store + Prism::Orm::Store.new(File.join(@workdir, 'db', 'test.pstore')) + end + + def test_db_migrate_as_a_real_child_process_applies_pending_migrations + widgets_migration + + stdout, stderr, status = run_prism('db:migrate', '--database=db/test.pstore') + + assert status.success?, "expected success, got #{status.exitstatus}, stderr: #{stderr}" + assert_includes stdout, 'migrated 20260101000001' + assert_empty stderr + assert store.table_exists?(:widgets_for_cli_db_subprocess_test) + end + + def test_db_migrate_as_a_real_child_process_with_nothing_pending + widgets_migration + run_prism('db:migrate', '--database=db/test.pstore') + + stdout, _stderr, status = run_prism('db:migrate', '--database=db/test.pstore') + + assert status.success? + assert_includes stdout, 'no pending migrations' + end + + def test_db_rollback_as_a_real_child_process_reverts_the_latest_migration + widgets_migration + gadgets_migration + run_prism('db:migrate', '--database=db/test.pstore') + assert store.table_exists?(:gadgets_for_cli_db_subprocess_test) + + stdout, _stderr, status = run_prism('db:rollback', '--database=db/test.pstore') + + assert status.success? + assert_includes stdout, 'rolled back 20260101000002' + refute store.table_exists?(:gadgets_for_cli_db_subprocess_test) + assert store.table_exists?(:widgets_for_cli_db_subprocess_test) + end + + def test_db_status_as_a_real_child_process_lists_applied_and_pending + widgets_migration + gadgets_migration + run_prism('db:migrate', '--database=db/test.pstore') + run_prism('db:rollback', '--database=db/test.pstore') + + stdout, _stderr, status = run_prism('db:status', '--database=db/test.pstore') + + assert status.success? + assert_match(/up\s+20260101000001\s+create_widgets_for_cli_db_subprocess_test/, stdout) + assert_match(/down\s+20260101000002\s+create_gadgets_for_cli_db_subprocess_test/, stdout) + end + + # bin/prism's own UsageError->exit(1) handling (`rescue ...; warn ...; + # exit 1`) only runs when invoked as a real process - calling + # Cli#db_migrate directly, as test_cli_db.rb does, raises the exception + # straight into the caller instead of going through that rescue at all, + # so it cannot prove the process exits non-zero and prints to stderr. + def test_unknown_flag_exits_non_zero_with_an_error_on_stderr_not_stdout + stdout, stderr, status = run_prism('db:migrate', '--bogus=1') + + refute status.success? + assert_equal 1, status.exitstatus + assert_match(/error:/, stderr) + assert_empty stdout + end + + def test_unknown_command_exits_non_zero_with_an_error + _stdout, stderr, status = run_prism('db:frobnicate') + + refute status.success? + assert_equal 1, status.exitstatus + assert_match(/unknown command/, stderr) + end +end From fef1a13343cab14e763a621b748441d9ce8de081 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 21:06:07 +0200 Subject: [PATCH 146/245] test: cover CliJobsEchoJob and log --- test/test_cli_jobs.rb | 158 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 test/test_cli_jobs.rb diff --git a/test/test_cli_jobs.rb b/test/test_cli_jobs.rb new file mode 100644 index 0000000..ea46253 --- /dev/null +++ b/test/test_cli_jobs.rb @@ -0,0 +1,158 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' +require 'stringio' + +class CliJobsEchoJob + def self.log + @log ||= [] + end + + def perform(value) + self.class.log << value + end +end + +class CliJobsAlwaysFailsJob + def perform + raise 'boom' + end +end + +class CliJobsTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-cli-jobs') + @cli = Prism::Cli.new(@dir) + @queue_path = File.join(@dir, 'db', 'jobs.pstore') + CliJobsEchoJob.log.clear + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def queue + Prism::Jobs::Queue.new(@queue_path) + end + + def test_jobs_work_once_drains_ready_jobs_and_returns_a_single_cycle + queue.enqueue(job_class: 'CliJobsEchoJob', args: ['a']) + queue.enqueue(job_class: 'CliJobsEchoJob', args: ['b']) + + out = StringIO.new + cycles = @cli.jobs_work(['--once', '--verbose'], out: out) + + assert_equal 1, cycles + assert_equal %w[a b], CliJobsEchoJob.log.sort + assert_equal 0, queue.size + assert_match(/processed 2 job\(s\)/, out.string) + end + + def test_jobs_work_respects_custom_file_option + custom_path = File.join(@dir, 'custom_jobs.pstore') + Prism::Jobs::Queue.new(custom_path).enqueue(job_class: 'CliJobsEchoJob', args: ['custom']) + + @cli.jobs_work(['--once', '--file=custom_jobs.pstore']) + + assert_equal ['custom'], CliJobsEchoJob.log + assert_equal 0, Prism::Jobs::Queue.new(custom_path).size + end + + def test_jobs_work_scopes_to_queue_name + queue.enqueue(job_class: 'CliJobsEchoJob', args: ['default']) + queue.enqueue(job_class: 'CliJobsEchoJob', args: ['mailers'], queue_name: 'mailers') + + @cli.jobs_work(['--once', '--queue=mailers']) + + assert_equal ['mailers'], CliJobsEchoJob.log + assert_equal 1, queue.size + end + + def test_jobs_work_stops_after_max_cycles_without_sleeping_forever + cycles = @cli.jobs_work(['--max-cycles=3', '--interval=0']) + + assert_equal 3, cycles + end + + def test_jobs_work_does_not_crash_on_failing_jobs_and_reports_via_error_output + queue.enqueue(job_class: 'CliJobsAlwaysFailsJob', args: [], max_attempts: 1) + + _out, err = capture_io { @cli.jobs_work(['--once']) } + + assert_match(/CliJobsAlwaysFailsJob.*failed: boom/, err) + assert_equal 1, queue.dead_letter_size + end + + def test_jobs_work_requires_extra_files_before_running + job_file = File.join(@dir, 'extra_job.rb') + File.write(job_file, <<~RUBY) + class CliJobsRequiredJob + def perform(marker_path) + File.write(marker_path, 'ran') + end + end + RUBY + marker_path = File.join(@dir, 'marker.txt') + queue.enqueue(job_class: 'CliJobsRequiredJob', args: [marker_path]) + + @cli.jobs_work(['--once', '--require=extra_job.rb']) + + assert_equal 'ran', File.read(marker_path) + end + + def test_jobs_work_rejects_unknown_options + assert_raises(Prism::Cli::UsageError) { @cli.jobs_work(['--bogus']) } + end + + def test_run_dispatches_jobs_work_through_the_public_cli_entrypoint + queue.enqueue(job_class: 'CliJobsEchoJob', args: ['via-run']) + + @cli.run(['jobs', 'work', '--once']) + + assert_equal ['via-run'], CliJobsEchoJob.log + end + + def test_run_rejects_unknown_jobs_subcommand + assert_raises(Prism::Cli::UsageError) { @cli.run(['jobs', 'bogus']) } + end + + def test_jobs_work_writes_its_own_pid_while_running_and_removes_it_on_exit + pidfile = File.join(@dir, 'jobs.pid') + + thread = Thread.new { @cli.jobs_work(['--max-cycles=3', '--interval=0.2', "--pidfile=#{pidfile}"]) } + sleep 0.05 until File.exist?(pidfile) + assert_equal Process.pid.to_s, File.read(pidfile).strip + + thread.join + + refute File.exist?(pidfile), 'pidfile should be removed once the worker stops' + end + + def test_jobs_work_refuses_to_start_while_a_live_process_holds_the_pidfile + pidfile = File.join(@dir, 'jobs.pid') + # A genuinely different, currently-alive OS process (a plain child that + # sleeps) - the same scenario a second real daemon would face, unlike + # writing our own pid, which the cleanup step would then be entitled to + # remove because it would truly be ours. + other_pid = Process.spawn('sleep', '5') + File.write(pidfile, other_pid.to_s) + + error = assert_raises(Prism::Cli::UsageError) { @cli.jobs_work(['--once', "--pidfile=#{pidfile}"]) } + + assert_match(/already running/, error.message) + assert_equal other_pid.to_s, File.read(pidfile).strip, "the other process's pidfile must be left untouched" + ensure + Process.kill('TERM', other_pid) + Process.wait(other_pid) + end + + def test_jobs_work_reclaims_a_stale_pidfile_left_by_a_dead_process + pidfile = File.join(@dir, 'jobs.pid') + dead_pid = fork { exit } + Process.wait(dead_pid) + File.write(pidfile, dead_pid.to_s) + + queue.enqueue(job_class: 'CliJobsEchoJob', args: ['after-stale-pid']) + @cli.jobs_work(['--once', "--pidfile=#{pidfile}"]) + From a31d8d284f16e3cacda52fe9af4affeac45398d7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 21:40:18 +0200 Subject: [PATCH 147/245] test: cover test_daemon_process_drains_jobs_and_shuts_down_cleanly_on... --- test/test_cli_jobs.rb | 90 ++++++++++++++++++++++++ test/test_cli_jobs_concurrent_daemons.rb | 74 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 test/test_cli_jobs_concurrent_daemons.rb diff --git a/test/test_cli_jobs.rb b/test/test_cli_jobs.rb index ea46253..4903743 100644 --- a/test/test_cli_jobs.rb +++ b/test/test_cli_jobs.rb @@ -156,3 +156,93 @@ class CliJobsTest < Minitest::Test queue.enqueue(job_class: 'CliJobsEchoJob', args: ['after-stale-pid']) @cli.jobs_work(['--once', "--pidfile=#{pidfile}"]) + assert_equal ['after-stale-pid'], CliJobsEchoJob.log + refute File.exist?(pidfile) + end + + # Real end-to-end daemon smoke test: runs `bin/prism jobs work` as its own + # OS process (no --once, no --max-cycles), lets it poll and drain a job, + # then sends it SIGINT the same way an operator or init system would and + # confirms it shuts down cleanly rather than needing to be killed. + def test_daemon_process_drains_jobs_and_shuts_down_cleanly_on_sigint + job_file = File.join(@dir, 'daemon_job.rb') + marker_path = File.join(@dir, 'daemon_marker.txt') + File.write(job_file, <<~RUBY) + class CliJobsDaemonJob + def perform(marker_path) + File.write(marker_path, 'ran') + end + end + RUBY + queue.enqueue(job_class: 'CliJobsDaemonJob', args: [marker_path]) + + bin = File.expand_path('../bin/prism', __dir__) + pid = Process.spawn( + bin, 'jobs', 'work', '--interval=0.05', '--require=daemon_job.rb', + chdir: @dir, out: File::NULL, err: File::NULL + ) + + deadline = Time.now + 5 + sleep 0.05 until File.exist?(marker_path) || Time.now > deadline + assert File.exist?(marker_path), 'daemon did not process the job within 5s' + assert_equal 'ran', File.read(marker_path) + + Process.kill('INT', pid) + _, status = Process.wait2(pid) + assert status.success?, "daemon did not exit cleanly after SIGINT (status: #{status})" + end + + # Real horizontal-scaling scenario: two independent `bin/prism jobs work` + # daemons (separate OS processes, no pidfile since neither is meant to be + # "the only one") polling the SAME queue file. Jobs::Queue#claim_next! is + # already proven race-free at the storage layer (test_jobs_queue.rb spawns + # 4 bare claimer processes), but that does not by itself prove the CLI + # daemon loop (poll -> work_off -> sleep -> repeat, plus its own error + # handling/verbose logging) is safe to run in more than one copy - this + # drives the actual `bin/prism jobs work` entrypoint twice and checks both + # that every job runs exactly once AND that work genuinely got split + # between the two processes rather than one daemon draining the whole + # queue before the other gets a chance to poll. + def test_two_real_daemon_processes_share_the_same_queue_without_dropping_or_duplicating_jobs + job_count = 60 + results_dir = File.join(@dir, 'daemon_results') + FileUtils.mkdir_p(results_dir) + + job_file = File.join(@dir, 'multi_daemon_job.rb') + File.write(job_file, <<~RUBY) + class CliJobsMultiDaemonJob + def perform(id, results_dir) + sleep 0.02 + File.write(File.join(results_dir, "job-\#{id}.done"), Process.pid.to_s) + end + end + RUBY + + job_count.times { |i| queue.enqueue(job_class: 'CliJobsMultiDaemonJob', args: [i, results_dir]) } + + bin = File.expand_path('../bin/prism', __dir__) + pids = 2.times.map do + Process.spawn( + bin, 'jobs', 'work', '--interval=0.01', '--require=multi_daemon_job.rb', + chdir: @dir, out: File::NULL, err: File::NULL + ) + end + + deadline = Time.now + 10 + sleep 0.05 while queue.size.positive? && Time.now < deadline + + pids.each { |pid| Process.kill('INT', pid) } + pids.each { |pid| Process.wait2(pid) } + + assert_equal 0, queue.size, 'both daemons together should have drained the whole queue within the deadline' + + result_files = Dir.glob(File.join(results_dir, 'job-*.done')) + assert_equal job_count, result_files.size, 'every job should have run exactly once (no drops, no duplicates)' + expected_names = (0...job_count).map { |i| "job-#{i}.done" }.sort + assert_equal expected_names, result_files.map { |f| File.basename(f) }.sort + + processing_pids = result_files.map { |f| File.read(f).strip }.uniq + assert_equal pids.map(&:to_s).sort, processing_pids.sort, + 'both daemon processes should have actually processed jobs, not just one of them' + end +end diff --git a/test/test_cli_jobs_concurrent_daemons.rb b/test/test_cli_jobs_concurrent_daemons.rb new file mode 100644 index 0000000..80ddfcf --- /dev/null +++ b/test/test_cli_jobs_concurrent_daemons.rb @@ -0,0 +1,74 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +# The unit-level race (Queue#claim_next! across raw concurrent processes, +# test_jobs_queue.rb) and the single-daemon pidfile guard (test_cli_jobs.rb) +# were both already covered, but nothing exercised the shape a real +# horizontally-scaled deploy actually takes: several `prism jobs work` +# daemons - each going through the FULL Cli#jobs_work -> Jobs::Worker -> +# job#perform -> mark_completed pipeline, each with its OWN pidfile so the +# single-daemon guard never gets in their way - pointed at the SAME queue +# file at the same time. +class CliJobsConcurrentDaemonsTest < Minitest::Test + DAEMON_COUNT = 5 + JOB_COUNT = 60 + + def setup + @dir = Dir.mktmpdir('prism-cli-jobs-concurrent') + @queue_path = File.join(@dir, 'db', 'jobs.pstore') + @claims_dir = File.join(@dir, 'claims') + @pids_dir = File.join(@dir, 'pids') + FileUtils.mkdir_p(@claims_dir) + FileUtils.mkdir_p(@pids_dir) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_concurrent_daemons_each_process_every_job_exactly_once_with_no_lost_or_duplicated_work + queue = Prism::Jobs::Queue.new(@queue_path) + markers = JOB_COUNT.times.map(&:to_s) + markers.each { |marker| queue.enqueue(job_class: 'CliJobsConcurrentClaimJob', args: [marker]) } + + job_class_file = File.join(@dir, 'claim_job.rb') + File.write(job_class_file, <<~RUBY) + class CliJobsConcurrentClaimJob + def perform(marker) + sleep(rand * 0.01) + File.write(File.join(#{@claims_dir.inspect}, "\#{marker}.claim"), Process.pid.to_s) + end + end + RUBY + + daemon_script = File.join(@dir, 'daemon.rb') + File.write(daemon_script, <<~RUBY) + require 'prism' + cli = Prism::Cli.new(#{@dir.inspect}) + cli.jobs_work( + ['--once', '--file=#{@queue_path}', '--require=#{job_class_file}', "--pidfile=\#{ARGV[0]}"], + out: File.open(File::NULL, 'w') + ) + RUBY + + pids = DAEMON_COUNT.times.map do |i| + pidfile = File.join(@pids_dir, "worker-#{i}.pid") + Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), daemon_script, pidfile, + out: File::NULL, err: File::NULL) + end + pids.each { |pid| Process.waitpid(pid) } + + claim_files = Dir.glob(File.join(@claims_dir, '*.claim')) + claimed_markers = claim_files.map { |f| File.basename(f, '.claim') } + + assert_equal JOB_COUNT, claim_files.size, 'every enqueued job should have been performed exactly once' + assert_equal markers.sort, claimed_markers.sort, 'the exact set of enqueued jobs should have been claimed, no more, no less' + assert_equal claimed_markers.size, claimed_markers.uniq.size, 'no job should have been performed twice by two different daemons' + + assert_equal 0, queue.size, 'the queue should be fully drained' + assert_equal 0, queue.dead_letter_size, 'no job should have failed' + assert_empty Dir.glob(File.join(@pids_dir, '*.pid')), 'every daemon should have removed its own pidfile on exit' + end +end From 1ec66059bb80faef93fb53381755829eb0da2332 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 21:52:49 +0200 Subject: [PATCH 148/245] test: cover CliRoutesTest and teardown --- test/test_cli_routes.rb | 146 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 test/test_cli_routes.rb diff --git a/test/test_cli_routes.rb b/test/test_cli_routes.rb new file mode 100644 index 0000000..4e880c2 --- /dev/null +++ b/test/test_cli_routes.rb @@ -0,0 +1,146 @@ +require 'minitest/autorun' +require 'prism' +require 'stringio' +require 'tmpdir' +require 'fileutils' + +class CliRoutesTest < Minitest::Test + def setup + @workdir = Dir.mktmpdir('prism-cli-routes') + @cli = Prism::Cli.new(@workdir) + end + + def teardown + FileUtils.remove_entry(@workdir) + %i[RoutesTestApp RoutesTestOtherApp].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name) + end + end + + def write_fixture(filename, content) + path = File.join(@workdir, filename) + File.write(path, content) + filename + end + + def run_routes(extra_args = []) + out = StringIO.new + @cli.routes(extra_args, out: out) + out.string + end + + def test_lists_named_and_unnamed_routes_with_verb_path_and_target + write_fixture('app.rb', <<~RUBY) + class RoutesTestApp < Prism::Application + root to: 'posts#index' + get '/posts/:id', to: 'posts#show', as: :post + post '/posts', to: 'posts#create' + end + RUBY + + output = run_routes(['--require=app.rb']) + + assert_match(/GET\s+\/\s+posts#index\s+root_path/, output) + assert_match(/GET\s+\/posts\/:id\s+posts#show\s+post_path/, output) + assert_match(/POST\s+\/posts\s+posts#create\s*$/, output) + end + + def test_resources_expands_into_seven_named_routes + write_fixture('app.rb', <<~RUBY) + class RoutesTestApp < Prism::Application + resources :posts + end + RUBY + + output = run_routes(['--require=app.rb']) + lines = output.lines.map(&:strip) + + # 7 REST actions, but :update maps to both PATCH and PUT (Rails does the + # same) so that is 8 route rows total. + assert_equal 8, lines.count { |line| line.include?('posts#') } + assert(lines.any? { |line| line.start_with?('GET') && line.include?('posts_path') && line.include?('posts#index') }) + assert(lines.any? { |line| line.include?('new_post_path') && line.include?('posts#new') }) + assert(lines.any? { |line| line.include?('edit_post_path') && line.include?('posts#edit') }) + end + + def test_inline_block_route_is_labeled_block_instead_of_controller_action + write_fixture('app.rb', <<~RUBY) + class RoutesTestApp < Prism::Application + get('/ping') { |req, res| res.write('pong') } + end + RUBY + + output = run_routes(['--require=app.rb']) + assert_match(%r{GET\s+/ping\s+\(block\)}, output) + end + + def test_mounted_apps_are_listed_separately_from_regular_routes + write_fixture('app.rb', <<~'RUBY') + class RoutesTestApp < Prism::Application + root to: 'posts#index' + mount '/admin', ->(env) { [200, {}, ['admin']] } + end + RUBY + + output = run_routes(['--require=app.rb']) + assert_includes output, 'MOUNT /admin ->' + refute_match(/MOUNT.*posts#index/, output) + end + + def test_raises_when_no_application_subclass_is_loaded + assert_raises(Prism::Cli::UsageError) { run_routes([]) } + end + + def test_raises_and_names_both_classes_when_multiple_applications_are_loaded_without_dash_dash_app + write_fixture('app.rb', <<~RUBY) + class RoutesTestApp < Prism::Application + root to: 'posts#index' + end + class RoutesTestOtherApp < Prism::Application + root to: 'comments#index' + end + RUBY + + error = assert_raises(Prism::Cli::UsageError) { run_routes(['--require=app.rb']) } + assert_includes error.message, 'RoutesTestApp' + assert_includes error.message, 'RoutesTestOtherApp' + assert_includes error.message, '--app=' + end + + def test_dash_dash_app_disambiguates_between_multiple_loaded_applications + write_fixture('app.rb', <<~RUBY) + class RoutesTestApp < Prism::Application + root to: 'posts#index' + end + class RoutesTestOtherApp < Prism::Application + root to: 'comments#index' + end + RUBY + + output = run_routes(['--require=app.rb', '--app=RoutesTestOtherApp']) + assert_includes output, 'comments#index' + refute_includes output, 'posts#index' + end + + def test_unknown_option_raises_usage_error + assert_raises(Prism::Cli::UsageError) { @cli.routes(['--bogus'], out: StringIO.new) } + end + + def test_run_dispatches_routes_command + write_fixture('app.rb', <<~RUBY) + class RoutesTestApp < Prism::Application + root to: 'posts#index' + end + RUBY + + out = StringIO.new + original_stdout = $stdout + $stdout = out + begin + @cli.run(['routes', "--require=#{File.join(@workdir, 'app.rb')}"]) + ensure + $stdout = original_stdout + end + assert_includes out.string, 'posts#index' + end +end From 330cd1f35fe09501e268f010ab59beb9b066e5ba Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 22:25:57 +0200 Subject: [PATCH 149/245] test: cover ArticlePagesController and show --- test/test_conditional_get.rb | 140 +++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 test/test_conditional_get.rb diff --git a/test/test_conditional_get.rb b/test/test_conditional_get.rb new file mode 100644 index 0000000..395a73f --- /dev/null +++ b/test/test_conditional_get.rb @@ -0,0 +1,140 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class ArticlePagesController < Prism::Controller + CURRENT_ETAG = 'article-42-v3' + CURRENT_MODIFIED = Time.utc(2026, 1, 1, 12, 0, 0) + + def show + fresh_when(etag: CURRENT_ETAG, last_modified: CURRENT_MODIFIED) + html('full article body') + end + + def show_strong + fresh_when(etag: CURRENT_ETAG, weak: false) + html('full article body') + end + + def edit + if stale?(etag: CURRENT_ETAG) + html('editable article body') + else + raise 'should not reach render when not stale' + end + end + + def update + fresh_when(etag: CURRENT_ETAG, last_modified: CURRENT_MODIFIED) + html('updated') + end +end + +class ConditionalGetApp < Prism::Application + get '/article', to: 'article_pages#show' + get '/article/strong', to: 'article_pages#show_strong' + get '/article/edit', to: 'article_pages#edit' + patch '/article', to: 'article_pages#update' +end + +class ConditionalGetTest < Minitest::Test + include TestSupport + + def call(method, path, **opts) + ConditionalGetApp.call(env_for(method, path, **opts)) + end + + def test_first_request_renders_and_sets_etag_and_last_modified + status, headers, body = call('GET', '/article') + assert_equal 200, status + assert_equal 'full article body', body.join + refute_nil headers['ETag'] + assert_equal 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"', headers['ETag'] + assert_equal ArticlePagesController::CURRENT_MODIFIED.httpdate, headers['Last-Modified'] + end + + def test_matching_if_none_match_returns_304_with_empty_body + etag = 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"' + status, _, body = call('GET', '/article', headers: { 'If-None-Match' => etag }) + assert_equal 304, status + assert_equal '', body.join + end + + def test_stale_if_none_match_still_renders_with_200 + status, _, body = call('GET', '/article', headers: { 'If-None-Match' => 'W/"stale-value"' }) + assert_equal 200, status + assert_equal 'full article body', body.join + end + + def test_wildcard_if_none_match_is_always_fresh + status, = call('GET', '/article', headers: { 'If-None-Match' => '*' }) + assert_equal 304, status + end + + def test_if_modified_since_after_last_modified_is_fresh + status, = call('GET', '/article', headers: { 'If-Modified-Since' => (ArticlePagesController::CURRENT_MODIFIED + 3600).httpdate }) + assert_equal 304, status + end + + def test_if_modified_since_before_last_modified_is_stale + status, _, body = call('GET', '/article', headers: { 'If-Modified-Since' => (ArticlePagesController::CURRENT_MODIFIED - 3600).httpdate }) + assert_equal 200, status + assert_equal 'full article body', body.join + end + + def test_strong_etag_has_no_weak_prefix + status, headers, = call('GET', '/article/strong') + assert_equal 200, status + assert_equal '"' + Digest::SHA1.hexdigest('article-42-v3') + '"', headers['ETag'] + end + + def test_stale_predicate_renders_when_no_conditional_headers_sent + status, _, body = call('GET', '/article/edit') + assert_equal 200, status + assert_equal 'editable article body', body.join + end + + def test_stale_predicate_halts_with_304_and_skips_render_when_fresh + etag = 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"' + status, _, body = call('GET', '/article/edit', headers: { 'If-None-Match' => etag }) + assert_equal 304, status + assert_equal '', body.join + end + + def test_matching_if_match_lets_the_write_through + etag = 'W/"' + Digest::SHA1.hexdigest('article-42-v3') + '"' + status, _, body = call('PATCH', '/article', headers: { 'If-Match' => etag }) + assert_equal 200, status + assert_equal 'updated', body.join + end + + def test_stale_if_match_returns_412_and_skips_the_write + status, _, body = call('PATCH', '/article', headers: { 'If-Match' => 'W/"someone-elses-version"' }) + assert_equal 412, status + assert_equal '', body.join + end + + def test_wildcard_if_match_always_passes_the_precondition + status, = call('PATCH', '/article', headers: { 'If-Match' => '*' }) + assert_equal 200, status + end + + def test_if_unmodified_since_before_last_modified_returns_412 + header = (ArticlePagesController::CURRENT_MODIFIED - 3600).httpdate + status, _, body = call('PATCH', '/article', headers: { 'If-Unmodified-Since' => header }) + assert_equal 412, status + assert_equal '', body.join + end + + def test_if_unmodified_since_at_or_after_last_modified_lets_the_write_through + header = ArticlePagesController::CURRENT_MODIFIED.httpdate + status, _, body = call('PATCH', '/article', headers: { 'If-Unmodified-Since' => header }) + assert_equal 200, status + assert_equal 'updated', body.join + end + + def test_no_precondition_headers_lets_the_write_through + status, = call('PATCH', '/article') + assert_equal 200, status + end +end From a4701cba339c94c16c8509b27736bddc85d01a7e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 22:39:24 +0200 Subject: [PATCH 150/245] test: cover ScopedIndexController, buggy_index and ContentForTest --- test/test_conditional_get_user_scoped_etag.rb | 93 +++++++++++++++++++ test/test_content_for.rb | 71 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 test/test_conditional_get_user_scoped_etag.rb create mode 100644 test/test_content_for.rb diff --git a/test/test_conditional_get_user_scoped_etag.rb b/test/test_conditional_get_user_scoped_etag.rb new file mode 100644 index 0000000..48d7066 --- /dev/null +++ b/test/test_conditional_get_user_scoped_etag.rb @@ -0,0 +1,93 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +# Session 42's documented "etag/current_user audit" debt, closed here after +# being deferred across sessions 42-61. The audit itself: grep every +# fresh_when call in examples/blog/app/controllers and check whether the +# view it guards renders anything current_user-dependent. Two of the three +# call sites already included current_user&.id in their etag components +# (PostsController#show, CommentsController#index) - PostsController#index +# was the one real gap, found and fixed directly in +# examples/blog/app/controllers/posts_controller.rb: its view gates the +# "new post" form and the layout's signed-in-as banner on +# user_signed_in?/current_user, but the etag never accounted for viewer +# identity, so a shared cache keying on URL+ETag alone (no per-user Vary) +# could serve one visitor's signed-in body back as a 304 to a different, or +# signed-out, visitor whose If-None-Match happened to match. +# +# This file does not require examples/blog/app.rb directly - see +# test_integration_preview.rb's fixture comment: that file calls +# Prism::Orm.connect as a process-wide global that would leak into every +# other test in this suite. Instead it reproduces the exact shape of the +# bug (buggy_index) and the fix (fixed_index) against an isolated app, the +# same convention test_cors_conditional_get.rb already established for +# ConditionalGet. +class ScopedIndexController < Prism::Controller + def buggy_index + viewer = request.env['HTTP_X_TEST_USER'] + fresh_when(etag: ['same-page-content']) # the bug: omits viewer identity + html(viewer ? "signed in as #{viewer}" : 'anonymous visitor') + end + + def fixed_index + viewer = request.env['HTTP_X_TEST_USER'] + fresh_when(etag: ['same-page-content', viewer]) # the fix + html(viewer ? "signed in as #{viewer}" : 'anonymous visitor') + end +end + +class ScopedIndexApp < Prism::Application + get '/buggy', to: 'scoped_index#buggy_index' + get '/fixed', to: 'scoped_index#fixed_index' +end + +class ConditionalGetUserScopedEtagTest < Minitest::Test + include TestSupport + + def call(path, headers = {}) + ScopedIndexApp.call(env_for('GET', path, headers: headers)) + end + + def test_buggy_etag_is_identical_across_different_viewers + _, anon_headers, = call('/buggy') + _, alice_headers, = call('/buggy', 'X-Test-User' => 'alice') + + assert_equal anon_headers['ETag'], alice_headers['ETag'], + 'demonstrates the bug: rendered content differs by viewer but the etag does not' + end + + def test_buggy_etag_lets_a_cached_signed_in_body_leak_back_to_an_anonymous_request + _, alice_headers, alice_body = call('/buggy', 'X-Test-User' => 'alice') + assert_equal 'signed in as alice', alice_body.join + + status, _, anon_body = call('/buggy', 'If-None-Match' => alice_headers['ETag']) + assert_equal 304, status, + "the bug: an anonymous visitor whose If-None-Match happens to equal alice's is told nothing changed" + assert_equal '', anon_body.join + end + + def test_fixed_etag_differs_by_viewer + _, anon_headers, = call('/fixed') + _, alice_headers, = call('/fixed', 'X-Test-User' => 'alice') + + refute_equal anon_headers['ETag'], alice_headers['ETag'] + end + + def test_fixed_etag_never_returns_a_304_across_different_viewers + _, alice_headers, = call('/fixed', 'X-Test-User' => 'alice') + + status, _, anon_body = call('/fixed', 'If-None-Match' => alice_headers['ETag']) + assert_equal 200, status, "an anonymous visitor must get a fresh render, never alice's cached 304" + assert_equal 'anonymous visitor', anon_body.join + end + + def test_fixed_etag_still_returns_304_for_the_same_viewer + _, alice_headers, = call('/fixed', 'X-Test-User' => 'alice') + + status, headers, body = call('/fixed', 'X-Test-User' => 'alice', 'If-None-Match' => alice_headers['ETag']) + assert_equal 304, status + assert_equal '', body.join + assert_equal alice_headers['ETag'], headers['ETag'] + end +end diff --git a/test/test_content_for.rb b/test/test_content_for.rb new file mode 100644 index 0000000..fcd78f4 --- /dev/null +++ b/test/test_content_for.rb @@ -0,0 +1,71 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class ContentForTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-content-for') + FileUtils.mkdir_p(File.join(@dir, 'pages')) + FileUtils.mkdir_p(File.join(@dir, 'layouts')) + @view = Prism::View.new(@dir) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def write(relative, source) + File.write(File.join(@dir, relative), source) + end + + def test_content_for_slot_is_readable_in_the_layout_rendered_after_it + write('pages/show.erb', '<% content_for(:title) do %>Custom Title<% end %>Body text') + write('layouts/application.erb', '[<%= content_for(:title) %>] <%== content %>') + + result = @view.render('pages/show', layout: :application) + assert_equal '[Custom Title] Body text', result + end + + def test_content_for_without_a_call_reads_back_empty_string + write('pages/show.erb', 'Body only') + write('layouts/application.erb', 'title=[<%= content_for(:title) %>]<%== content %>') + + result = @view.render('pages/show', layout: :application) + assert_equal 'title=[]Body only', result + end + + def test_content_for_question_mark_reports_presence + write('pages/show.erb', '<% content_for(:sidebar) do %>widgets<% end %>') + write('layouts/application.erb', '<% if content_for?(:sidebar) %>has-sidebar<% else %>no-sidebar<% end %>') + + assert_equal 'has-sidebar', @view.render('pages/show', layout: :application) + end + + def test_content_for_question_mark_is_false_when_never_set + write('pages/show.erb', 'nothing here') + write('layouts/application.erb', '<% if content_for?(:sidebar) %>has-sidebar<% else %>no-sidebar<% end %>') + + assert_equal 'no-sidebar', @view.render('pages/show', layout: :application) + end + + def test_multiple_content_for_calls_for_the_same_slot_accumulate + write('pages/show.erb', <<~ERB) + <% content_for(:scripts) do %>a.js<% end %> + <% content_for(:scripts) do %>b.js<% end %> + ERB + write('layouts/application.erb', '<%= content_for(:scripts) %>') + + assert_equal 'a.jsb.js', @view.render('pages/show', layout: :application) + end + + def test_content_for_state_does_not_leak_between_separate_render_calls + write('pages/one.erb', '<% content_for(:title) do %>One<% end %>') + write('pages/two.erb', 'no title here') + write('layouts/application.erb', '[<%= content_for(:title) %>]<%== content %>') + + @view.render('pages/one', layout: :application) + result = @view.render('pages/two', layout: :application) + assert_equal '[]no title here', result + end +end From daa6632b393fa91e03fa0f6de3c8a41b39237632 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 12:36:52 +0200 Subject: [PATCH 151/245] test: cover BaseWithFiltersController, stamp_base --- test/test_controller_filters.rb | 61 ++++++++++++++++ test/test_controller_rescue_from.rb | 104 ++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 test/test_controller_rescue_from.rb diff --git a/test/test_controller_filters.rb b/test/test_controller_filters.rb index 812d492..238de91 100644 --- a/test/test_controller_filters.rb +++ b/test/test_controller_filters.rb @@ -40,6 +40,48 @@ class FilterApp < Prism::Application get '/public', to: 'filter#public_action' end +class BaseWithFiltersController < Prism::Controller + before_action :stamp_base, only: %i[index] + after_action :stamp_base_after, only: %i[index] + + private + + def stamp_base + @trail = (@trail || []) << 'base-before' + end + + def stamp_base_after + self.class.trail_after << 'base-before-runs-first?' + end +end + +class InheritedFiltersController < BaseWithFiltersController + before_action :stamp_sub, only: %i[index] + + def self.trail_after + @trail_after ||= [] + end + + def index + json(trail: @trail) + end + + def other + json(trail: @trail) + end + + private + + def stamp_sub + @trail << 'sub-before' + end +end + +class InheritedFiltersApp < Prism::Application + get '/inherited', to: 'inherited_filters#index' + get '/inherited-other', to: 'inherited_filters#other' +end + class ControllerFiltersTest < Minitest::Test include TestSupport @@ -51,6 +93,10 @@ class ControllerFiltersTest < Minitest::Test FilterApp.call(env_for(method, path, **opts)) end + def call_inherited(method, path, **opts) + InheritedFiltersApp.call(env_for(method, path, **opts)) + end + def test_inline_before_action_block_runs_and_scoped_to_only _, _, body = call('GET', '/index?token=ok') assert_equal ['inline-before'], JSON.parse(body.join)['log'] @@ -83,4 +129,19 @@ class ControllerFiltersTest < Minitest::Test call('GET', '/public') refute_includes FilterController.after_log, 'public_action' end + + def test_before_action_declared_on_a_superclass_runs_for_a_subclass_action + _, _, body = call_inherited('GET', '/inherited') + assert_equal %w[base-before sub-before], JSON.parse(body.join)['trail'] + end + + def test_after_action_declared_on_a_superclass_runs_for_a_subclass_action + call_inherited('GET', '/inherited') + assert_includes InheritedFiltersController.trail_after, 'base-before-runs-first?' + end + + def test_inherited_before_action_only_option_still_scopes_to_the_declared_action + _, _, body = call_inherited('GET', '/inherited-other') + assert_nil JSON.parse(body.join)['trail'] + end end diff --git a/test/test_controller_rescue_from.rb b/test/test_controller_rescue_from.rb new file mode 100644 index 0000000..879bd06 --- /dev/null +++ b/test/test_controller_rescue_from.rb @@ -0,0 +1,104 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class RescueFromMissingRecordError < StandardError; end +class RescueFromSpecificError < RescueFromMissingRecordError; end + +class RescueFromBaseController < Prism::Controller + rescue_from StandardError, with: :handle_any_error + + private + + def handle_any_error(exception) + halt(500, "base handled #{exception.class}") + end +end + +# Declares a MORE specific handler than its superclass's catch-all - proves +# subclass handlers win even though the superclass's StandardError handler +# would also match. +class RescueFromController < RescueFromBaseController + rescue_from RescueFromMissingRecordError, with: :handle_missing_record + rescue_from ArgumentError do |exception| + halt(400, "block handled #{exception.message}") + end + + def boom_missing + raise RescueFromMissingRecordError, 'gone' + end + + def boom_specific_subclass + raise RescueFromSpecificError, 'gone' + end + + def boom_argument + raise ArgumentError, 'bad input' + end + + def boom_unhandled + raise ZeroDivisionError, 'nope' + end + + def before_action_boom + raise RescueFromMissingRecordError, 'blocked before the action ran' + end + + before_action :before_action_boom, only: %i[protected_action] + + def protected_action + halt(200, 'should never reach here') + end + + private + + def handle_missing_record(exception) + halt(404, "missing #{exception.message}") + end +end + +class RescueFromApp < Prism::Application + get '/missing', to: 'rescue_from#boom_missing' + get '/missing_subclass', to: 'rescue_from#boom_specific_subclass' + get '/argument', to: 'rescue_from#boom_argument' + get '/unhandled', to: 'rescue_from#boom_unhandled' + get '/protected', to: 'rescue_from#protected_action' +end + +class ControllerRescueFromTest < Minitest::Test + include TestSupport + + def call(method, path, **opts) + RescueFromApp.call(env_for(method, path, **opts)) + end + + def test_specific_handler_declared_on_the_subclass_wins_over_the_superclass_catch_all + status, _, body = call('GET', '/missing') + assert_equal 404, status + assert_equal 'missing gone', body.join + end + + def test_is_a_matching_catches_a_subclass_of_the_registered_exception + status, _, body = call('GET', '/missing_subclass') + assert_equal 404, status + assert_equal 'missing gone', body.join + end + + def test_block_handler_runs_with_the_controller_as_self + status, _, body = call('GET', '/argument') + assert_equal 400, status + assert_equal 'block handled bad input', body.join + end + + def test_falls_back_to_the_superclass_catch_all_when_no_specific_handler_matches + status, _, body = call('GET', '/unhandled') + assert_equal 500, status + assert_equal 'base handled ZeroDivisionError', body.join + end + + def test_rescue_from_also_catches_exceptions_raised_inside_before_actions + status, _, body = call('GET', '/protected') + assert_equal 404, status + assert_equal 'missing blocked before the action ran', body.join + end +end From 6290bede30aa2e3d7c8b466f5356a9961fc8e6ce Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 12:37:02 +0200 Subject: [PATCH 152/245] test: cover NotesController, received_log and StreamingExportController --- test/test_controller_security.rb | 118 ++++++++++++++++++++++++++++++ test/test_controller_streaming.rb | 83 +++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 test/test_controller_security.rb create mode 100644 test/test_controller_streaming.rb diff --git a/test/test_controller_security.rb b/test/test_controller_security.rb new file mode 100644 index 0000000..aeed5db --- /dev/null +++ b/test/test_controller_security.rb @@ -0,0 +1,118 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class NotesController < Prism::Controller + protect_from_forgery except: [:token] + + def self.received_log + @received_log ||= [] + end + + def token + html(csrf_token) + end + + def create + self.class.received_log << params['title'] + halt(201, JSON.generate(ok: true)) + end + + def write_cookie + cookies.signed[:uid] = 42 + cookies.encrypted[:secret_note] = 'top secret' + halt(200, 'stored') + end + + def read_cookie + halt(200, JSON.generate(uid: cookies.signed[:uid], note: cookies.encrypted[:secret_note])) + end + + def read_raw_cookie + halt(200, JSON.generate(raw: cookies['uid'])) + end +end + +class SecureNotesApp < Prism::Application + enable_sessions(secret: 'notes-secret') + + get '/notes/token', to: 'notes#token' + post '/notes', to: 'notes#create' + get '/notes/cookie', to: 'notes#write_cookie' + get '/notes/read_cookie', to: 'notes#read_cookie' + get '/notes/read_raw_cookie', to: 'notes#read_raw_cookie' +end + +class ControllerSecurityTest < Minitest::Test + include TestSupport + + def setup + NotesController.received_log.clear + end + + def call(method, path, **opts) + SecureNotesApp.call(env_for(method, path, **opts)) + end + + def session_cookie_from(headers) + line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?('prism.session=') } + line.split(';').first + end + + def test_post_without_authenticity_token_is_rejected + status, = call('POST', '/notes', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + body: 'title=hi') + assert_equal 422, status + assert_equal [], NotesController.received_log + end + + def test_post_with_valid_authenticity_token_is_accepted + _, headers, body = call('GET', '/notes/token') + token = body.join + cookie = session_cookie_from(headers) + + status, = call('POST', '/notes', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + cookies: cookie, + body: "title=hi&authenticity_token=#{CGI.escape(token)}") + assert_equal 201, status + assert_equal ['hi'], NotesController.received_log + end + + def test_post_with_token_from_a_different_session_is_rejected + _, other_headers, other_body = call('GET', '/notes/token') + other_token = other_body.join + refute_nil session_cookie_from(other_headers) + + _, first_headers, = call('GET', '/notes/token') + first_cookie = session_cookie_from(first_headers) + + status, = call('POST', '/notes', + headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, + cookies: first_cookie, + body: "title=hi&authenticity_token=#{CGI.escape(other_token)}") + assert_equal 422, status + end + + def test_signed_and_encrypted_cookies_round_trip_through_a_real_request_cycle + _, write_headers, = call('GET', '/notes/cookie') + lines = write_headers['Set-Cookie'].split("\n") + cookie_header = lines.map { |line| line.split(';').first }.join('; ') + + _, _, body = call('GET', '/notes/read_cookie', cookies: cookie_header) + parsed = JSON.parse(body.join) + assert_equal 42, parsed['uid'] + assert_equal 'top secret', parsed['note'] + end + + def test_signed_cookie_value_is_not_stored_in_plaintext + _, write_headers, = call('GET', '/notes/cookie') + lines = write_headers['Set-Cookie'].split("\n") + cookie_header = lines.map { |line| line.split(';').first }.join('; ') + + _, _, body = call('GET', '/notes/read_raw_cookie', cookies: cookie_header) + raw = JSON.parse(body.join)['raw'] + refute_nil raw + refute_equal '42', raw + end +end diff --git a/test/test_controller_streaming.rb b/test/test_controller_streaming.rb new file mode 100644 index 0000000..4ba8e7c --- /dev/null +++ b/test/test_controller_streaming.rb @@ -0,0 +1,83 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class StreamingExportController < Prism::Controller + after_action :log_after + + def self.after_log + @after_log ||= [] + end + + def export + headers('Content-Type' => 'text/csv') + stream do |out| + out.write("id,title\n") + %w[one two three].each_with_index do |title, index| + out.write("#{index + 1},#{title}\n") + end + end + end + + def plain + json(ok: true) + end + + private + + def log_after + self.class.after_log << action_name + end +end + +class StreamingApp < Prism::Application + get '/export', to: 'streaming_export#export' + get '/plain', to: 'streaming_export#plain' +end + +class ControllerStreamingTest < Minitest::Test + include TestSupport + + def setup + StreamingExportController.after_log.clear + end + + def call(env) + StreamingApp.call(env) + end + + def test_streaming_action_returns_a_streaming_body + _status, _headers, body = call(env_for(:get, '/export')) + assert_instance_of Prism::StreamingBody, body + end + + def test_streaming_action_produces_chunks_in_order_when_drained + _status, _headers, body = call(env_for(:get, '/export')) + collected = [] + body.each { |chunk| collected << chunk } + assert_equal ["id,title\n", "1,one\n", "2,two\n", "3,three\n"], collected + end + + def test_streaming_action_keeps_status_and_explicit_headers + status, headers, _body = call(env_for(:get, '/export')) + assert_equal 200, status + assert_equal 'text/csv', headers['Content-Type'] + end + + # Regression guard: finalize_body used to call `@response.body.empty?` + # unconditionally after every action, which raises NoMethodError against + # a StreamingBody (it deliberately has no #empty?, since a producer + # block's total chunk count is not known up front). + def test_after_actions_still_run_for_a_streaming_action + call(env_for(:get, '/export')) + assert_equal ['export'], StreamingExportController.after_log + end + + # Non-streaming actions on the same controller are unaffected by the + # streaming? guard added to finalize_body. + def test_ordinary_action_on_the_same_controller_is_unaffected + status, _headers, body = call(env_for(:get, '/plain')) + assert_equal 200, status + assert_equal ['{"ok":true}'], body + end +end From 21a8601dfc8c2131decbc54e3709ffd22235d7b5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 12:47:39 +0200 Subject: [PATCH 153/245] test: cover CookieJarTest and jar_for --- test/test_cookie_jar.rb | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/test_cookie_jar.rb diff --git a/test/test_cookie_jar.rb b/test/test_cookie_jar.rb new file mode 100644 index 0000000..e5bd737 --- /dev/null +++ b/test/test_cookie_jar.rb @@ -0,0 +1,124 @@ +require 'minitest/autorun' +require 'prism' +require 'stringio' + +class CookieJarTest < Minitest::Test + def setup + @secret = 'jar-secret-key' + end + + def jar_for(cookie_header, secret: @secret, previous_secrets: []) + env = { + 'REQUEST_METHOD' => 'GET', + 'PATH_INFO' => '/', + 'QUERY_STRING' => '', + 'rack.input' => StringIO.new(''), + 'CONTENT_LENGTH' => '0' + } + env['HTTP_COOKIE'] = cookie_header if cookie_header + request = Prism::Request.new(env) + response = Prism::Response.new + [Prism::CookieJar.new(request, response, secret: secret, previous_secrets: previous_secrets), response] + end + + def test_plain_set_and_read + jar, response = jar_for(nil) + jar[:theme] = 'dark' + assert_match(/theme=dark/, response['Set-Cookie']) + end + + def test_signed_round_trip_via_two_requests + jar, response = jar_for(nil) + jar.signed[:user_id] = 42 + cookie_header = extract_cookie_header(response, 'user_id') + + reader_jar, = jar_for(cookie_header) + assert_equal 42, reader_jar.signed[:user_id] + end + + def test_signed_read_rejects_tampered_cookie + jar, response = jar_for(nil) + jar.signed[:user_id] = 42 + cookie_header = extract_cookie_header(response, 'user_id') + tampered = cookie_header.sub('user_id=', 'user_id=tampered') + + reader_jar, = jar_for(tampered) + assert_nil reader_jar.signed[:user_id] + end + + def test_encrypted_round_trip_hides_plaintext + jar, response = jar_for(nil) + jar.encrypted[:secret] = 'super-sensitive-value' + cookie_header = extract_cookie_header(response, 'secret') + refute_includes cookie_header, 'super-sensitive-value' + + reader_jar, = jar_for(cookie_header) + assert_equal 'super-sensitive-value', reader_jar.encrypted[:secret] + end + + def test_encrypted_read_rejects_tampered_cookie + jar, response = jar_for(nil) + jar.encrypted[:secret] = 'value' + cookie_header = extract_cookie_header(response, 'secret') + tampered = cookie_header + 'AA' + + reader_jar, = jar_for(tampered) + assert_nil reader_jar.encrypted[:secret] + end + + def test_missing_cookie_returns_nil_for_signed_and_encrypted + jar, = jar_for(nil) + assert_nil jar.signed[:missing] + assert_nil jar.encrypted[:missing] + end + + def test_value_hash_form_passes_through_cookie_options + jar, response = jar_for(nil) + jar.signed[:remember] = { value: 'token', max_age: 3600 } + header = response['Set-Cookie'] + assert_match(/Max-Age=3600/, header) + end + + def test_signed_previous_secret_still_decodes_after_rotation + old_jar, response = jar_for(nil, secret: 'old-jar-secret') + old_jar.signed[:user_id] = 42 + cookie_header = extract_cookie_header(response, 'user_id') + + rotated_jar, = jar_for(cookie_header, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret']) + assert_equal 42, rotated_jar.signed[:user_id] + end + + def test_encrypted_previous_secret_still_decodes_after_rotation + old_jar, response = jar_for(nil, secret: 'old-jar-secret') + old_jar.encrypted[:secret] = 'value-from-before-rotation' + cookie_header = extract_cookie_header(response, 'secret') + + rotated_jar, = jar_for(cookie_header, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret']) + assert_equal 'value-from-before-rotation', rotated_jar.encrypted[:secret] + end + + def test_signed_read_rejects_a_secret_that_was_never_current_or_previous + old_jar, response = jar_for(nil, secret: 'unrelated-secret') + old_jar.signed[:user_id] = 42 + cookie_header = extract_cookie_header(response, 'user_id') + + rotated_jar, = jar_for(cookie_header, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret']) + assert_nil rotated_jar.signed[:user_id] + end + + def test_writes_after_rotation_use_the_current_secret_not_a_previous_one + jar, response = jar_for(nil, secret: 'new-jar-secret', previous_secrets: ['old-jar-secret']) + jar.signed[:user_id] = 42 + cookie_header = extract_cookie_header(response, 'user_id') + + reader_on_old_secret_only, = jar_for(cookie_header, secret: 'old-jar-secret') + assert_nil reader_on_old_secret_only.signed[:user_id] + end + + private + + def extract_cookie_header(response, name) + line = response['Set-Cookie'].split("\n").find { |entry| entry.start_with?("#{name}=") } + line.split(';').first + end +end From 8c06f5decb8bb4c21f134bf8941ea7ced8436735 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 14:33:43 +0200 Subject: [PATCH 154/245] test: cover CorsDslTest and teardown --- test/test_cors.rb | 118 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 test/test_cors.rb diff --git a/test/test_cors.rb b/test/test_cors.rb new file mode 100644 index 0000000..a964eaa --- /dev/null +++ b/test/test_cors.rb @@ -0,0 +1,118 @@ +require 'minitest/autorun' +require 'prism' + +class CorsDslTest < Minitest::Test + def setup + Prism::Cors.reset! + end + + def teardown + Prism::Cors.reset! + end + + def test_matches_allowed_origin_and_resource_pattern + Prism::Cors.configure do |config| + config.allow do + origins 'https://app.example.com' + resource '/api/*', methods: %w[GET POST] + end + end + + rule, resource = Prism::Cors.resource_for('https://app.example.com', '/api/posts') + refute_nil rule + assert_equal %w[GET POST], resource.methods + end + + def test_unmatched_origin_returns_nil + Prism::Cors.configure do |config| + config.allow do + origins 'https://app.example.com' + resource '/api/*' + end + end + + assert_nil Prism::Cors.resource_for('https://evil.example.com', '/api/posts') + end + + def test_unmatched_path_returns_nil_even_with_allowed_origin + Prism::Cors.configure do |config| + config.allow do + origins 'https://app.example.com' + resource '/api/*' + end + end + + assert_nil Prism::Cors.resource_for('https://app.example.com', '/admin/secrets') + end + + def test_wildcard_origin_matches_anything + Prism::Cors.configure do |config| + config.allow do + origins '*' + resource '/public/*' + end + end + + refute_nil Prism::Cors.resource_for('https://anyone.example.com', '/public/feed') + end + + def test_regexp_origin_matches + Prism::Cors.configure do |config| + config.allow do + origins(/\Ahttps:\/\/.*\.example\.com\z/) + resource '/api/*' + end + end + + refute_nil Prism::Cors.resource_for('https://sub.example.com', '/api/posts') + assert_nil Prism::Cors.resource_for('https://sub.example.org', '/api/posts') + end + + def test_first_matching_rule_wins_when_multiple_rules_allow_the_origin + Prism::Cors.configure do |config| + config.allow do + origins '*' + resource '/api/*', methods: %w[GET] + end + config.allow do + origins '*' + resource '/api/*', methods: %w[GET POST DELETE] + end + end + + _rule, resource = Prism::Cors.resource_for('https://x.example.com', '/api/posts') + assert_equal %w[GET], resource.methods + end + + def test_reset_clears_every_rule + Prism::Cors.configure { |config| config.allow { origins '*'; resource '/api/*' } } + Prism::Cors.reset! + assert_empty Prism::Cors.configuration.rules + end +end + +class CorsResourceTest < Minitest::Test + def test_allows_headers_any_accepts_everything + resource = Prism::Cors::Resource.new('/api/*', headers: :any) + assert resource.allows_headers?('X-Custom-Header, Authorization') + end + + def test_allows_headers_checks_each_requested_header_case_insensitively + resource = Prism::Cors::Resource.new('/api/*', headers: %w[Authorization X-Request-Id]) + assert resource.allows_headers?('authorization, x-request-id') + refute resource.allows_headers?('authorization, x-secret') + end + + def test_allows_headers_with_blank_request_is_always_true + resource = Prism::Cors::Resource.new('/api/*', headers: %w[Authorization]) + assert resource.allows_headers?(nil) + assert resource.allows_headers?('') + end + + def test_allows_method_is_case_insensitive_on_registration + resource = Prism::Cors::Resource.new('/api/*', methods: %w[get post]) + assert resource.allows_method?('GET') + assert resource.allows_method?('post') + refute resource.allows_method?('DELETE') + end +end From 4786db1de5d77ac21af4b0c4ca13ceda729ba805 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 15:04:26 +0200 Subject: [PATCH 155/245] test: cover CorsArticlesController, show and CsrfTest --- test/test_cors_conditional_get.rb | 90 +++++++++++++++++++++++++++++++ test/test_csrf.rb | 51 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 test/test_cors_conditional_get.rb create mode 100644 test/test_csrf.rb diff --git a/test/test_cors_conditional_get.rb b/test/test_cors_conditional_get.rb new file mode 100644 index 0000000..0d10e6f --- /dev/null +++ b/test/test_cors_conditional_get.rb @@ -0,0 +1,90 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +# Session 52's documented debt: Cors and ConditionalGet were each tested in +# isolation, but never together - and the two subsystems interact in a way +# that is easy to get wrong. Prism::Middleware::Cors stamps headers onto +# whatever [status, headers, body] the app below it returns +# (lib/prism/middleware/cors.rb#call), without special-casing any status +# code, and Controller#halt(304, '') (used by fresh_when/stale?) short- +# circuits rendering but still returns through the SAME response path as a +# normal 200 - so a cross-origin conditional GET that turns out fresh +# should still come back as a CORS-tagged 304 with an empty body, not lose +# its Access-Control-* headers just because nothing was actually rendered. +class CorsArticlesController < Prism::Controller + ETAG = 'cors-article-v1'.freeze + + def show + fresh_when(etag: ETAG) + html('full article body') + end +end + +class CorsConditionalGetApp < Prism::Application + get '/api/articles/1', to: 'cors_articles#show' +end + +class CorsConditionalGetTest < Minitest::Test + include TestSupport + + ORIGIN = 'https://app.example.com'.freeze + FRESH_ETAG = 'W/"' + Digest::SHA1.hexdigest(CorsArticlesController::ETAG) + '"' + + def setup + Prism::Cors.reset! + Prism::Cors.configure do |config| + config.allow do + origins ORIGIN + resource '/api/*', methods: %w[GET] + end + end + @app = Prism::Middleware::Cors.new(CorsConditionalGetApp) + end + + def teardown + Prism::Cors.reset! + end + + def call(headers) + @app.call(env_for('GET', '/api/articles/1', headers: headers)) + end + + def test_first_cross_origin_request_renders_and_still_gets_cors_headers + status, headers, body = call('Origin' => ORIGIN) + + assert_equal 200, status + assert_equal 'full article body', body.join + assert_equal FRESH_ETAG, headers['ETag'] + assert_equal ORIGIN, headers['Access-Control-Allow-Origin'] + assert_equal 'Origin', headers['Vary'] + end + + def test_fresh_cross_origin_request_gets_304_with_cors_headers_intact + status, headers, body = call('Origin' => ORIGIN, 'If-None-Match' => FRESH_ETAG) + + assert_equal 304, status + assert_equal '', body.join + assert_equal FRESH_ETAG, headers['ETag'], 'ETag must still be set on a 304, the client needs it to keep caching' + assert_equal ORIGIN, headers['Access-Control-Allow-Origin'], + 'a 304 short-circuited by halt() must not lose the CORS headers Cors already knows to stamp on any status' + assert_equal 'Origin', headers['Vary'] + end + + def test_fresh_same_origin_request_gets_304_without_cors_headers + status, headers, body = call('If-None-Match' => FRESH_ETAG) + + assert_equal 304, status + assert_equal '', body.join + assert_equal FRESH_ETAG, headers['ETag'] + assert_nil headers['Access-Control-Allow-Origin'] + end + + def test_stale_cross_origin_request_from_a_disallowed_origin_still_renders_without_cors_headers + status, headers, body = call('Origin' => 'https://evil.example.com', 'If-None-Match' => FRESH_ETAG) + + assert_equal 304, status, 'ConditionalGet does not know or care about CORS - freshness alone decides the status' + assert_equal '', body.join + assert_nil headers['Access-Control-Allow-Origin'] + end +end diff --git a/test/test_csrf.rb b/test/test_csrf.rb new file mode 100644 index 0000000..7fb325e --- /dev/null +++ b/test/test_csrf.rb @@ -0,0 +1,51 @@ +require 'minitest/autorun' +require 'prism' + +class CsrfTest < Minitest::Test + def setup + @session = {} + @csrf = Prism::Csrf.new(@session) + end + + def test_raw_token_is_persisted_in_session + token = @csrf.raw_token + assert_equal token, @session[Prism::Csrf::SESSION_KEY] + end + + def test_raw_token_is_stable_across_calls + assert_equal @csrf.raw_token, @csrf.raw_token + end + + def test_masked_token_differs_each_call + refute_equal @csrf.masked_token, @csrf.masked_token + end + + def test_masked_token_is_valid + assert @csrf.valid_token?(@csrf.masked_token) + end + + def test_multiple_masked_tokens_from_same_raw_token_all_validate + tokens = Array.new(5) { @csrf.masked_token } + assert tokens.all? { |token| @csrf.valid_token?(token) } + assert_equal 5, tokens.uniq.size + end + + def test_token_from_different_session_is_rejected + other_session = {} + other_csrf = Prism::Csrf.new(other_session) + refute @csrf.valid_token?(other_csrf.masked_token) + end + + def test_blank_or_garbage_tokens_are_rejected + refute @csrf.valid_token?(nil) + refute @csrf.valid_token?('') + refute @csrf.valid_token?('not-base64!!!') + refute @csrf.valid_token?('dGhpcyBpcyB0b28gc2hvcnQ=') + end + + def test_tampering_with_masked_token_invalidates_it + masked = @csrf.masked_token + tampered = masked.sub(/.\z/) { |c| c == 'a' ? 'b' : 'a' } + refute @csrf.valid_token?(tampered) + end +end From 35494fae6dd56d7183ffc2d44ddeb96453fca8bf Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 15:37:35 +0200 Subject: [PATCH 156/245] test: cover FormBuilderTest and persisted? --- test/test_form_builder.rb | 150 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/test_form_builder.rb diff --git a/test/test_form_builder.rb b/test/test_form_builder.rb new file mode 100644 index 0000000..cc70a4f --- /dev/null +++ b/test/test_form_builder.rb @@ -0,0 +1,150 @@ +require 'minitest/autorun' +require 'prism' + +class FormBuilderTest < Minitest::Test + FakeErrors = Struct.new(:store) do + def [](attribute) + store.fetch(attribute, []) + end + end + + FakeModel = Struct.new(:id, :title, :published, :errors, :comments) do + def persisted? + !id.nil? + end + end + + FakeComment = Struct.new(:id, :body, :errors) + + def new_post(title: 'Hello', published: false, id: nil, errors: {}, comments: []) + FakeModel.new(id, title, published, FakeErrors.new(errors), comments) + end + + def new_comment(id:, body:) + FakeComment.new(id, body, FakeErrors.new({})) + end + + def builder_for(model, object_name: nil) + Prism::FormBuilder.new(model, object_name: object_name) + end + + def test_text_field_uses_rails_style_nested_name_and_id + html = builder_for(new_post).text_field(:title) + assert_equal '', html + end + + def test_text_field_value_can_be_overridden + html = builder_for(new_post).text_field(:title, value: 'Overridden') + assert_includes html, 'value="Overridden"' + end + + def test_text_field_escapes_value + html = builder_for(new_post(title: '')).text_field(:title) + assert_includes html, 'value="<b>"' + end + + def test_label_defaults_to_humanized_attribute + html = builder_for(new_post).label(:title) + assert_equal '', html + end + + def test_label_accepts_custom_text + html = builder_for(new_post).label(:title, 'Post title') + assert_includes html, '>Post title' + end + + def test_hidden_field + html = builder_for(new_post(id: 7)).hidden_field(:id) + assert_equal '', html + end + + def test_text_area_renders_content_between_tags_not_as_value_attribute + html = builder_for(new_post(title: "multi\nline")).text_area(:title) + assert_equal "", html + end + + def test_check_box_emits_hidden_fallback_and_reflects_current_value + html = builder_for(new_post(published: true)).check_box(:published) + assert_includes html, 'name="fake_model[published]" value="0"' + assert_includes html, 'type="checkbox"' + assert_includes html, 'checked="checked"' + end + + def test_check_box_unchecked_when_value_falsey + html = builder_for(new_post(published: false)).check_box(:published) + refute_includes html, 'checked' + end + + def test_select_marks_current_value_selected + html = builder_for(new_post(title: 'b')).select(:title, %w[a b c]) + assert_includes html, '' + assert_includes html, '' + end + + def test_select_with_label_value_pairs + html = builder_for(new_post(title: '2')).select(:title, [['One', '1'], ['Two', '2']]) + assert_includes html, '' + end + + def test_select_include_blank + html = builder_for(new_post).select(:title, %w[a b], include_blank: true) + assert_includes html, '' + end + + def test_submit_default_and_custom_value + assert_equal '', builder_for(new_post).submit + assert_equal '', builder_for(new_post).submit('Go') + end + + def test_errors_for_reads_from_object_errors + model = new_post(errors: { title: ['is too short'] }) + assert_equal ['is too short'], builder_for(model).errors_for(:title) + end + + def test_error_messages_renders_wrapper_only_when_present + clean = builder_for(new_post) + assert_equal '', clean.error_messages(:title) + + dirty = builder_for(new_post(errors: { title: ['is blank', 'is too short'] })) + assert_equal 'is blank, is too short', dirty.error_messages(:title) + end + + def test_object_name_can_be_overridden + html = builder_for(new_post, object_name: :post).text_field(:title) + assert_includes html, 'name="post[title]"' + assert_includes html, 'id="post_title"' + end + + def test_fields_for_yields_one_nested_builder_per_record_with_indexed_names + post = new_post(id: 1, comments: [new_comment(id: 10, body: 'first'), new_comment(id: 11, body: 'second')]) + builder = builder_for(post, object_name: :post) + + rendered = [] + builder.fields_for(:comments) { |f| rendered << f.text_field(:body) } + + assert_equal 2, rendered.size + assert_equal '', rendered[0] + assert_equal '', rendered[1] + end + + def test_fields_for_hidden_id_carries_the_record_id + post = new_post(id: 1, comments: [new_comment(id: 42, body: 'x')]) + builder = builder_for(post, object_name: :post) + + html = nil + builder.fields_for(:comments) { |f| html = f.hidden_field(:id) } + assert_equal '', html + end + + def test_fields_for_accepts_an_explicit_collection_override + post = new_post(id: 1) + builder = builder_for(post, object_name: :post) + + seen = [] + builder.fields_for(:comments, [new_comment(id: 5, body: 'explicit')]) { |f| seen << f.object.id } + assert_equal [5], seen + end +end From 83467c6d9d21b3e81bd35d42b4a53f38884d22f5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 16:07:28 +0200 Subject: [PATCH 157/245] test: cover FormForHelperTest, persisted? --- test/test_form_for_helper.rb | 83 ++++++++++++++++++ .../test_fragment_cache_viewer_independent.rb | 86 +++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 test/test_form_for_helper.rb create mode 100644 test/test_fragment_cache_viewer_independent.rb diff --git a/test/test_form_for_helper.rb b/test/test_form_for_helper.rb new file mode 100644 index 0000000..909e8a3 --- /dev/null +++ b/test/test_form_for_helper.rb @@ -0,0 +1,83 @@ +require 'minitest/autorun' +require 'prism' + +class FormForHelperTest < Minitest::Test + FakeModel = Struct.new(:id, :title) do + def persisted? + !id.nil? + end + end + + HelpersWithCsrf = Struct.new(:csrf_token) + + def render(source, helpers = nil) + Prism::Template.render(source, {}, nil, helpers) + end + + def test_form_for_new_record_uses_post + html = render(<<~ERB) + <% form_for(FormForHelperTest::FakeModel.new, url: "/posts") do |f| %> + <%== f.text_field(:title) %> + <% end %> + ERB + assert_includes html, '
' + refute_includes html, '_method' + assert_includes html, 'name="fake_model[title]"' + assert_includes html, '
' + end + + def test_form_for_persisted_record_defaults_to_patch_via_method_override + html = render(<<~ERB) + <% form_for(FormForHelperTest::FakeModel.new(1, "hi"), url: "/posts/1") do |f| %> + <%== f.submit %> + <% end %> + ERB + assert_includes html, '
' + assert_includes html, '' + end + + def test_form_for_explicit_method_delete + html = render(<<~ERB) + <% form_for(FormForHelperTest::FakeModel.new, url: "/posts/1", method: :delete) do |f| %> + <% end %> + ERB + assert_includes html, '' + end + + def test_form_for_embeds_csrf_token_when_helpers_provide_one + helpers = HelpersWithCsrf.new('secrettoken') + html = render(<<~ERB, helpers) + <% form_for(FormForHelperTest::FakeModel.new, url: "/posts") do |f| %> + <% end %> + ERB + assert_includes html, '' + end + + def test_form_for_without_helpers_has_no_csrf_field + html = render(<<~ERB) + <% form_for(FormForHelperTest::FakeModel.new, url: "/posts") do |f| %> + <% end %> + ERB + refute_includes html, 'authenticity_token' + end + + def test_form_for_get_has_no_csrf_field + helpers = HelpersWithCsrf.new('secrettoken') + html = render(<<~ERB, helpers) + <% form_for(FormForHelperTest::FakeModel.new, url: "/posts", method: :get) do |f| %> + <% end %> + ERB + refute_includes html, 'authenticity_token' + end + + def test_content_between_form_fields_is_placed_in_order + html = render(<<~ERB) + <% form_for(FormForHelperTest::FakeModel.new, url: "/posts") do |f| %> + <%== f.label(:title) %> + <%== f.text_field(:title) %> + <% end %> + ERB + assert html.index('') + end +end diff --git a/test/test_fragment_cache_viewer_independent.rb b/test/test_fragment_cache_viewer_independent.rb new file mode 100644 index 0000000..63ad12a --- /dev/null +++ b/test/test_fragment_cache_viewer_independent.rb @@ -0,0 +1,86 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +# Session 62's "kierunki dalsze" debt, closed here: examples/blog/app/views/ +# posts/index.erb wraps its "recent posts" sidebar in `cache(:recent_posts_ +# sidebar, ...)` but does NOT include current_user in the cache key, unlike +# the fresh_when etag on the same action (fixed in session 62, see +# test_conditional_get_user_scoped_etag.rb). That asymmetry is deliberate, +# not a leftover bug: the sidebar's own content (5 most recent post titles) +# never depends on who is looking, and the parts of the page that DO vary +# per viewer (signed-in-as banner, new-post form) render outside the cached +# block entirely - so unlike the etag case, sharing one cache entry across +# every viewer can never leak one visitor's per-user markup into another's +# response. This test proves that reasoning against the exact template +# shape (a cache block with viewer-independent content, sitting next to +# viewer-dependent content rendered outside it), the same isolated-app +# convention test_cache_helpers.rb and test_conditional_get_user_scoped_ +# etag.rb already use rather than loading examples/blog/app.rb directly. +class FragmentCacheViewerIndependentTest < Minitest::Test + def setup + Prism::Cache.store.clear + @dir = Dir.mktmpdir('prism-fragment-cache-viewer') + FileUtils.mkdir_p(File.join(@dir, 'pages')) + @view = Prism::View.new(@dir) + write('pages/show.erb', <<~ERB) + <% cache(:recent_posts_sidebar, expires_in: 60) do %> + <%= render_count! %> recent posts, none of them yours + <% end %> + signed in as <%= viewer %> + ERB + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Cache.store.clear + end + + def write(relative, source) + File.write(File.join(@dir, relative), source) + end + + def render_as(viewer, tracker) + @view = Prism::View.new(@dir, helpers: Tracker.new(tracker)) + @view.render('pages/show', locals: { viewer: viewer }) + end + + def test_cache_block_renders_once_and_is_shared_across_different_viewers + tracker = [] + + anon_output = render_as('anonymous', tracker) + alice_output = render_as('alice', tracker) + bob_output = render_as('bob', tracker) + + assert_equal 1, tracker.size, + 'the fragment inside cache() should render exactly once total, not once per viewer' + assert_includes anon_output, '1 recent posts, none of them yours' + cached_line = anon_output.lines.find { |line| line.include?('recent posts') } + assert_equal cached_line, alice_output.lines.find { |line| line.include?('recent posts') } + assert_equal cached_line, bob_output.lines.find { |line| line.include?('recent posts') } + end + + def test_content_outside_the_cache_block_still_varies_per_viewer + tracker = [] + + anon_output = render_as('anonymous', tracker) + alice_output = render_as('alice', tracker) + + assert_includes anon_output, 'signed in as anonymous' + assert_includes alice_output, 'signed in as alice' + refute_equal anon_output, alice_output, + 'the viewer-dependent line must still differ even though the cached fragment is shared' + end + + class Tracker + def initialize(calls) + @calls = calls + end + + def render_count! + @calls << true + @calls.size + end + end +end From d57b53db31cc30ec69f273e15768a331e18af1f4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 17:28:03 +0200 Subject: [PATCH 158/245] test: cover HttpRangeTest, test_no_header_returns_nil and I18nModuleTest --- test/test_http_range.rb | 82 +++++++++++++++++++++++++++++++++++++ test/test_i18n.rb | 90 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 test/test_http_range.rb create mode 100644 test/test_i18n.rb diff --git a/test/test_http_range.rb b/test/test_http_range.rb new file mode 100644 index 0000000..043a378 --- /dev/null +++ b/test/test_http_range.rb @@ -0,0 +1,82 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class HttpRangeTest < Minitest::Test + def test_no_header_returns_nil + assert_nil Prism::HttpRange.parse(nil, 100) + end + + def test_non_bytes_unit_is_ignored + assert_nil Prism::HttpRange.parse('items=0-1', 100) + end + + def test_multi_range_is_ignored_not_unsatisfiable + assert_nil Prism::HttpRange.parse('bytes=0-10,20-30', 100) + end + + def test_simple_start_end + result = Prism::HttpRange.parse('bytes=0-9', 100) + assert_equal 0, result.start + assert_equal 9, result.finish + assert_equal 10, result.length + end + + def test_open_ended_range_clamps_to_file_size + result = Prism::HttpRange.parse('bytes=90-', 100) + assert_equal 90, result.start + assert_equal 99, result.finish + assert_equal 10, result.length + end + + def test_end_beyond_file_size_is_clamped_not_rejected + result = Prism::HttpRange.parse('bytes=0-999', 100) + assert_equal 99, result.finish + end + + def test_suffix_range_returns_last_n_bytes + result = Prism::HttpRange.parse('bytes=-10', 100) + assert_equal 90, result.start + assert_equal 99, result.finish + assert_equal 10, result.length + end + + def test_suffix_range_larger_than_file_clamps_to_whole_file + result = Prism::HttpRange.parse('bytes=-1000', 100) + assert_equal 0, result.start + assert_equal 99, result.finish + end + + def test_start_beyond_file_size_is_unsatisfiable + assert_equal :unsatisfiable, Prism::HttpRange.parse('bytes=100-200', 100) + end + + def test_start_after_end_is_unsatisfiable + assert_equal :unsatisfiable, Prism::HttpRange.parse('bytes=50-10', 100) + end + + def test_empty_start_and_end_is_unsatisfiable + assert_equal :unsatisfiable, Prism::HttpRange.parse('bytes=-', 100) + end + + def test_garbage_spec_is_unsatisfiable + assert_equal :unsatisfiable, Prism::HttpRange.parse('bytes=abc-def', 100) + end + + def test_zero_length_suffix_is_unsatisfiable + assert_equal :unsatisfiable, Prism::HttpRange.parse('bytes=-0', 100) + end + + def test_empty_file_is_always_unsatisfiable + assert_equal :unsatisfiable, Prism::HttpRange.parse('bytes=0-0', 0) + end + + def test_read_slice_returns_only_the_requested_bytes + Dir.mktmpdir do |dir| + path = File.join(dir, 'data.bin') + File.binwrite(path, '0123456789') + slice = Prism::HttpRange.parse('bytes=2-5', 10) + assert_equal '2345', Prism::HttpRange.read_slice(path, slice) + end + end +end diff --git a/test/test_i18n.rb b/test/test_i18n.rb new file mode 100644 index 0000000..cec3fa9 --- /dev/null +++ b/test/test_i18n.rb @@ -0,0 +1,90 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class I18nModuleTest < Minitest::Test + def setup + Prism::I18n.reset! + Prism::I18n.store_translations(:en, 'hello' => 'Hello') + Prism::I18n.store_translations(:pl, 'hello' => 'Czesc') + end + + def teardown + Prism::I18n.reset! + end + + def test_default_locale_is_en_out_of_the_box + assert_equal :en, Prism::I18n.default_locale + end + + def test_locale_reads_and_writes_current_locale + assert_equal :en, Prism::I18n.locale + Prism::I18n.locale = :pl + assert_equal :pl, Prism::I18n.locale + end + + def test_t_and_l_are_aliases + assert_equal Prism::I18n.translate('hello', locale: :en), Prism::I18n.t('hello', locale: :en) + time = Time.utc(2026, 1, 1) + assert_equal Prism::I18n.localize(time, locale: :en), Prism::I18n.l(time, locale: :en) + end + + def test_translate_uses_current_locale_by_default + Prism::I18n.locale = :pl + assert_equal 'Czesc', Prism::I18n.t('hello') + end + + def test_with_locale_is_scoped_and_restores_previous_value + Prism::I18n.locale = :en + Prism::I18n.with_locale(:pl) do + assert_equal 'Czesc', Prism::I18n.t('hello') + end + assert_equal :en, Prism::I18n.locale + end + + def test_with_locale_nests_correctly + Prism::I18n.with_locale(:en) do + Prism::I18n.with_locale(:pl) do + assert_equal :pl, Prism::I18n.locale + end + assert_equal :en, Prism::I18n.locale + end + end + + def test_available_locales_reflects_stored_translations + assert_equal [:en, :pl], Prism::I18n.available_locales.sort + end + + def test_load_translations_reads_every_json_file_under_load_path + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'fr.json'), '{"hello": "Bonjour"}') + Prism::I18n.load_path << dir + Prism::I18n.load_translations! + assert_equal 'Bonjour', Prism::I18n.t('hello', locale: :fr) + end + end + + def test_best_locale_from_accept_picks_exact_match_by_q_value + header = 'fr;q=0.5, pl;q=0.9, en;q=0.1' + assert_equal :pl, Prism::I18n.best_locale_from_accept(header) + end + + def test_best_locale_from_accept_matches_primary_subtag + assert_equal :en, Prism::I18n.best_locale_from_accept('en-US,en;q=0.8') + end + + def test_best_locale_from_accept_returns_nil_when_nothing_available + assert_nil Prism::I18n.best_locale_from_accept('de,es;q=0.5') + end + + def test_best_locale_from_accept_returns_nil_for_blank_header + assert_nil Prism::I18n.best_locale_from_accept('') + assert_nil Prism::I18n.best_locale_from_accept(nil) + end + + def test_changing_default_locale_invalidates_cached_translator + assert_equal 'Hello', Prism::I18n.t('hello', locale: :en) + Prism::I18n.default_locale = :pl + assert_equal 'Czesc', Prism::I18n.t('hello') + end +end From c5ce7d5ff430cbe81ba186739390b94104cc06a9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 17:29:17 +0200 Subject: [PATCH 159/245] test: cover I18nBackendTest, test_lookup_returns_nil_for_unknown_key --- test/test_i18n_backend.rb | 53 ++++++++++++++++++++++ test/test_i18n_controller.rb | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 test/test_i18n_backend.rb create mode 100644 test/test_i18n_controller.rb diff --git a/test/test_i18n_backend.rb b/test/test_i18n_backend.rb new file mode 100644 index 0000000..3668586 --- /dev/null +++ b/test/test_i18n_backend.rb @@ -0,0 +1,53 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class I18nBackendTest < Minitest::Test + def setup + @backend = Prism::I18n::Backend.new + end + + def test_lookup_returns_nil_for_unknown_key + assert_nil @backend.lookup(:en, 'missing.key') + end + + def test_store_and_lookup_nested_key + @backend.store_translations(:en, 'posts' => { 'title' => 'Title', 'body' => 'Body' }) + assert_equal 'Title', @backend.lookup(:en, 'posts.title') + assert_equal 'Body', @backend.lookup(:en, :'posts.body') + end + + def test_store_translations_deep_merges_instead_of_replacing + @backend.store_translations(:en, 'posts' => { 'title' => 'Title' }) + @backend.store_translations(:en, 'posts' => { 'body' => 'Body' }) + assert_equal 'Title', @backend.lookup(:en, 'posts.title') + assert_equal 'Body', @backend.lookup(:en, 'posts.body') + end + + def test_locales_lists_every_stored_locale + @backend.store_translations(:en, 'hello' => 'Hello') + @backend.store_translations(:pl, 'hello' => 'Czesc') + assert_equal [:en, :pl], @backend.locales.sort + end + + def test_lookup_returns_nil_when_key_path_hits_a_non_hash + @backend.store_translations(:en, 'hello' => 'Hello') + assert_nil @backend.lookup(:en, 'hello.nested') + end + + def test_load_file_reads_json_and_uses_basename_as_locale + Dir.mktmpdir do |dir| + path = File.join(dir, 'en.json') + File.write(path, '{"greeting": "Hi %{name}"}') + locale = @backend.load_file(path) + assert_equal :en, locale + assert_equal 'Hi %{name}', @backend.lookup(:en, 'greeting') + end + end + + def test_clear_removes_all_translations + @backend.store_translations(:en, 'hello' => 'Hello') + @backend.clear! + assert_empty @backend.locales + end +end diff --git a/test/test_i18n_controller.rb b/test/test_i18n_controller.rb new file mode 100644 index 0000000..e5a57ec --- /dev/null +++ b/test/test_i18n_controller.rb @@ -0,0 +1,85 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class GreetingsController < Prism::Controller + def show + html(t('hello')) + end + + def remember + session[:locale] = params['locale'] + html(t('hello')) + end + + def stamp + html(l(Time.utc(2026, 3, 5, 8, 30, 0))) + end +end + +class GreetingsApp < Prism::Application + enable_sessions secret: 'i18n-controller-test-secret' + + get '/greet', to: 'greetings#show' + get '/greet/remember', to: 'greetings#remember' + get '/greet/stamp', to: 'greetings#stamp' +end + +class I18nControllerTest < Minitest::Test + include TestSupport + + def setup + Prism::I18n.reset! + Prism::I18n.store_translations(:en, 'hello' => 'Hello') + Prism::I18n.store_translations(:pl, 'hello' => 'Czesc') + end + + def teardown + Prism::I18n.reset! + end + + def call(method, path, **opts) + GreetingsApp.call(env_for(method, path, **opts)) + end + + def test_defaults_to_default_locale_with_no_hints + _, _, body = call('GET', '/greet') + assert_equal 'Hello', body.join + end + + def test_locale_query_param_overrides_default + _, _, body = call('GET', '/greet?locale=pl') + assert_equal 'Czesc', body.join + end + + def test_unknown_locale_query_param_is_ignored + _, _, body = call('GET', '/greet?locale=de') + assert_equal 'Hello', body.join + end + + def test_accept_language_header_is_used_when_no_param_given + _, _, body = call('GET', '/greet', headers: { 'Accept-Language' => 'pl,en;q=0.5' }) + assert_equal 'Czesc', body.join + end + + def test_locale_param_wins_over_accept_language_header + _, _, body = call('GET', '/greet?locale=en', headers: { 'Accept-Language' => 'pl' }) + assert_equal 'Hello', body.join + end + + def test_session_locale_is_sticky_across_requests + status, headers, body = call('GET', '/greet/remember?locale=pl') + assert_equal 200, status + assert_equal 'Czesc', body.join + line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?('prism.session=') } + cookie = line.split(';').first + + _, _, body = call('GET', '/greet', cookies: cookie) + assert_equal 'Czesc', body.join + end + + def test_localize_helper_formats_time_in_view + _, _, body = call('GET', '/greet/stamp') + assert_equal '2026-03-05 08:30:00', body.join + end +end From 36b9c25afbb931d2c38bcb0e6ded219687d584af Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:07:20 +0200 Subject: [PATCH 160/245] test: cover I18nTranslatorTest and test_translate_plain_string --- test/test_i18n_translator.rb | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/test_i18n_translator.rb diff --git a/test/test_i18n_translator.rb b/test/test_i18n_translator.rb new file mode 100644 index 0000000..3eb225c --- /dev/null +++ b/test/test_i18n_translator.rb @@ -0,0 +1,82 @@ +require 'minitest/autorun' +require 'prism' + +class I18nTranslatorTest < Minitest::Test + def setup + @backend = Prism::I18n::Backend.new + @backend.store_translations(:en, { + 'greeting' => 'Hello, %{name}!', + 'apples' => { 'zero' => 'no apples', 'one' => 'one apple', 'other' => '%{count} apples' }, + 'cars' => { 'one' => 'one car', 'other' => '%{count} cars' }, + 'nav' => { 'home' => 'Home' } + }) + @backend.store_translations(:pl, 'nav' => { 'home' => 'Strona glowna' }) + @translator = Prism::I18n::Translator.new(@backend, default_locale: :en) + end + + def test_translate_plain_string + assert_equal 'Home', @translator.translate('nav.home', locale: :en) + end + + def test_translate_interpolates_named_placeholders + assert_equal 'Hello, Ada!', @translator.translate('greeting', locale: :en, name: 'Ada') + end + + def test_translate_missing_interpolation_argument_raises + assert_raises(Prism::I18n::Translator::MissingInterpolationError) do + @translator.translate('greeting', locale: :en) + end + end + + def test_translate_missing_key_raises_by_default + assert_raises(Prism::I18n::Translator::MissingTranslationError) do + @translator.translate('nowhere.at.all', locale: :en) + end + end + + def test_translate_missing_key_uses_default_when_given + assert_equal 'fallback text', @translator.translate('nowhere.at.all', locale: :en, default: 'fallback text') + end + + def test_translate_pluralizes_zero_one_other + assert_equal 'no apples', @translator.translate('apples', locale: :en, count: 0) + assert_equal 'one apple', @translator.translate('apples', locale: :en, count: 1) + assert_equal '5 apples', @translator.translate('apples', locale: :en, count: 5) + end + + def test_translate_pluralizes_zero_as_other_when_no_zero_key + assert_equal '0 cars', @translator.translate('cars', locale: :en, count: 0) + end + + def test_translate_falls_back_to_default_locale_for_missing_key_in_requested_locale + assert_equal 'Hello, Ada!', @translator.translate('greeting', locale: :pl, name: 'Ada') + end + + def test_translate_prefers_requested_locale_over_fallback + assert_equal 'Strona glowna', @translator.translate('nav.home', locale: :pl) + end + + def test_custom_fallback_chain_is_tried_before_default_locale + backend = Prism::I18n::Backend.new + backend.store_translations(:en, 'only_en' => 'English only') + backend.store_translations(:'pt-BR', 'greeting' => 'Ola') + translator = Prism::I18n::Translator.new(backend, default_locale: :en, fallbacks: { pt: [:'pt-BR'] }) + assert_equal 'Ola', translator.translate('greeting', locale: :pt) + end + + def test_localize_time_with_default_format + time = Time.utc(2026, 3, 5, 8, 30, 0) + assert_equal '2026-03-05 08:30:00', @translator.localize(time, locale: :en) + end + + def test_localize_date_with_default_format + date = Date.new(2026, 3, 5) + assert_equal '2026-03-05', @translator.localize(date, locale: :en) + end + + def test_localize_uses_named_format_from_backend + @backend.store_translations(:en, 'time' => { 'formats' => { 'short' => '%d.%m.%Y' } }) + time = Time.utc(2026, 3, 5, 8, 30, 0) + assert_equal '05.03.2026', @translator.localize(time, locale: :en, format: :short) + end +end From 3764cd98228c48654c522af163c236a8eb6e7853 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:09:30 +0200 Subject: [PATCH 161/245] test: cover InstrumentationTest and test_instrument_returns_block_value --- test/test_instrumentation.rb | 187 +++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/test_instrumentation.rb diff --git a/test/test_instrumentation.rb b/test/test_instrumentation.rb new file mode 100644 index 0000000..27308d8 --- /dev/null +++ b/test/test_instrumentation.rb @@ -0,0 +1,187 @@ +require 'minitest/autorun' +require 'prism' +require 'stringio' + +class InstrumentationTest < Minitest::Test + def setup + @bus = Prism::Instrumentation::Bus.new + end + + def test_instrument_returns_block_value + result = @bus.instrument('demo.event') { 42 } + assert_equal 42, result + end + + def test_subscriber_receives_event_with_name_and_payload + events = [] + @bus.subscribe('demo.event') { |event| events << event } + @bus.instrument('demo.event', foo: 'bar') { :ok } + + assert_equal 1, events.size + assert_equal 'demo.event', events.first.name + assert_equal 'bar', events.first.payload[:foo] + end + + def test_duration_is_a_non_negative_float + events = [] + @bus.subscribe('demo.event') { |event| events << event } + @bus.instrument('demo.event') { sleep 0.01 } + + assert_kind_of Float, events.first.duration + assert_operator events.first.duration, :>=, 0.01 + assert_operator events.first.duration_ms, :>=, 10.0 + end + + def test_string_pattern_matches_exact_name_only + events = [] + @bus.subscribe('demo.event') { |event| events << event } + @bus.instrument('other.event') { :ok } + + assert_empty events + end + + def test_regexp_pattern_matches_by_suffix + events = [] + @bus.subscribe(/\.prism\z/) { |event| events << event } + @bus.instrument('sql.prism') { :ok } + @bus.instrument('unrelated.event') { :ok } + + assert_equal ['sql.prism'], events.map(&:name) + end + + def test_nil_pattern_matches_everything + events = [] + @bus.subscribe { |event| events << event } + @bus.instrument('a') { :ok } + @bus.instrument('b') { :ok } + + assert_equal %w[a b], events.map(&:name) + end + + def test_multiple_subscribers_all_notified + calls = [] + @bus.subscribe('demo.event') { calls << :first } + @bus.subscribe('demo.event') { calls << :second } + @bus.instrument('demo.event') { :ok } + + assert_equal %i[first second], calls + end + + def test_unsubscribe_stops_future_notifications + events = [] + subscriber = @bus.subscribe('demo.event') { |event| events << event } + @bus.instrument('demo.event') { :ok } + @bus.unsubscribe(subscriber) + @bus.instrument('demo.event') { :ok } + + assert_equal 1, events.size + end + + def test_block_can_mutate_payload_before_publish + events = [] + @bus.subscribe('demo.event') { |event| events << event } + @bus.instrument('demo.event', {}) { |payload| payload[:computed] = 'late' } + + assert_equal 'late', events.first.payload[:computed] + end + + def test_exception_propagates_and_is_recorded_on_payload + events = [] + @bus.subscribe('demo.event') { |event| events << event } + + assert_raises(RuntimeError) do + @bus.instrument('demo.event') { raise 'boom' } + end + + assert_equal 1, events.size, 'event must still publish when the block raises' + assert_equal ['RuntimeError', 'boom'], events.first.payload[:exception] + assert_kind_of RuntimeError, events.first.payload[:exception_object] + end + + def test_instrument_without_block_returns_nil_and_still_publishes + events = [] + @bus.subscribe('demo.event') { |event| events << event } + result = @bus.instrument('demo.event') + + assert_nil result + assert_equal 1, events.size + end + + def test_concurrent_instrument_and_subscribe_do_not_corrupt_state + counter = 0 + mutex = Mutex.new + @bus.subscribe('demo.event') { mutex.synchronize { counter += 1 } } + + threads = Array.new(20) do + Thread.new { 50.times { @bus.instrument('demo.event') { :ok } } } + end + threads.each(&:join) + + assert_equal 1000, counter + end + + def test_module_level_bus_is_a_singleton + assert_same Prism::Instrumentation.bus, Prism::Instrumentation.bus + end + + def test_module_level_instrument_and_subscribe_delegate_to_bus + events = [] + subscriber = Prism::Instrumentation.subscribe('module.demo') { |event| events << event } + Prism::Instrumentation.instrument('module.demo') { :ok } + Prism::Instrumentation.unsubscribe(subscriber) + + assert_equal 1, events.size + end +end + +class InstrumentationLogSubscriberTest < Minitest::Test + def setup + @bus = Prism::Instrumentation::Bus.new + @io = StringIO.new + end + + def test_attach_writes_one_formatted_line_per_event + subscribers = attach_to(@bus, @io, patterns: ['sql.prism']) + @bus.instrument('sql.prism', operation: 'SELECT', table: 'posts') { :ok } + subscribers.each { |s| @bus.unsubscribe(s) } + + line = @io.string.lines.first + assert_match(/sql\.prism \(\d+\.\d ms\)/, line.gsub('ms)', ' ms)')) + assert_match(/operation="SELECT"/, line) + assert_match(/table="posts"/, line) + end + + def test_attach_ignores_events_not_matching_pattern + attach_to(@bus, @io, patterns: ['sql.prism']) + @bus.instrument('render_template.prism', identifier: 'posts/index') { :ok } + + assert_empty @io.string + end + + def test_attach_hides_exception_object_from_output + attach_to(@bus, @io, patterns: ['demo.event']) + assert_raises(RuntimeError) { @bus.instrument('demo.event') { raise 'boom' } } + + refute_match(/exception_object/, @io.string) + assert_match(/exception=\["RuntimeError", "boom"\]/, @io.string) + end + + def test_attach_wires_to_the_process_wide_bus + subscribers = Prism::Instrumentation::LogSubscriber.attach(@io, patterns: ['demo.singleton']) + Prism::Instrumentation.instrument('demo.singleton', a: 1) { :ok } + subscribers.each { |s| Prism::Instrumentation.unsubscribe(s) } + Prism::Instrumentation.instrument('demo.singleton', a: 2) { :ok } + + assert_equal 1, @io.string.lines.size + assert_match(/a=1/, @io.string) + end + + private + + # Most assertions here only need LogSubscriber's formatting, so they run + # against a throwaway Bus instead of the process-wide singleton to stay + # isolated from other tests; #attach itself is exercised separately above. + def attach_to(bus, io, patterns:) + patterns.map { |pattern| bus.subscribe(pattern) { |event| io.puts(Prism::Instrumentation::LogSubscriber.format_event(event)) } } + end +end From 726e1cd9089c349a43d4a4be62457a46246265d4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:15:57 +0200 Subject: [PATCH 162/245] 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 From c0f208e83a546a90f97ebcd5e3c55ac720b8075c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:45:17 +0200 Subject: [PATCH 163/245] test: cover WelcomeEmailJob and sent_to --- test/test_jobs_job.rb | 94 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 test/test_jobs_job.rb diff --git a/test/test_jobs_job.rb b/test/test_jobs_job.rb new file mode 100644 index 0000000..aa208d3 --- /dev/null +++ b/test/test_jobs_job.rb @@ -0,0 +1,94 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class WelcomeEmailJob < Prism::Jobs::Job + queue_as :mailers + retry_on max_attempts: 7, backoff: :linear, base_delay: 5 + + def self.sent_to + @sent_to ||= [] + end + + def perform(user_id) + self.class.sent_to << user_id + end +end + +class PlainJob < Prism::Jobs::Job + def self.ran + @ran ||= false + end + + def self.reset! + @ran = false + end + + def perform + self.class.instance_variable_set(:@ran, true) + end +end + +class JobTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-jobs-job') + @queue = Prism::Jobs::Queue.new(File.join(@dir, 'jobs.pstore')) + Prism::Jobs.default_queue = @queue + WelcomeEmailJob.sent_to.clear + PlainJob.reset! + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_perform_now_runs_synchronously_without_touching_the_queue + WelcomeEmailJob.perform_now(101) + + assert_equal [101], WelcomeEmailJob.sent_to + assert_equal 0, @queue.size + end + + def test_perform_later_enqueues_with_the_declared_queue_and_retry_policy + WelcomeEmailJob.perform_later(202) + + job = @queue.pending_jobs.first + assert_equal 'WelcomeEmailJob', job[:job_class] + assert_equal [202], job[:args] + assert_equal 'mailers', job[:queue_name] + assert_equal 7, job[:max_attempts] + assert_equal({ strategy: :linear, base_delay: 5 }, job[:backoff]) + assert_empty WelcomeEmailJob.sent_to + end + + def test_perform_later_can_override_the_declared_queue + WelcomeEmailJob.perform_later(303, queue: 'urgent_mailers') + + assert_equal 'urgent_mailers', @queue.pending_jobs.first[:queue_name] + end + + def test_perform_later_honors_an_explicit_run_at + future = Time.now + 3600 + WelcomeEmailJob.perform_later(404, run_at: future) + + assert_nil @queue.next_ready + job = @queue.pending_jobs.first + assert_in_delta future.to_f, job[:run_at].to_f, 1.0 + end + + def test_job_without_queue_as_or_retry_on_uses_sane_defaults + PlainJob.perform_later + + job = @queue.pending_jobs.first + assert_equal 'default', job[:queue_name] + assert_equal 5, job[:max_attempts] + end + + def test_enqueued_jobs_actually_run_through_a_worker + WelcomeEmailJob.perform_later(505) + Prism::Jobs::Worker.new(@queue).work_off + + assert_equal [505], WelcomeEmailJob.sent_to + end +end From c26e4c77dc4022b128aae774501dd96e79833131 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 20:14:49 +0200 Subject: [PATCH 164/245] test: cover JobsQueueTest and teardown --- test/test_jobs_queue.rb | 187 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/test_jobs_queue.rb diff --git a/test/test_jobs_queue.rb b/test/test_jobs_queue.rb new file mode 100644 index 0000000..136b574 --- /dev/null +++ b/test/test_jobs_queue.rb @@ -0,0 +1,187 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class JobsQueueTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-jobs-queue') + @queue = Prism::Jobs::Queue.new(File.join(@dir, 'jobs.pstore')) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_enqueue_returns_an_incrementing_id + first = @queue.enqueue(job_class: 'X', args: []) + second = @queue.enqueue(job_class: 'X', args: []) + assert_equal first + 1, second + end + + def test_next_ready_returns_the_earliest_due_pending_job + later = @queue.enqueue(job_class: 'X', args: [1], run_at: Time.now - 5) + sooner = @queue.enqueue(job_class: 'X', args: [2], run_at: Time.now - 50) + job = @queue.next_ready + assert_equal sooner, job[:id] + refute_equal later, job[:id] + end + + def test_jobs_scheduled_in_the_future_are_not_ready + @queue.enqueue(job_class: 'X', args: [], run_at: Time.now + 3600) + assert_nil @queue.next_ready + end + + def test_next_ready_respects_queue_name_filter + @queue.enqueue(job_class: 'X', args: [], queue_name: 'mailers') + critical_id = @queue.enqueue(job_class: 'X', args: [], queue_name: 'critical') + job = @queue.next_ready(queue_name: 'critical') + assert_equal critical_id, job[:id] + end + + def test_claim_next_returns_the_earliest_due_pending_job_and_marks_it_processing + later = @queue.enqueue(job_class: 'X', args: [1], run_at: Time.now - 5) + sooner = @queue.enqueue(job_class: 'X', args: [2], run_at: Time.now - 50) + + job = @queue.claim_next! + assert_equal sooner, job[:id] + refute_equal later, job[:id] + assert_equal 'processing', job[:status] + assert_equal 'processing', @queue.pending_jobs.find { |j| j[:id] == sooner }[:status] + end + + def test_claim_next_never_returns_the_same_job_twice + id = @queue.enqueue(job_class: 'X', args: []) + first = @queue.claim_next! + second = @queue.claim_next! + assert_equal id, first[:id] + assert_nil second + end + + def test_claim_next_respects_queue_name_filter + @queue.enqueue(job_class: 'X', args: [], queue_name: 'mailers') + critical_id = @queue.enqueue(job_class: 'X', args: [], queue_name: 'critical') + job = @queue.claim_next!(queue_name: 'critical') + assert_equal critical_id, job[:id] + end + + def test_claim_next_is_nil_when_nothing_is_ready + @queue.enqueue(job_class: 'X', args: [], run_at: Time.now + 3600) + assert_nil @queue.claim_next! + end + + # Real cross-process race: two OS processes sharing the same PStore file + # both try to grab the same batch of jobs at once. #claim_next! selects + # and flips status to 'processing' inside one flock'd write transaction, + # so the file itself is the arbiter of who wins each job - no job should + # ever be claimed by more than one process. + def test_claim_next_is_safe_across_real_concurrent_processes + queue_path = File.join(@dir, 'jobs.pstore') + job_count = 40 + job_count.times { @queue.enqueue(job_class: 'X', args: []) } + + claims_dir = File.join(@dir, 'claims') + FileUtils.mkdir_p(claims_dir) + claimer_script = File.join(@dir, 'claimer.rb') + File.write(claimer_script, <<~RUBY) + require 'prism' + queue = Prism::Jobs::Queue.new(#{queue_path.inspect}) + claims_dir = #{claims_dir.inspect} + loop do + job = queue.claim_next! + break unless job + File.write(File.join(claims_dir, "\#{job[:id]}.claim"), Process.pid.to_s) + end + RUBY + + pids = 4.times.map do + Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), claimer_script, + out: File::NULL, err: File::NULL) + end + pids.each { |pid| Process.waitpid(pid) } + + claim_files = Dir.glob(File.join(claims_dir, '*.claim')) + assert_equal job_count, claim_files.size, 'every enqueued job should have been claimed exactly once' + assert_equal claim_files.size, claim_files.map { |f| File.basename(f) }.uniq.size, + 'no job id should have produced more than one claim file' + end + + def test_mark_completed_removes_the_job + id = @queue.enqueue(job_class: 'X', args: []) + @queue.mark_completed(id) + assert_nil @queue.next_ready + assert_equal 0, @queue.size + end + + def test_mark_failed_reschedules_with_exponential_backoff_until_max_attempts + id = @queue.enqueue(job_class: 'X', args: [], max_attempts: 3, backoff: { strategy: :exponential, base_delay: 2 }) + before = Time.now + @queue.mark_failed(id, 'boom') + job = @queue.pending_jobs.first + assert_equal 1, job[:attempts] + assert_equal 'pending', job[:status] + assert_equal 'boom', job[:last_error] + assert_in_delta (before + 2).to_f, job[:run_at].to_f, 1.0 + + @queue.mark_failed(id, 'boom again') + job = @queue.pending_jobs.first + assert_equal 2, job[:attempts] + assert_in_delta (before + 4).to_f, job[:run_at].to_f, 1.0 + end + + def test_mark_failed_dead_letters_after_exhausting_max_attempts + id = @queue.enqueue(job_class: 'X', args: [], max_attempts: 2) + @queue.mark_failed(id, 'first failure') + @queue.mark_failed(id, 'second failure') + + assert_equal 0, @queue.size + assert_equal 1, @queue.dead_letter_size + dead = @queue.dead_jobs.first + assert_equal 'dead', dead[:status] + assert_equal 'second failure', dead[:last_error] + end + + def test_retry_dead_moves_a_dead_job_back_to_pending + id = @queue.enqueue(job_class: 'X', args: [], max_attempts: 1) + @queue.mark_failed(id, 'fatal') + assert_equal 1, @queue.dead_letter_size + + @queue.retry_dead(id) + assert_equal 0, @queue.dead_letter_size + job = @queue.next_ready + assert_equal id, job[:id] + assert_equal 0, job[:attempts] + assert_nil job[:last_error] + end + + def test_linear_backoff_grows_by_a_constant_step + id = @queue.enqueue(job_class: 'X', args: [], max_attempts: 5, backoff: { strategy: :linear, base_delay: 3 }) + before = Time.now + @queue.mark_failed(id, 'e1') + job = @queue.pending_jobs.first + assert_in_delta (before + 3).to_f, job[:run_at].to_f, 1.0 + + @queue.mark_failed(id, 'e2') + job = @queue.pending_jobs.first + assert_in_delta (before + 6).to_f, job[:run_at].to_f, 1.0 + end + + def test_state_survives_reopening_the_store + id = @queue.enqueue(job_class: 'PersistedJob', args: [42]) + path = File.join(@dir, 'jobs.pstore') + reopened = Prism::Jobs::Queue.new(path) + job = reopened.next_ready + assert_equal id, job[:id] + assert_equal [42], job[:args] + end + + def test_clear_empties_both_tables + @queue.enqueue(job_class: 'X', args: []) + dead_id = @queue.enqueue(job_class: 'X', args: [], max_attempts: 1) + @queue.mark_failed(dead_id, 'boom') + + @queue.clear! + assert_equal 0, @queue.size + assert_equal 0, @queue.dead_letter_size + end +end From 10cabe922dde50325eab876c0213472a2eb3f6f5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 21:41:09 +0200 Subject: [PATCH 165/245] test: cover RecordingJob and log --- test/test_jobs_worker.rb | 160 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/test_jobs_worker.rb diff --git a/test/test_jobs_worker.rb b/test/test_jobs_worker.rb new file mode 100644 index 0000000..fd31b09 --- /dev/null +++ b/test/test_jobs_worker.rb @@ -0,0 +1,160 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class RecordingJob + def self.log + @log ||= [] + end + + def perform(value) + self.class.log << value + end +end + +class FlakyJob + def self.attempts + @attempts ||= 0 + end + + def self.reset! + @attempts = 0 + end + + def perform + self.class.instance_variable_set(:@attempts, self.class.attempts + 1) + raise "not yet (attempt #{self.class.attempts})" if self.class.attempts < 3 + end +end + +class AlwaysFailsJob + def perform + raise 'nope' + end +end + +class WorkerTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-jobs-worker') + @queue = Prism::Jobs::Queue.new(File.join(@dir, 'jobs.pstore')) + RecordingJob.log.clear + FlakyJob.reset! + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_work_off_performs_every_ready_job + @queue.enqueue(job_class: 'RecordingJob', args: ['a']) + @queue.enqueue(job_class: 'RecordingJob', args: ['b']) + + processed = Prism::Jobs::Worker.new(@queue).work_off + + assert_equal 2, processed + assert_equal %w[a b], RecordingJob.log.sort + assert_equal 0, @queue.size + end + + def test_work_off_does_not_touch_jobs_scheduled_in_the_future + @queue.enqueue(job_class: 'RecordingJob', args: ['now']) + @queue.enqueue(job_class: 'RecordingJob', args: ['later'], run_at: Time.now + 3600) + + processed = Prism::Jobs::Worker.new(@queue).work_off + + assert_equal 1, processed + assert_equal ['now'], RecordingJob.log + assert_equal 1, @queue.size + end + + def test_failing_job_is_retried_and_eventually_succeeds + @queue.enqueue(job_class: 'FlakyJob', args: []) + worker = Prism::Jobs::Worker.new(@queue) + + worker.work_off + assert_equal 0, worker.work_off, 'retry is backed off into the future, not immediately ready' + force_all_due + + worker.work_off + force_all_due + worker.work_off + + assert_equal 0, @queue.size + assert_equal 3, FlakyJob.attempts + end + + def test_job_that_always_fails_is_dead_lettered_after_max_attempts + @queue.enqueue(job_class: 'AlwaysFailsJob', args: [], max_attempts: 2) + worker = Prism::Jobs::Worker.new(@queue) + + worker.work_off + force_all_due + worker.work_off + + assert_equal 0, @queue.size + assert_equal 1, @queue.dead_letter_size + assert_match(/nope/, @queue.dead_jobs.first[:last_error]) + end + + def test_error_handler_is_invoked_with_the_exception_and_job + seen = [] + @queue.enqueue(job_class: 'AlwaysFailsJob', args: [], max_attempts: 5) + worker = Prism::Jobs::Worker.new(@queue, error_handler: ->(error, job) { seen << [error.message, job[:job_class]] }) + + worker.work_off + + assert_equal [['nope', 'AlwaysFailsJob']], seen + end + + def test_work_off_can_be_scoped_to_a_single_queue_name + @queue.enqueue(job_class: 'RecordingJob', args: ['default-queue']) + @queue.enqueue(job_class: 'RecordingJob', args: ['mailers-queue'], queue_name: 'mailers') + + processed = Prism::Jobs::Worker.new(@queue, queue_name: 'mailers').work_off + + assert_equal 1, processed + assert_equal ['mailers-queue'], RecordingJob.log + assert_equal 1, @queue.size + end + + def test_process_publishes_perform_job_instrumentation_event + @queue.enqueue(job_class: 'RecordingJob', args: ['c'], queue_name: 'mailers') + events = [] + subscriber = Prism::Instrumentation.subscribe('perform_job.prism') { |event| events << event } + + Prism::Jobs::Worker.new(@queue, queue_name: 'mailers').work_off + + assert_equal 1, events.size + assert_equal 'RecordingJob', events.first.payload[:job_class] + assert_equal 'mailers', events.first.payload[:queue_name] + assert_equal 'completed', events.first.payload[:status] + assert_kind_of Float, events.first.duration + ensure + Prism::Instrumentation.unsubscribe(subscriber) + end + + def test_process_publishes_instrumentation_event_with_exception_on_failure + @queue.enqueue(job_class: 'AlwaysFailsJob', args: [], max_attempts: 5) + events = [] + subscriber = Prism::Instrumentation.subscribe('perform_job.prism') { |event| events << event } + + Prism::Jobs::Worker.new(@queue).work_off + + assert_equal 1, events.size + assert_nil events.first.payload[:status] + assert_equal 'nope', events.first.payload[:exception][1] + ensure + Prism::Instrumentation.unsubscribe(subscriber) + end + + private + + # Real retries are scheduled with backoff into the future; tests that + # need to observe a second/third attempt without sleeping reach into the + # store to pull a job's run_at back into the past instead. + def force_all_due + pstore = @queue.instance_variable_get(:@pstore) + pstore.transaction { pstore[:jobs].each_value { |job| job[:run_at] = Time.now - 1 } } + end +end From ebd0b88bd66049303d620fe9d5d007d2589adaf0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 10:11:04 +0200 Subject: [PATCH 166/245] test: cover MailerBaseTest, welcome and MailerDeliveryJobTest --- test/test_mailer_base.rb | 108 +++++++++++++++++++++++++++++++ test/test_mailer_delivery_job.rb | 86 ++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 test/test_mailer_base.rb create mode 100644 test/test_mailer_delivery_job.rb diff --git a/test/test_mailer_base.rb b/test/test_mailer_base.rb new file mode 100644 index 0000000..bfeecf3 --- /dev/null +++ b/test/test_mailer_base.rb @@ -0,0 +1,108 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class MailerBaseTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-mailer-base') + FileUtils.mkdir_p(File.join(@dir, 'user')) + Prism::Mailer.deliveries.clear + Prism::Mailer.delivery_method = :test + + write('user/welcome.text.erb', "Hi <%= name %>, welcome!\n") + write('user/welcome.html.erb', "

Hi <%= name %>, welcome!

\n") + write('user/text_only.text.erb', "plain only for <%= name %>\n") + + mailer_dir = @dir + @mailer_class = Class.new(Prism::Mailer::Base) do + self.template_roots = [mailer_dir] + default from: 'noreply@example.com' + + def welcome(user) + @name = user[:name] + mail(to: user[:email], subject: 'Welcome!') + end + + def text_only(user) + @name = user[:name] + mail(to: user[:email], subject: 'Plain', template: 'text_only') + end + + def missing_template(user) + mail(to: user[:email], subject: 'Nope', template: 'does_not_exist') + end + end + Object.const_set(:UserMailer, @mailer_class) + end + + def teardown + Object.send(:remove_const, :UserMailer) if Object.const_defined?(:UserMailer) + FileUtils.remove_entry(@dir) + end + + def write(relative, source) + File.write(File.join(@dir, relative), source) + end + + def test_mailer_name_strips_the_mailer_suffix_and_underscores + assert_equal 'user', UserMailer.mailer_name + end + + def test_calling_an_action_as_a_class_method_renders_both_bodies_with_auto_locals + delivery = UserMailer.welcome(name: 'Ada', email: 'ada@example.com') + message = delivery.message + assert_equal ['ada@example.com'], message.to + assert_equal 'Welcome!', message.subject + assert_equal 'noreply@example.com', message.from + assert_equal "Hi Ada, welcome!\n", message.text_body + assert_equal "

Hi Ada, welcome!

\n", message.html_body + end + + def test_default_options_apply_before_the_action_runs + delivery = UserMailer.welcome(name: 'Bob', email: 'bob@example.com') + assert_equal 'noreply@example.com', delivery.message.from + end + + def test_deliver_now_hands_the_message_to_prism_mailer + UserMailer.welcome(name: 'Cleo', email: 'cleo@example.com').deliver_now + assert_equal 1, Prism::Mailer.deliveries.size + assert_includes Prism::Mailer.deliveries.last.to_s, 'Cleo' + end + + def test_deliver_now_publishes_deliver_mail_instrumentation_event + events = [] + subscriber = Prism::Instrumentation.subscribe('deliver_mail.prism') { |event| events << event } + + UserMailer.welcome(name: 'Eve', email: 'eve@example.com').deliver_now + + assert_equal 1, events.size + assert_equal 'Welcome!', events.first.payload[:subject] + assert_equal ['eve@example.com'], events.first.payload[:to] + assert_equal :test, events.first.payload[:delivery_method] + assert_kind_of Float, events.first.duration + ensure + Prism::Instrumentation.unsubscribe(subscriber) + end + + def test_explicit_template_option_overrides_the_action_name + delivery = UserMailer.text_only(name: 'Dee', email: 'dee@example.com') + assert_equal "plain only for Dee\n", delivery.message.text_body + assert_nil delivery.message.html_body + end + + def test_missing_template_for_both_variants_raises_template_not_found + assert_raises(Prism::View::TemplateNotFound) do + UserMailer.missing_template(email: 'nobody@example.com') + end + end + + def test_unknown_class_method_still_raises_no_method_error + assert_raises(NoMethodError) { UserMailer.does_not_exist_at_all } + end + + def test_responds_to_defined_actions_but_not_arbitrary_names + assert UserMailer.respond_to?(:welcome) + refute UserMailer.respond_to?(:not_a_real_action) + end +end diff --git a/test/test_mailer_delivery_job.rb b/test/test_mailer_delivery_job.rb new file mode 100644 index 0000000..2166bbb --- /dev/null +++ b/test/test_mailer_delivery_job.rb @@ -0,0 +1,86 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class MailerDeliveryJobTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-mailer-delivery-job') + FileUtils.mkdir_p(File.join(@dir, 'templates', 'ping')) + File.write(File.join(@dir, 'templates', 'ping', 'ping.text.erb'), "ping <%= name %>\n") + + Prism::Mailer.deliveries.clear + Prism::Mailer.delivery_method = :test + + template_dir = File.join(@dir, 'templates') + @mailer_class = Class.new(Prism::Mailer::Base) do + self.template_roots = [template_dir] + default from: 'jobs@example.com' + + def ping(user) + @name = user[:name] + mail(to: user[:email], subject: 'Ping') + end + end + Object.const_set(:PingMailer, @mailer_class) + + @queue = Prism::Jobs::Queue.new(File.join(@dir, 'jobs.pstore')) + @previous_queue = Prism::Jobs.default_queue + Prism::Jobs.default_queue = @queue + end + + def teardown + Prism::Jobs.default_queue = @previous_queue + Object.send(:remove_const, :PingMailer) if Object.const_defined?(:PingMailer) + FileUtils.remove_entry(@dir) + end + + def test_deliver_later_enqueues_a_real_job_that_a_worker_can_process + PingMailer.ping(name: 'Finn', email: 'finn@example.com').deliver_later + assert_equal 1, @queue.size + + worker = Prism::Jobs::Worker.new(@queue) + processed = worker.work_off + + assert_equal 1, processed + assert_equal 0, @queue.size + assert_equal 1, Prism::Mailer.deliveries.size + assert_includes Prism::Mailer.deliveries.last.to_s, 'ping Finn' + end + + def test_deliver_later_job_survives_reading_back_from_a_freshly_opened_queue + PingMailer.ping(name: 'Gwen', email: 'gwen@example.com').deliver_later + + reopened = Prism::Jobs::Queue.new(File.join(@dir, 'jobs.pstore')) + worker = Prism::Jobs::Worker.new(reopened) + assert_equal 1, worker.work_off + assert_equal 1, Prism::Mailer.deliveries.size + end + + def test_failed_delivery_is_retried_then_dead_lettered_like_any_other_job + PingMailer.ping(name: 'Hana', email: 'hana@example.com').deliver_later(queue: 'mailers') + Prism::Mailer.delivery_method = :smtp + Prism::Mailer.smtp_settings = { address: '127.0.0.1', port: 1 } + + worker = Prism::Jobs::Worker.new(@queue, queue_name: 'mailers') + 5.times do + worker.work_off + force_all_due + end + + assert_equal 1, @queue.dead_letter_size + assert_equal 0, Prism::Mailer.deliveries.size + ensure + Prism::Mailer.delivery_method = :test + end + + private + + # Same trick as test_jobs_worker.rb: retries are scheduled with real + # backoff into the future, so tests pull run_at back into the past + # instead of sleeping for it. + def force_all_due + pstore = @queue.instance_variable_get(:@pstore) + pstore.transaction { pstore[:jobs].each_value { |job| job[:run_at] = Time.now - 1 } } + end +end From 7587a4010d5d23f90c5a6e27bf199f0d0c466c39 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 11:01:26 +0200 Subject: [PATCH 167/245] test: cover MailerMessageTest, test_requires_a_from_address --- test/test_mailer_message.rb | 99 +++++++++++++++++++++++++++++++++++++ test/test_mailer_preview.rb | 70 ++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 test/test_mailer_message.rb create mode 100644 test/test_mailer_preview.rb diff --git a/test/test_mailer_message.rb b/test/test_mailer_message.rb new file mode 100644 index 0000000..a790f9c --- /dev/null +++ b/test/test_mailer_message.rb @@ -0,0 +1,99 @@ +require 'minitest/autorun' +require 'prism' + +class MailerMessageTest < Minitest::Test + def setup + @message = Prism::Mailer::Message.new + @message.from = 'sender@example.com' + @message.to = 'receiver@example.com' + @message.subject = 'Test subject' + end + + def test_requires_a_from_address + @message.from = nil + @message.text_body = 'body' + assert_raises(ArgumentError) { @message.to_s } + end + + def test_requires_at_least_one_recipient + @message.to = [] + @message.text_body = 'body' + assert_raises(ArgumentError) { @message.to_s } + end + + def test_requires_a_body + assert_raises(ArgumentError) { @message.to_s } + end + + def test_to_cc_bcc_coerce_a_single_address_into_an_array + @message.to = 'a@example.com' + @message.cc = 'b@example.com' + @message.bcc = 'c@example.com' + assert_equal ['a@example.com'], @message.to + assert_equal %w[a@example.com b@example.com c@example.com], @message.all_recipients + end + + def test_plain_text_only_message_has_a_single_text_content_type + @message.text_body = 'hello world' + raw = @message.to_s + assert_includes raw, "Content-Type: text/plain; charset=UTF-8\r\n\r\nhello world" + refute_includes raw, 'multipart' + end + + def test_html_only_message_has_a_single_html_content_type + @message.html_body = '

hello

' + raw = @message.to_s + assert_includes raw, "Content-Type: text/html; charset=UTF-8\r\n\r\n

hello

" + refute_includes raw, 'multipart' + end + + def test_text_and_html_together_build_a_multipart_alternative_part + @message.text_body = 'plain body' + @message.html_body = '

html body

' + raw = @message.to_s + assert_match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/, raw) + boundary = raw[/boundary="([^"]+)"/, 1] + assert_includes raw, "--#{boundary}\r\nContent-Type: text/plain" + assert_includes raw, "--#{boundary}\r\nContent-Type: text/html" + assert_includes raw, "--#{boundary}--" + end + + def test_attachment_wraps_the_body_in_multipart_mixed_and_base64_encodes_content + @message.text_body = 'see attached' + @message.add_attachment('data.bin', "\xFF\x00\xFE binary".b, mime_type: 'application/octet-stream') + raw = @message.to_s + assert_match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/, raw) + assert_includes raw, 'Content-Disposition: attachment; filename="data.bin"' + assert_includes raw, 'Content-Transfer-Encoding: base64' + encoded = raw[/Content-Disposition: attachment; filename="data\.bin"\r\n\r\n(.+?)\r\n--/m, 1] + assert_equal "\xFF\x00\xFE binary".b, encoded.unpack1('m0') + end + + def test_headers_writer_adds_a_custom_header_line + @message.text_body = 'body' + @message['X-Custom'] = 'yes' + assert_includes @message.to_s, "X-Custom: yes\r\n" + end + + def test_reply_to_and_cc_appear_in_headers + @message.text_body = 'body' + @message.cc = 'cc@example.com' + @message.reply_to = 'reply@example.com' + raw = @message.to_s + assert_includes raw, "Cc: cc@example.com\r\n" + assert_includes raw, "Reply-To: reply@example.com\r\n" + end + + def test_non_ascii_subject_is_mime_encoded + @message.subject = "Zaproszenie na przyj\u0119cie" + @message.text_body = 'body' + subject_line = @message.to_s.lines.find { |l| l.start_with?('Subject:') } + assert_match(/Subject: =\?UTF-8\?B\?.+\?=/, subject_line) + end + + def test_ascii_subject_is_left_untouched + @message.text_body = 'body' + subject_line = @message.to_s.lines.find { |l| l.start_with?('Subject:') } + assert_equal 'Subject: Test subject', subject_line.chomp("\n") + end +end diff --git a/test/test_mailer_preview.rb b/test/test_mailer_preview.rb new file mode 100644 index 0000000..7c37667 --- /dev/null +++ b/test/test_mailer_preview.rb @@ -0,0 +1,70 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class MailerPreviewTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-mailer-preview') + FileUtils.mkdir_p(File.join(@dir, 'preview_user')) + File.write(File.join(@dir, 'preview_user/welcome.text.erb'), "Hi <%= name %>\n") + + mailer_dir = @dir + @mailer_class = Class.new(Prism::Mailer::Base) do + self.template_roots = [mailer_dir] + + def welcome(name) + @name = name + mail(to: 'someone@example.com', subject: 'Welcome!') + end + end + Object.const_set(:PreviewUserMailer, @mailer_class) + + mailer_class = @mailer_class + @preview_class = Class.new(Prism::Mailer::Preview) do + define_method(:welcome) { mailer_class.welcome('Fixture Fred') } + end + Object.const_set(:UserMailerPreview, @preview_class) + end + + def teardown + Prism::Mailer::Preview.registry.delete(@preview_class) + %i[UserMailerPreview PreviewUserMailer].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name) + end + FileUtils.remove_entry(@dir) + end + + def test_subclassing_registers_the_preview + assert_includes Prism::Mailer::Preview.registry, UserMailerPreview + end + + def test_preview_name_strips_the_preview_suffix_and_underscores + assert_equal 'user_mailer', UserMailerPreview.preview_name + end + + def test_emails_lists_the_defined_preview_actions + assert_equal ['welcome'], UserMailerPreview.emails + end + + def test_call_renders_the_action_and_returns_its_delivery + delivery = UserMailerPreview.call(:welcome) + assert_equal "Hi Fixture Fred\n", delivery.message.text_body + end + + def test_call_never_delivers_the_message + Prism::Mailer.deliveries.clear + UserMailerPreview.call(:welcome) + assert_empty Prism::Mailer.deliveries + end + + def test_call_raises_for_an_unknown_email + error = assert_raises(ArgumentError) { UserMailerPreview.call(:does_not_exist) } + assert_match(/unknown preview/, error.message) + end + + def test_find_looks_up_a_registered_preview_by_name + assert_equal UserMailerPreview, Prism::Mailer::Preview.find('user_mailer') + assert_nil Prism::Mailer::Preview.find('nonexistent') + end +end From 3fb6c9f5f390ccc7a6bffec681e51f0acba803f1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 13:23:03 +0200 Subject: [PATCH 168/245] test: cover MailerPreviewAppTest and welcome --- test/test_mailer_preview_app.rb | 100 ++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/test_mailer_preview_app.rb diff --git a/test/test_mailer_preview_app.rb b/test/test_mailer_preview_app.rb new file mode 100644 index 0000000..8729002 --- /dev/null +++ b/test/test_mailer_preview_app.rb @@ -0,0 +1,100 @@ +require 'minitest/autorun' +require 'prism' +require 'support' +require 'tmpdir' +require 'fileutils' + +class MailerPreviewAppTest < Minitest::Test + include TestSupport + + def setup + @dir = Dir.mktmpdir('prism-mailer-preview-app') + FileUtils.mkdir_p(File.join(@dir, 'preview_widget')) + File.write(File.join(@dir, 'preview_widget/welcome.text.erb'), "Hi <%= name %>\n") + File.write(File.join(@dir, 'preview_widget/welcome.html.erb'), "

Hi <%= name %>

\n") + + mailer_dir = @dir + @mailer_class = Class.new(Prism::Mailer::Base) do + self.template_roots = [mailer_dir] + + def welcome(name) + @name = name + mail(to: 'someone@example.com', from: 'app@example.com', subject: 'Welcome!') + end + end + Object.const_set(:PreviewWidgetMailer, @mailer_class) + + mailer_class = @mailer_class + @preview_class = Class.new(Prism::Mailer::Preview) do + define_method(:welcome) { mailer_class.welcome('Fixture Fred') } + end + Object.const_set(:WidgetMailerPreview, @preview_class) + + @app = Prism::Mailer::PreviewApp.new + end + + def teardown + Prism::Mailer::Preview.registry.delete(@preview_class) + %i[WidgetMailerPreview PreviewWidgetMailer].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name) + end + FileUtils.remove_entry(@dir) + end + + def call(path, script_name: '') + env = env_for('GET', path) + env['SCRIPT_NAME'] = script_name + @app.call(env) + end + + def test_index_lists_registered_previews + status, _, body = call('/') + assert_equal 200, status + assert_includes body.join, 'widget_mailer' + end + + def test_preview_page_lists_its_email_actions + status, _, body = call('/widget_mailer') + assert_equal 200, status + assert_includes body.join, 'welcome' + end + + def test_email_page_renders_the_actual_rendered_html_body + status, headers, body = call('/widget_mailer/welcome') + assert_equal 200, status + assert_equal 'text/html; charset=UTF-8', headers['Content-Type'] + assert_includes body.join, '

Hi Fixture Fred

' + assert_includes body.join, 'Welcome!' + end + + def test_email_page_never_delivers_the_message + Prism::Mailer.deliveries.clear + call('/widget_mailer/welcome') + assert_empty Prism::Mailer.deliveries + end + + def test_unknown_preview_is_a_404 + status, = call('/does_not_exist') + assert_equal 404, status + end + + def test_unknown_email_action_is_a_404 + status, = call('/widget_mailer/does_not_exist') + assert_equal 404, status + end + + def test_links_are_built_from_script_name_so_mounting_anywhere_works + _, _, body = call('/', script_name: '/rails/mailers') + assert_includes body.join, 'href="/rails/mailers/widget_mailer"' + end + + def test_mounted_end_to_end_through_an_application + host_app = Class.new(Prism::Application) do + mount '/rails/mailers', Prism::Mailer::PreviewApp.new + end + + status, _, body = host_app.call(env_for('GET', '/rails/mailers/widget_mailer/welcome')) + assert_equal 200, status + assert_includes body.join, '

Hi Fixture Fred

' + end +end From e1ef112db8bc59592d327dab7dca92221b63f6a8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 14:03:37 +0200 Subject: [PATCH 169/245] test: cover FakeSmtpServer and initialize --- test/test_mailer_smtp.rb | 144 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 test/test_mailer_smtp.rb diff --git a/test/test_mailer_smtp.rb b/test/test_mailer_smtp.rb new file mode 100644 index 0000000..b2445a3 --- /dev/null +++ b/test/test_mailer_smtp.rb @@ -0,0 +1,144 @@ +require 'minitest/autorun' +require 'prism' +require 'socket' + +# A minimal but real SMTP server: enough of RFC 5321 (EHLO/MAIL FROM/RCPT +# TO/DATA/QUIT) for Ruby's Net::SMTP client to complete a full session +# against it over an actual TCP socket. Used to prove Prism::Mailer's :smtp +# delivery path really speaks SMTP, not just that it calls into Net::SMTP. +class FakeSmtpServer + attr_reader :commands, :data + + def initialize + @server = TCPServer.new('127.0.0.1', 0) + @commands = [] + @data = nil + @mutex = Mutex.new + end + + def port + @server.addr[1] + end + + def start + @thread = Thread.new { accept_loop } + self + end + + def stop + @server.close + @thread&.join(1) + end + + private + + def accept_loop + loop do + client = @server.accept + handle(client) + client.close + end + rescue IOError, Errno::EBADF + nil + end + + def handle(client) + client.print "220 fake.smtp ready\r\n" + loop do + line = client.gets + break unless line + + record(line.chomp) + break unless dispatch(client, line) + end + end + + def record(line) + @mutex.synchronize { @commands << line } + end + + def dispatch(client, line) + case line + when /\A(EHLO|HELO)/i + client.print "250 fake.smtp\r\n" + when /\AMAIL FROM/i, /\ARCPT TO/i + client.print "250 OK\r\n" + when /\ADATA/i + client.print "354 go ahead\r\n" + read_data(client) + client.print "250 OK queued\r\n" + when /\AQUIT/i + client.print "221 bye\r\n" + return false + else + client.print "500 unrecognized\r\n" + end + true + end + + def read_data(client) + body = +'' + while (line = client.gets) + break if line == ".\r\n" + + body << line + end + @mutex.synchronize { @data = body } + end +end + +class MailerSmtpTest < Minitest::Test + def setup + @previous_method = Prism::Mailer.delivery_method + @previous_settings = Prism::Mailer.smtp_settings + end + + def teardown + Prism::Mailer.delivery_method = @previous_method + Prism::Mailer.smtp_settings = @previous_settings + end + + def test_smtp_delivery_speaks_real_smtp_to_a_live_server_over_tcp + server = FakeSmtpServer.new.start + Prism::Mailer.delivery_method = :smtp + Prism::Mailer.smtp_settings = { address: '127.0.0.1', port: server.port, domain: 'test.local' } + + message = Prism::Mailer::Message.new + message.from = 'sender@example.com' + message.to = 'receiver@example.com' + message.subject = 'Live SMTP test' + message.text_body = 'delivered over a real socket' + + Prism::Mailer.deliver!(message) + server.stop + + assert(server.commands.any? { |c| c =~ /\AMAIL FROM:/i }) + assert(server.commands.any? { |c| c =~ /\ARCPT TO:/i }) + assert_includes server.data, 'delivered over a real socket' + assert_includes server.data, 'Subject: Live SMTP test' + end + + def test_smtp_delivery_raises_when_no_server_is_listening + Prism::Mailer.delivery_method = :smtp + Prism::Mailer.smtp_settings = { address: '127.0.0.1', port: 1, domain: 'test.local' } + + message = Prism::Mailer::Message.new + message.from = 'sender@example.com' + message.to = 'receiver@example.com' + message.subject = 'Unreachable' + message.text_body = 'never sent' + + assert_raises(StandardError) { Prism::Mailer.deliver!(message) } + end + + def test_unknown_delivery_method_raises_immediately + Prism::Mailer.delivery_method = :carrier_pigeon + message = Prism::Mailer::Message.new + message.from = 'sender@example.com' + message.to = 'receiver@example.com' + message.subject = 'x' + message.text_body = 'x' + + assert_raises(ArgumentError) { Prism::Mailer.deliver!(message) } + end +end From 9445c0b35fed7d4997d3ac102ca861772630c93f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 14:10:13 +0200 Subject: [PATCH 170/245] test: cover MailerUrlForTest, welcome and MessageEncryptorTest --- test/test_mailer_url_for.rb | 86 ++++++++++++++++++++++++++++++++++ test/test_message_encryptor.rb | 59 +++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 test/test_mailer_url_for.rb create mode 100644 test/test_message_encryptor.rb diff --git a/test/test_mailer_url_for.rb b/test/test_mailer_url_for.rb new file mode 100644 index 0000000..22f06b8 --- /dev/null +++ b/test/test_mailer_url_for.rb @@ -0,0 +1,86 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class MailerUrlForTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-mailer-url-for') + FileUtils.mkdir_p(File.join(@dir, 'url_for')) + write('url_for/welcome.text.erb', "Click here: <%= url_for(:confirmation, id: user_id) %>\n") + + @router = Prism::Router.new + @router.add('get', '/confirmations/:id', name: :confirmation) + + mailer_dir = @dir + @mailer_class = Class.new(Prism::Mailer::Base) do + self.template_roots = [mailer_dir] + + def welcome(user_id) + @user_id = user_id + mail(to: 'someone@example.com', subject: 'Welcome!') + end + end + Object.const_set(:UrlForMailer, @mailer_class) + end + + def teardown + Object.send(:remove_const, :UrlForMailer) if Object.const_defined?(:UrlForMailer) + FileUtils.remove_entry(@dir) + Prism::Mailer.router = nil + Prism::Mailer.default_url_options = nil + end + + def write(relative, source) + File.write(File.join(@dir, relative), source) + end + + def test_url_for_raises_without_a_configured_router + Prism::Mailer.router = nil + error = assert_raises(RuntimeError) { UrlForMailer.welcome(42) } + assert_match(/no router configured/, error.message) + end + + def test_url_for_raises_without_a_host + Prism::Mailer.router = @router + error = assert_raises(ArgumentError) { UrlForMailer.welcome(42) } + assert_match(/:host/, error.message) + end + + def test_url_for_builds_an_absolute_url_from_global_default_url_options + Prism::Mailer.router = @router + Prism::Mailer.default_url_options = { host: 'example.com' } + + body = UrlForMailer.welcome(42).message.text_body + assert_equal "Click here: http://example.com/confirmations/42\n", body + end + + def test_per_mailer_class_default_url_options_win_over_the_global_ones + Prism::Mailer.router = @router + Prism::Mailer.default_url_options = { host: 'example.com', protocol: 'http' } + UrlForMailer.default_url_options(host: 'mail.example.com', protocol: 'https') + + body = UrlForMailer.welcome(42).message.text_body + assert_equal "Click here: https://mail.example.com/confirmations/42\n", body + end + + def test_port_is_included_when_given + Prism::Mailer.router = @router + Prism::Mailer.default_url_options = { host: 'example.com', port: 3000 } + + body = UrlForMailer.welcome(42).message.text_body + assert_equal "Click here: http://example.com:3000/confirmations/42\n", body + end + + def test_application_enable_mailer_wires_router_and_default_url_options + app_class = Class.new(Prism::Application) + app_class.router.add('get', '/confirmations/:id', name: :confirmation) + app_class.enable_mailer(host: 'app.example.com', protocol: 'https') + + assert_same app_class.router, Prism::Mailer.router + assert_equal({ host: 'app.example.com', protocol: 'https' }, Prism::Mailer.default_url_options) + + body = UrlForMailer.welcome(7).message.text_body + assert_equal "Click here: https://app.example.com/confirmations/7\n", body + end +end diff --git a/test/test_message_encryptor.rb b/test/test_message_encryptor.rb new file mode 100644 index 0000000..89dfa0e --- /dev/null +++ b/test/test_message_encryptor.rb @@ -0,0 +1,59 @@ +require 'minitest/autorun' +require 'prism' + +class MessageEncryptorTest < Minitest::Test + def setup + @key = Prism::MessageEncryptor.derive_key('super-secret-app-key') + @encryptor = Prism::MessageEncryptor.new(@key) + end + + def test_derived_key_has_correct_length + assert_equal 32, @key.bytesize + end + + def test_derive_key_is_deterministic_for_same_secret_and_salt + assert_equal @key, Prism::MessageEncryptor.derive_key('super-secret-app-key') + end + + def test_derive_key_differs_per_salt + other = Prism::MessageEncryptor.derive_key('super-secret-app-key', salt: 'other-salt') + refute_equal @key, other + end + + def test_round_trips_a_hash + token = @encryptor.encrypt_and_sign('admin' => true, 'user_id' => 7) + assert_equal({ 'admin' => true, 'user_id' => 7 }, @encryptor.decrypt_and_verify(token)) + end + + def test_ciphertext_does_not_contain_plaintext + token = @encryptor.encrypt_and_sign('super-secret-value-xyz') + refute_includes token, 'super-secret-value-xyz' + end + + def test_two_encryptions_of_same_value_differ + first = @encryptor.encrypt_and_sign('same value') + second = @encryptor.encrypt_and_sign('same value') + refute_equal first, second + end + + def test_tampered_ciphertext_fails_to_decrypt + token = @encryptor.encrypt_and_sign('user_id' => 1) + encrypted, iv, tag = token.split('--', 3) + tampered = "#{encrypted}AA--#{iv}--#{tag}" + assert_raises(Prism::MessageEncryptor::InvalidMessage) { @encryptor.decrypt_and_verify(tampered) } + end + + def test_wrong_key_cannot_decrypt + token = @encryptor.encrypt_and_sign('user_id' => 1) + other = Prism::MessageEncryptor.new(Prism::MessageEncryptor.derive_key('a-completely-different-key')) + assert_raises(Prism::MessageEncryptor::InvalidMessage) { other.decrypt_and_verify(token) } + end + + def test_malformed_message_raises + assert_raises(Prism::MessageEncryptor::InvalidMessage) { @encryptor.decrypt_and_verify('garbage') } + end + + def test_rejects_key_of_wrong_length + assert_raises(ArgumentError) { Prism::MessageEncryptor.new('too-short') } + end +end From 3f8aadad03a129639c17250911d2526927611380 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 15:28:55 +0200 Subject: [PATCH 171/245] test: cover MessageVerifierTest and test_round_trips_a_hash --- test/test_message_verifier.rb | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/test_message_verifier.rb diff --git a/test/test_message_verifier.rb b/test/test_message_verifier.rb new file mode 100644 index 0000000..fb35226 --- /dev/null +++ b/test/test_message_verifier.rb @@ -0,0 +1,100 @@ +require 'minitest/autorun' +require 'prism' + +class MessageVerifierTest < Minitest::Test + def setup + @verifier = Prism::MessageVerifier.new('s3cr3t') + end + + def test_round_trips_a_hash + signed = @verifier.generate('user_id' => 42) + assert_equal({ 'user_id' => 42 }, @verifier.verify(signed)) + end + + def test_round_trips_a_string + signed = @verifier.generate('hello world') + assert_equal 'hello world', @verifier.verify(signed) + end + + def test_tampered_payload_is_rejected + signed = @verifier.generate('user_id' => 1) + data, signature = signed.split('--', 2) + tampered = "#{data}x--#{signature}" + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify(tampered) } + end + + def test_tampered_signature_is_rejected + signed = @verifier.generate('user_id' => 1) + data, = signed.split('--', 2) + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify("#{data}--deadbeef") } + end + + def test_wrong_secret_cannot_verify + signed = @verifier.generate('user_id' => 1) + other = Prism::MessageVerifier.new('different-secret') + assert_raises(Prism::MessageVerifier::InvalidSignature) { other.verify(signed) } + end + + def test_malformed_message_is_rejected + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify('not-a-signed-message') } + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify(nil) } + end + + def test_verified_returns_nil_instead_of_raising + assert_nil @verifier.verified('garbage') + end + + def test_blank_secret_is_rejected + assert_raises(ArgumentError) { Prism::MessageVerifier.new('') } + assert_raises(ArgumentError) { Prism::MessageVerifier.new(nil) } + end + + def test_expires_at_in_the_future_still_verifies + signed = @verifier.generate({ 'id' => 1 }, expires_at: Time.now.utc + 60) + assert_equal({ 'id' => 1 }, @verifier.verify(signed)) + end + + def test_expires_at_in_the_past_is_rejected + signed = @verifier.generate({ 'id' => 1 }, expires_at: Time.now.utc - 1) + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify(signed) } + end + + def test_expires_in_computes_expires_at_from_now + signed = @verifier.generate('hello', expires_in: 10) + assert_equal 'hello', @verifier.verify(signed) + end + + def test_expired_message_does_not_survive_tampering_style_verification + signed = @verifier.generate('hello', expires_in: -1) + assert_nil @verifier.verified(signed) + end + + def test_purpose_must_match_on_verify + signed = @verifier.generate('secret-token', purpose: 'password_reset') + assert_equal 'secret-token', @verifier.verify(signed, purpose: 'password_reset') + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify(signed, purpose: 'email_confirmation') } + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify(signed) } + end + + def test_purposeless_message_rejected_when_a_purpose_is_required + signed = @verifier.generate('plain') + assert_raises(Prism::MessageVerifier::InvalidSignature) { @verifier.verify(signed, purpose: 'password_reset') } + end + + def test_purpose_accepts_symbol_or_string_interchangeably + signed = @verifier.generate('value', purpose: :password_reset) + assert_equal 'value', @verifier.verify(signed, purpose: 'password_reset') + end + + def test_plain_generate_still_round_trips_without_envelope_overhead + signed = @verifier.generate('user_id' => 1) + refute_includes decode_payload(signed).keys, '_prism_envelope' + end + + private + + def decode_payload(signed) + data, = signed.split('--', 2) + JSON.parse(data.unpack1('m0')) + end +end From b20699642fffe9c6e286907ab3fa7481c0697cc0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 15:35:10 +0200 Subject: [PATCH 172/245] test: cover MiddlewareAssetsTest and teardown --- test/test_middleware_assets.rb | 160 +++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/test_middleware_assets.rb diff --git a/test/test_middleware_assets.rb b/test/test_middleware_assets.rb new file mode 100644 index 0000000..3bc902e --- /dev/null +++ b/test/test_middleware_assets.rb @@ -0,0 +1,160 @@ +require 'minitest/autorun' +require 'prism' +require 'support' +require 'tmpdir' +require 'fileutils' + +class MiddlewareAssetsTest < Minitest::Test + include TestSupport + + def setup + @dir = Dir.mktmpdir('prism-assets-mw') + @downstream_calls = 0 + downstream = lambda do |_env| + @downstream_calls += 1 + [404, {}, ['not found downstream']] + end + @app = Prism::Middleware::Assets.new(downstream, root: @dir) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def write_fingerprinted(name, content) + File.write(File.join(@dir, name), content) + end + + def test_serves_a_fingerprinted_file_with_far_future_cache_control + write_fingerprinted('application-deadbeef.css', 'body{color:red}') + + status, headers, body = @app.call(env_for('GET', '/assets/application-deadbeef.css')) + + assert_equal 200, status + assert_equal 'body{color:red}', body.first + assert_equal 'text/css', headers['Content-Type'] + assert_equal 'public, max-age=31536000, immutable', headers['Cache-Control'] + end + + def test_does_not_cache_forever_a_file_without_a_fingerprint + write_fingerprinted('manifest.json', '{}') + + _status, headers, = @app.call(env_for('GET', '/assets/manifest.json')) + + refute headers.key?('Cache-Control') + end + + def test_passes_through_to_downstream_app_for_paths_outside_the_prefix + status, = @app.call(env_for('GET', '/posts')) + + assert_equal 404, status + assert_equal 1, @downstream_calls + end + + def test_passes_through_to_downstream_app_when_the_file_is_missing + status, = @app.call(env_for('GET', '/assets/does-not-exist.css')) + + assert_equal 404, status + assert_equal 1, @downstream_calls + end + + def test_rejects_path_traversal_outside_the_root + write_fingerprinted('secret.txt', 'nope') + outside = File.join(File.dirname(@dir), 'outside.txt') + File.write(outside, 'top secret') + + status, = @app.call(env_for('GET', "/assets/../#{File.basename(outside)}")) + + assert_equal 404, status + assert_equal 1, @downstream_calls + ensure + FileUtils.rm_f(outside) + end + + def test_custom_prefix + app = Prism::Middleware::Assets.new(->(_env) { [404, {}, []] }, root: @dir, prefix: '/static') + write_fingerprinted('app-11111111.js', 'console.log(1)') + + status, = app.call(env_for('GET', '/static/app-11111111.js')) + + assert_equal 200, status + end + + def test_serves_a_byte_range_as_206_with_content_range + write_fingerprinted('video-cafebabe.bin', '0123456789') + + status, headers, body = @app.call(env_for('GET', '/assets/video-cafebabe.bin', headers: { 'Range' => 'bytes=2-5' })) + + assert_equal 206, status + assert_equal '2345', body.first + assert_equal 'bytes 2-5/10', headers['Content-Range'] + assert_equal '4', headers['Content-Length'] + assert_equal 'public, max-age=31536000, immutable', headers['Cache-Control'] + end + + def test_unsatisfiable_range_is_416_with_no_body + write_fingerprinted('video-cafebabe.bin', '0123456789') + + status, headers, body = @app.call(env_for('GET', '/assets/video-cafebabe.bin', headers: { 'Range' => 'bytes=100-200' })) + + assert_equal 416, status + assert_equal 'bytes */10', headers['Content-Range'] + assert_equal [], body + end + + def test_head_request_with_range_has_no_body_but_keeps_headers + write_fingerprinted('video-cafebabe.bin', '0123456789') + + status, headers, body = @app.call(env_for('HEAD', '/assets/video-cafebabe.bin', headers: { 'Range' => 'bytes=0-3' })) + + assert_equal 206, status + assert_equal [], body + assert_equal 'bytes 0-3/10', headers['Content-Range'] + assert_equal '4', headers['Content-Length'] + end + + def test_fingerprinted_file_gets_a_strong_hash_derived_etag + write_fingerprinted('application-deadbeef.css', 'body{color:red}') + + _status, headers, = @app.call(env_for('GET', '/assets/application-deadbeef.css')) + + assert_equal '"deadbeef"', headers['ETag'] + end + + def test_non_fingerprinted_file_gets_a_weak_etag + write_fingerprinted('manifest.json', '{}') + + _status, headers, = @app.call(env_for('GET', '/assets/manifest.json')) + + assert_match(/\AW\/"\d+-\d+"\z/, headers['ETag']) + end + + def test_if_none_match_against_fingerprint_etag_returns_304 + write_fingerprinted('application-deadbeef.css', 'body{color:red}') + + status, headers, body = @app.call(env_for('GET', '/assets/application-deadbeef.css', headers: { 'If-None-Match' => '"deadbeef"' })) + + assert_equal 304, status + assert_equal [], body + assert_equal '"deadbeef"', headers['ETag'] + end + + def test_if_range_by_strong_fingerprint_etag_honors_the_range + write_fingerprinted('video-cafebabe.bin', '0123456789') + + status, headers, body = @app.call(env_for('GET', '/assets/video-cafebabe.bin', headers: { 'Range' => 'bytes=0-3', 'If-Range' => '"cafebabe"' })) + + assert_equal 206, status + assert_equal '0123', body.first + assert_equal 'bytes 0-3/10', headers['Content-Range'] + end + + def test_if_range_by_mismatched_fingerprint_etag_falls_back_to_full_200 + write_fingerprinted('video-cafebabe.bin', '0123456789') + + status, _, body = @app.call(env_for('GET', '/assets/video-cafebabe.bin', headers: { 'Range' => 'bytes=0-3', 'If-Range' => '"stale0000"' })) + + assert_equal 200, status + assert_equal '0123456789', body.first + end +end From 79563dce7fecb42c578f5149d8ae1b453593a36e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 15:39:20 +0200 Subject: [PATCH 173/245] test: cover MiddlewareCompressionTest and gunzip --- test/test_middleware_compression.rb | 164 ++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 test/test_middleware_compression.rb diff --git a/test/test_middleware_compression.rb b/test/test_middleware_compression.rb new file mode 100644 index 0000000..1f95c36 --- /dev/null +++ b/test/test_middleware_compression.rb @@ -0,0 +1,164 @@ +require 'minitest/autorun' +require 'prism' +require 'support' +require 'zlib' +require 'stringio' + +class MiddlewareCompressionTest < Minitest::Test + include TestSupport + + LARGE_BODY = 'x' * 1000 + + def gunzip(bytes) + Zlib::GzipReader.new(StringIO.new(bytes)).read + end + + def app_returning(status, headers, body) + Prism::Middleware::Compression.new(lambda { |_env| [status, headers, body] }) + end + + def test_compresses_a_large_buffered_body_when_client_accepts_gzip + app = app_returning(200, {'Content-Type' => 'text/html'}, [LARGE_BODY]) + env = env_for('GET', '/posts', headers: {'Accept-Encoding' => 'gzip'}) + status, headers, body = app.call(env) + + assert_equal 200, status + assert_equal 'gzip', headers['Content-Encoding'] + assert_equal LARGE_BODY, gunzip(body.join) + assert_equal body.join.bytesize.to_s, headers['Content-Length'] + end + + def test_leaves_the_body_alone_when_the_client_sends_no_accept_encoding + app = app_returning(200, {'Content-Type' => 'text/html'}, [LARGE_BODY]) + status, headers, body = app.call(env_for('GET', '/posts')) + + assert_equal 200, status + assert_nil headers['Content-Encoding'] + assert_equal LARGE_BODY, body.join + end + + def test_respects_an_explicit_gzip_q0_declining_the_encoding + app = app_returning(200, {'Content-Type' => 'text/html'}, [LARGE_BODY]) + env = env_for('GET', '/posts', headers: {'Accept-Encoding' => 'gzip;q=0, *;q=1'}) + _, headers, body = app.call(env) + + assert_nil headers['Content-Encoding'] + assert_equal LARGE_BODY, body.join + end + + def test_a_wildcard_accept_encoding_is_honored + app = app_returning(200, {'Content-Type' => 'text/html'}, [LARGE_BODY]) + env = env_for('GET', '/posts', headers: {'Accept-Encoding' => '*'}) + _, headers, body = app.call(env) + + assert_equal 'gzip', headers['Content-Encoding'] + assert_equal LARGE_BODY, gunzip(body.join) + end + + def test_skips_bodies_smaller_than_the_threshold + app = app_returning(200, {'Content-Type' => 'text/html'}, ['tiny']) + env = env_for('GET', '/posts', headers: {'Accept-Encoding' => 'gzip'}) + _, headers, body = app.call(env) + + assert_nil headers['Content-Encoding'] + assert_equal 'tiny', body.join + end + + def test_skips_a_response_that_already_carries_a_content_encoding + headers = {'Content-Type' => 'text/html', 'Content-Encoding' => 'br'} + app = app_returning(200, headers, [LARGE_BODY]) + env = env_for('GET', '/posts', headers: {'Accept-Encoding' => 'gzip'}) + _, out_headers, body = app.call(env) + + assert_equal 'br', out_headers['Content-Encoding'] + assert_equal LARGE_BODY, body.join + end + + def test_skips_media_content_types + app = app_returning(200, {'Content-Type' => 'image/png'}, [LARGE_BODY]) + env = env_for('GET', '/logo.png', headers: {'Accept-Encoding' => 'gzip'}) + _, headers, body = app.call(env) + + assert_nil headers['Content-Encoding'] + assert_equal LARGE_BODY, body.join + end + + def test_skips_a_partial_content_range_response + headers = {'Content-Type' => 'text/plain', 'Content-Range' => 'bytes 0-99/1000'} + app = app_returning(206, headers, [LARGE_BODY[0, 100]]) + env = env_for('GET', '/file', headers: {'Accept-Encoding' => 'gzip'}) + status, out_headers, = app.call(env) + + assert_equal 206, status + assert_nil out_headers['Content-Encoding'] + end + + def test_skips_a_304_not_modified_response + app = app_returning(304, {}, []) + env = env_for('GET', '/file', headers: {'Accept-Encoding' => 'gzip'}) + status, headers, = app.call(env) + + assert_equal 304, status + assert_nil headers['Content-Encoding'] + end + + def test_skips_head_requests + app = app_returning(200, {'Content-Type' => 'text/html'}, [LARGE_BODY]) + env = env_for('HEAD', '/posts', headers: {'Accept-Encoding' => 'gzip'}) + _, headers, body = app.call(env) + + assert_nil headers['Content-Encoding'] + assert_equal LARGE_BODY, body.join + end + + def test_appends_to_an_existing_vary_header_instead_of_replacing_it + app = app_returning(200, {'Content-Type' => 'text/html', 'Vary' => 'Accept'}, [LARGE_BODY]) + env = env_for('GET', '/posts', headers: {'Accept-Encoding' => 'gzip'}) + _, headers, = app.call(env) + + assert_equal 'Accept, Accept-Encoding', headers['Vary'] + end + + def test_streams_a_streaming_body_as_a_single_gzip_stream_chunk_by_chunk + written_before_producer_returns = nil + downstream = lambda do |_env| + body = Prism::StreamingBody.new do |writer| + writer.write("id,title\n") + writer.write("1,one\n") + writer.write("2,two\n") + end + [200, {'Content-Type' => 'text/csv'}, body] + end + app = Prism::Middleware::Compression.new(downstream) + env = env_for('GET', '/posts/export', headers: {'Accept-Encoding' => 'gzip'}) + status, headers, body = app.call(env) + + assert_equal 200, status + assert_equal 'gzip', headers['Content-Encoding'] + assert_nil headers['Content-Length'] + assert_kind_of Prism::StreamingBody, body + + chunks = [] + body.each { |chunk| chunks << chunk } + assert chunks.size > 1, 'expected more than one gzip chunk (one per producer write)' + assert_equal "id,title\n1,one\n2,two\n", gunzip(chunks.join) + end + + def test_a_second_each_on_the_wrapped_streaming_body_reruns_the_producer_with_a_fresh_gzip_stream + downstream = lambda do |_env| + body = Prism::StreamingBody.new { |writer| writer.write('hello world') } + [200, {'Content-Type' => 'text/plain'}, body] + end + app = Prism::Middleware::Compression.new(downstream) + env = env_for('GET', '/x', headers: {'Accept-Encoding' => 'gzip'}) + _, _, body = app.call(env) + + first = +'' + body.each { |chunk| first << chunk } + second = +'' + body.each { |chunk| second << chunk } + + assert_equal 'hello world', gunzip(first) + assert_equal 'hello world', gunzip(second) + end +end From d706a146597a0f9c42d443dc1199b75db44679b7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 16:00:31 +0200 Subject: [PATCH 174/245] test: cover MiddlewareCorsTest and teardown --- test/test_middleware_cors.rb | 150 +++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/test_middleware_cors.rb diff --git a/test/test_middleware_cors.rb b/test/test_middleware_cors.rb new file mode 100644 index 0000000..2a5f311 --- /dev/null +++ b/test/test_middleware_cors.rb @@ -0,0 +1,150 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class MiddlewareCorsTest < Minitest::Test + include TestSupport + + def setup + Prism::Cors.reset! + @downstream_calls = 0 + downstream = lambda do |_env| + @downstream_calls += 1 + [200, {'Content-Type' => 'application/json'}, ['{}']] + end + @app = Prism::Middleware::Cors.new(downstream) + end + + def teardown + Prism::Cors.reset! + end + + def configure_api(**resource_options) + Prism::Cors.configure do |config| + config.allow do + origins 'https://app.example.com' + resource '/api/*', **resource_options + end + end + end + + def test_request_without_origin_header_passes_through_untouched + status, headers, = @app.call(env_for('GET', '/api/posts')) + assert_equal 200, status + assert_nil headers['Access-Control-Allow-Origin'] + assert_equal 1, @downstream_calls + end + + def test_request_with_unmatched_origin_passes_through_without_cors_headers + configure_api(methods: %w[GET]) + env = env_for('GET', '/api/posts', headers: {'Origin' => 'https://evil.example.com'}) + status, headers, = @app.call(env) + assert_equal 200, status + assert_nil headers['Access-Control-Allow-Origin'] + assert_equal 1, @downstream_calls + end + + def test_simple_request_with_allowed_origin_gets_cors_headers_and_reaches_the_app + configure_api(methods: %w[GET]) + env = env_for('GET', '/api/posts', headers: {'Origin' => 'https://app.example.com'}) + status, headers, = @app.call(env) + + assert_equal 200, status + assert_equal 'https://app.example.com', headers['Access-Control-Allow-Origin'] + assert_equal 'Origin', headers['Vary'] + assert_equal 1, @downstream_calls + end + + def test_preflight_request_is_answered_directly_without_reaching_the_app + configure_api(methods: %w[GET POST], headers: %w[Content-Type]) + env = env_for('OPTIONS', '/api/posts', headers: { + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'POST', + 'Access-Control-Request-Headers' => 'Content-Type' + }) + status, headers, body = @app.call(env) + + assert_equal 204, status + assert_equal 'https://app.example.com', headers['Access-Control-Allow-Origin'] + assert_equal 'GET, POST', headers['Access-Control-Allow-Methods'] + assert_equal 'Content-Type', headers['Access-Control-Allow-Headers'] + assert_empty body + assert_equal 0, @downstream_calls + end + + def test_preflight_with_disallowed_method_is_rejected + configure_api(methods: %w[GET]) + env = env_for('OPTIONS', '/api/posts', headers: { + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'DELETE' + }) + status, = @app.call(env) + + assert_equal 403, status + assert_equal 0, @downstream_calls + end + + def test_preflight_with_disallowed_header_is_rejected + configure_api(methods: %w[POST], headers: %w[Content-Type]) + env = env_for('OPTIONS', '/api/posts', headers: { + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'POST', + 'Access-Control-Request-Headers' => 'X-Secret' + }) + status, = @app.call(env) + + assert_equal 403, status + end + + def test_max_age_is_only_sent_when_configured + configure_api(methods: %w[GET], max_age: 600) + env = env_for('OPTIONS', '/api/posts', headers: { + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'GET' + }) + _, headers, = @app.call(env) + assert_equal '600', headers['Access-Control-Max-Age'] + end + + def test_credentials_true_echoes_origin_and_sets_allow_credentials + configure_api(methods: %w[GET], credentials: true) + env = env_for('GET', '/api/posts', headers: {'Origin' => 'https://app.example.com'}) + _, headers, = @app.call(env) + + assert_equal 'https://app.example.com', headers['Access-Control-Allow-Origin'] + assert_equal 'true', headers['Access-Control-Allow-Credentials'] + end + + def test_expose_headers_only_sent_when_configured + configure_api(methods: %w[GET], expose: %w[X-Total-Count]) + env = env_for('GET', '/api/posts', headers: {'Origin' => 'https://app.example.com'}) + _, headers, = @app.call(env) + assert_equal 'X-Total-Count', headers['Access-Control-Expose-Headers'] + end + + def test_vary_origin_is_appended_to_an_existing_vary_header + Prism::Cors.reset! + downstream = lambda { |_env| [200, {'Vary' => 'Accept'}, ['{}']] } + app = Prism::Middleware::Cors.new(downstream) + configure_api(methods: %w[GET]) + env = env_for('GET', '/api/posts', headers: {'Origin' => 'https://app.example.com'}) + _, headers, = app.call(env) + assert_equal 'Accept, Origin', headers['Vary'] + end + + def test_a_resource_registered_without_a_format_extension_still_matches_a_dot_json_path + configure_api(methods: %w[GET]) + env = env_for('GET', '/api/posts.json', headers: {'Origin' => 'https://app.example.com'}) + status, headers, = @app.call(env) + assert_equal 200, status + assert_equal 'https://app.example.com', headers['Access-Control-Allow-Origin'] + end + + def test_options_without_request_method_header_is_treated_as_a_simple_request + configure_api(methods: %w[GET]) + env = env_for('OPTIONS', '/api/posts', headers: {'Origin' => 'https://app.example.com'}) + status, = @app.call(env) + assert_equal 200, status + assert_equal 1, @downstream_calls + end +end From 0d777aaacab6f9d4ca77a28c409525df399f1463 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 16:14:16 +0200 Subject: [PATCH 175/245] test: cover MiddlewareRateLimitingTest and teardown --- test/test_middleware_rate_limiting.rb | 101 ++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 test/test_middleware_rate_limiting.rb diff --git a/test/test_middleware_rate_limiting.rb b/test/test_middleware_rate_limiting.rb new file mode 100644 index 0000000..c80821c --- /dev/null +++ b/test/test_middleware_rate_limiting.rb @@ -0,0 +1,101 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class MiddlewareRateLimitingTest < Minitest::Test + include TestSupport + + def setup + Prism::RateLimiter.reset! + @downstream_calls = 0 + downstream = lambda do |_env| + @downstream_calls += 1 + [200, {}, ['ok']] + end + @app = Prism::Middleware::RateLimiting.new(downstream) + end + + def teardown + Prism::RateLimiter.reset! + end + + def request(ip: '1.1.1.1', path: '/login') + env = env_for('POST', path) + env['REMOTE_ADDR'] = ip + @app.call(env) + end + + def test_requests_under_the_limit_pass_through + Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] } + + 2.times do + status, = request + assert_equal 200, status + end + assert_equal 2, @downstream_calls + end + + def test_request_over_the_limit_is_throttled_with_retry_after + Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] } + + 2.times { request } + status, headers, body = request + + assert_equal 429, status + assert_equal '60', headers['Retry-After'] + assert_includes body.join, 'Too Many Requests' + assert_equal 2, @downstream_calls, 'the throttled request must not reach the app' + end + + def test_throttle_keys_are_independent_per_discriminator_value + Prism::RateLimiter.throttle('logins/ip', limit: 1, period: 60) { |req| req.env['REMOTE_ADDR'] } + + assert_equal 200, request(ip: '1.1.1.1').first + assert_equal 200, request(ip: '2.2.2.2').first + assert_equal 429, request(ip: '1.1.1.1').first + end + + def test_discriminator_returning_nil_skips_the_rule + Prism::RateLimiter.throttle('logins/ip', limit: 1, period: 60) { |_req| nil } + + 5.times { assert_equal 200, request.first } + end + + def test_blocklisted_request_is_rejected_with_403_before_reaching_app + Prism::RateLimiter.blocklist('scanners') { |req| req.path == '/etc/passwd' } + + status, = request(path: '/etc/passwd') + assert_equal 403, status + assert_equal 0, @downstream_calls + end + + def test_safelist_bypasses_an_otherwise_matching_blocklist + Prism::RateLimiter.blocklist('scanners') { |_req| true } + Prism::RateLimiter.safelist('trusted') { |req| req.env['REMOTE_ADDR'] == '9.9.9.9' } + + assert_equal 200, request(ip: '9.9.9.9').first + assert_equal 403, request(ip: '1.1.1.1').first + end + + def test_track_rule_observes_but_never_blocks + tracked = [] + Prism::RateLimiter.track('all-logins') { |req| req.env['REMOTE_ADDR'] } + Prism::RateLimiter.on_notify { |type, name, _req, payload| tracked << [type, name, payload] } + + assert_equal 200, request(ip: '3.3.3.3').first + assert_equal [[:track, 'all-logins', '3.3.3.3']], tracked + end + + def test_fail2ban_style_blocklist_bans_after_repeated_offenses + Prism::RateLimiter.blocklist('fail2ban pentesters') do |req| + Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do + req.path.include?('/etc/passwd') + end + end + + assert_equal 200, request(ip: '5.5.5.5', path: '/etc/passwd').first + assert_equal 403, request(ip: '5.5.5.5', path: '/etc/passwd').first + assert_equal 403, request(ip: '5.5.5.5', path: '/harmless').first, 'stays banned without a new offense' + assert_equal 200, request(ip: '6.6.6.6', path: '/etc/passwd').first, 'other IPs are unaffected' + end +end From 3e4dc3987b651d6faf75afea97972c94474d5041 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 17:16:48 +0200 Subject: [PATCH 176/245] test: cover MiddlewareRateLimitingPStoreStoreTest and teardown --- ...t_middleware_rate_limiting_pstore_store.rb | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 test/test_middleware_rate_limiting_pstore_store.rb diff --git a/test/test_middleware_rate_limiting_pstore_store.rb b/test/test_middleware_rate_limiting_pstore_store.rb new file mode 100644 index 0000000..9aaa349 --- /dev/null +++ b/test/test_middleware_rate_limiting_pstore_store.rb @@ -0,0 +1,138 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' +require 'support' + +# Session 85 wired Prism::RateLimiter::PStoreStore into examples/blog and +# proved durability with a real two-process demo (blog_rate_limit_ +# persistence_demo.rb), but test_middleware_rate_limiting.rb only ever +# exercises the default MemoryStore - so Middleware::RateLimiting itself +# was never unit-tested against the durable store it actually ships with +# in the example app. This file closes that gap: same middleware, same +# rule DSL, but every counter/ban lives in a PStoreStore file on disk, and +# the durability claim itself is proven in-process by dropping the store +# object and opening a brand new one against the same path (the cheapest +# faithful stand-in for "the process restarted"). +class MiddlewareRateLimitingPStoreStoreTest < Minitest::Test + include TestSupport + + def setup + # Minitest runs every test method from every loaded file in one + # randomized order within a single process, so Prism::RateLimiter's + # module-level store must be put back exactly as found - otherwise a + # later test in test_middleware_rate_limiting.rb or + # test_rate_limiter.rb would inherit this file's PStoreStore pointed + # at a tmpdir this test already deleted, and blow up with ENOENT. + @original_store = Prism::RateLimiter.store + @dir = Dir.mktmpdir('prism-rate-limit-pstore-') + @path = File.join(@dir, 'rate_limits.pstore') + # Assign the new store BEFORE calling reset! - reset! resets whatever + # store is currently set, and it must not touch @original_store's file. + Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path) + Prism::RateLimiter.reset! + @downstream_calls = 0 + downstream = lambda do |_env| + @downstream_calls += 1 + [200, {}, ['ok']] + end + @app = Prism::Middleware::RateLimiting.new(downstream) + end + + def teardown + Prism::RateLimiter.reset! + Prism::RateLimiter.store = @original_store + FileUtils.remove_entry(@dir) + end + + def request(ip: '1.1.1.1', path: '/login') + env = env_for('POST', path) + env['REMOTE_ADDR'] = ip + @app.call(env) + end + + def test_requests_under_the_limit_pass_through + Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] } + + 2.times do + status, = request + assert_equal 200, status + end + assert_equal 2, @downstream_calls + end + + def test_request_over_the_limit_is_throttled_with_retry_after + Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] } + + 2.times { request } + status, headers, body = request + + assert_equal 429, status + assert_equal '60', headers['Retry-After'] + assert_includes body.join, 'Too Many Requests' + assert_equal 2, @downstream_calls, 'the throttled request must not reach the app' + end + + def test_blocklisted_request_is_rejected_with_403_before_reaching_app + Prism::RateLimiter.blocklist('scanners') { |req| req.path == '/etc/passwd' } + + status, = request(path: '/etc/passwd') + assert_equal 403, status + assert_equal 0, @downstream_calls + end + + def test_fail2ban_style_blocklist_bans_after_repeated_offenses + Prism::RateLimiter.blocklist('fail2ban pentesters') do |req| + Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do + req.path.include?('/etc/passwd') + end + end + + assert_equal 200, request(ip: '5.5.5.5', path: '/etc/passwd').first + assert_equal 403, request(ip: '5.5.5.5', path: '/etc/passwd').first + assert_equal 403, request(ip: '5.5.5.5', path: '/harmless').first, 'stays banned without a new offense' + end + + def test_throttle_state_survives_a_simulated_process_restart + Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] } + + 2.times { assert_equal 200, request.first } + + # Simulate the process dying and a brand new one booting: a fresh + # PStoreStore instance against the same file, with no in-memory state + # inherited from the object above. The rule DSL (throttles/blocklists) + # is process-local config re-declared on boot, exactly like + # examples/blog/app.rb re-declares its throttles every start; only the + # counters/bans themselves need to survive on disk. + Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path) + Prism::RateLimiter.throttle('logins/ip', limit: 2, period: 60) { |req| req.env['REMOTE_ADDR'] } + + status, headers, = request + assert_equal 429, status, 'the window counter must have been read back from disk, not reset to zero' + assert_equal '60', headers['Retry-After'] + end + + def test_ban_state_survives_a_simulated_process_restart + Prism::RateLimiter.blocklist('fail2ban pentesters') do |req| + Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do + req.path.include?('/etc/passwd') + end + end + + # maxretry: 2 - the 1st offense only records a strike (still let + # through), the 2nd offense crosses the threshold and this same + # request is already rejected. + assert_equal 200, request(ip: '7.7.7.7', path: '/etc/passwd').first + assert_equal 403, request(ip: '7.7.7.7', path: '/etc/passwd').first + + Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(@path) + Prism::RateLimiter.blocklist('fail2ban pentesters') do |req| + Prism::RateLimiter.fail2ban_filter(req.env['REMOTE_ADDR'], maxretry: 2, findtime: 60, bantime: 300) do + req.path.include?('/etc/passwd') + end + end + + status, = request(ip: '7.7.7.7', path: '/harmless') + assert_equal 403, status, 'the ban must have been read back from disk, not lost with the old store object' + end +end From 3841df37a3e6ab521354847205628f6bc55a3373 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 17:20:25 +0200 Subject: [PATCH 177/245] test: cover MiddlewareSessionTest and app_for --- test/test_middleware_session.rb | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 test/test_middleware_session.rb diff --git a/test/test_middleware_session.rb b/test/test_middleware_session.rb new file mode 100644 index 0000000..fa87f8c --- /dev/null +++ b/test/test_middleware_session.rb @@ -0,0 +1,84 @@ +require 'minitest/autorun' +require 'prism' +require 'support' + +class MiddlewareSessionTest < Minitest::Test + include TestSupport + + def app_for(secret:, previous_secrets: [], &handler) + handler ||= ->(_env) { [200, {}, ['ok']] } + Prism::Middleware::Session.new(handler, secret: secret, previous_secrets: previous_secrets) + end + + def cookie_from(response_headers) + line = response_headers['Set-Cookie'].split("\n").find { |entry| entry.start_with?("#{Prism::Middleware::Session::CookieName}=") } + line.split(';').first + end + + def test_session_written_by_one_request_is_readable_by_the_next + write_app = app_for(secret: 's3cr3t') do |env| + env['prism.session']['user'] = 'foksiu' + [200, {}, ['ok']] + end + _, headers, = write_app.call(env_for('GET', '/')) + cookie = cookie_from(headers) + + read_app = app_for(secret: 's3cr3t') do |env| + [200, {}, [env['prism.session']['user']]] + end + _, _, body = read_app.call(env_for('GET', '/', cookies: cookie)) + assert_equal ['foksiu'], body + end + + def test_rotated_secret_still_reads_a_session_cookie_signed_under_the_retired_one + old_app = app_for(secret: 'old-secret') do |env| + env['prism.session']['user'] = 'foksiu' + [200, {}, ['ok']] + end + _, headers, = old_app.call(env_for('GET', '/')) + cookie = cookie_from(headers) + + rotated_app = app_for(secret: 'new-secret', previous_secrets: ['old-secret']) do |env| + [200, {}, [env['prism.session']['user']]] + end + _, _, body = rotated_app.call(env_for('GET', '/', cookies: cookie)) + assert_equal ['foksiu'], body + end + + def test_a_secret_that_is_neither_current_nor_previous_gets_an_empty_session + old_app = app_for(secret: 'unrelated-secret') do |env| + env['prism.session']['user'] = 'foksiu' + [200, {}, ['ok']] + end + _, headers, = old_app.call(env_for('GET', '/')) + cookie = cookie_from(headers) + + rotated_app = app_for(secret: 'new-secret', previous_secrets: ['old-secret']) do |env| + [200, {}, [env['prism.session']['user'].inspect]] + end + _, _, body = rotated_app.call(env_for('GET', '/', cookies: cookie)) + assert_equal [nil.inspect], body + end + + def test_a_session_re_saved_after_rotation_is_signed_under_the_current_secret_only + old_app = app_for(secret: 'old-secret') do |env| + env['prism.session']['user'] = 'foksiu' + [200, {}, ['ok']] + end + _, headers, = old_app.call(env_for('GET', '/')) + cookie = cookie_from(headers) + + rotated_app = app_for(secret: 'new-secret', previous_secrets: ['old-secret']) do |env| + env['prism.session']['visits'] = (env['prism.session']['visits'] || 0) + 1 + [200, {}, ['ok']] + end + _, rotated_headers, = rotated_app.call(env_for('GET', '/', cookies: cookie)) + re_signed_cookie = cookie_from(rotated_headers) + + new_secret_only_app = app_for(secret: 'new-secret') do |env| + [200, {}, [env['prism.session']['user']]] + end + _, _, body = new_secret_only_app.call(env_for('GET', '/', cookies: re_signed_cookie)) + assert_equal ['foksiu'], body + end +end From 3fe62a3f2f8df210cd292274279d5ba3d818fa18 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:26:26 +0200 Subject: [PATCH 178/245] test: cover env_for, test_full_response_advertises_accept_ranges --- test/test_middleware_static.rb | 146 ++++++++++++++++++++++++++++++++- test/test_mime.rb | 53 ++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 test/test_mime.rb diff --git a/test/test_middleware_static.rb b/test/test_middleware_static.rb index f236894..46ed335 100644 --- a/test/test_middleware_static.rb +++ b/test/test_middleware_static.rb @@ -17,8 +17,10 @@ class MiddlewareStaticTest < Minitest::Test FileUtils.remove_entry(@root) end - def env_for(path, method: 'GET') - { 'REQUEST_METHOD' => method, 'PATH_INFO' => path } + def env_for(path, method: 'GET', range: nil) + env = { 'REQUEST_METHOD' => method, 'PATH_INFO' => path } + env['HTTP_RANGE'] = range if range + env end def test_serves_existing_file @@ -68,4 +70,144 @@ class MiddlewareStaticTest < Minitest::Test assert_equal 404, status assert_equal 'app: /index.html', body.join end + + def test_full_response_advertises_accept_ranges + status, headers, = @app.call(env_for('/index.html')) + assert_equal 200, status + assert_equal 'bytes', headers['Accept-Ranges'] + end + + def test_serves_a_byte_range_as_206 + File.write(File.join(@root, 'data.bin'), '0123456789') + + status, headers, body = @app.call(env_for('/data.bin', range: 'bytes=0-3')) + + assert_equal 206, status + assert_equal '0123', body.join + assert_equal 'bytes 0-3/10', headers['Content-Range'] + assert_equal '4', headers['Content-Length'] + end + + def test_serves_a_suffix_range + File.write(File.join(@root, 'data.bin'), '0123456789') + + status, headers, body = @app.call(env_for('/data.bin', range: 'bytes=-3')) + + assert_equal 206, status + assert_equal '789', body.join + assert_equal 'bytes 7-9/10', headers['Content-Range'] + end + + def test_unsatisfiable_range_returns_416 + File.write(File.join(@root, 'data.bin'), '0123456789') + + status, headers, body = @app.call(env_for('/data.bin', range: 'bytes=50-60')) + + assert_equal 416, status + assert_equal 'bytes */10', headers['Content-Range'] + assert_equal [], body + end + + def test_multi_range_request_falls_back_to_full_200 + File.write(File.join(@root, 'data.bin'), '0123456789') + + status, _, body = @app.call(env_for('/data.bin', range: 'bytes=0-1,3-4')) + + assert_equal 200, status + assert_equal '0123456789', body.join + end + + def test_head_request_omits_body_but_reports_full_length + status, headers, body = @app.call(env_for('/index.html', method: 'HEAD')) + + assert_equal 200, status + assert_equal [], body + assert_equal '

static

'.bytesize.to_s, headers['Content-Length'] + end + + def test_full_response_carries_etag_and_last_modified + _status, headers, = @app.call(env_for('/index.html')) + + assert_match(/\AW\/"\d+-\d+"\z/, headers['ETag']) + refute_nil headers['Last-Modified'] + end + + def env_with_header(path, name, value, method: 'GET') + env = env_for(path, method: method) + env[name] = value + env + end + + def test_if_none_match_matching_etag_returns_304_without_body + _status, headers, = @app.call(env_for('/index.html')) + etag = headers['ETag'] + + status, headers_304, body = @app.call(env_with_header('/index.html', 'HTTP_IF_NONE_MATCH', etag)) + + assert_equal 304, status + assert_equal [], body + assert_equal etag, headers_304['ETag'] + assert_nil headers_304['Content-Type'] + assert_nil headers_304['Content-Length'] + end + + def test_if_none_match_stale_etag_returns_full_200 + status, headers, body = @app.call(env_with_header('/index.html', 'HTTP_IF_NONE_MATCH', '"stale"')) + + assert_equal 200, status + assert_equal '

static

', body.join + refute_nil headers['ETag'] + end + + def test_if_modified_since_in_the_future_returns_304 + status, _, body = @app.call(env_with_header('/index.html', 'HTTP_IF_MODIFIED_SINCE', (Time.now + 3600).utc.httpdate)) + + assert_equal 304, status + assert_equal [], body + end + + def test_if_modified_since_in_the_past_returns_full_200 + status, _, body = @app.call(env_with_header('/index.html', 'HTTP_IF_MODIFIED_SINCE', Time.at(0).utc.httpdate)) + + assert_equal 200, status + assert_equal '

static

', body.join + end + + def test_if_range_with_weak_etag_never_satisfies_so_range_is_ignored + File.write(File.join(@root, 'data.bin'), '0123456789') + _status, headers, = @app.call(env_for('/data.bin')) + weak_etag = headers['ETag'] + + env = env_with_header('/data.bin', 'HTTP_IF_RANGE', weak_etag) + env['HTTP_RANGE'] = 'bytes=0-3' + status, _, body = @app.call(env) + + assert_equal 200, status + assert_equal '0123456789', body.join + end + + def test_if_range_with_stale_date_falls_back_to_full_200 + File.write(File.join(@root, 'data.bin'), '0123456789') + + env = env_with_header('/data.bin', 'HTTP_IF_RANGE', Time.at(0).utc.httpdate) + env['HTTP_RANGE'] = 'bytes=0-3' + status, _, body = @app.call(env) + + assert_equal 200, status + assert_equal '0123456789', body.join + end + + def test_if_range_with_exact_matching_date_honors_the_range + File.write(File.join(@root, 'data.bin'), '0123456789') + _status, headers, = @app.call(env_for('/data.bin')) + last_modified = headers['Last-Modified'] + + env = env_with_header('/data.bin', 'HTTP_IF_RANGE', last_modified) + env['HTTP_RANGE'] = 'bytes=0-3' + status, headers_206, body = @app.call(env) + + assert_equal 206, status + assert_equal '0123', body.join + assert_equal 'bytes 0-3/10', headers_206['Content-Range'] + end end diff --git a/test/test_mime.rb b/test/test_mime.rb new file mode 100644 index 0000000..58e7819 --- /dev/null +++ b/test/test_mime.rb @@ -0,0 +1,53 @@ +require 'minitest/autorun' +require 'prism' + +class MimeTest < Minitest::Test + def test_lookup_by_symbol + assert_equal 'application/json', Prism::Mime[:json] + assert_equal 'text/html', Prism::Mime[:html] + end + + def test_unknown_symbol_raises + assert_raises(Prism::Mime::UnknownMimeType) { Prism::Mime[:yaml] } + end + + def test_known_predicate + assert Prism::Mime.known?(:json) + refute Prism::Mime.known?(:yaml) + end + + def test_symbol_for_content_type_strips_charset + assert_equal :json, Prism::Mime.symbol_for_content_type('application/json; charset=utf-8') + assert_equal :html, Prism::Mime.symbol_for_content_type('text/html') + assert_nil Prism::Mime.symbol_for_content_type('application/octet-stream') + assert_nil Prism::Mime.symbol_for_content_type(nil) + end + + def test_best_from_accept_picks_highest_q + header = 'text/html;q=0.5, application/json;q=0.9, application/xml;q=0.1' + assert_equal :json, Prism::Mime.best_from_accept(header) + end + + def test_best_from_accept_defaults_q_to_one + header = 'application/xml;q=0.5, text/html' + assert_equal :html, Prism::Mime.best_from_accept(header) + end + + def test_best_from_accept_preserves_order_on_tie + header = 'application/xml, application/json' + assert_equal :xml, Prism::Mime.best_from_accept(header) + end + + def test_best_from_accept_wildcard_defaults_to_html + assert_equal :html, Prism::Mime.best_from_accept('*/*') + end + + def test_best_from_accept_returns_nil_for_unknown_types_only + assert_nil Prism::Mime.best_from_accept('application/octet-stream') + end + + def test_best_from_accept_returns_nil_for_blank_header + assert_nil Prism::Mime.best_from_accept(nil) + assert_nil Prism::Mime.best_from_accept('') + end +end From 845f341fe269436b9c1a2edfd84ce2ade7b762e8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:30:32 +0200 Subject: [PATCH 179/245] test: cover MultipartTest, build_body and OrmAttributesTest --- test/test_multipart.rb | 82 ++++++++++++++++++++++++ test/test_orm_attributes.rb | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 test/test_multipart.rb create mode 100644 test/test_orm_attributes.rb diff --git a/test/test_multipart.rb b/test/test_multipart.rb new file mode 100644 index 0000000..636541e --- /dev/null +++ b/test/test_multipart.rb @@ -0,0 +1,82 @@ +require 'minitest/autorun' +require 'prism' + +class MultipartTest < Minitest::Test + BOUNDARY = '----PrismTestBoundary7331' + + def build_body(fields: {}, files: {}) + parts = fields.map do |name, value| + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n" + end + parts += files.map do |name, spec| + filename, content_type, content = spec + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: #{content_type}\r\n\r\n#{content}\r\n" + end + parts.join + "--#{BOUNDARY}--\r\n" + end + + def test_multipart_detects_content_type + assert Prism::Multipart.multipart?('multipart/form-data; boundary=abc') + refute Prism::Multipart.multipart?('application/json') + refute Prism::Multipart.multipart?(nil) + end + + def test_boundary_from_quoted_and_bare + assert_equal 'abc123', Prism::Multipart.boundary_from('multipart/form-data; boundary="abc123"') + assert_equal 'abc123', Prism::Multipart.boundary_from('multipart/form-data; boundary=abc123') + assert_nil Prism::Multipart.boundary_from('multipart/form-data') + end + + def test_parses_plain_fields + body = build_body(fields: { 'title' => 'hello world', 'count' => '3' }) + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal 'hello world', result['title'] + assert_equal '3', result['count'] + end + + def test_parses_uploaded_file + body = build_body( + fields: { 'title' => 'my post' }, + files: { 'avatar' => ['avatar.png', 'image/png', "\x89PNG\r\ndata\x00"] } + ) + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal 'my post', result['title'] + + file = result['avatar'] + assert_instance_of Prism::UploadedFile, file + assert_equal 'avatar.png', file.original_filename + assert_equal 'image/png', file.content_type + assert_equal "\x89PNG\r\ndata\x00".b, file.read + assert_equal file.size, File.size(file.path) + end + + def test_binary_content_is_preserved_byte_for_byte + binary = (0..255).map(&:chr).join.b + body = build_body(files: { 'blob' => ['blob.bin', 'application/octet-stream', binary] }) + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal binary, result['blob'].read + end + + def test_array_field_names_collect_into_arrays + parts = [ + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"tags[]\"\r\n\r\nruby\r\n", + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"tags[]\"\r\n\r\nweb\r\n", + "--#{BOUNDARY}--\r\n" + ] + result = Prism::Multipart.parse(parts.join, BOUNDARY) + assert_equal %w[ruby web], result['tags'] + end + + def test_empty_boundary_returns_empty_hash + assert_equal({}, Prism::Multipart.parse('anything', nil)) + assert_equal({}, Prism::Multipart.parse('anything', '')) + end + + def test_ignores_preamble_and_epilogue + body = "this is a preamble that must be ignored\r\n" + build_body(fields: { 'a' => '1' }) + "trailing epilogue" + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal '1', result['a'] + assert_equal 1, result.size + end +end diff --git a/test/test_orm_attributes.rb b/test/test_orm_attributes.rb new file mode 100644 index 0000000..0773374 --- /dev/null +++ b/test/test_orm_attributes.rb @@ -0,0 +1,123 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'json' + +class OrmAttributesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-attributes-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id title featured views price published_at]) + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + attribute :featured, :boolean, default: false + attribute :views, :integer + attribute :price, :float + attribute :published_at, :datetime + end + widget_class.store = @store + Object.const_set(:Widget, widget_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Widget) if Object.const_defined?(:Widget) + Object.send(:remove_const, :Gadget) if Object.const_defined?(:Gadget) + Object.send(:remove_const, :SpecialGadget) if Object.const_defined?(:SpecialGadget) + end + + def test_string_form_input_casts_to_the_declared_type_on_write + widget = Widget.new(featured: '1', views: '42', price: '9.5') + assert_equal true, widget.featured + assert_equal 42, widget.views + assert_in_delta 9.5, widget.price + end + + def test_cast_value_is_what_actually_gets_stored_not_the_raw_string + widget = Widget.create!(title: 'a', featured: '1', views: '42') + assert_equal true, widget.to_h['featured'] + assert_equal 42, widget.to_h['views'] + end + + def test_default_is_applied_when_the_attribute_is_omitted + assert_equal false, Widget.new(title: 'a').featured + end + + def test_an_explicit_value_wins_over_the_default + assert_equal true, Widget.new(title: 'a', featured: 'yes').featured + end + + def test_default_survives_a_real_save_and_reload + widget = Widget.create!(title: 'a') + reloaded = Widget.find(widget.id) + assert_equal false, reloaded.featured + end + + def test_proc_default_is_evaluated_per_instance + Object.const_set(:Gadget, Class.new(Prism::Orm::Model) do + table_name :widgets + attribute :views, :integer, default: -> { 7 } + end) + Gadget.store = @store + assert_equal 7, Gadget.new(title: 'a').views + assert_equal 7, Gadget.new(title: 'b').views + end + + def test_reloading_a_saved_record_returns_real_typed_values_not_strings + widget = Widget.create!(title: 'a', featured: '1', views: '42', price: '9.5') + reloaded = Widget.find(widget.id) + assert_equal true, reloaded.featured + assert_kind_of Integer, reloaded.views + assert_equal 42, reloaded.views + assert_kind_of Float, reloaded.price + end + + def test_getter_defensively_casts_a_legacy_raw_value_never_written_through_the_setter + id = @store.insert(:widgets, 'title' => 'legacy', 'featured' => '1', 'views' => '3') + legacy = Widget.find(id) + assert_equal true, legacy.featured + assert_equal 3, legacy.views + end + + def test_datetime_attribute_parses_an_iso8601_string + widget = Widget.new(title: 'a', published_at: '2026-02-01T12:00:00Z') + assert_kind_of Time, widget.published_at + assert_equal 2026, widget.published_at.year + end + + def test_invalid_input_raises_invalid_cast_instead_of_saving_garbage + assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Widget.new(title: 'a', views: 'not-a-number') } + end + + def test_serializable_hash_reflects_the_cast_type_not_the_raw_string + widget = Widget.create!(title: 'a', featured: '1', views: '42') + payload = JSON.parse(widget.to_json) + assert_equal true, payload['featured'] + assert_equal 42, payload['views'] + end + + # Same regression shape as Callbacks#callbacks_for / Controller#before_actions + # (documented in this project's history as a bug found three separate + # times: a class-level registry stored as a plain `@ivar ||= {}` per + # class, with no superclass fallback, silently drops whatever a base + # class declared as soon as a subclass declares its own). attribute_types/ + # attribute_defaults use the same superclass-plus-own merge, so this + # proves a subclass's own `attribute` call does not erase its parent's. + def test_subclass_inherits_the_parent_classes_declared_attributes + Object.const_set(:Gadget, Class.new(Prism::Orm::Model) do + table_name :widgets + attribute :featured, :boolean, default: false + end) + Object.const_set(:SpecialGadget, Class.new(Gadget) do + table_name :widgets + attribute :views, :integer, default: 9 + end) + SpecialGadget.store = @store + + assert_equal %i[boolean integer].sort_by(&:to_s), [SpecialGadget.attribute_types['featured'], + SpecialGadget.attribute_types['views']].sort_by(&:to_s) + special = SpecialGadget.new(title: 'a') + assert_equal false, special.featured, 'inherited attribute default was dropped by the subclass' + assert_equal 9, special.views + end +end From 18ff5b2c794a0522dd0b23936d7f63666a3acfd3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 19:41:22 +0200 Subject: [PATCH 180/245] test: cover OrmBelongsToTouchTest, teardown and OrmCacheKeyTest --- test/test_orm_belongs_to_touch.rb | 139 ++++++++++++++++++++++++++++++ test/test_orm_cache_key.rb | 45 ++++++++++ 2 files changed, 184 insertions(+) create mode 100644 test/test_orm_belongs_to_touch.rb create mode 100644 test/test_orm_cache_key.rb diff --git a/test/test_orm_belongs_to_touch.rb b/test/test_orm_belongs_to_touch.rb new file mode 100644 index 0000000..0393318 --- /dev/null +++ b/test/test_orm_belongs_to_touch.rb @@ -0,0 +1,139 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmBelongsToTouchTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-belongs-to-touch-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title updated_at reviewed_at]) + @store.create_table(:comments, %w[id body post_id updated_at]) + @store.create_table(:notes, %w[id body post_id updated_at]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id' + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id', touch: true + end + note_class = Class.new(Prism::Orm::Model) do + table_name :notes + belongs_to :post, class_name: 'Post', foreign_key: 'post_id', touch: :reviewed_at + end + [post_class, comment_class, note_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Comment, comment_class) + Object.const_set(:Note, note_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Comment Note].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_creating_a_child_touches_the_owner + post = Post.create!(title: 'hello') + original = post.updated_at + sleep 0.01 + + Comment.create!(body: 'first', post_id: post.id) + + assert post.reload.updated_at > original + end + + def test_updating_a_child_touches_the_owner_even_without_changing_the_foreign_key + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'first', post_id: post.id) + original = post.reload.updated_at + sleep 0.01 + + comment.update!(body: 'first, edited') + + assert post.reload.updated_at > original + end + + def test_destroying_a_child_touches_the_owner + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'first', post_id: post.id) + original = post.reload.updated_at + sleep 0.01 + + comment.destroy + + assert post.reload.updated_at > original + end + + def test_reassigning_the_owner_touches_both_old_and_new_owner + post_a = Post.create!(title: 'a') + post_b = Post.create!(title: 'b') + comment = Comment.create!(body: 'wandering', post_id: post_a.id) + original_a = post_a.reload.updated_at + original_b = post_b.reload.updated_at + sleep 0.01 + + comment.update!(post_id: post_b.id) + + assert post_a.reload.updated_at > original_a + assert post_b.reload.updated_at > original_b + end + + def test_touch_with_a_symbol_column_touches_that_column_on_the_owner + post = Post.create!(title: 'hello') + refute post.reviewed_at + + Note.create!(body: 'looks good', post_id: post.id) + + assert post.reload.reviewed_at + end + + def test_creating_a_child_without_an_owner_does_not_raise + Comment.create!(body: 'orphan', post_id: nil) + end + + def test_touching_an_already_destroyed_owner_does_not_raise + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'first', post_id: post.id) + post.destroy + + comment.update!(body: 'edited after owner is gone') + end + + # Regression: an optimistically-locked, paranoid owner cascading its own + # destroy into touch: true children (has_many ..., dependent: :destroy) + # used to raise StaleObjectError - each cascaded child's after_destroy + # touch re-found and re-locked the owner's row, so by the time the + # owner's own soft-delete wrote back it was holding a now-stale + # lock_version it had read before the cascade started. Caught by + # examples/blog's blog_demo.rb (POST /posts/:id DELETE -> 500) before + # this test existed. + def test_destroying_a_locked_paranoid_owner_with_cascaded_touching_children_does_not_raise + @store.create_table(:locked_posts, %w[id title deleted_at lock_version]) + @store.create_table(:locked_comments, %w[id body locked_post_id deleted_at]) + + locked_post_class = Class.new(Prism::Orm::Model) do + table_name :locked_posts + acts_as_paranoid + has_many :comments, class_name: 'LockedComment', foreign_key: 'locked_post_id', dependent: :destroy + end + locked_comment_class = Class.new(Prism::Orm::Model) do + table_name :locked_comments + acts_as_paranoid + belongs_to :post, class_name: 'LockedPost', foreign_key: 'locked_post_id', touch: true + end + [locked_post_class, locked_comment_class].each { |klass| klass.store = @store } + Object.const_set(:LockedPost, locked_post_class) + Object.const_set(:LockedComment, locked_comment_class) + + post = LockedPost.create!(title: 'hello') + 3.times { |n| LockedComment.create!(body: "comment #{n}", locked_post_id: post.id) } + + post.destroy + + assert post.destroyed? + assert_nil LockedPost.find_by(id: post.id) + ensure + %i[LockedPost LockedComment].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end +end diff --git a/test/test_orm_cache_key.rb b/test/test_orm_cache_key.rb new file mode 100644 index 0000000..ea70043 --- /dev/null +++ b/test/test_orm_cache_key.rb @@ -0,0 +1,45 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmCacheKeyTest < Minitest::Test + def setup + Object.send(:remove_const, :Snippet) if Object.const_defined?(:Snippet) + @dir = Dir.mktmpdir('prism-cache-key-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:snippets, %w[id body created_at updated_at]) + + snippet_class = Class.new(Prism::Orm::Model) { table_name :snippets } + snippet_class.store = @store + Object.const_set(:Snippet, snippet_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Snippet) if Object.const_defined?(:Snippet) + end + + def test_cache_key_includes_table_name_and_id + snippet = Snippet.create!(body: 'hello') + assert_equal "snippets/#{snippet.id}-#{snippet.updated_at.to_i}", snippet.cache_key + end + + def test_cache_key_changes_when_the_record_is_touched + snippet = Snippet.create!(body: 'hello') + original = snippet.cache_key + sleep 1.01 + snippet.update!(body: 'goodbye') + refute_equal original, snippet.cache_key + end + + def test_cache_key_without_timestamps_falls_back_to_table_and_id + Object.send(:remove_const, :Snippet) + @store.create_table(:plain_snippets, %w[id body]) + plain_class = Class.new(Prism::Orm::Model) { table_name :plain_snippets } + plain_class.store = @store + Object.const_set(:Snippet, plain_class) + + snippet = Snippet.create!(body: 'hello') + assert_equal "plain_snippets/#{snippet.id}", snippet.cache_key + end +end From 7df5bd213dc795ede96f369d1eab83b9d96fec80 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 19:44:26 +0200 Subject: [PATCH 181/245] test: cover OrmCallbacksTest and teardown --- test/test_orm_callbacks.rb | 107 +++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 test/test_orm_callbacks.rb diff --git a/test/test_orm_callbacks.rb b/test/test_orm_callbacks.rb new file mode 100644 index 0000000..baf7319 --- /dev/null +++ b/test/test_orm_callbacks.rb @@ -0,0 +1,107 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmCallbacksTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-callbacks-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name]) + @log = [] + log = @log + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + validates :name, presence: true + + before_validation { log << :before_validation } + after_validation { log << :after_validation } + before_save { log << :before_save } + after_save { log << :after_save } + before_create { log << :before_create } + after_create { log << :after_create } + before_update { log << :before_update } + after_update { log << :after_update } + before_destroy { log << :before_destroy } + after_destroy { log << :after_destroy } + end + widget_class.store = @store + Object.const_set(:Widget, widget_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Widget) if Object.const_defined?(:Widget) + end + + def test_full_callback_order_on_create + Widget.create!(name: 'gizmo') + assert_equal %i[before_validation after_validation before_save before_create after_create after_save], @log + end + + def test_full_callback_order_on_update + widget = Widget.create!(name: 'gizmo') + @log.clear + widget.update!(name: 'sprocket') + assert_equal %i[before_validation after_validation before_save before_update after_update after_save], @log + end + + def test_destroy_runs_before_and_after_destroy + widget = Widget.create!(name: 'gizmo') + @log.clear + widget.destroy + assert_equal %i[before_destroy after_destroy], @log + assert widget.destroyed? + end + + def test_before_save_abort_halts_the_chain_and_leaves_record_unsaved + Widget.before_save { throw :abort } + widget = Widget.new(name: 'gizmo') + refute widget.save + assert widget.new_record? + refute_includes @log, :before_create + refute_includes @log, :after_save + end + + def test_before_destroy_abort_keeps_the_record + Widget.before_destroy { throw :abort } + widget = Widget.create!(name: 'gizmo') + @log.clear + widget.destroy + refute widget.destroyed? + assert Widget.find(widget.id) + refute_includes @log, :after_destroy + end + + def test_before_validation_abort_makes_the_record_invalid_without_running_validators + Widget.before_validation { throw :abort } + widget = Widget.new(name: 'gizmo') + refute widget.valid? + assert_empty widget.errors + end + + def test_symbol_callback_can_mutate_the_record + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + before_save :normalize_name + + def normalize_name + self.name = name.to_s.strip.downcase + end + end + widget_class.store = @store + widget = widget_class.create!(name: ' LOUD ') + assert_equal 'loud', widget.reload.name + end + + def test_callbacks_are_inherited_and_run_base_before_subclass + log = @log + subclass = Class.new(Widget) do + table_name :widgets + before_save { log << :subclass_before_save } + end + subclass.store = @store + subclass.create!(name: 'gizmo') + before_save_calls = @log.select { |entry| entry == :before_save || entry == :subclass_before_save } + assert_equal %i[before_save subclass_before_save], before_save_calls + end +end From 659271b3ecdda26d8e36b9a3dbdf1814c9868390 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 19:57:35 +0200 Subject: [PATCH 182/245] test: cover OrmCounterCacheTest and teardown --- test/test_orm_counter_cache.rb | 118 +++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 test/test_orm_counter_cache.rb diff --git a/test/test_orm_counter_cache.rb b/test/test_orm_counter_cache.rb new file mode 100644 index 0000000..02ec74e --- /dev/null +++ b/test/test_orm_counter_cache.rb @@ -0,0 +1,118 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmCounterCacheTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-counter-cache-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title comments_count annotations]) + @store.create_table(:comments, %w[id body post_id deleted_at]) + @store.create_table(:notes, %w[id body post_id]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id' + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + acts_as_paranoid + belongs_to :post, class_name: 'Post', foreign_key: 'post_id', counter_cache: true + end + note_class = Class.new(Prism::Orm::Model) do + table_name :notes + belongs_to :post, class_name: 'Post', foreign_key: 'post_id', counter_cache: :annotations + end + [post_class, comment_class, note_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Comment, comment_class) + Object.const_set(:Note, note_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Comment Note].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_create_increments_default_column + post = Post.create!(title: 'hello', comments_count: 0) + Comment.create!(body: 'first', post_id: post.id) + Comment.create!(body: 'second', post_id: post.id) + + assert_equal 2, post.reload.comments_count + end + + def test_destroy_decrements_default_column + post = Post.create!(title: 'hello', comments_count: 0) + c1 = Comment.create!(body: 'first', post_id: post.id) + Comment.create!(body: 'second', post_id: post.id) + + c1.destroy + + assert_equal 1, post.reload.comments_count + end + + def test_reassigning_the_owner_moves_the_count + post_a = Post.create!(title: 'a', comments_count: 0) + post_b = Post.create!(title: 'b', comments_count: 0) + comment = Comment.create!(body: 'wandering', post_id: post_a.id) + + comment.update!(post_id: post_b.id) + + assert_equal 0, post_a.reload.comments_count + assert_equal 1, post_b.reload.comments_count + end + + def test_clearing_the_owner_decrements_without_incrementing_anything + post = Post.create!(title: 'a', comments_count: 0) + comment = Comment.create!(body: 'orphanable', post_id: post.id) + + comment.update!(post_id: nil) + + assert_equal 0, post.reload.comments_count + end + + def test_custom_column_name_is_used_when_given_a_symbol + post = Post.create!(title: 'hello', comments_count: 0, annotations: 0) + Note.create!(body: 'note one', post_id: post.id) + + assert_equal 1, post.reload.annotations + assert_equal 0, post.comments_count + end + + def test_creating_without_an_owner_does_not_touch_any_counter + Comment.create!(body: 'orphan', post_id: nil) + # No exception, and no post exists to have been bumped - the point of + # this test is simply that the after_create hook tolerates a nil owner. + end + + def test_unrelated_update_does_not_double_count + post = Post.create!(title: 'hello', comments_count: 0) + comment = Comment.create!(body: 'first', post_id: post.id) + + comment.update!(body: 'first, edited') + + assert_equal 1, post.reload.comments_count + end + + def test_destroying_every_child_through_the_association_zeroes_the_counter + post = Post.create!(title: 'hello', comments_count: 0) + Comment.create!(body: 'first', post_id: post.id) + Comment.create!(body: 'second', post_id: post.id) + + post.comments.each(&:destroy) + + assert_equal 0, post.reload.comments_count + end + + def test_restoring_a_soft_deleted_child_reincrements_the_counter + post = Post.create!(title: 'hello', comments_count: 0) + comment = Comment.create!(body: 'first', post_id: post.id) + comment.destroy + assert_equal 0, post.reload.comments_count + + comment.restore + + assert_equal 1, post.reload.comments_count + end +end From 995eac6bea82ae6717b046c10d8293d321f5252e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:24:51 +0200 Subject: [PATCH 183/245] test: cover OrmCounterCacheCascadeDestroyTest, teardown --- .../test_orm_counter_cache_cascade_destroy.rb | 91 +++++++++++++++++++ test/test_orm_dependent_destroy.rb | 71 +++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 test/test_orm_counter_cache_cascade_destroy.rb create mode 100644 test/test_orm_dependent_destroy.rb diff --git a/test/test_orm_counter_cache_cascade_destroy.rb b/test/test_orm_counter_cache_cascade_destroy.rb new file mode 100644 index 0000000..a287006 --- /dev/null +++ b/test/test_orm_counter_cache_cascade_destroy.rb @@ -0,0 +1,91 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +# Regression test for a real bug found through examples/blog integration +# (blog_demo.rb liking a post AND one of its comments, then trashing and +# restoring the post): a three-level paranoid dependent: :destroy cascade +# where the MIDDLE record (Comment) is both a counter_cache CHILD (of Post) +# and a counter_cache OWNER (of its own Like) used to lose counter updates. +# +# Model#save/Paranoia#soft_delete_row used to write the record's FULL +# @attributes hash on every update, including the soft-delete stamp. When +# Comment's own dependent: :destroy cascade (its Like) bumped Comment's +# likes_count via Store#increment_counter (a raw row mutation bypassing +# Comment's in-memory @attributes) and THEN Comment's own soft_delete_row +# wrote its stale in-memory @attributes back over the row, the fresh +# counter mutation was clobbered. The mirror bug existed on Paranoia#restore +# too, since it calls the ordinary #save (full-attribute overwrite) BEFORE +# cascade_restore_dependent_associations increments the counter again, +# corrupting the count from the other direction (double count instead of +# lost count). Both are fixed by only ever writing changed attributes +# (Model#dirty_attributes) instead of the full @attributes hash. +class OrmCounterCacheCascadeDestroyTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-counter-cache-cascade-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title comments_count likes_count deleted_at]) + @store.create_table(:comments, %w[id post_id body likes_count deleted_at]) + @store.create_table(:likes, %w[id likeable_id likeable_type deleted_at]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, foreign_key: 'post_id', dependent: :destroy + has_many :likes, as: :likeable, dependent: :destroy + acts_as_paranoid + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id', counter_cache: true + has_many :likes, as: :likeable, dependent: :destroy + acts_as_paranoid + end + like_class = Class.new(Prism::Orm::Model) do + table_name :likes + belongs_to :likeable, polymorphic: true, counter_cache: true + acts_as_paranoid + end + [post_class, comment_class, like_class].each { |k| k.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Comment, comment_class) + Object.const_set(:Like, like_class) + + @post = Post.create!(title: 'hi') + @comment = Comment.create!(post_id: @post.id, body: 'yo') + @like_on_post = Like.create!(likeable_id: @post.id, likeable_type: 'Post') + @like_on_comment = Like.create!(likeable_id: @comment.id, likeable_type: 'Comment') + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Comment Like].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_counters_start_correct + assert_equal 1, @post.reload.comments_count + assert_equal 1, @post.reload.likes_count + assert_equal 1, @comment.reload.likes_count + end + + def test_cascading_soft_destroy_does_not_clobber_a_counter_bumped_by_its_own_nested_cascade + @post.destroy + + # Comment's own Like was destroyed as part of Comment's own cascade + # (triggered by Post's cascade destroying Comment), decrementing + # Comment#likes_count to 0 - and Comment's subsequent soft-delete stamp + # must not silently revert that back to the stale in-memory value (1). + assert_equal 0, @store.find(:comments, @comment.id)['likes_count'] + assert_equal 0, @store.find(:posts, @post.id)['comments_count'] + assert_equal 0, @store.find(:posts, @post.id)['likes_count'] + end + + def test_recursive_restore_brings_every_counter_back_exactly_once + @post.destroy + @post.restore(recursive: true) + + assert_equal 1, @post.reload.comments_count + assert_equal 1, @post.reload.likes_count + assert_equal 1, Comment.find(@comment.id).likes_count + end +end diff --git a/test/test_orm_dependent_destroy.rb b/test/test_orm_dependent_destroy.rb new file mode 100644 index 0000000..bd5ed17 --- /dev/null +++ b/test/test_orm_dependent_destroy.rb @@ -0,0 +1,71 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmDependentDestroyTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-dependent-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + @store.create_table(:profiles, %w[id author_id bio]) + + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id', dependent: :destroy + has_one :profile, class_name: 'Profile', foreign_key: 'author_id', dependent: :nullify + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + profile_class = Class.new(Prism::Orm::Model) do + table_name :profiles + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [author_class, post_class, profile_class].each { |klass| klass.store = @store } + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + Object.const_set(:Profile, profile_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Author Post Profile].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_dependent_destroy_removes_has_many_children + author = Author.create!(name: 'foksiu') + p1 = Post.create!(title: 'a', author_id: author.id) + p2 = Post.create!(title: 'b', author_id: author.id) + + author.destroy + + assert_raises(Prism::Orm::RecordNotFound) { Post.find(p1.id) } + assert_raises(Prism::Orm::RecordNotFound) { Post.find(p2.id) } + end + + def test_dependent_nullify_clears_foreign_key_on_has_one + author = Author.create!(name: 'foksiu') + profile = Profile.create!(bio: 'hello', author_id: author.id) + + author.destroy + + reloaded = Profile.find(profile.id) + assert_nil reloaded.author_id + end + + def test_destroying_childless_author_does_not_raise + author = Author.create!(name: 'lonely') + assert author.destroy.destroyed? + end + + def test_unsupported_dependent_option_raises_at_declaration_time + assert_raises(ArgumentError) do + Class.new(Prism::Orm::Model) do + table_name :posts + has_many :whatever, class_name: 'Post', foreign_key: 'author_id', dependent: :delete_all + end + end + end +end From f961ce99d275487c107759891df9761c5db996d5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:38:47 +0200 Subject: [PATCH 184/245] test: cover OrmDirtyTest and teardown --- test/test_orm_dirty.rb | 123 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 test/test_orm_dirty.rb diff --git a/test/test_orm_dirty.rb b/test/test_orm_dirty.rb new file mode 100644 index 0000000..a74127f --- /dev/null +++ b/test/test_orm_dirty.rb @@ -0,0 +1,123 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmDirtyTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-dirty-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name notes]) + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + end + widget_class.store = @store + Object.const_set(:Widget, widget_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Widget) if Object.const_defined?(:Widget) + end + + def test_new_record_shows_assigned_attributes_as_changed + widget = Widget.new(name: 'gizmo') + assert widget.changed? + assert_includes widget.changed, 'name' + assert_equal [nil, 'gizmo'], widget.changes['name'] + end + + def test_persisted_record_is_clean_right_after_load + widget = Widget.create!(name: 'gizmo') + refute widget.changed? + reloaded = Widget.find(widget.id) + refute reloaded.changed? + assert_empty reloaded.changes + end + + def test_setting_an_attribute_marks_it_changed_with_old_and_new_value + widget = Widget.create!(name: 'gizmo') + widget.name = 'sprocket' + assert widget.changed? + assert widget.attribute_changed?(:name) + assert_equal 'gizmo', widget.attribute_was(:name) + assert_equal %w[gizmo sprocket], widget.attribute_change(:name) + refute widget.attribute_changed?(:notes) + end + + def test_setting_an_attribute_back_to_its_original_value_clears_the_change + widget = Widget.create!(name: 'gizmo') + widget.name = 'sprocket' + widget.name = 'gizmo' + refute widget.changed? + refute widget.attribute_changed?(:name) + end + + def test_restore_attributes_reverts_unsaved_changes + widget = Widget.create!(name: 'gizmo', notes: 'first') + widget.name = 'sprocket' + widget.notes = 'second' + widget.restore_attributes(['name']) + assert_equal 'gizmo', widget.name + assert_equal 'second', widget.notes + assert widget.changed? + widget.restore_attributes + refute widget.changed? + assert_equal 'first', widget.notes + end + + def test_save_clears_current_changes_and_populates_saved_changes + widget = Widget.create!(name: 'gizmo') + widget.name = 'sprocket' + widget.save! + refute widget.changed? + assert widget.saved_change_to_attribute?('name') + assert_equal %w[gizmo sprocket], widget.saved_change_to_attribute('name') + assert_equal %w[gizmo sprocket], widget.saved_changes['name'] + end + + def test_saved_changes_on_create_shows_nil_to_assigned_value + widget = Widget.create!(name: 'gizmo') + assert_equal [nil, 'gizmo'], widget.saved_changes['name'] + end + + def test_saving_with_no_changes_yields_empty_saved_changes + widget = Widget.create!(name: 'gizmo') + widget.save! + assert_empty widget.saved_changes + end + + def test_update_only_reports_the_actually_different_attribute + widget = Widget.create!(name: 'gizmo', notes: 'first') + widget.update!(name: 'gizmo', notes: 'second') + assert_equal %w[notes], widget.saved_changes.keys + end + + def test_reload_discards_unsaved_changes + widget = Widget.create!(name: 'gizmo') + widget.name = 'sprocket' + widget.reload + assert_equal 'gizmo', widget.name + refute widget.changed? + end + + def test_before_save_callback_can_inspect_pending_changes + seen = [] + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + before_save { seen << attribute_changed?(:name) } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + widget.update!(name: 'sprocket') + widget.save! + assert_equal [true, true, false], seen + end + + def test_touching_updated_at_column_is_itself_tracked_as_a_change + @store.create_table(:stamped, %w[id name updated_at]) + stamped_class = Class.new(Prism::Orm::Model) { table_name :stamped } + stamped_class.store = @store + record = stamped_class.create!(name: 'a') + assert record.saved_change_to_attribute?('updated_at') + end +end From afe1cd4e8186784ad0e19ddb3e887d42881648be Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:55:33 +0200 Subject: [PATCH 185/245] test: cover OrmDirtyEncryptedTest and teardown --- test/test_orm_dirty_encrypted.rb | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 test/test_orm_dirty_encrypted.rb diff --git a/test/test_orm_dirty_encrypted.rb b/test/test_orm_dirty_encrypted.rb new file mode 100644 index 0000000..b76b49f --- /dev/null +++ b/test/test_orm_dirty_encrypted.rb @@ -0,0 +1,110 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Session 55/56/58 left this as a known, unverified gap: Dirty (ActiveModel:: +# Dirty-lite) reads @attributes directly, so on an `encrypts`-backed column +# every dirty-tracking call (changed_attributes/changes/attribute_was/ +# attribute_change) leaked raw ciphertext instead of the plaintext the +# model's own reader returns. This file is the end-to-end proof that the +# fix (Dirty#humanize_dirty_value, Encryption's setter no-op-on-same-value) +# actually closes that gap, on the same non-deterministic and deterministic +# split test_orm_encryption.rb already exercises for plain reads. +class OrmDirtyEncryptedTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-dirty-encrypted-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:patients, %w[id name ssn notes]) + Prism::Orm::Encryption.primary_key = 'test-primary-key-do-not-use-in-prod' + + patient_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :ssn, deterministic: true + encrypts :notes + end + patient_class.store = @store + Object.const_set(:DirtyEncryptedPatient, patient_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Orm::Encryption.primary_key = nil + Object.send(:remove_const, :DirtyEncryptedPatient) if Object.const_defined?(:DirtyEncryptedPatient) + end + + def test_new_record_reports_plaintext_not_ciphertext_in_changes + patient = DirtyEncryptedPatient.new(name: 'Alice', ssn: '123-45-6789', notes: 'note') + old_value, new_value = patient.changes['ssn'] + assert_nil old_value + assert_equal '123-45-6789', new_value + refute_includes patient.changes['ssn'].to_s, patient['ssn'].to_s + end + + def test_changed_attributes_reports_plaintext_old_value + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'old note') + patient.notes = 'new note' + assert_equal 'old note', patient.changed_attributes['notes'] + end + + def test_attribute_was_and_attribute_change_report_plaintext_on_deterministic_column + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'n') + patient.ssn = '999-99-9999' + assert_equal '123-45-6789', patient.attribute_was('ssn') + assert_equal ['123-45-6789', '999-99-9999'], patient.attribute_change('ssn') + end + + def test_attribute_was_and_attribute_change_report_plaintext_on_nondeterministic_column + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'old note') + patient.notes = 'new note' + assert_equal 'old note', patient.attribute_was('notes') + assert_equal ['old note', 'new note'], patient.attribute_change('notes') + end + + def test_reassigning_the_same_plaintext_on_a_nondeterministic_column_is_not_dirty + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'same note') + raw_before = patient['notes'] + patient.notes = 'same note' + refute patient.attribute_changed?('notes'), 'assigning the value already held must not be dirty' + refute patient.changed? + assert_equal raw_before, patient['notes'], 'a true no-op must not even rewrite the ciphertext' + end + + def test_reassigning_the_same_plaintext_on_a_deterministic_column_is_not_dirty + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'n') + patient.ssn = '123-45-6789' + refute patient.attribute_changed?('ssn') + refute patient.changed? + end + + def test_saved_changes_after_create_reports_plaintext + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + assert_equal [nil, '123-45-6789'], patient.saved_changes['ssn'] + assert_equal [nil, 'note'], patient.saved_changes['notes'] + end + + def test_saved_changes_after_update_reports_plaintext_and_only_the_real_change + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'old note') + patient.update!(notes: 'new note') + assert_equal ['old note', 'new note'], patient.saved_changes['notes'] + refute_includes patient.saved_changes.keys, 'ssn' + end + + def test_restore_attributes_still_reverts_the_underlying_ciphertext_correctly + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'old note') + patient.notes = 'new note' + patient.restore_attributes(['notes']) + assert_equal 'old note', patient.notes + refute patient.changed? + end + + def test_a_corrupted_current_value_does_not_block_overwriting_it + patient = DirtyEncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + row = @store.find(:patients, patient.id) + tampered = row.dup + tampered['notes'] = row['notes'].sub(/^./) { |c| c == 'A' ? 'B' : 'A' } + @store.update(:patients, patient.id, tampered) + reloaded = DirtyEncryptedPatient.find(patient.id) + reloaded.notes = 'fresh note' + assert_equal 'fresh note', reloaded.notes + end +end From 11e15aec0144517407ba048c81b080dd45313f8d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 21:21:15 +0200 Subject: [PATCH 186/245] test: cover OrmEagerLoadingTest and teardown --- test/test_orm_eager_loading.rb | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 test/test_orm_eager_loading.rb diff --git a/test/test_orm_eager_loading.rb b/test/test_orm_eager_loading.rb new file mode 100644 index 0000000..78fe16b --- /dev/null +++ b/test/test_orm_eager_loading.rb @@ -0,0 +1,127 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmEagerLoadingTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-eager-store') + @store = CountingStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + @store.create_table(:bios, %w[id author_id headline]) + + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id' + has_one :bio, class_name: 'Bio', foreign_key: 'author_id' + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + bio_class = Class.new(Prism::Orm::Model) do + table_name :bios + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [author_class, post_class, bio_class].each { |klass| klass.store = @store } + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + Object.const_set(:Bio, bio_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Author Post Bio].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_includes_has_many_avoids_n_plus_one_queries + 3.times do |i| + author = Author.create!(name: "author#{i}") + 2.times { |j| Post.create!(title: "post#{i}-#{j}", author_id: author.id) } + end + + @store.reset_query_count! + authors = Author.all.includes(:posts).to_a + baseline_queries = @store.query_count + + authors.each { |author| author.posts.each(&:title) } + assert_equal baseline_queries, @store.query_count, + "expected preloaded #posts to be free, but it issued #{@store.query_count - baseline_queries} more queries" + + total_posts = authors.sum { |author| author.posts.size } + assert_equal 6, total_posts + end + + def test_includes_belongs_to_preloads_single_target + author = Author.create!(name: 'solo') + Post.create!(title: 'a', author_id: author.id) + Post.create!(title: 'b', author_id: author.id) + + @store.reset_query_count! + posts = Post.all.includes(:author).to_a + baseline = @store.query_count + names = posts.map { |post| post.author.name } + + assert_equal baseline, @store.query_count + assert_equal %w[solo solo], names + end + + def test_includes_has_one_preloads + author = Author.create!(name: 'writer') + Bio.create!(headline: 'hello there', author_id: author.id) + + loaded = Author.all.includes(:bio).to_a.first + assert_equal 'hello there', loaded.bio.headline + end + + def test_includes_unknown_association_raises + Author.create!(name: 'x') + assert_raises(ArgumentError) { Author.all.includes(:nope).to_a } + end + + def test_includes_reduces_queries_compared_to_lazy_access + 3.times do |i| + author = Author.create!(name: "author#{i}") + 2.times { |j| Post.create!(title: "post#{i}-#{j}", author_id: author.id) } + end + + @store.reset_query_count! + Author.all.to_a.each { |author| author.posts.to_a } + lazy_queries = @store.query_count + + @store.reset_query_count! + Author.all.includes(:posts).to_a.each { |author| author.posts.to_a } + eager_queries = @store.query_count + + assert_equal 4, lazy_queries # 1 authors query + 1 posts query per author (N+1) + assert_equal 2, eager_queries # 1 authors query + 1 batched posts query + assert_operator eager_queries, :<, lazy_queries + end +end + +# Wraps Store#all/#find to count reads, so tests can assert on actual +# query volume instead of trusting behavior "looks" batched. +class CountingStore < Prism::Orm::Store + def initialize(*) + super + @query_count = 0 + end + + def query_count + @query_count + end + + def reset_query_count! + @query_count = 0 + end + + def all(table) + @query_count += 1 + super + end + + def find(table, id) + @query_count += 1 + super + end +end From ad908680784f9ee75578f6ba31342ef4a7c22ff7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 21:30:07 +0200 Subject: [PATCH 187/245] test: cover OrmEncryptionTest and teardown --- test/test_orm_encryption.rb | 149 ++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 test/test_orm_encryption.rb diff --git a/test/test_orm_encryption.rb b/test/test_orm_encryption.rb new file mode 100644 index 0000000..1cf3c22 --- /dev/null +++ b/test/test_orm_encryption.rb @@ -0,0 +1,149 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmEncryptionTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-encryption-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:patients, %w[id name ssn notes]) + Prism::Orm::Encryption.primary_key = 'test-primary-key-do-not-use-in-prod' + + patient_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :ssn, deterministic: true + encrypts :notes + end + patient_class.store = @store + Object.const_set(:EncryptedPatient, patient_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Orm::Encryption.primary_key = nil + Object.send(:remove_const, :EncryptedPatient) if Object.const_defined?(:EncryptedPatient) + end + + def test_reading_back_the_attribute_returns_plaintext + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'allergic to penicillin') + assert_equal '123-45-6789', patient.ssn + assert_equal 'allergic to penicillin', patient.notes + end + + def test_the_stored_column_holds_ciphertext_not_plaintext + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'secret note') + raw_row = @store.find(:patients, patient.id) + refute_equal '123-45-6789', raw_row['ssn'] + refute_equal 'secret note', raw_row['notes'] + refute_includes raw_row['ssn'], '123-45-6789' + end + + def test_plaintext_survives_a_reload_from_disk + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + reloaded = EncryptedPatient.find(patient.id) + assert_equal '123-45-6789', reloaded.ssn + assert_equal 'note', reloaded.notes + end + + def test_non_deterministic_column_produces_different_ciphertext_each_time + a = EncryptedPatient.create!(name: 'A', ssn: '111-11-1111', notes: 'same note') + b = EncryptedPatient.create!(name: 'B', ssn: '222-22-2222', notes: 'same note') + raw_a = @store.find(:patients, a.id) + raw_b = @store.find(:patients, b.id) + refute_equal raw_a['notes'], raw_b['notes'] + end + + def test_deterministic_column_produces_the_same_ciphertext_for_the_same_plaintext + a = EncryptedPatient.create!(name: 'A', ssn: '999-99-9999', notes: 'x') + b = EncryptedPatient.create!(name: 'B', ssn: '999-99-9999', notes: 'y') + raw_a = @store.find(:patients, a.id) + raw_b = @store.find(:patients, b.id) + assert_equal raw_a['ssn'], raw_b['ssn'] + end + + def test_where_on_a_deterministic_column_matches_by_plaintext + match = EncryptedPatient.create!(name: 'Match', ssn: '555-55-5555', notes: 'n') + EncryptedPatient.create!(name: 'Other', ssn: '666-66-6666', notes: 'n') + found = EncryptedPatient.where(ssn: '555-55-5555').to_a + assert_equal [match.id], found.map(&:id) + end + + def test_find_by_on_a_deterministic_column_matches_by_plaintext + match = EncryptedPatient.create!(name: 'Match', ssn: '777-77-7777', notes: 'n') + found = EncryptedPatient.find_by(ssn: '777-77-7777') + assert_equal match.id, found.id + end + + def test_querying_a_non_deterministic_column_by_plaintext_raises + error = assert_raises(Prism::Orm::Encryption::Error) { EncryptedPatient.where(notes: 'anything').to_a } + assert_match(/not deterministic/, error.message) + end + + def test_nil_round_trips_as_nil + patient = EncryptedPatient.create!(name: 'Nil', ssn: nil, notes: nil) + assert_nil patient.ssn + assert_nil patient.notes + raw_row = @store.find(:patients, patient.id) + assert_nil raw_row['ssn'] + reloaded = EncryptedPatient.find(patient.id) + assert_nil reloaded.ssn + end + + def test_a_tampered_ciphertext_raises_on_read_instead_of_returning_garbage + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + row = @store.find(:patients, patient.id) + tampered = row.dup + tampered['notes'] = row['notes'].sub(/^./) { |c| c == 'A' ? 'B' : 'A' } + @store.update(:patients, patient.id, tampered) + reloaded = EncryptedPatient.find(patient.id) + assert_raises(Prism::Orm::Encryption::Error) { reloaded.notes } + end + + def test_encrypted_attributes_are_hidden_from_json_by_default + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + hash = patient.serializable_hash + refute_includes hash.keys, 'ssn' + refute_includes hash.keys, 'notes' + assert_equal 'Alice', hash['name'] + end + + def test_an_explicit_only_can_still_opt_a_hidden_column_back_into_json + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + hash = patient.serializable_hash(only: [:ssn]) + assert_equal patient['ssn'], hash['ssn'] + end + + def test_two_different_attributes_on_the_same_record_get_independent_keys + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: '123-45-6789') + row = @store.find(:patients, patient.id) + refute_equal row['ssn'], row['notes'] + end + + def test_a_column_scoped_key_overrides_the_shared_primary_key + scoped_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes, key: 'a-completely-different-secret' + end + scoped_class.store = @store + record = scoped_class.create!(name: 'Bob', notes: 'private') + assert_equal 'private', record.notes + + default_key_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes + end + default_key_class.store = @store + assert_raises(Prism::Orm::Encryption::Error) { default_key_class.find(record.id).notes } + end + + def test_encrypts_without_any_key_configured_raises_a_clear_error + Prism::Orm::Encryption.primary_key = nil + unconfigured_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes + end + unconfigured_class.store = @store + error = assert_raises(Prism::Orm::Encryption::Error) { unconfigured_class.new(notes: 'x') } + assert_match(/primary_key=/, error.message) + end +end From ff3be9780dd6fe9c52e5e48853c88bd1152a2328 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 21:57:20 +0200 Subject: [PATCH 188/245] test: cover OrmEncryptionRotationTest and teardown --- test/test_orm_encryption_rotation.rb | 159 +++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 test/test_orm_encryption_rotation.rb diff --git a/test/test_orm_encryption_rotation.rb b/test/test_orm_encryption_rotation.rb new file mode 100644 index 0000000..1bbf9e1 --- /dev/null +++ b/test/test_orm_encryption_rotation.rb @@ -0,0 +1,159 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Key rotation for Prism::Orm::Encryption: retiring an application secret +# (a suspected leak, a scheduled policy) should not make every already- +# encrypted row unreadable the moment the new secret goes live. These tests +# encrypt a row under an OLD secret, then simulate the moment of rotation +# (primary_key flips to a new secret, the old one moves to previous_keys), +# and check that reads/queries against the old row keep working while +# writes immediately start using the new key. +class OrmEncryptionRotationTest < Minitest::Test + OLD_SECRET = 'rotation-old-secret-do-not-use-in-prod'.freeze + NEW_SECRET = 'rotation-new-secret-do-not-use-in-prod'.freeze + + def setup + @dir = Dir.mktmpdir('prism-encryption-rotation-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:patients, %w[id name ssn notes]) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Orm::Encryption.primary_key = nil + Prism::Orm::Encryption.previous_keys = [] + Object.send(:remove_const, :RotatingPatient) if Object.const_defined?(:RotatingPatient) + end + + def define_patient_class + klass = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :ssn, deterministic: true + encrypts :notes + end + klass.store = @store + Object.const_set(:RotatingPatient, klass) + klass + end + + def test_a_row_encrypted_under_the_old_key_is_unreadable_without_rotation_configured + Prism::Orm::Encryption.primary_key = OLD_SECRET + old_class = define_patient_class + patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'pre-rotation note') + + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = NEW_SECRET + new_class = define_patient_class + + error = assert_raises(Prism::Orm::Encryption::Error) { new_class.find(patient.id).notes } + assert_match(/wrong key/, error.message) + end + + def test_a_row_encrypted_under_the_old_key_stays_readable_once_rotation_is_configured + Prism::Orm::Encryption.primary_key = OLD_SECRET + old_class = define_patient_class + patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'pre-rotation note') + + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = NEW_SECRET + Prism::Orm::Encryption.previous_keys = [OLD_SECRET] + new_class = define_patient_class + + reloaded = new_class.find(patient.id) + assert_equal '123-45-6789', reloaded.ssn + assert_equal 'pre-rotation note', reloaded.notes + end + + def test_a_row_re_saved_after_rotation_is_re_encrypted_under_the_new_key + Prism::Orm::Encryption.primary_key = OLD_SECRET + old_class = define_patient_class + patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'pre-rotation note') + + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = NEW_SECRET + Prism::Orm::Encryption.previous_keys = [OLD_SECRET] + new_class = define_patient_class + + record = new_class.find(patient.id) + record.notes = 'still the same note, just re-saved' + record.save! + + # Once re-saved, the OLD key alone (rotation not configured on THIS + # class) must no longer be able to decrypt it - proving the write path + # switched to the new key rather than continuing to use whichever key + # happened to work on read. + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = OLD_SECRET + Prism::Orm::Encryption.previous_keys = [] + old_key_only_class = define_patient_class + error = assert_raises(Prism::Orm::Encryption::Error) { old_key_only_class.find(patient.id).notes } + assert_match(/wrong key/, error.message) + end + + def test_where_on_a_deterministic_column_finds_rows_encrypted_under_either_key + Prism::Orm::Encryption.primary_key = OLD_SECRET + old_class = define_patient_class + stale = old_class.create!(name: 'Stale', ssn: '555-55-5555', notes: 'n') + + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = NEW_SECRET + Prism::Orm::Encryption.previous_keys = [OLD_SECRET] + new_class = define_patient_class + fresh = new_class.create!(name: 'Fresh', ssn: '555-55-5555', notes: 'n') + + found = new_class.where(ssn: '555-55-5555').to_a.map(&:id).sort + assert_equal [stale.id, fresh.id].sort, found + end + + def test_find_by_on_a_deterministic_column_matches_a_row_still_encrypted_under_the_old_key + Prism::Orm::Encryption.primary_key = OLD_SECRET + old_class = define_patient_class + stale = old_class.create!(name: 'Stale', ssn: '999-99-9999', notes: 'n') + + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = NEW_SECRET + Prism::Orm::Encryption.previous_keys = [OLD_SECRET] + new_class = define_patient_class + + found = new_class.find_by(ssn: '999-99-9999') + assert_equal stale.id, found.id + end + + def test_per_attribute_previous_keys_overrides_the_global_rotation_list + Prism::Orm::Encryption.primary_key = NEW_SECRET + Prism::Orm::Encryption.previous_keys = ['a-secret-that-was-never-used'] + + scoped_old_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes, key: OLD_SECRET + end + scoped_old_class.store = @store + patient = scoped_old_class.create!(name: 'Alice', notes: 'scoped secret note') + + scoped_new_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes, key: NEW_SECRET, previous_keys: [OLD_SECRET] + end + scoped_new_class.store = @store + + assert_equal 'scoped secret note', scoped_new_class.find(patient.id).notes + end + + def test_a_tampered_ciphertext_still_raises_even_with_rotation_configured + Prism::Orm::Encryption.primary_key = OLD_SECRET + old_class = define_patient_class + patient = old_class.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + row = @store.find(:patients, patient.id) + tampered = row.dup + tampered['notes'] = row['notes'].sub(/^./) { |c| c == 'A' ? 'B' : 'A' } + @store.update(:patients, patient.id, tampered) + + Object.send(:remove_const, :RotatingPatient) + Prism::Orm::Encryption.primary_key = NEW_SECRET + Prism::Orm::Encryption.previous_keys = [OLD_SECRET] + new_class = define_patient_class + + assert_raises(Prism::Orm::Encryption::Error) { new_class.find(patient.id).notes } + end +end From d862a5e9736f1edc8948f97e1f550f9b7003ed51 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 22:04:26 +0200 Subject: [PATCH 189/245] test: cover OrmEnumTest and teardown --- test/test_orm_enum.rb | 179 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 test/test_orm_enum.rb diff --git a/test/test_orm_enum.rb b/test/test_orm_enum.rb new file mode 100644 index 0000000..8c15ed4 --- /dev/null +++ b/test/test_orm_enum.rb @@ -0,0 +1,179 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmEnumTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-enum-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title status role]) + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + enum :status, { draft: 0, published: 1, archived: 2 } + enum role: { author: 0, editor: 1 }, prefix: true + end + post_class.store = @store + Object.const_set(:EnumPost, post_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :EnumPost) if Object.const_defined?(:EnumPost) + end + + def test_reader_translates_raw_value_to_label + post = EnumPost.create!(title: 'a', status: :published) + assert_equal 'published', post.status + assert_equal 1, post['status'] + end + + def test_writer_accepts_symbol_string_or_raw_value + post = EnumPost.new(title: 'a') + post.status = :draft + assert_equal 0, post['status'] + post.status = 'published' + assert_equal 1, post['status'] + post.status = 2 + assert_equal 'archived', post.status + end + + def test_invalid_value_raises_argument_error_immediately + post = EnumPost.new(title: 'a') + assert_raises(ArgumentError) { post.status = :deleted } + end + + def test_nil_clears_the_attribute + post = EnumPost.new(title: 'a', status: :draft) + post.status = nil + assert_nil post.status + assert_nil post['status'] + end + + def test_predicate_methods + post = EnumPost.new(title: 'a', status: :draft) + assert post.draft? + refute post.published? + post.status = :published + assert post.published? + refute post.draft? + end + + def test_bang_method_sets_and_persists + post = EnumPost.create!(title: 'a', status: :draft) + post.published! + assert_equal 'published', post.status + assert_equal 'published', EnumPost.find(post.id).status + end + + def test_class_scope_matches_generated_predicate + EnumPost.create!(title: 'draft one', status: :draft) + EnumPost.create!(title: 'published one', status: :published) + EnumPost.create!(title: 'published two', status: :published) + + assert_equal 2, EnumPost.published.count + assert_equal ['published one', 'published two'], EnumPost.published.order(:title).map(&:title) + end + + def test_mapping_accessor + assert_equal({ 'draft' => 0, 'published' => 1, 'archived' => 2 }, EnumPost.statuses) + end + + def test_prefixed_enum_method_names + post = EnumPost.new(title: 'a', role: :editor) + assert post.role_editor? + refute post.role_author? + assert EnumPost.respond_to?(:role_editor) + end + + def test_duplicate_raw_values_are_rejected_at_declaration + assert_raises(ArgumentError) do + Class.new(Prism::Orm::Model) do + table_name :posts + enum :status, { draft: 0, published: 0 } + end + end + end + + def test_serializable_hash_shows_the_label_not_the_raw_value + post = EnumPost.create!(title: 'a', status: :published, role: :editor) + json = post.as_json + assert_equal 'published', json['status'] + assert_equal 'editor', json['role'] + end + + def test_where_translates_symbol_label_to_raw_value + draft = EnumPost.create!(title: 'a', status: :draft) + EnumPost.create!(title: 'b', status: :published) + + assert_equal [draft], EnumPost.where(status: :draft).to_a + end + + def test_where_translates_string_label_to_raw_value + EnumPost.create!(title: 'a', status: :draft) + published = EnumPost.create!(title: 'b', status: :published) + + assert_equal [published], EnumPost.where(status: 'published').to_a + end + + def test_where_still_accepts_the_raw_stored_value_directly + draft = EnumPost.create!(title: 'a', status: :draft) + EnumPost.create!(title: 'b', status: :published) + + assert_equal [draft], EnumPost.where(status: 0).to_a + end + + def test_where_translates_an_array_of_labels_for_in_style_matching + draft = EnumPost.create!(title: 'a', status: :draft) + published = EnumPost.create!(title: 'b', status: :published) + EnumPost.create!(title: 'c', status: :archived) + + result = EnumPost.where(status: [:draft, 'published']).order(:title).to_a + assert_equal [draft, published], result + end + + def test_where_translates_labels_inside_an_operator_hash + EnumPost.create!(title: 'a', status: :draft) + published = EnumPost.create!(title: 'b', status: :published) + archived = EnumPost.create!(title: 'c', status: :archived) + + result = EnumPost.where(status: { not_in: [:draft] }).order(:title).to_a + assert_equal [published, archived], result + + gt_result = EnumPost.where(status: { gt: :draft }).order(:title).to_a + assert_equal [published, archived], gt_result + end + + def test_not_translates_enum_labels + draft = EnumPost.create!(title: 'a', status: :draft) + EnumPost.create!(title: 'b', status: :published) + + assert_equal [draft], EnumPost.not(status: :published).to_a + end + + def test_find_by_translates_enum_labels + published = EnumPost.create!(title: 'a', status: :published) + assert_equal published, EnumPost.find_by(status: :published) + assert_nil EnumPost.find_by(status: :archived) + end + + def test_where_with_unknown_enum_label_raises_argument_error + assert_raises(ArgumentError) { EnumPost.where(status: :deleted).to_a } + end + + def test_where_on_non_enum_column_is_unaffected + post = EnumPost.create!(title: 'unique-title', status: :draft) + assert_equal [post], EnumPost.where(title: 'unique-title').to_a + end + + def test_subclass_inherits_enum_definitions + subclass = Class.new(EnumPost) + subclass.store = @store + subclass.table_name(:posts) + Object.const_set(:EnumSubPost, subclass) + post = EnumSubPost.new(title: 'a', status: :archived) + assert_equal 'archived', post.status + assert post.archived? + ensure + Object.send(:remove_const, :EnumSubPost) if Object.const_defined?(:EnumSubPost) + end +end From 49c71527908b1f5b616eeaa79f3d3b19e6372f4f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 22:22:11 +0200 Subject: [PATCH 190/245] test: cover OrmErrorsJsonapiTest, test_empty_errors_produce_empty_array --- test/test_orm_errors_jsonapi.rb | 57 ++++++++++++++ test/test_orm_giveaway_locking.rb | 124 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 test/test_orm_errors_jsonapi.rb create mode 100644 test/test_orm_giveaway_locking.rb diff --git a/test/test_orm_errors_jsonapi.rb b/test/test_orm_errors_jsonapi.rb new file mode 100644 index 0000000..807d53e --- /dev/null +++ b/test/test_orm_errors_jsonapi.rb @@ -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 diff --git a/test/test_orm_giveaway_locking.rb b/test/test_orm_giveaway_locking.rb new file mode 100644 index 0000000..86502fa --- /dev/null +++ b/test/test_orm_giveaway_locking.rb @@ -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 From 9a0294edde4da951c864412a7131c8632279d18c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 11:20:28 +0200 Subject: [PATCH 191/245] test: cover OrmHabtmTest and teardown --- test/test_orm_habtm.rb | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/test_orm_habtm.rb diff --git a/test/test_orm_habtm.rb b/test/test_orm_habtm.rb new file mode 100644 index 0000000..ad51ecc --- /dev/null +++ b/test/test_orm_habtm.rb @@ -0,0 +1,82 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmHabtmTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-habtm-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:students, %w[id name]) + @store.create_table(:courses, %w[id title]) + @store.create_table(:courses_students, %w[id course_id student_id]) + + student_class = Class.new(Prism::Orm::Model) do + table_name :students + has_and_belongs_to_many :courses, class_name: 'Course', join_table: 'courses_students', + foreign_key: 'student_id', association_foreign_key: 'course_id' + end + course_class = Class.new(Prism::Orm::Model) do + table_name :courses + has_and_belongs_to_many :students, class_name: 'Student', join_table: 'courses_students', + foreign_key: 'course_id', association_foreign_key: 'student_id' + end + student_class.store = @store + course_class.store = @store + Object.const_set(:Student, student_class) + Object.const_set(:Course, course_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Student Course].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_adding_and_reading_from_both_sides + alice = Student.create!(name: 'alice') + bob = Student.create!(name: 'bob') + math = Course.create!(title: 'math') + physics = Course.create!(title: 'physics') + + alice.courses << math + alice.courses << physics + bob.courses << math + + assert_equal %w[math physics], alice.courses.map(&:title) + assert_equal %w[math], bob.courses.map(&:title) + assert_equal %w[alice bob], math.students.map(&:name).sort + assert_equal %w[alice], physics.students.map(&:name) + end + + def test_adding_twice_does_not_duplicate + alice = Student.create!(name: 'alice') + math = Course.create!(title: 'math') + + alice.courses << math + alice.courses << math + + assert_equal 1, alice.courses.count + end + + def test_delete_removes_association_without_touching_other_side + alice = Student.create!(name: 'alice') + math = Course.create!(title: 'math') + alice.courses << math + + alice.courses.delete(math) + + assert_equal 0, alice.courses.count + assert Student.find(alice.id) # student itself untouched + assert Course.find(math.id) # course itself untouched + end + + def test_replace_via_setter_swaps_full_set + alice = Student.create!(name: 'alice') + math = Course.create!(title: 'math') + physics = Course.create!(title: 'physics') + alice.courses << math + + alice.courses = [physics] + + assert_equal %w[physics], alice.courses.map(&:title) + end +end From 05bcfc48ef5dc6b6d854600687757b13b3fcd64d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:07:40 +0200 Subject: [PATCH 192/245] test: cover OrmHasManyForeignKeyIndexTest and teardown --- test/test_orm_has_many_foreign_key_index.rb | 175 ++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 test/test_orm_has_many_foreign_key_index.rb diff --git a/test/test_orm_has_many_foreign_key_index.rb b/test/test_orm_has_many_foreign_key_index.rb new file mode 100644 index 0000000..f45f8ad --- /dev/null +++ b/test/test_orm_has_many_foreign_key_index.rb @@ -0,0 +1,175 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# `has_many`'s generated reader (Associable#has_many, no `through:`/`as:`) builds +# `Target.where(foreign_key => id)` directly - a single HashNode equality - so it +# is answerable by Store#lookup_by_index exactly like any other `where(column: value)` +# once the foreign key column carries an index. Before this index exists, every call +# to `parent.children` full-scans the child table, even though it is one of the most +# frequently issued queries in any has_many-shaped app (examples/blog's `post.comments` +# runs on every single post page load). +class OrmHasManyForeignKeyIndexTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-fk-index-store') + @store = CountingFkIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:parents, %w[id name]) + @store.create_table(:children, %w[id parent_id label]) + + parent_class = Class.new(Prism::Orm::Model) do + table_name :parents + has_many :children, class_name: 'Child', foreign_key: 'parent_id' + end + child_class = Class.new(Prism::Orm::Model) { table_name :children } + [parent_class, child_class].each { |klass| klass.store = @store } + Object.const_set(:FkParent, parent_class) + Object.const_set(:Child, child_class) + + @mom = FkParent.create!(name: 'mom') + @dad = FkParent.create!(name: 'dad') + Child.create!(parent_id: @mom.id, label: 'a') + Child.create!(parent_id: @mom.id, label: 'b') + Child.create!(parent_id: @dad.id, label: 'c') + end + + def teardown + FileUtils.remove_entry(@dir) + %i[FkParent Child].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_has_many_reader_full_scans_without_an_index_on_the_foreign_key + @store.reset_counts! + labels = @mom.children.to_a.map { |child| child['label'] }.sort + assert_equal %w[a b], labels + assert_equal 1, @store.scan_count, 'without an index, Target.where(foreign_key => id) must fall back to a scan' + assert_equal 0, @store.index_hits + end + + def test_has_many_reader_never_scans_once_the_foreign_key_is_indexed + @store.add_index(:children, :parent_id) + + @store.reset_counts! + labels = @mom.children.to_a.map { |child| child['label'] }.sort + assert_equal %w[a b], labels + assert_equal 0, @store.scan_count + assert_equal 1, @store.index_hits + + @store.reset_counts! + dad_labels = @dad.children.to_a.map { |child| child['label'] } + assert_equal ['c'], dad_labels + assert_equal 0, @store.scan_count + end + + def test_indexed_foreign_key_reflects_writes_immediately + @store.add_index(:children, :parent_id) + Child.create!(parent_id: @mom.id, label: 'd') + + @store.reset_counts! + labels = @mom.children.to_a.map { |child| child['label'] }.sort + assert_equal %w[a b d], labels + assert_equal 0, @store.scan_count + end +end + +# `has_many ..., as: :taggable` (Associable#define_has_many_polymorphic) builds a +# two-column HashNode equality (`likeable_id` + `likeable_type` in examples/blog's +# `Like`). Store#lookup_by_index only requires an index's own columns to be present +# in the equalities (Relation#rows always re-applies the full @condition afterward), +# so a single-column index on just `likeable_id` is already enough to skip the table +# scan - the leftover `likeable_type` equality is filtered from the (small) candidate +# set in memory instead of against the whole table. Both shapes are proven below. +class OrmHasManyPolymorphicForeignKeyIndexTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-fk-poly-index-store') + @store = CountingFkIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + @store.create_table(:comments, %w[id body]) + @store.create_table(:tags, %w[id taggable_id taggable_type label]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :tags, class_name: 'Tag', as: :taggable + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + has_many :tags, class_name: 'Tag', as: :taggable + end + tag_class = Class.new(Prism::Orm::Model) { table_name :tags } + [post_class, comment_class, tag_class].each { |klass| klass.store = @store } + Object.const_set(:TaggablePost, post_class) + Object.const_set(:TaggableComment, comment_class) + Object.const_set(:Tag, tag_class) + + @post = TaggablePost.create!(title: 'hello') + @comment = TaggableComment.create!(body: 'hi') + Tag.create!(taggable_id: @post.id, taggable_type: 'TaggablePost', label: 'ruby') + Tag.create!(taggable_id: @comment.id, taggable_type: 'TaggableComment', label: 'ruby') + end + + def teardown + FileUtils.remove_entry(@dir) + %i[TaggablePost TaggableComment Tag].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_without_any_index_the_polymorphic_reader_falls_back_to_a_scan + @store.reset_counts! + labels = @post.tags.to_a.map { |tag| tag['label'] } + assert_equal ['ruby'], labels + assert_equal 1, @store.scan_count + end + + def test_single_column_index_on_the_id_half_already_avoids_the_scan + @store.add_index(:tags, :taggable_id) + + @store.reset_counts! + labels = @post.tags.to_a.map { |tag| tag['label'] } + assert_equal ['ruby'], labels + assert_equal 0, @store.scan_count, + 'the index narrows candidates by id; the leftover type equality is filtered in memory, not by scanning' + end + + def test_composite_index_over_both_columns_avoids_the_scan_and_stays_type_scoped + @store.add_index(:tags, %i[taggable_id taggable_type]) + + @store.reset_counts! + post_labels = @post.tags.to_a.map { |tag| tag['label'] } + assert_equal ['ruby'], post_labels + assert_equal 0, @store.scan_count + assert_equal 1, @store.index_hits + + @store.reset_counts! + comment_labels = @comment.tags.to_a.map { |tag| tag['label'] } + assert_equal ['ruby'], comment_labels + assert_equal 0, @store.scan_count + end +end + +# Wraps Store#all/#lookup_by_index so tests can assert on whether a query hit +# the index or fell back to a full table scan, instead of trusting that it +# "looks" indexed. Relation#rows only calls #all when indexed_candidates comes +# back nil, so #all's call count is exactly the full-scan count. +class CountingFkIndexStore < Prism::Orm::Store + attr_reader :scan_count, :index_hits + + def initialize(*args) + super + @scan_count = 0 + @index_hits = 0 + end + + def all(table) + @scan_count += 1 + super + end + + def lookup_by_index(table, equalities) + result = super + @index_hits += 1 unless result.nil? + result + end + + def reset_counts! + @scan_count = 0 + @index_hits = 0 + end +end From f2be48d165f72919e4bb65712d3e6ff6d720eda4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:07:48 +0200 Subject: [PATCH 193/245] test: cover OrmHasManyThroughTest and teardown --- test/test_orm_has_many_through.rb | 150 ++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/test_orm_has_many_through.rb diff --git a/test/test_orm_has_many_through.rb b/test/test_orm_has_many_through.rb new file mode 100644 index 0000000..2ea0d65 --- /dev/null +++ b/test/test_orm_has_many_through.rb @@ -0,0 +1,150 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmHasManyThroughTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-through-store') + @store = CountingThroughStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + @store.create_table(:commenters, %w[id name]) + @store.create_table(:comments, %w[id post_id commenter_id body]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id', dependent: :destroy + has_many :commenters, through: :comments, source: :commenter + end + commenter_class = Class.new(Prism::Orm::Model) do + table_name :commenters + has_many :comments, class_name: 'Comment', foreign_key: 'commenter_id' + has_many :posts, through: :comments, source: :post + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id' + belongs_to :commenter, class_name: 'Commenter', foreign_key: 'commenter_id' + end + [post_class, commenter_class, comment_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Commenter, commenter_class) + Object.const_set(:Comment, comment_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Commenter Comment].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_has_many_through_collects_source_records + post = Post.create!(title: 'hello world') + alice = Commenter.create!(name: 'alice') + bob = Commenter.create!(name: 'bob') + Comment.create!(post_id: post.id, commenter_id: alice.id, body: 'nice') + Comment.create!(post_id: post.id, commenter_id: bob.id, body: 'agreed') + + names = post.commenters.map(&:name).sort + assert_equal %w[alice bob], names + end + + def test_has_many_through_dedupes_by_id + post = Post.create!(title: 'hello world') + alice = Commenter.create!(name: 'alice') + Comment.create!(post_id: post.id, commenter_id: alice.id, body: 'first') + Comment.create!(post_id: post.id, commenter_id: alice.id, body: 'second') + + assert_equal [alice.id], post.commenters.map(&:id) + end + + def test_has_many_through_is_symmetric_from_the_other_side + post = Post.create!(title: 'hello world') + alice = Commenter.create!(name: 'alice') + Comment.create!(post_id: post.id, commenter_id: alice.id, body: 'first') + + assert_equal [post.id], alice.posts.map(&:id) + end + + def test_has_many_through_returns_empty_array_without_through_records + post = Post.create!(title: 'lonely') + assert_equal [], post.commenters + end + + def test_has_many_through_default_source_is_singularized_name + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id' + has_many :commenters, through: :comments + end + post_class.store = @store + post = post_class.create!(title: 'defaults') + alice = Commenter.create!(name: 'alice') + Comment.create!(post_id: post.id, commenter_id: alice.id, body: 'default source') + + assert_equal ['alice'], post_class.find(post.id).commenters.map(&:name) + end + + def test_has_many_through_rejects_dependent_option + error = assert_raises(ArgumentError) do + Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id' + has_many :commenters, through: :comments, source: :commenter, dependent: :destroy + end + end + assert_match(/dependent/, error.message) + end + + def test_includes_has_many_through_avoids_n_plus_one_queries + 3.times do |i| + post = Post.create!(title: "post#{i}") + 2.times do |j| + commenter = Commenter.create!(name: "commenter#{i}-#{j}") + Comment.create!(post_id: post.id, commenter_id: commenter.id, body: 'hi') + end + end + + @store.reset_query_count! + posts = Post.all.includes(:commenters).to_a + baseline = @store.query_count + + total = posts.sum { |post| post.commenters.size } + assert_equal baseline, @store.query_count, + "expected preloaded #commenters to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal 6, total + end + + def test_includes_has_many_through_unknown_through_raises + broken_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :commenters, through: :nonexistent, source: :commenter + end + broken_class.store = @store + broken_class.create!(title: 'x') + assert_raises(ArgumentError) { broken_class.all.includes(:commenters).to_a } + end +end + +# Wraps Store#all/#find to count reads, mirroring test_orm_eager_loading.rb's +# CountingStore so this file has no load-order dependency on it. +class CountingThroughStore < Prism::Orm::Store + def initialize(*) + super + @query_count = 0 + end + + attr_reader :query_count + + def reset_query_count! + @query_count = 0 + end + + def all(table) + @query_count += 1 + super + end + + def find(table, id) + @query_count += 1 + super + end +end From b3ae5aca763d5f2b6a2cc55c125c27f2544f5941 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:22:14 +0200 Subject: [PATCH 194/245] test: cover OrmHasManyThroughBelongsToTest and teardown --- test/test_orm_has_many_through_belongs_to.rb | 118 +++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 test/test_orm_has_many_through_belongs_to.rb diff --git a/test/test_orm_has_many_through_belongs_to.rb b/test/test_orm_has_many_through_belongs_to.rb new file mode 100644 index 0000000..e705603 --- /dev/null +++ b/test/test_orm_has_many_through_belongs_to.rb @@ -0,0 +1,118 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Covers has_many :through where the through step is a belongs_to (the +# owner holds the foreign key, so many owners can share one through +# record) rather than a has_many/has_one (owner.id looked up by foreign +# key on the through table). Both the lazy reader and the includes() +# eager-load path need to handle this shape. +class OrmHasManyThroughBelongsToTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-through-belongs-to-store') + @store = CountingBelongsToThroughStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + @store.create_table(:comments, %w[id post_id body]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id', dependent: :destroy + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id' + has_many :co_comments, through: :post, source: :comments + end + [post_class, comment_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Comment, comment_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Comment].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_has_many_through_belongs_to_collects_source_records + post = Post.create!(title: 'hello world') + first = Comment.create!(post_id: post.id, body: 'first') + second = Comment.create!(post_id: post.id, body: 'second') + + ids = first.co_comments.map(&:id).sort + assert_equal [first.id, second.id].sort, ids + end + + def test_has_many_through_belongs_to_scopes_to_own_post + post_a = Post.create!(title: 'a') + post_b = Post.create!(title: 'b') + comment_a = Comment.create!(post_id: post_a.id, body: 'on a') + Comment.create!(post_id: post_b.id, body: 'on b') + + assert_equal [comment_a.id], comment_a.co_comments.map(&:id) + end + + def test_has_many_through_belongs_to_returns_empty_without_owner + orphan = Comment.new(post_id: nil, body: 'no post') + assert_equal [], orphan.co_comments + end + + def test_includes_has_many_through_belongs_to_avoids_n_plus_one_queries + 3.times do |i| + post = Post.create!(title: "post#{i}") + 2.times { |j| Comment.create!(post_id: post.id, body: "c#{i}-#{j}") } + end + + @store.reset_query_count! + comments = Comment.all.includes(:co_comments).to_a + baseline = @store.query_count + + total = comments.sum { |comment| comment.co_comments.size } + assert_equal baseline, @store.query_count, + "expected preloaded #co_comments to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal 12, total + end + + def test_includes_has_many_through_belongs_to_shares_one_through_record + post = Post.create!(title: 'shared') + Comment.create!(post_id: post.id, body: 'x') + Comment.create!(post_id: post.id, body: 'y') + + @store.reset_query_count! + comments = Comment.all.includes(:co_comments).to_a + # one query to load comments, one to batch-load its source comments by + # post_id (no index on that column here, so still a real #all scan). + # Batch-loading the (single, shared) through post by id is NOT a third + # counted query: Store#lookup_by_index_in treats the primary key as + # always indexed (#rows is already a Hash keyed by id) and this + # counting store only wraps #all/#find, not the index lookup methods - + # see Store#lookup_by_index_in. + assert_equal 2, @store.query_count + assert_equal [2, 2], comments.map { |comment| comment.co_comments.size } + end +end + +# Wraps Store#all/#find to count reads, mirroring +# test_orm_has_many_through.rb's CountingThroughStore so this file has no +# load-order dependency on it. +class CountingBelongsToThroughStore < Prism::Orm::Store + def initialize(*) + super + @query_count = 0 + end + + attr_reader :query_count + + def reset_query_count! + @query_count = 0 + end + + def all(table) + @query_count += 1 + super + end + + def find(table, id) + @query_count += 1 + super + end +end From ed452e46f6685df79a4c948863724ec547c203de Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 14:49:15 +0200 Subject: [PATCH 195/245] test: cover OrmHasOneThroughTest and teardown --- test/test_orm_has_one_through.rb | 178 +++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 test/test_orm_has_one_through.rb diff --git a/test/test_orm_has_one_through.rb b/test/test_orm_has_one_through.rb new file mode 100644 index 0000000..e9cfc55 --- /dev/null +++ b/test/test_orm_has_one_through.rb @@ -0,0 +1,178 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmHasOneThroughTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-has-one-through-store') + @store = CountingHasOneThroughStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:suppliers, %w[id name]) + @store.create_table(:accounts, %w[id supplier_id plan]) + @store.create_table(:account_histories, %w[id account_id notes]) + + supplier_class = Class.new(Prism::Orm::Model) do + table_name :suppliers + has_one :account, class_name: 'Account', foreign_key: 'supplier_id' + has_one :account_history, through: :account + end + account_class = Class.new(Prism::Orm::Model) do + table_name :accounts + belongs_to :supplier, class_name: 'Supplier', foreign_key: 'supplier_id' + has_one :account_history, class_name: 'AccountHistory', foreign_key: 'account_id' + end + history_class = Class.new(Prism::Orm::Model) do + table_name :account_histories + belongs_to :account, class_name: 'Account', foreign_key: 'account_id' + has_one :supplier, through: :account + end + [supplier_class, account_class, history_class].each { |klass| klass.store = @store } + Object.const_set(:Supplier, supplier_class) + Object.const_set(:Account, account_class) + Object.const_set(:AccountHistory, history_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Supplier Account AccountHistory].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_has_one_through_reads_source_off_a_has_one_through_record + supplier = Supplier.create!(name: 'acme') + account = Account.create!(supplier_id: supplier.id, plan: 'gold') + history = AccountHistory.create!(account_id: account.id, notes: 'upgraded') + + assert_equal history.id, supplier.account_history.id + end + + def test_has_one_through_reads_source_off_a_belongs_to_through_record + supplier = Supplier.create!(name: 'acme') + account = Account.create!(supplier_id: supplier.id, plan: 'gold') + history = AccountHistory.create!(account_id: account.id, notes: 'upgraded') + + assert_equal supplier.id, history.supplier.id + end + + def test_has_one_through_returns_nil_without_through_record + supplier = Supplier.create!(name: 'lonely') + assert_nil supplier.account_history + end + + def test_has_one_through_returns_nil_when_through_exists_but_source_missing + supplier = Supplier.create!(name: 'acme') + Account.create!(supplier_id: supplier.id, plan: 'gold') + assert_nil supplier.account_history + end + + def test_has_one_through_honors_explicit_source_when_name_differs + history_with_alias = Class.new(Prism::Orm::Model) do + table_name :account_histories + belongs_to :account, class_name: 'Account', foreign_key: 'account_id' + has_one :owner, through: :account, source: :supplier + end + history_with_alias.store = @store + + supplier = Supplier.create!(name: 'acme') + account = Account.create!(supplier_id: supplier.id, plan: 'gold') + record = history_with_alias.create!(account_id: account.id, notes: 'n/a') + + assert_equal supplier.id, record.owner.id + end + + def test_has_one_through_rejects_dependent_option + error = assert_raises(ArgumentError) do + Class.new(Prism::Orm::Model) do + table_name :suppliers + has_one :account, class_name: 'Account', foreign_key: 'supplier_id' + has_one :account_history, through: :account, dependent: :destroy + end + end + assert_match(/dependent/, error.message) + end + + def test_includes_has_one_through_avoids_n_plus_one_queries + 3.times do |i| + supplier = Supplier.create!(name: "supplier#{i}") + account = Account.create!(supplier_id: supplier.id, plan: 'gold') + AccountHistory.create!(account_id: account.id, notes: "notes#{i}") + end + lonely = Supplier.create!(name: 'no account') + + @store.reset_query_count! + suppliers = Supplier.all.includes(:account_history).to_a + baseline = @store.query_count + + notes = suppliers.map { |supplier| supplier.account_history&.notes } + assert_equal baseline, @store.query_count, + "expected preloaded #account_history to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal ['notes0', 'notes1', 'notes2', nil], notes.compact.sort + [nil] + assert_includes suppliers.map(&:name), lonely.name + end + + # Mirrors test_includes_has_one_through_avoids_n_plus_one_queries above, + # but from the AccountHistory side where the through step (:account) is a + # belongs_to rather than a has_one - exercises the `when :belongs_to then + # preload_belongs_to(...)` branch in preload_has_one_through specifically, + # which the has_one-through variant above never touches. + def test_includes_has_one_through_by_belongs_to_avoids_n_plus_one_queries + 3.times do |i| + supplier = Supplier.create!(name: "supplier#{i}") + account = Account.create!(supplier_id: supplier.id, plan: 'gold') + AccountHistory.create!(account_id: account.id, notes: "notes#{i}") + end + + @store.reset_query_count! + histories = AccountHistory.all.includes(:supplier).to_a + baseline = @store.query_count + + names = histories.map { |history| history.supplier.name }.sort + assert_equal baseline, @store.query_count, + "expected preloaded #supplier to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal %w[supplier0 supplier1 supplier2], names + end + + def test_includes_has_one_through_unknown_through_raises + broken_class = Class.new(Prism::Orm::Model) do + table_name :suppliers + has_one :account_history, through: :nonexistent + end + broken_class.store = @store + broken_class.create!(name: 'x') + assert_raises(ArgumentError) { broken_class.all.includes(:account_history).to_a } + end + + def test_includes_has_one_through_rejects_has_many_through_association + broken_class = Class.new(Prism::Orm::Model) do + table_name :suppliers + has_many :accounts, class_name: 'Account', foreign_key: 'supplier_id' + has_one :account_history, through: :accounts + end + broken_class.store = @store + broken_class.create!(name: 'x') + assert_raises(ArgumentError) { broken_class.all.includes(:account_history).to_a } + end +end + +# Wraps Store#all/#find to count reads, mirroring CountingThroughStore in +# test_orm_has_many_through.rb so this file has no load-order dependency. +class CountingHasOneThroughStore < Prism::Orm::Store + def initialize(*) + super + @query_count = 0 + end + + attr_reader :query_count + + def reset_query_count! + @query_count = 0 + end + + def all(table) + @query_count += 1 + super + end + + def find(table, id) + @query_count += 1 + super + end +end From 84554dae1144b502e57c948d97bb38ad7e1de312 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 15:31:37 +0200 Subject: [PATCH 196/245] test: cover OrmIdLookupIndexTest and teardown --- test/test_orm_id_lookup_index.rb | 108 +++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test/test_orm_id_lookup_index.rb diff --git a/test/test_orm_id_lookup_index.rb b/test/test_orm_id_lookup_index.rb new file mode 100644 index 0000000..6a0c682 --- /dev/null +++ b/test/test_orm_id_lookup_index.rb @@ -0,0 +1,108 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# `find_by(id: x)`/`where(id: x).first` go through Relation#rows exactly like +# any other equality lookup, which used to mean Store#lookup_by_index had to +# find a registered secondary index covering the 'id' column - but no +# framework code ever calls `add_index(table, :id)` (id is the primary key, +# already a Hash key on definition[:rows]), so that search always failed and +# silently fell back to a full table scan (Store#all). This was real, live +# behavior, not a theoretical gap: Prism::Authenticatable#current_user calls +# `authenticatable_model.find_by(id: id)` on every single authenticated +# request (lib/prism/authenticatable.rb), and examples/blog's +# ApplicationController#comment_post_label/#comment_post_linkable? call +# `Post.with_deleted.find_by(id: comment.post_id)` once per rendered comment +# row - both were full-scanning their table on every hit before this fix +# (discovered via examples/blog's new AccountController#moderation page, +# which renders several such rows per response and made the N full scans +# impossible to miss in the sql.prism log). #lookup_by_index_in already had +# this exact same "id is always indexed for free" special case for IN-lists +# (see its own comment) - this closes the matching gap for a plain scalar +# equality. +class OrmIdLookupIndexTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-id-lookup-index-store') + @store = CountingIdIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id sku]) + @a = @store.insert(:widgets, sku: 'AAA') + @b = @store.insert(:widgets, sku: 'BBB') + + widget_class = Class.new(Prism::Orm::Model) { table_name :widgets } + widget_class.store = @store + Object.const_set(:IdLookupWidget, widget_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :IdLookupWidget) if Object.const_defined?(:IdLookupWidget) + end + + def test_lookup_by_index_answers_a_bare_id_equality_without_any_registered_index + matches = @store.lookup_by_index(:widgets, { 'id' => @a }) + refute_nil matches, 'a plain id equality must never report "no index" (nil means fall back to a full scan)' + assert_equal ['AAA'], matches.map { |row| row['sku'] } + end + + def test_lookup_by_index_on_a_missing_id_is_an_empty_hit_not_a_scan_signal + assert_equal [], @store.lookup_by_index(:widgets, { 'id' => @a + 999 }) + end + + def test_extra_equality_keys_alongside_id_are_still_resolved_by_id_alone + # No index exists on `sku`, and the wrong sku is paired with the right + # id - the id lookup still returns the row (Relation#rows re-checks the + # full condition afterward and would reject it there, not here). + matches = @store.lookup_by_index(:widgets, { 'id' => @a, 'sku' => 'WRONG' }) + assert_equal ['AAA'], matches.map { |row| row['sku'] } + end + + def test_model_find_by_id_never_scans + @store.reset_counts! + widget = IdLookupWidget.find_by(id: @a) + assert_equal 'AAA', widget.sku + assert_equal 0, @store.scan_count + assert_equal 1, @store.id_index_hits + end + + def test_where_id_first_never_scans_even_with_an_unrelated_condition_chained_on + @store.reset_counts! + widget = IdLookupWidget.where(id: @a).where(sku: 'AAA').first + assert_equal 'AAA', widget.sku + assert_equal 0, @store.scan_count + end + + def test_find_by_id_for_a_nonexistent_row_never_scans + @store.reset_counts! + assert_nil IdLookupWidget.find_by(id: @a + 999) + assert_equal 0, @store.scan_count + end +end + +# Same shape as CountingFkIndexStore (test_orm_has_many_foreign_key_index.rb) +# plus a counter specifically for id-equality hits, since #lookup_by_index is +# the one method under test here rather than #lookup_by_index_in. +class CountingIdIndexStore < Prism::Orm::Store + attr_reader :scan_count, :id_index_hits + + def initialize(*args) + super + @scan_count = 0 + @id_index_hits = 0 + end + + def all(table) + @scan_count += 1 + super + end + + def lookup_by_index(table, equalities) + result = super + @id_index_hits += 1 if !result.nil? && equalities.key?('id') + result + end + + def reset_counts! + @scan_count = 0 + @id_index_hits = 0 + end +end From 5ce2152a701e70795f95a9f7a325155e12e089bd Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:11:59 +0200 Subject: [PATCH 197/245] test: cover OrmIndexStoreTest and teardown --- test/test_orm_index.rb | 160 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/test_orm_index.rb diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb new file mode 100644 index 0000000..dfebb66 --- /dev/null +++ b/test/test_orm_index.rb @@ -0,0 +1,160 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmIndexStoreTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:users, %w[id email age]) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_add_index_returns_a_default_name_and_lists_it + name = @store.add_index(:users, :email) + assert_equal 'index_users_on_email', name + assert_equal [{ name: 'index_users_on_email', columns: ['email'], unique: false }], @store.indexes(:users) + end + + def test_add_index_accepts_an_explicit_name + name = @store.add_index(:users, :email, name: 'idx_email') + assert_equal 'idx_email', name + end + + def test_add_index_twice_with_the_same_name_raises + @store.add_index(:users, :email) + assert_raises(Prism::Orm::Store::IndexExists) { @store.add_index(:users, :email) } + end + + def test_add_index_backfills_existing_rows + @store.insert(:users, email: 'a@x.com', age: 20) + @store.insert(:users, email: 'b@x.com', age: 30) + @store.add_index(:users, :email) + + matches = @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) + assert_equal ['a@x.com'], matches.map { |row| row['email'] } + end + + def test_add_unique_index_over_existing_duplicates_raises_and_creates_nothing + @store.insert(:users, email: 'dup@x.com', age: 20) + @store.insert(:users, email: 'dup@x.com', age: 30) + + assert_raises(Prism::Orm::Store::UniqueConstraintViolation) do + @store.add_index(:users, :email, unique: true) + end + assert_equal [], @store.indexes(:users) + end + + def test_remove_index_by_name + @store.add_index(:users, :email, name: 'idx_email') + @store.remove_index(:users, 'idx_email') + assert_equal [], @store.indexes(:users) + end + + def test_remove_index_by_column_list + @store.add_index(:users, :email) + @store.remove_index(:users, :email) + assert_equal [], @store.indexes(:users) + end + + def test_remove_unknown_index_raises + assert_raises(Prism::Orm::Store::UnknownIndex) { @store.remove_index(:users, :email) } + end + + def test_lookup_by_index_returns_nil_without_a_covering_index + @store.insert(:users, email: 'a@x.com', age: 20) + assert_nil @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) + end + + def test_lookup_by_index_returns_empty_array_for_no_match + @store.add_index(:users, :email) + @store.insert(:users, email: 'a@x.com', age: 20) + assert_equal [], @store.lookup_by_index(:users, { 'email' => 'nobody@x.com' }) + end + + def test_composite_index_requires_every_column_present_in_the_lookup + @store.add_index(:users, [:email, :age]) + @store.insert(:users, email: 'a@x.com', age: 20) + + assert_nil @store.lookup_by_index(:users, { 'email' => 'a@x.com' }), + 'a lookup missing one of the composite columns should not use this index' + matches = @store.lookup_by_index(:users, { 'email' => 'a@x.com', 'age' => 20 }) + assert_equal 1, matches.size + end + + def test_lookup_by_index_prefers_the_most_specific_covering_index + @store.add_index(:users, :email) + @store.add_index(:users, [:email, :age]) + @store.insert(:users, email: 'a@x.com', age: 20) + @store.insert(:users, email: 'a@x.com', age: 99) + + matches = @store.lookup_by_index(:users, { 'email' => 'a@x.com', 'age' => 20 }) + assert_equal [20], matches.map { |row| row['age'] } + end + + def test_unique_index_rejects_a_duplicate_insert + @store.add_index(:users, :email, unique: true) + @store.insert(:users, email: 'a@x.com', age: 20) + + assert_raises(Prism::Orm::Store::UniqueConstraintViolation) do + @store.insert(:users, email: 'a@x.com', age: 30) + end + assert_equal 1, @store.count(:users), 'the rejected insert must not have partially applied' + end + + def test_unique_index_rejects_an_update_that_collides_with_another_row + @store.add_index(:users, :email, unique: true) + id1 = @store.insert(:users, email: 'a@x.com', age: 20) + id2 = @store.insert(:users, email: 'b@x.com', age: 30) + + assert_raises(Prism::Orm::Store::UniqueConstraintViolation) do + @store.update(:users, id2, email: 'a@x.com') + end + assert_equal 'b@x.com', @store.find(:users, id2)['email'], 'the rejected update must not have partially applied' + assert_equal 'a@x.com', @store.find(:users, id1)['email'] + end + + def test_unique_index_allows_updating_a_row_to_its_own_current_value + @store.add_index(:users, :email, unique: true) + id = @store.insert(:users, email: 'a@x.com', age: 20) + @store.update(:users, id, age: 21) + assert_equal 21, @store.find(:users, id)['age'] + end + + def test_index_tracks_updates_old_key_freed_new_key_claimed + @store.add_index(:users, :email) + id = @store.insert(:users, email: 'a@x.com', age: 20) + @store.update(:users, id, email: 'b@x.com') + + assert_equal [], @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) + assert_equal [id], @store.lookup_by_index(:users, { 'email' => 'b@x.com' }).map { |row| row['id'] } + end + + def test_index_forgets_deleted_rows + @store.add_index(:users, :email) + id = @store.insert(:users, email: 'a@x.com', age: 20) + @store.delete(:users, id) + assert_equal [], @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) + end + + def test_deleting_a_nonexistent_id_is_still_a_silent_no_op + @store.add_index(:users, :email) + @store.delete(:users, 999) + end +end + +# add_column/remove_column must keep any index that covers the affected +# column in sync - see the comments on Store#add_column/#remove_column for +# why a stale map here is a silent-wrong-results bug (Relation#rows trusts +# a non-nil #lookup_by_index result as definitive), not just a missed +# optimization. +class OrmIndexColumnMigrationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-column-migration') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + end + From 4ca517b1a7b241e6ef2ba9ada0ac3d1f34f9085f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:16:07 +0200 Subject: [PATCH 198/245] test: cover teardown and test_add_column_backfill_rebuilds_an_index_b... --- test/test_orm_index.rb | 162 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index dfebb66..1c23195 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -158,3 +158,165 @@ class OrmIndexColumnMigrationTest < Minitest::Test @store.create_table(:posts, %w[id title]) end + def teardown + FileUtils.remove_entry(@dir) + end + + def test_add_column_backfill_rebuilds_an_index_built_before_the_column_existed + id1 = @store.insert(:posts, title: 'first') + id2 = @store.insert(:posts, title: 'second') + @store.add_index(:posts, :status) + + @store.add_column(:posts, :status, default: 'draft') + + matches = @store.lookup_by_index(:posts, { 'status' => 'draft' }) + refute_nil matches, 'the index should still cover the column after the backfill' + assert_equal [id1, id2].sort, matches.map { |row| row['id'] }.sort + end + + def test_add_column_backfill_drops_the_stale_nil_key_from_the_index + @store.insert(:posts, title: 'first') + @store.add_index(:posts, :status) + + @store.add_column(:posts, :status, default: 'draft') + + assert_equal [], @store.lookup_by_index(:posts, { 'status' => nil }), + 'no row should still be indexed under the pre-backfill nil value' + end + + def test_add_column_backfill_keeps_a_range_capable_index_usable + id1 = @store.insert(:posts, title: 'first') + id2 = @store.insert(:posts, title: 'second') + @store.add_index(:posts, :priority) + + @store.add_column(:posts, :priority, default: 5) + + matches = @store.lookup_by_index_range(:posts, :priority, gte: 1) + refute_nil matches + assert_equal [id1, id2].sort, matches.map { |row| row['id'] }.sort + end + + def test_remove_column_drops_a_single_column_index_covering_it + @store.add_index(:posts, :status, name: 'idx_status') + @store.remove_column(:posts, :status) + + assert_equal [], @store.indexes(:posts) + assert_nil @store.lookup_by_index(:posts, { 'status' => 'draft' }), + 'a dropped column must never be reported as indexed again' + end + + def test_remove_column_drops_a_composite_index_covering_it + @store.add_column(:posts, :status, default: 'draft') + @store.add_index(:posts, %i[title status]) + + @store.remove_column(:posts, :title) + + assert_equal [], @store.indexes(:posts), + 'a composite index cannot survive with one of its columns gone' + end + + def test_remove_column_leaves_indexes_on_other_columns_untouched + @store.add_column(:posts, :status, default: 'draft') + @store.add_index(:posts, :title) + @store.add_index(:posts, :status) + + @store.remove_column(:posts, :status) + + assert_equal ['index_posts_on_title'], @store.indexes(:posts).map { |index| index[:name] } + end + + def test_remove_column_then_readding_it_starts_with_a_clean_index_free_slate + id = @store.insert(:posts, title: 'first') + @store.add_index(:posts, :status) + @store.add_column(:posts, :status, default: 'draft') + @store.remove_column(:posts, :status) + + @store.add_column(:posts, :status, default: 'archived') + + assert_equal [], @store.indexes(:posts), + 'removing the column must have dropped the earlier index for good' + assert_equal 'archived', @store.find(:posts, id)['status'] + end +end + +class OrmIndexRangeStoreTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-range-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:users, %w[id email age]) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_single_column_index_is_range_capable_and_sorted + @store.insert(:users, email: 'c@x.com', age: 30) + @store.insert(:users, email: 'a@x.com', age: 10) + @store.insert(:users, email: 'b@x.com', age: 20) + @store.add_index(:users, :age) + + matches = @store.lookup_by_index_range(:users, :age, gte: 20) + assert_equal [20, 30], matches.map { |row| row['age'] }.sort + end + + def test_composite_index_is_never_range_capable + @store.add_index(:users, [:email, :age]) + @store.insert(:users, email: 'a@x.com', age: 10) + assert_nil @store.lookup_by_index_range(:users, :age, gte: 0) + end + + def test_lookup_by_index_range_returns_nil_without_a_covering_index + @store.insert(:users, email: 'a@x.com', age: 10) + assert_nil @store.lookup_by_index_range(:users, :age, gte: 0) + end + + def test_gte_is_inclusive + @store.add_index(:users, :age) + @store.insert(:users, email: 'a@x.com', age: 20) + matches = @store.lookup_by_index_range(:users, :age, gte: 20) + assert_equal 1, matches.size + end + + def test_gt_is_exclusive + @store.add_index(:users, :age) + @store.insert(:users, email: 'a@x.com', age: 20) + assert_equal [], @store.lookup_by_index_range(:users, :age, gt: 20) + end + + def test_lte_is_inclusive + @store.add_index(:users, :age) + @store.insert(:users, email: 'a@x.com', age: 20) + matches = @store.lookup_by_index_range(:users, :age, lte: 20) + assert_equal 1, matches.size + end + + def test_lt_is_exclusive + @store.add_index(:users, :age) + @store.insert(:users, email: 'a@x.com', age: 20) + assert_equal [], @store.lookup_by_index_range(:users, :age, lt: 20) + end + + def test_combined_lower_and_upper_bound + @store.add_index(:users, :age) + [5, 15, 25, 35, 45].each { |age| @store.insert(:users, email: "#{age}@x.com", age: age) } + matches = @store.lookup_by_index_range(:users, :age, gte: 15, lte: 35) + assert_equal [15, 25, 35], matches.map { |row| row['age'] }.sort + end + + def test_nil_valued_rows_are_never_returned_by_a_range_lookup + @store.add_index(:users, :age) + @store.insert(:users, email: 'a@x.com', age: nil) + @store.insert(:users, email: 'b@x.com', age: 10) + matches = @store.lookup_by_index_range(:users, :age, gte: 0) + assert_equal ['b@x.com'], matches.map { |row| row['email'] } + end + + def test_index_backfilled_from_existing_rows_is_range_capable + @store.insert(:users, email: 'a@x.com', age: 10) + @store.insert(:users, email: 'b@x.com', age: 20) + @store.add_index(:users, :age) + matches = @store.lookup_by_index_range(:users, :age, gte: 15) + assert_equal ['b@x.com'], matches.map { |row| row['email'] } + end + From cc4ec7e3d586054c5c62d75cf76f389ca46d2bf9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:37:50 +0200 Subject: [PATCH 199/245] test: cover test_range_lookup_tracks_updates --- test/test_orm_index.rb | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index 1c23195..e236fda 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -320,3 +320,162 @@ class OrmIndexRangeStoreTest < Minitest::Test assert_equal ['b@x.com'], matches.map { |row| row['email'] } end + def test_range_lookup_tracks_updates + @store.add_index(:users, :age) + id = @store.insert(:users, email: 'a@x.com', age: 10) + @store.update(:users, id, age: 99) + + assert_equal [id], @store.lookup_by_index_range(:users, :age, gte: 90).map { |r| r['id'] } + assert_equal [], @store.lookup_by_index_range(:users, :age, gte: 5, lte: 10) + end + + def test_range_lookup_forgets_deleted_rows + @store.add_index(:users, :age) + id = @store.insert(:users, email: 'a@x.com', age: 10) + @store.delete(:users, id) + assert_equal [], @store.lookup_by_index_range(:users, :age, gte: 0) + end + + def test_reused_value_after_update_and_reinsert_still_resolves_correctly + @store.add_index(:users, :age) + a = @store.insert(:users, email: 'a@x.com', age: 10) + @store.insert(:users, email: 'b@x.com', age: 10) + @store.update(:users, a, age: 50) + + assert_equal ['b@x.com'], @store.lookup_by_index_range(:users, :age, gte: 0, lte: 20).map { |r| r['email'] } + assert_equal ['a@x.com'], @store.lookup_by_index_range(:users, :age, gte: 40).map { |r| r['email'] } + end +end + +# Covers Store#lookup_by_index_in - the IN-list lookup backing +# `where(column: [v1, v2, ...])`, most importantly `where(id: [...])`, +# which every belongs_to/has_one/has_many/has_many-through eager-load +# batches its preload query through (see Relation#preload_belongs_to and +# friends). +class OrmIndexInListStoreTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-in-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:users, %w[id email age]) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_id_lookup_needs_no_add_index_call_at_all + a = @store.insert(:users, email: 'a@x.com', age: 10) + @store.insert(:users, email: 'b@x.com', age: 20) + c = @store.insert(:users, email: 'c@x.com', age: 30) + + matches = @store.lookup_by_index_in(:users, :id, [a, c]) + assert_equal ['a@x.com', 'c@x.com'], matches.map { |row| row['email'] }.sort + end + + def test_id_lookup_ignores_ids_that_do_not_exist + a = @store.insert(:users, email: 'a@x.com', age: 10) + matches = @store.lookup_by_index_in(:users, :id, [a, a + 1000]) + assert_equal [a], matches.map { |row| row['id'] } + end + + def test_id_lookup_deduplicates_repeated_values + a = @store.insert(:users, email: 'a@x.com', age: 10) + matches = @store.lookup_by_index_in(:users, :id, [a, a, a]) + assert_equal 1, matches.size + end + + def test_lookup_by_index_in_returns_nil_without_a_covering_index_on_a_non_id_column + @store.insert(:users, email: 'a@x.com', age: 10) + assert_nil @store.lookup_by_index_in(:users, :email, ['a@x.com']) + end + + def test_lookup_by_index_in_uses_a_single_column_index_when_present + @store.insert(:users, email: 'a@x.com', age: 10) + @store.insert(:users, email: 'b@x.com', age: 20) + @store.insert(:users, email: 'c@x.com', age: 30) + @store.add_index(:users, :email) + + matches = @store.lookup_by_index_in(:users, :email, ['a@x.com', 'c@x.com']) + assert_equal ['a@x.com', 'c@x.com'], matches.map { |row| row['email'] }.sort + end + + def test_composite_index_cannot_answer_an_in_list_on_one_of_its_columns + @store.add_index(:users, [:email, :age]) + @store.insert(:users, email: 'a@x.com', age: 10) + assert_nil @store.lookup_by_index_in(:users, :email, ['a@x.com']) + end + + def test_in_list_lookup_tracks_updates + @store.add_index(:users, :email) + id = @store.insert(:users, email: 'a@x.com', age: 10) + @store.update(:users, id, email: 'new@x.com') + + assert_equal [], @store.lookup_by_index_in(:users, :email, ['a@x.com']) + assert_equal [id], @store.lookup_by_index_in(:users, :email, ['new@x.com']).map { |r| r['id'] } + end + + def test_in_list_lookup_forgets_deleted_rows + @store.add_index(:users, :email) + id = @store.insert(:users, email: 'a@x.com', age: 10) + @store.delete(:users, id) + assert_equal [], @store.lookup_by_index_in(:users, :email, ['a@x.com']) + assert_equal [], @store.lookup_by_index_in(:users, :id, [id]) + end + + def test_an_empty_values_list_matches_nothing_without_touching_the_table + @store.insert(:users, email: 'a@x.com', age: 10) + assert_equal [], @store.lookup_by_index_in(:users, :id, []) + end +end + +# Counts full-table scans (Store#all) separately from index lookups +# (Store#lookup_by_index that actually found a covering index), the same +# shape as CountingStore/CountingThroughStore used by the eager-loading +# tests, so Relation's index usage can be proven rather than assumed. +class CountingIndexStore < Prism::Orm::Store + attr_reader :scan_count + + attr_reader :range_hits + + attr_reader :in_hits + + attr_reader :eq_range_hits + + def initialize(*args) + super + @scan_count = 0 + @index_hits = 0 + @range_hits = 0 + @in_hits = 0 + @eq_range_hits = 0 + end + + def all(table) + @scan_count += 1 + super + end + + def lookup_by_index(table, equalities) + result = super + @index_hits += 1 unless result.nil? + result + end + + def lookup_by_index_range(table, column, operators) + result = super + @range_hits += 1 unless result.nil? + result + end + + def lookup_by_index_in(table, column, values) + result = super + @in_hits += 1 unless result.nil? + result + end + + def lookup_by_index_equality_range(table, equalities, column, operators) + result = super + @eq_range_hits += 1 unless result.nil? + result + end + From a1118e05ac627ff6f796fd35839c05cb774ae814 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:52:08 +0200 Subject: [PATCH 200/245] test: cover reset_counts! and index_hits --- test/test_orm_index.rb | 158 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index e236fda..d72c434 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -479,3 +479,161 @@ class CountingIndexStore < Prism::Orm::Store result end + def reset_counts! + @scan_count = 0 + @index_hits = 0 + @range_hits = 0 + @in_hits = 0 + @eq_range_hits = 0 + end + + def index_hits + @index_hits + end +end + +class OrmIndexRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title status]) + @store.add_index(:posts, :status) + + post_class = Class.new(Prism::Orm::Model) { table_name :posts } + post_class.store = @store + Object.const_set(:IndexedPost, post_class) + + IndexedPost.create!(title: 'a', status: 'draft') + IndexedPost.create!(title: 'b', status: 'published') + IndexedPost.create!(title: 'c', status: 'draft') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :IndexedPost) if Object.const_defined?(:IndexedPost) + end + + def test_where_on_an_indexed_column_never_scans_the_full_table + @store.reset_counts! + results = IndexedPost.where(status: 'draft').to_a + assert_equal ['a', 'c'], results.map { |post| post['title'] }.sort + assert_equal 0, @store.scan_count + assert_equal 1, @store.index_hits + end + + def test_where_on_an_unindexed_column_falls_back_to_a_full_scan + @store.reset_counts! + results = IndexedPost.where(title: 'a').to_a + assert_equal ['a'], results.map { |post| post['title'] } + assert_equal 1, @store.scan_count + assert_equal 0, @store.index_hits + end + + def test_chained_where_mixing_indexed_and_unindexed_columns_is_still_correct + @store.reset_counts! + results = IndexedPost.where(status: 'draft').where(title: 'c').to_a + assert_equal ['c'], results.map { |post| post['title'] } + assert_equal 0, @store.scan_count, 'the indexed equality should still narrow the candidate set' + end + + def test_or_condition_cannot_use_the_index_but_is_still_correct + @store.reset_counts! + results = IndexedPost.where(status: 'draft').or(IndexedPost.where(title: 'b')).to_a + assert_equal ['a', 'b', 'c'], results.map { |post| post['title'] }.sort + assert_equal 1, @store.scan_count, 'an OR across columns cannot guarantee a single index key, so it must scan' + end + + def test_find_by_uses_the_index + @store.reset_counts! + post = IndexedPost.find_by(status: 'published') + assert_equal 'b', post['title'] + assert_equal 0, @store.scan_count + end + + def test_or_condition_unions_index_hits_once_both_branches_are_indexed + @store.add_index(:posts, :title) + @store.reset_counts! + + results = IndexedPost.where(status: 'draft').or(IndexedPost.where(title: 'b')).to_a + assert_equal ['a', 'b', 'c'], results.map { |post| post['title'] }.sort + assert_equal 0, @store.scan_count, 'both branches are indexed, so the union must never fall back to a scan' + assert_equal 2, @store.index_hits, 'one lookup per branch of the Or' + end + + def test_or_condition_dedupes_a_row_matched_by_both_indexed_branches + @store.add_index(:posts, :title) + @store.reset_counts! + + # post "a" is status draft AND title "a" - both branches of the Or match + # the same row, so the union must not return it twice. + results = IndexedPost.where(status: 'draft').or(IndexedPost.where(title: 'a')).to_a + assert_equal ['a', 'c'], results.map { |post| post['title'] }.sort + assert_equal 2, results.size + assert_equal 0, @store.scan_count + end + + # `a.or(b).or(c)` builds OrNode(OrNode(a, b), c) - a left-nested tree, not + # a flat three-way union. OrNode#indexed_candidates only recurses into + # @left/@right, so this exercises that the recursion actually reaches the + # innermost branch rather than treating the outer node's @left (itself an + # OrNode) as an opaque leaf. Left untested since the index-union Or was + # first added, per prism-postep.md "kierunki dalsze". + def test_three_way_or_uses_the_index_on_every_branch + @store.add_index(:posts, :title) + IndexedPost.create!(title: 'd', status: 'published') + @store.reset_counts! + + results = IndexedPost.where(status: 'draft') + .or(IndexedPost.where(title: 'b')) + .or(IndexedPost.where(title: 'd')) + .to_a + assert_equal ['a', 'b', 'c', 'd'], results.map { |post| post['title'] }.sort + assert_equal 0, @store.scan_count, 'every branch of the three-way Or is indexed, so it must never fall back to a scan' + assert_equal 3, @store.index_hits, 'one lookup per branch, including the nested inner Or' + end + + def test_three_way_or_falls_back_to_a_scan_when_one_branch_is_unindexed + @store.reset_counts! + + # title carries no index in this test (unlike the one above), so the + # whole union must scan rather than silently dropping that branch's + # matches while still answering the other two from their indexes. + results = IndexedPost.where(status: 'draft') + .or(IndexedPost.where(title: 'b')) + .or(IndexedPost.where(status: 'archived')) + .to_a + assert_equal ['a', 'b', 'c'], results.map { |post| post['title'] }.sort + assert_equal 1, @store.scan_count + end +end + +class OrmIndexRangeRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-range-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:products, %w[id name price]) + @store.add_index(:products, :price) + + product_class = Class.new(Prism::Orm::Model) { table_name :products } + product_class.store = @store + Object.const_set(:RangeIndexedProduct, product_class) + + RangeIndexedProduct.create!(name: 'cheap', price: 5) + RangeIndexedProduct.create!(name: 'mid', price: 15) + RangeIndexedProduct.create!(name: 'pricey', price: 25) + RangeIndexedProduct.create!(name: 'luxury', price: 35) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :RangeIndexedProduct) if Object.const_defined?(:RangeIndexedProduct) + end + + def test_gte_on_an_indexed_column_never_scans_the_full_table + @store.reset_counts! + results = RangeIndexedProduct.where(price: { gte: 15 }).order(:price).to_a + assert_equal %w[mid pricey luxury], results.map { |p| p['name'] } + assert_equal 0, @store.scan_count + assert_equal 1, @store.range_hits + end + From 3c554dff31fabc6c6d6a5b8b87c48d0e07de35ae Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:58:08 +0200 Subject: [PATCH 201/245] test: cover test_combined_gte_and_lte_in_one_hash_uses_the_range_index --- test/test_orm_index.rb | 157 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index d72c434..94704c8 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -637,3 +637,160 @@ class OrmIndexRangeRelationTest < Minitest::Test assert_equal 1, @store.range_hits end + def test_combined_gte_and_lte_in_one_hash_uses_the_range_index + @store.reset_counts! + results = RangeIndexedProduct.where(price: { gte: 10, lte: 30 }).order(:price).to_a + assert_equal %w[mid pricey], results.map { |p| p['name'] } + assert_equal 0, @store.scan_count + assert_equal 1, @store.range_hits + end + + def test_a_ruby_range_value_uses_the_range_index + @store.reset_counts! + results = RangeIndexedProduct.where(price: 10..30).order(:price).to_a + assert_equal %w[mid pricey], results.map { |p| p['name'] } + assert_equal 0, @store.scan_count + assert_equal 1, @store.range_hits + end + + def test_an_exclusive_end_ruby_range_excludes_the_boundary + @store.reset_counts! + results = RangeIndexedProduct.where(price: 15...25).to_a + assert_equal ['mid'], results.map { |p| p['name'] } + assert_equal 0, @store.scan_count + end + + def test_two_chained_wheres_narrow_the_same_column_via_and + @store.reset_counts! + results = RangeIndexedProduct.where(price: { gte: 10 }).where(price: { lte: 30 }).order(:price).to_a + assert_equal %w[mid pricey], results.map { |p| p['name'] } + assert_equal 0, @store.scan_count, 'the AND of two range clauses on the same column must still avoid a scan' + end + + def test_a_hash_mixing_a_range_operator_with_a_non_range_operator_falls_back_to_a_scan + @store.reset_counts! + results = RangeIndexedProduct.where(price: { gte: 10, ne: 25 }).order(:price).to_a + assert_equal %w[mid luxury], results.map { |p| p['name'] } + assert_equal 1, @store.scan_count, ':ne cannot be expressed as a contiguous bound, so this must be a full scan' + assert_equal 0, @store.range_hits + end + + def test_or_across_columns_still_falls_back_to_a_scan_when_only_one_side_is_indexed + @store.reset_counts! + results = RangeIndexedProduct.where(price: { gte: 30 }).or(RangeIndexedProduct.where(name: 'cheap')).to_a + assert_equal %w[cheap luxury], results.map { |p| p['name'] }.sort + assert_equal 1, @store.scan_count, 'name has no index, so OrNode cannot union candidates from both sides' + # The price side IS individually range-indexed, and OrNode#indexed_candidates + # tries it before discovering the name side has no covering index at all - + # that one probe is cheap (an O(log n) index lookup, not a scan) and its + # result is simply discarded once the other side comes back nil. + assert_equal 1, @store.range_hits + end +end + +class OrmIndexInListRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-in-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id sku status]) + @store.add_index(:widgets, :sku) + + widget_class = Class.new(Prism::Orm::Model) { table_name :widgets } + widget_class.store = @store + Object.const_set(:InListWidget, widget_class) + + @a = InListWidget.create!(sku: 'AAA', status: 'active') + @b = InListWidget.create!(sku: 'BBB', status: 'active') + @c = InListWidget.create!(sku: 'CCC', status: 'retired') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :InListWidget) if Object.const_defined?(:InListWidget) + end + + def test_where_id_in_never_scans_even_with_no_add_index_call_for_id + @store.reset_counts! + results = InListWidget.where(id: [@a.id, @c.id]).to_a + assert_equal %w[AAA CCC], results.map { |w| w['sku'] }.sort + assert_equal 0, @store.scan_count + assert_equal 1, @store.in_hits + end + + def test_where_on_an_in_indexed_column_never_scans + @store.reset_counts! + results = InListWidget.where(sku: %w[AAA CCC]).to_a + assert_equal %w[AAA CCC], results.map { |w| w['sku'] }.sort + assert_equal 0, @store.scan_count + assert_equal 1, @store.in_hits + end + + def test_where_on_an_unindexed_column_in_list_falls_back_to_a_scan + @store.reset_counts! + results = InListWidget.where(status: ['retired']).to_a + assert_equal ['CCC'], results.map { |w| w['sku'] } + assert_equal 1, @store.scan_count + assert_equal 0, @store.in_hits + end + + def test_unknown_ids_are_silently_dropped_not_nil_rows + @store.reset_counts! + results = InListWidget.where(id: [@a.id, @a.id + 999]).to_a + assert_equal [@a.id], results.map(&:id) + end + + def test_two_chained_id_in_wheres_intersect_instead_of_scanning + @store.reset_counts! + results = InListWidget.where(id: [@a.id, @b.id, @c.id]).where(id: [@b.id, @c.id]).to_a + assert_equal [@b.id, @c.id].sort, results.map(&:id).sort + assert_equal 0, @store.scan_count + end + + def test_an_equality_condition_alongside_an_id_in_list_still_uses_an_index_and_stays_correct + @store.reset_counts! + results = InListWidget.where(id: [@a.id, @b.id, @c.id]).where(sku: 'BBB').to_a + assert_equal ['BBB'], results.map { |w| w['sku'] } + assert_equal 0, @store.scan_count + end +end + +class OrmIndexEqualityRangeStoreTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-eq-range-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id featured position]) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_returns_nil_without_a_covering_composite_index + @store.insert(:posts, featured: true, position: 1) + assert_nil @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, gte: 0) + end + + def test_a_single_column_index_on_the_range_column_alone_does_not_cover_it + @store.add_index(:posts, :position) + @store.insert(:posts, featured: true, position: 1) + assert_nil @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, gte: 0) + end + + def test_finds_rows_within_the_prefix_and_bounds + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1) + @store.insert(:posts, featured: true, position: 2) + @store.insert(:posts, featured: true, position: 3) + @store.insert(:posts, featured: false, position: 1) + + matches = @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, lt: 3) + assert_equal [1, 2], matches.map { |row| row['position'] }.sort + end + + def test_equality_key_order_from_the_caller_does_not_matter + wide = Prism::Orm::Store.new(File.join(@dir, 'order.pstore')) + wide.create_table(:tasks, %w[id project priority position]) + wide.add_index(:tasks, %i[project priority position]) + wide.insert(:tasks, project: 'a', priority: 'high', position: 1) + wide.insert(:tasks, project: 'a', priority: 'high', position: 2) + From 6b7a2bd965c65664503ef577ad6559336b1ebcd7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:01:31 +0200 Subject: [PATCH 202/245] test: cover test_backfills_from_existing_rows --- test/test_orm_index.rb | 188 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index 94704c8..c00380d 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -794,3 +794,191 @@ class OrmIndexEqualityRangeStoreTest < Minitest::Test wide.insert(:tasks, project: 'a', priority: 'high', position: 1) wide.insert(:tasks, project: 'a', priority: 'high', position: 2) + declared_order = wide.lookup_by_index_equality_range(:tasks, { project: 'a', priority: 'high' }, :position, gte: 0) + reversed_order = wide.lookup_by_index_equality_range(:tasks, { priority: 'high', project: 'a' }, :position, gte: 0) + assert_equal [1, 2], declared_order.map { |row| row['position'] }.sort + assert_equal declared_order.map { |row| row['id'] }.sort, reversed_order.map { |row| row['id'] }.sort + end + + def test_backfills_from_existing_rows + @store.insert(:posts, featured: true, position: 1) + @store.insert(:posts, featured: true, position: 2) + @store.add_index(:posts, %i[featured position]) + + matches = @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, gte: 2) + assert_equal [2], matches.map { |row| row['position'] } + end + + def test_tracks_inserts_updates_and_deletes + @store.add_index(:posts, %i[featured position]) + a = @store.insert(:posts, featured: true, position: 1) + @store.insert(:posts, featured: true, position: 2) + + assert_equal [1, 2], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, + gte: 0).map { |r| r['position'] }.sort + + @store.update(:posts, a, position: 9) + assert_equal [2, 9], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, + gte: 0).map { |r| r['position'] }.sort + + @store.delete(:posts, a) + assert_equal [2], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, + gte: 0).map { |r| r['position'] } + end + + def test_switching_the_prefix_value_moves_a_row_to_the_other_partition + @store.add_index(:posts, %i[featured position]) + id = @store.insert(:posts, featured: true, position: 1) + + @store.update(:posts, id, featured: false) + assert_equal [], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, gte: 0) + assert_equal [1], @store.lookup_by_index_equality_range(:posts, { 'featured' => false }, :position, + gte: 0).map { |r| r['position'] } + end + + def test_an_unknown_prefix_value_matches_nothing_without_a_scan + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1) + + assert_equal [], @store.lookup_by_index_equality_range(:posts, { 'featured' => 'nonexistent' }, :position, gte: 0) + end + + def test_a_three_column_composite_index_still_works_with_a_two_column_prefix + @store = Prism::Orm::Store.new(File.join(@dir, 'wide.pstore')) + @store.create_table(:tasks, %w[id project priority position]) + @store.add_index(:tasks, %i[project priority position]) + @store.insert(:tasks, project: 'a', priority: 'high', position: 1) + @store.insert(:tasks, project: 'a', priority: 'high', position: 2) + @store.insert(:tasks, project: 'a', priority: 'low', position: 1) + + matches = @store.lookup_by_index_equality_range(:tasks, { project: 'a', priority: 'high' }, :position, gte: 0) + assert_equal [1, 2], matches.map { |row| row['position'] }.sort + end + + def test_lookup_by_index_range_still_refuses_a_composite_index_unchanged_behavior + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1) + assert_nil @store.lookup_by_index_range(:posts, :position, gte: 0) + end + + # A paranoid model's Model.all already carries `where(deleted_at: nil)` + # before any caller-added `.where(featured: ...)` - real regression this + # session's blog_demo.rb run caught: the index prefix is only `featured`, + # so `equalities` legitimately arrives with an EXTRA column + # (`deleted_at`) the index knows nothing about. That must still resolve + # through the index (using only the subset it actually covers), not fall + # back to a scan just because the caller happened to also constrain an + # unrelated column. + def test_an_equality_column_outside_the_index_prefix_does_not_block_the_lookup + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1, deleted_at: nil) + @store.insert(:posts, featured: true, position: 5, deleted_at: nil) + + matches = @store.lookup_by_index_equality_range(:posts, { 'deleted_at' => nil, 'featured' => true }, + :position, lt: 3) + assert_equal [1], matches.map { |row| row['position'] } + end + + def test_the_most_specific_covering_composite_index_wins_when_several_could_answer + wide = Prism::Orm::Store.new(File.join(@dir, 'multi.pstore')) + wide.create_table(:tasks, %w[id project priority position]) + wide.add_index(:tasks, %i[project position]) + wide.add_index(:tasks, %i[project priority position]) + wide.insert(:tasks, project: 'a', priority: 'high', position: 1) + wide.insert(:tasks, project: 'a', priority: 'low', position: 2) + + matches = wide.lookup_by_index_equality_range(:tasks, { project: 'a', priority: 'high' }, :position, gte: 0) + assert_equal [1], matches.map { |row| row['position'] }, + 'the 3-column index covering priority too should be preferred over the 2-column one' + end +end + +class OrmIndexEqualityRangeRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-eq-range-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id featured position]) + @store.add_index(:posts, %i[featured position]) + + post_class = Class.new(Prism::Orm::Model) { table_name :posts } + post_class.store = @store + Object.const_set(:EqRangeIndexedPost, post_class) + + EqRangeIndexedPost.create!(featured: true, position: 1) + EqRangeIndexedPost.create!(featured: true, position: 2) + EqRangeIndexedPost.create!(featured: true, position: 3) + EqRangeIndexedPost.create!(featured: false, position: 1) + EqRangeIndexedPost.create!(featured: false, position: 2) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :EqRangeIndexedPost) if Object.const_defined?(:EqRangeIndexedPost) + end + + def test_two_chained_wheres_scope_equality_then_range_never_scan + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true).where(position: { lt: 3 }).order(:position).to_a + assert_equal [1, 2], results.map { |p| p['position'] } + assert_equal 0, @store.scan_count + assert_equal 1, @store.eq_range_hits + end + + def test_single_where_with_both_conditions_in_one_hash_never_scans + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true, position: { gte: 2 }).to_a + assert_equal [2, 3], results.map { |p| p['position'] }.sort + assert_equal 0, @store.scan_count + assert_equal 1, @store.eq_range_hits + end + + def test_a_ruby_range_value_alongside_an_equality_uses_the_composite_index + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: false).where(position: 1..1).to_a + assert_equal [1], results.map { |p| p['position'] } + assert_equal 0, @store.scan_count + end + + def test_the_other_scope_partition_is_unaffected_by_the_first + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: false).where(position: { gte: 0 }).to_a + assert_equal [1, 2], results.map { |p| p['position'] }.sort + assert_equal 0, @store.scan_count + end + + def test_range_without_any_equality_falls_back_to_a_scan_not_the_composite_index + # This fixture only has the composite [featured, position] index, no + # standalone index on position - a bare range with no equality column + # to pair with can use neither the plain range path (single-column + # only) nor the composite one (needs an equality prefix), so a scan is + # the correct, honest answer here, not a false "it found an index". + @store.reset_counts! + results = EqRangeIndexedPost.where(position: { gte: 3 }).to_a + assert_equal [3], results.map { |p| p['position'] } + assert_equal 1, @store.scan_count + assert_equal 0, @store.eq_range_hits, 'no equality column means no composite prefix to look up' + end + + def test_equality_without_any_range_falls_back_to_a_scan_not_the_composite_index + # Same reasoning as above, mirrored: this fixture has no standalone + # index on featured either, only the composite - a bare equality with + # no range column to pair with can't use a composite index that + # requires ALL its columns pinned (see Store#lookup_by_index), so this + # is a scan too, not a spurious composite hit. + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true).to_a + assert_equal 3, results.size + assert_equal 1, @store.scan_count + assert_equal 0, @store.eq_range_hits + end + + def test_reflects_a_reorder_through_the_underlying_model + post = EqRangeIndexedPost.where(featured: true).where(position: 1).first + post.class.store.increment_counter(post.class.table_name, post.id, 'position', 10) + + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true).where(position: { lt: 3 }).to_a + assert_equal [2], results.map { |p| p['position'] } + assert_equal 0, @store.scan_count + end +end From f5598988d4f89af942e14835a839d81238b369b7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:03:46 +0200 Subject: [PATCH 203/245] test: cover OrmListPositionTest and teardown --- test/test_orm_list_position.rb | 161 +++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/test_orm_list_position.rb diff --git a/test/test_orm_list_position.rb b/test/test_orm_list_position.rb new file mode 100644 index 0000000..bb69ab3 --- /dev/null +++ b/test/test_orm_list_position.rb @@ -0,0 +1,161 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmListPositionTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-list-position-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:tasks, %w[id title position deleted_at]) + @store.create_table(:cards, %w[id title position board_id]) + + task_class = Class.new(Prism::Orm::Model) do + table_name :tasks + acts_as_paranoid + acts_as_list column: :position + end + card_class = Class.new(Prism::Orm::Model) do + table_name :cards + acts_as_list column: :position, scope: :board_id + end + plain_class = Class.new(Prism::Orm::Model) { table_name :tasks } + + [task_class, card_class].each { |klass| klass.store = @store } + plain_class.store = @store + Object.const_set(:Task, task_class) + Object.const_set(:Card, card_class) + Object.const_set(:PlainTask, plain_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Task Card PlainTask].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_a_model_that_never_called_acts_as_list_is_unaffected + refute PlainTask.acts_as_list? + assert Task.acts_as_list? + end + + def test_fresh_records_are_appended_to_the_bottom_in_creation_order + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + assert_equal [1, 2, 3], [a, b, c].map(&:list_position) + end + + def test_move_higher_swaps_with_the_previous_item_and_is_a_noop_at_the_top + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + b.move_higher + assert_equal 1, b.list_position + assert_equal 2, a.reload.list_position + + b.reload.move_higher # b is already first - nothing above it to swap with + assert_equal 1, b.list_position + assert_equal 2, a.reload.list_position + end + + def test_move_lower_swaps_with_the_next_item_and_is_a_noop_at_the_bottom + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + a.move_lower + assert_equal 2, a.list_position + assert_equal 1, b.reload.list_position + + b.reload.move_lower # b is now first (pos 1), a is last (pos 2) - moving b lower swaps back + assert_equal 2, b.list_position + assert_equal 1, a.reload.list_position + end + + def test_move_lower_on_the_last_item_is_a_noop + a = Task.create!(title: 'a') + Task.create!(title: 'b') + b2 = Task.find_by!(title: 'b') + b2.move_lower + assert_equal 2, b2.list_position + assert_equal 1, a.reload.list_position + end + + def test_move_higher_on_the_first_item_is_a_noop + a = Task.create!(title: 'a') + Task.create!(title: 'b') + a.move_higher + assert_equal 1, a.list_position + end + + def test_first_and_last_in_list_predicates + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + assert a.first_in_list? + refute a.last_in_list? + assert c.last_in_list? + refute c.first_in_list? + refute b.first_in_list? + refute b.last_in_list? + end + + def test_move_to_top_and_move_to_bottom_reposition_and_shift_everyone_between + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + d = Task.create!(title: 'd') + + c.move_to_top + assert_equal [1, 2, 3, 4], [c, a, b, d].map { |t| t.reload.list_position } + + a.reload.move_to_bottom + assert_equal [1, 2, 3, 4], [c, b, d, a].map { |t| t.reload.list_position } + end + + def test_insert_at_moves_an_item_to_an_arbitrary_middle_slot + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + d = Task.create!(title: 'd') + + d.insert_at(2) + assert_equal [1, 2, 3, 4], [a, d, b, c].map { |t| t.reload.list_position } + end + + def test_insert_at_on_a_record_that_is_not_persisted_raises + task = Task.new(title: 'unsaved') + assert_raises(Prism::Orm::RecordNotFound) { task.insert_at(1) } + end + + def test_destroying_an_item_closes_the_gap_for_everyone_below_it + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + b.destroy + assert_equal 1, a.reload.list_position + assert_equal 2, c.reload.list_position + end + + def test_soft_deleting_a_paranoid_item_also_closes_the_gap + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + b.destroy # paranoid: soft-delete, not a real row removal + assert b.deleted? + assert_equal 1, a.reload.list_position + assert_equal 2, c.reload.list_position + end + + def test_restoring_a_soft_deleted_item_reappends_it_to_the_bottom_not_its_old_slot + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + c = Task.create!(title: 'c') + b.destroy + d = Task.create!(title: 'd') + assert_equal [1, 2, 3], [a.reload, c.reload, d.reload].map(&:list_position) + + restored = Task.only_deleted.find_by!(title: 'b') + restored.restore + assert_equal 4, restored.list_position + # nobody else collides with the reappended slot + assert_equal [1, 2, 3, 4], [a.reload, c.reload, d.reload, restored.reload].map(&:list_position).sort + end + From 7b3a13a982bbb3c9ffcfc8683b48bb50b1827d68 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:04:32 +0200 Subject: [PATCH 204/245] test: cover test_scoped_lists_are_independent_per_scope_value --- test/test_orm_list_position.rb | 147 +++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/test/test_orm_list_position.rb b/test/test_orm_list_position.rb index bb69ab3..bb1615b 100644 --- a/test/test_orm_list_position.rb +++ b/test/test_orm_list_position.rb @@ -159,3 +159,150 @@ class OrmListPositionTest < Minitest::Test assert_equal [1, 2, 3, 4], [a.reload, c.reload, d.reload, restored.reload].map(&:list_position).sort end + def test_scoped_lists_are_independent_per_scope_value + a1 = Card.create!(title: 'a1', board_id: 1) + a2 = Card.create!(title: 'a2', board_id: 1) + b1 = Card.create!(title: 'b1', board_id: 2) + assert_equal 1, a1.list_position + assert_equal 2, a2.list_position + assert_equal 1, b1.list_position # own scope, own sequence + + a1.move_lower + assert_equal 1, b1.reload.list_position # untouched by a1/a2's board + end + + def test_explicit_position_on_create_shifts_existing_siblings_down + a = Task.create!(title: 'a') + b = Task.create!(title: 'b') + inserted = Task.create!(title: 'new-first', position: 1) + assert_equal 1, inserted.list_position + assert_equal 2, a.reload.list_position + assert_equal 3, b.reload.list_position + end + + def test_changing_the_scope_column_moves_a_record_between_lists_on_update + a1 = Card.create!(title: 'a1', board_id: 1) + a2 = Card.create!(title: 'a2', board_id: 1) + a3 = Card.create!(title: 'a3', board_id: 1) + b1 = Card.create!(title: 'b1', board_id: 2) + + a2.update!(board_id: 2) + + # board 1's gap at a2's old slot (2) closed + assert_equal 1, a1.reload.list_position + assert_equal 2, a3.reload.list_position + # a2 joined board 2 at the bottom, not colliding with b1 + assert_equal 1, b1.reload.list_position + assert_equal 2, a2.reload.list_position + end + + def test_updating_without_touching_the_scope_column_leaves_position_alone + a = Card.create!(title: 'a', board_id: 1) + b = Card.create!(title: 'b', board_id: 1) + a.update!(title: 'a renamed') + assert_equal 1, a.list_position + assert_equal 2, b.reload.list_position + end + + # Everything below forks real OS processes, same technique and same + # justification as test_orm_pessimistic.rb (Store#within_transaction takes + # a real File::LOCK_EX/LOCK_SH per transaction - this is not a simulation). + # + # These pin down a real bug that existed before move_higher/move_lower/ + # move_to_top/move_to_bottom/insert_at were wrapped in with_lock: + # higher_item/lower_item/minimum/maximum used to run in their OWN + # read-only transaction, which releases Store's file lock the moment it + # returns, before the write transaction opened - a stale neighbor read by + # one process could still be used to compute a swap after another process + # already moved that same neighbor, corrupting the dense 1..N position + # invariant every method in this file depends on. + def test_swap_list_position_with_is_racy_without_a_held_lock + skip 'fork not supported on this platform' unless Process.respond_to?(:fork) + + a = Task.create!(title: 'a') # position 1 + b = Task.create!(title: 'b') # position 2 + c = Task.create!(title: 'c') # position 3 + + a_read, a_wrote_read_signal = IO.pipe + b_read, b_wrote_read_signal = IO.pipe + + # Forces both processes to read their (self, neighbor) positions from + # the ORIGINAL 1/2/3 layout before either one calls the private, + # lock-free swap_list_position_with directly (bypassing with_lock, + # exactly the shape move_higher/move_lower had before this fix) - only + # the reads are ordered, deliberately not the writes, since the race + # only needs both computed deltas to be based on the same stale + # snapshot, not any particular write order. + pid_x = Process.fork do + a_read.close + b_wrote_read_signal.close + x_self = Task.find(a.id) # position 1 + x_neighbor = Task.find(b.id) # position 2, read before b moves at all + a_wrote_read_signal.write('r') + a_wrote_read_signal.close + b_read.read(1) + b_read.close + x_self.send(:swap_list_position_with, x_neighbor) + exit!(0) + end + + pid_y = Process.fork do + a_wrote_read_signal.close + b_read.close + a_read.read(1) + a_read.close + y_self = Task.find(c.id) # position 3 + y_neighbor = Task.find(b.id) # position 2, same stale snapshot as x_neighbor + b_wrote_read_signal.write('r') + b_wrote_read_signal.close + y_self.send(:swap_list_position_with, y_neighbor) + exit!(0) + end + + [a_read, a_wrote_read_signal, b_read, b_wrote_read_signal].each(&:close) + Process.waitpid(pid_x) + Process.waitpid(pid_y) + + # Both swaps computed their delta against B's stale on-disk value (2), + # so B absorbs both deltas (-1 then +1, or +1 then -1 depending on + # write order - either way net zero relative to its OWN stale + # baseline) while A and C both independently land on B's old slot: A + # gets +1 (1 -> 2), C gets -1 (3 -> 2), B ends up wherever the second + # writer's delta leaves the FIRST writer's already-moved value, which + # collapses to 2 either way. All three end up sharing position 2 - + # positions 1 and 3 are left completely empty, a corruption of the + # dense 1..N invariant that no valid outcome of this framework's API + # can otherwise produce. + assert_equal [2, 2, 2], [a, b, c].map { |t| t.reload.list_position }.sort + end + + def test_move_higher_and_move_lower_stay_race_free_under_real_concurrency + skip 'fork not supported on this platform' unless Process.respond_to?(:fork) + + a = Task.create!(title: 'a') # position 1 + b = Task.create!(title: 'b') # position 2 + c = Task.create!(title: 'c') # position 3 + + # No pipe-forced ordering needed here (unlike the racy test above): each + # move_lower/move_higher call now takes Store's real file lock for its + # ENTIRE reload-lookup-swap sequence via with_lock, so whichever process + # gets there first runs to completion before the other's lock! reload + # even sees the table - the second call always computes its neighbor + # from genuinely current data, not a stale snapshot, no matter how the + # OS schedules the two forks. + pid_x = Process.fork do + Task.find(a.id).move_lower + exit!(0) + end + pid_y = Process.fork do + Task.find(c.id).move_higher + exit!(0) + end + Process.waitpid(pid_x) + Process.waitpid(pid_y) + + positions = [a, b, c].map { |t| t.reload.list_position } + assert_equal [1, 2, 3], positions.sort, 'no duplicate or skipped positions after concurrent moves' + assert_equal 3, positions.uniq.size + end +end From 66f0ccc21ab5c6a5315c90a10ff7050e1bce7221 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:42:14 +0200 Subject: [PATCH 205/245] test: cover OrmLockingTest and teardown --- test/test_orm_locking.rb | 179 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 test/test_orm_locking.rb diff --git a/test/test_orm_locking.rb b/test/test_orm_locking.rb new file mode 100644 index 0000000..3ad13d4 --- /dev/null +++ b/test/test_orm_locking.rb @@ -0,0 +1,179 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmLockingTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-locking') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name lock_version]) + @store.create_table(:plain_things, %w[id name]) + + widget_class = Class.new(Prism::Orm::Model) { table_name :widgets } + plain_class = Class.new(Prism::Orm::Model) { table_name :plain_things } + [widget_class, plain_class].each { |klass| klass.store = @store } + Object.const_set(:Widget, widget_class) + Object.const_set(:PlainThing, plain_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Widget PlainThing].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_locking_enabled_when_lock_version_column_present + assert Widget.locking_enabled? + refute PlainThing.locking_enabled? + end + + def test_create_initializes_lock_version_to_zero + widget = Widget.create!(name: 'a') + assert_equal 0, widget.lock_version + end + + def test_create_does_not_overwrite_an_explicitly_assigned_lock_version + widget = Widget.create!(name: 'a', lock_version: 5) + assert_equal 5, widget.lock_version + end + + def test_update_increments_lock_version + widget = Widget.create!(name: 'a') + widget.update!(name: 'b') + assert_equal 1, widget.lock_version + widget.update!(name: 'c') + assert_equal 2, widget.lock_version + assert_equal 2, Widget.find(widget.id).lock_version + end + + def test_stale_update_raises_stale_object_error + original = Widget.create!(name: 'a') + stale_copy = Widget.find(original.id) + + original.update!(name: 'fresh') + + stale_copy.name = 'stale' + error = assert_raises(Prism::Orm::StaleObjectError) { stale_copy.save! } + assert_same stale_copy, error.record + assert_equal :update, error.attempted_action + + reloaded = Widget.find(original.id) + assert_equal 'fresh', reloaded.name, 'the stale write must not have overwritten the fresh row' + end + + def test_reloading_after_a_stale_error_allows_a_later_save_to_succeed + original = Widget.create!(name: 'a') + stale_copy = Widget.find(original.id) + original.update!(name: 'fresh') + + stale_copy.name = 'stale' + assert_raises(Prism::Orm::StaleObjectError) { stale_copy.save! } + + stale_copy.reload + stale_copy.name = 'retried' + assert stale_copy.save! + assert_equal 'retried', Widget.find(original.id).name + end + + def test_stale_destroy_raises_stale_object_error + original = Widget.create!(name: 'a') + stale_copy = Widget.find(original.id) + original.update!(name: 'changed') + + error = assert_raises(Prism::Orm::StaleObjectError) { stale_copy.destroy } + assert_equal :destroy, error.attempted_action + refute_nil Widget.find(original.id), 'stale destroy must not have deleted the row' + end + + def test_current_destroy_succeeds + widget = Widget.create!(name: 'a') + widget.destroy + assert_raises(Prism::Orm::RecordNotFound) { Widget.find(widget.id) } + end + + def test_model_without_lock_version_column_ignores_locking_entirely + thing = PlainThing.create!(name: 'a') + stale = PlainThing.find(thing.id) + thing.update!(name: 'first writer') + stale.update!(name: 'second writer wins, no locking') + assert_equal 'second writer wins, no locking', PlainThing.find(thing.id).name + end + + def test_locking_column_can_be_customized + @store.create_table(:gizmos, %w[id name version_number]) + gizmo_class = Class.new(Prism::Orm::Model) do + table_name :gizmos + locking_column :version_number + end + gizmo_class.store = @store + + gizmo = gizmo_class.create!(name: 'a') + assert_equal 0, gizmo.version_number + gizmo.update!(name: 'b') + assert_equal 1, gizmo.version_number + + stale = gizmo_class.find(gizmo.id) + gizmo.update!(name: 'c') + stale.name = 'stale' + assert_raises(Prism::Orm::StaleObjectError) { stale.save! } + end + + # Real cross-process proof: N processes each retry a load -> increment -> + # save loop against the SAME row until their own increment sticks. If the + # version check and the write were not inside the same PStore transaction + # (or if Model silently swallowed StaleObjectError instead of raising), + # concurrent writers would stomp on each other's increments and the final + # counter would fall short of the true total - the classic optimistic + # locking lost-update problem this feature exists to prevent. + def test_prevents_lost_updates_across_real_concurrent_processes + store_path = File.join(@dir, 'shared.pstore') + shared_store = Prism::Orm::Store.new(store_path) + shared_store.create_table(:counters, %w[id counter lock_version]) + shared_store.insert(:counters, id: 1, counter: 0, lock_version: 0) + + process_count = 4 + increments_per_process = 20 + + racer_script = File.join(@dir, 'racer.rb') + File.write(racer_script, <<~RUBY) + require 'prism' + store = Prism::Orm::Store.new(#{store_path.inspect}) + Counter = Class.new(Prism::Orm::Model) { table_name :counters } + Counter.store = store + + successes = 0 + retries = 0 + while successes < #{increments_per_process} + counter = Counter.find(1) + counter.counter = counter.counter + 1 + sleep(rand * 0.005) # widen the read-then-write window so processes actually overlap + begin + counter.save! + successes += 1 + rescue Prism::Orm::StaleObjectError + retries += 1 + end + end + File.write(File.join(#{@dir.inspect}, "\#{Process.pid}.retries"), retries.to_s) + RUBY + + pids = process_count.times.map do + Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), racer_script, + out: File::NULL, err: File::NULL) + end + pids.each { |pid| Process.waitpid(pid) } + + expected_total = process_count * increments_per_process + final = shared_store.find(:counters, 1) + assert_equal expected_total, final['counter'], + 'every successful save must have applied its own +1 - a lost update means ' \ + 'this falls short of the true total' + assert_equal expected_total, final['lock_version'] + + total_retries = pids.sum { |pid| Integer(File.read(File.join(@dir, "#{pid}.retries"))) } + assert total_retries.positive?, + 'with 4 processes racing on one row, at least one save should have collided and ' \ + 'had to retry - a suspiciously zero count would suggest the processes never ' \ + 'actually overlapped' + end +end From 0ede5093c488e689f77c2d1de43653fb585f6b69 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:54:07 +0200 Subject: [PATCH 206/245] test: cover test_status_reports_applied_and_pending_migrations_in_order --- test/test_orm_migration.rb | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/test_orm_migration.rb b/test/test_orm_migration.rb index 08fff3e..f159cd9 100644 --- a/test/test_orm_migration.rb +++ b/test/test_orm_migration.rb @@ -83,6 +83,37 @@ class OrmMigrationTest < Minitest::Test assert_empty @store.applied_migrations end + def test_status_reports_applied_and_pending_migrations_in_order + write_migration('20260101000004', 'CreateCogsForMigrationTest', <<~RUBY) + def up + create_table(:cogs_for_migration_test) { |t| t.string :name } + end + + def down + drop_table :cogs_for_migration_test + end + RUBY + write_migration('20260101000005', 'CreateBoltsForMigrationTest', <<~RUBY) + def up + create_table(:bolts_for_migration_test) { |t| t.string :name } + end + + def down + drop_table :bolts_for_migration_test + end + RUBY + + migrator = Prism::Orm::Migrator.new(@store, @migrations_dir) + migrator.migrate! + migrator.rollback! + + status = migrator.status + versions_and_state = status.map { |entry, applied| [entry.version, applied] } + assert_includes versions_and_state, ['20260101000004', true] + assert_includes versions_and_state, ['20260101000005', false] + assert_equal status.map { |entry, _| entry.version }, status.map { |entry, _| entry.version }.sort + end + def test_rollback_multiple_steps_reverts_in_reverse_order write_migration('20260101000020', 'FirstMultiRollbackTest', <<~RUBY) def up From 2ee0b0ef3de0d6226d8e6792582e2f485bbcaf4c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:58:05 +0200 Subject: [PATCH 207/245] test: cover OrmModelTransactionsTest and teardown --- test/test_orm_model_transactions.rb | 221 ++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 test/test_orm_model_transactions.rb diff --git a/test/test_orm_model_transactions.rb b/test/test_orm_model_transactions.rb new file mode 100644 index 0000000..3505db0 --- /dev/null +++ b/test/test_orm_model_transactions.rb @@ -0,0 +1,221 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmModelTransactionsTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-model-tx') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:accounts, %w[id name balance]) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + + account_class = Class.new(Prism::Orm::Model) { table_name :accounts } + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id', dependent: :destroy + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [account_class, author_class, post_class].each { |klass| klass.store = @store } + Object.const_set(:Account, account_class) + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Account Author Post].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_transaction_commits_every_write_made_by_multiple_records + Account.transaction do + Account.create!(name: 'alice', balance: 10) + Account.create!(name: 'bob', balance: 20) + end + assert_equal [10, 20], Account.all.map(&:balance) + end + + def test_exception_inside_transaction_rolls_back_every_write + assert_raises(RuntimeError) do + Account.transaction do + Account.create!(name: 'alice', balance: 10) + Account.create!(name: 'bob', balance: 20) + raise 'transfer failed midway' + end + end + assert_empty Account.all.to_a + end + + def test_rollback_exception_aborts_silently_without_propagating + result = Account.transaction do + Account.create!(name: 'alice', balance: 10) + raise Prism::Orm::Rollback + end + assert_nil result + assert_empty Account.all.to_a + end + + def test_transaction_can_be_entered_from_an_instance_and_from_a_different_model_class + alice = Account.create!(name: 'alice', balance: 0) + assert_raises(RuntimeError) do + alice.transaction do + alice.update!(balance: 100) + Author.create!(name: 'ghost') + raise 'boom' + end + end + assert_equal 0, alice.reload.balance + assert_empty Author.all.to_a + end + + def test_after_commit_fires_once_for_a_standalone_save_with_no_explicit_transaction + log = [] + Account.after_commit { log << [:commit, name] } + Account.after_rollback { log << [:rollback, name] } + Account.create!(name: 'alice', balance: 5) + assert_equal [[:commit, 'alice']], log + end + + def test_after_commit_fires_exactly_once_for_all_participants_of_an_explicit_transaction + log = [] + Account.after_commit { log << name } + Account.transaction do + Account.create!(name: 'alice', balance: 1) + Account.create!(name: 'bob', balance: 2) + end + assert_equal %w[alice bob], log.sort + end + + def test_after_rollback_fires_instead_of_after_commit_when_the_transaction_raises + committed = [] + rolled_back = [] + Account.after_commit { committed << name } + Account.after_rollback { rolled_back << name } + assert_raises(RuntimeError) do + Account.transaction do + Account.create!(name: 'alice', balance: 1) + raise 'boom' + end + end + assert_empty committed + assert_equal ['alice'], rolled_back + end + + def test_after_commit_on_filters_by_operation + creates = [] + updates = [] + Account.after_commit(on: :create) { creates << name } + Account.after_commit(on: :update) { updates << name } + account = Account.create!(name: 'alice', balance: 1) + account.update!(balance: 2) + assert_equal ['alice'], creates + assert_equal ['alice'], updates + end + + def test_cascaded_dependent_destroy_shares_one_transaction_with_the_parent + author = Author.create!(name: 'foksiu') + Post.create!(title: 'a', author_id: author.id) + Post.create!(title: 'b', author_id: author.id) + + Author.after_destroy { raise 'boom after destroy' } + assert_raises(RuntimeError) { author.destroy } + + assert Author.find(author.id), 'the author row must survive the aborted transaction' + assert_equal 2, Post.where(author_id: author.id).count, + 'cascaded child deletes must roll back together with the parent' + end + + def test_before_destroy_abort_never_reaches_the_store + Author.before_destroy { throw :abort } + author = Author.create!(name: 'foksiu') + author.destroy + refute author.destroyed? + assert Author.find(author.id) + end + + def test_plain_nested_transaction_still_flattens_and_rolls_back_everything + assert_raises(RuntimeError) do + Account.transaction do + Account.create!(name: 'alice', balance: 10) + Account.transaction do + Account.create!(name: 'bob', balance: 20) + raise 'boom' + end + end + end + assert_empty Account.all.to_a, 'a plain nested transaction is not a real savepoint' + end + + def test_requires_new_rolls_back_only_the_inner_writes_and_lets_the_outer_commit + Account.transaction do + Account.create!(name: 'alice', balance: 10) + assert_raises(RuntimeError) do + Account.transaction(requires_new: true) do + Account.create!(name: 'doomed', balance: 999) + raise 'inner failure' + end + end + Account.create!(name: 'bob', balance: 20) + end + assert_equal %w[alice bob], Account.all.map(&:name).sort + end + + def test_requires_new_with_explicit_rollback_is_swallowed_and_outer_keeps_going + Account.transaction do + Account.create!(name: 'alice', balance: 10) + result = Account.transaction(requires_new: true) do + Account.create!(name: 'doomed', balance: 999) + raise Prism::Orm::Rollback + end + assert_nil result + Account.create!(name: 'bob', balance: 20) + end + assert_equal %w[alice bob], Account.all.map(&:name).sort + end + + def test_requires_new_fires_after_rollback_immediately_without_waiting_for_the_outer_transaction + log = [] + Account.after_commit { log << [:commit, name] } + Account.after_rollback { log << [:rollback, name] } + + Account.transaction do + Account.create!(name: 'alice', balance: 10) + assert_raises(RuntimeError) do + Account.transaction(requires_new: true) do + Account.create!(name: 'doomed', balance: 999) + raise 'inner failure' + end + end + assert_equal [[:rollback, 'doomed']], log, + 'the savepoint must have rolled back already, before the outer transaction has even finished' + Account.create!(name: 'bob', balance: 20) + end + assert_equal [[:rollback, 'doomed'], [:commit, 'alice'], [:commit, 'bob']], log + end + + def test_requires_new_without_a_surrounding_transaction_behaves_like_a_plain_transaction + assert_raises(RuntimeError) do + Account.transaction(requires_new: true) do + Account.create!(name: 'alice', balance: 10) + raise 'boom' + end + end + assert_empty Account.all.to_a + end + + def test_outer_rollback_after_a_committed_savepoint_still_undoes_the_savepoint_writes + assert_raises(RuntimeError) do + Account.transaction do + Account.transaction(requires_new: true) do + Account.create!(name: 'alice', balance: 10) + end + raise 'outer failure' + end + end + assert_empty Account.all.to_a, 'a released savepoint is not a commit point on its own' + end +end From b8cca00f063e037b8124cba49fe7147a690da031 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 20:17:53 +0200 Subject: [PATCH 208/245] test: cover OrmNPlusOneDetectorTest and teardown --- test/test_orm_n_plus_one_detector.rb | 161 +++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/test_orm_n_plus_one_detector.rb diff --git a/test/test_orm_n_plus_one_detector.rb b/test/test_orm_n_plus_one_detector.rb new file mode 100644 index 0000000..d088c41 --- /dev/null +++ b/test/test_orm_n_plus_one_detector.rb @@ -0,0 +1,161 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'stringio' + +class OrmNPlusOneDetectorTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-n-plus-one-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + @store.create_table(:bios, %w[id author_id headline]) + + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id' + has_one :bio, class_name: 'Bio', foreign_key: 'author_id' + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + bio_class = Class.new(Prism::Orm::Model) do + table_name :bios + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [author_class, post_class, bio_class].each { |klass| klass.store = @store } + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + Object.const_set(:Bio, bio_class) + + @io = StringIO.new + end + + def teardown + Prism::Orm::NPlusOneDetector.disable! + Prism::Orm::NPlusOneDetector.reset! + FileUtils.remove_entry(@dir) + %i[Author Post Bio].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def create_authors_with_posts(count) + count.times do |i| + author = Author.create!(name: "author#{i}") + Post.create!(title: "post#{i}", author_id: author.id) + author + end + end + + def test_disabled_by_default_never_warns_even_on_an_obvious_n_plus_one + create_authors_with_posts(5) + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + assert_empty @io.string + end + + def test_below_threshold_does_not_warn + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(1) + + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string + end + + def test_reaching_threshold_across_distinct_owners_warns + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(3) + + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_match(/N\+1 query detected: Author#posts lazily loaded for 3 separate Author records/, @io.string) + assert_match(/\.includes\(:posts\)/, @io.string) + end + + def test_preloaded_via_includes_never_counts_as_a_lazy_load + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(3) + + Author.all.includes(:posts).to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string + end + + def test_repeated_reads_on_the_same_owner_do_not_inflate_the_count + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(1) + author = Author.first + + 5.times { author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string, 'one owner read five times is still one owner, not five' + end + + def test_two_different_associations_are_tracked_and_reported_independently + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + 3.times do |i| + author = Author.create!(name: "author#{i}") + Post.create!(title: "post#{i}", author_id: author.id) + Bio.create!(headline: "bio#{i}", author_id: author.id) + end + + Author.all.to_a.each do |author| + author.posts.to_a + author.bio + end + Prism::Orm::NPlusOneDetector.report! + + assert_match(/Author#posts lazily loaded for 3/, @io.string) + assert_match(/Author#bio lazily loaded for 3/, @io.string) + end + + def test_belongs_to_lazy_loads_across_distinct_children_are_detected_too + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + author = Author.create!(name: 'shared') + 3.times { |i| Post.create!(title: "post#{i}", author_id: author.id) } + + Post.all.to_a.each(&:author) + Prism::Orm::NPlusOneDetector.report! + + assert_match(/Post#author lazily loaded for 3 separate Post records/, @io.string) + end + + def test_raise_on_violation_raises_instead_of_only_logging + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io, raise_on_violation: true) + create_authors_with_posts(2) + + error = assert_raises(Prism::Orm::NPlusOneViolation) do + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + end + assert_match(/Author#posts/, error.message) + end + + def test_reset_clears_the_tracker_between_requests + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(1) + author = Author.first + + author.posts.to_a + Prism::Orm::NPlusOneDetector.reset! + author.posts.to_a + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string, 'reset! between requests must not let counts carry over' + end + + def test_context_is_included_in_the_warning_line + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(2) + + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report!(method: 'GET', path: '/authors') + + assert_match(%r{method=\"GET\" path=\"/authors\"}, @io.string) + end +end From 6b2d8f51a2b921ddd7ec23863025f943b342acd3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 20:49:40 +0200 Subject: [PATCH 209/245] test: cover OrmNestedAttributesTest and teardown --- test/test_orm_nested_attributes.rb | 166 +++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 test/test_orm_nested_attributes.rb diff --git a/test/test_orm_nested_attributes.rb b/test/test_orm_nested_attributes.rb new file mode 100644 index 0000000..7249ad3 --- /dev/null +++ b/test/test_orm_nested_attributes.rb @@ -0,0 +1,166 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmNestedAttributesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-nested-attrs-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + @store.create_table(:comments, %w[id body post_id]) + @store.create_table(:accounts, %w[id owner_id plan]) + @store.create_table(:users, %w[id name]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id', dependent: :destroy + accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: :all_blank, limit: 3 + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id' + validates :body, presence: true, length: { minimum: 2 } + end + user_class = Class.new(Prism::Orm::Model) do + table_name :users + has_one :account, class_name: 'Account', foreign_key: 'owner_id' + accepts_nested_attributes_for :account + end + account_class = Class.new(Prism::Orm::Model) do + table_name :accounts + belongs_to :owner, class_name: 'User', foreign_key: 'owner_id' + validates :plan, presence: true + end + + [post_class, comment_class, user_class, account_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Comment, comment_class) + Object.const_set(:User, user_class) + Object.const_set(:Account, account_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Comment User Account].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_builds_and_saves_new_children_of_a_new_parent + post = Post.create!( + title: 'hello', + comments_attributes: [{ body: 'first' }, { body: 'second' }] + ) + + assert_equal 2, Comment.where(post_id: post.id).count + bodies = Comment.where(post_id: post.id).map(&:body).sort + assert_equal %w[first second], bodies + end + + def test_accepts_indexed_hash_form_like_a_rails_form_builder + post = Post.create!( + title: 'hello', + comments_attributes: { '0' => { body: 'first' }, '1' => { body: 'second' } } + ) + + assert_equal 2, Comment.where(post_id: post.id).count + end + + def test_updates_an_existing_child_by_id + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'original', post_id: post.id) + + post.update!(comments_attributes: [{ id: comment.id, body: 'edited' }]) + + assert_equal 'edited', comment.reload.body + end + + def test_allow_destroy_removes_the_child + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'doomed', post_id: post.id) + + post.update!(comments_attributes: [{ id: comment.id, _destroy: '1' }]) + + assert_nil Comment.store.find(Comment.table_name, comment.id) + end + + def test_destroy_flag_is_ignored_without_allow_destroy + no_destroy_post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', foreign_key: 'post_id' + accepts_nested_attributes_for :comments + end + no_destroy_post_class.store = @store + post = no_destroy_post_class.create!(title: 'hello') + comment = Comment.create!(body: 'kept', post_id: post.id) + + post.update!(comments_attributes: [{ id: comment.id, _destroy: '1', body: 'still here' }]) + + refute_nil Comment.store.find(Comment.table_name, comment.id) + assert_equal 'still here', comment.reload.body + end + + def test_reject_if_all_blank_skips_empty_new_records + post = Post.create!(title: 'hello', comments_attributes: [{ body: '' }, { body: 'real' }]) + + assert_equal 1, Comment.where(post_id: post.id).count + assert_equal 'real', Comment.where(post_id: post.id).first.body + end + + def test_limit_raises_when_exceeded + assert_raises(Prism::Orm::NestedAttributes::TooManyRecords) do + Post.create!(title: 'hello', comments_attributes: [{ body: 'a' }, { body: 'b' }, { body: 'c' }, { body: 'd' }]) + end + end + + def test_invalid_nested_record_blocks_the_whole_save_and_reports_errors + post = Post.new(title: 'hello', comments_attributes: [{ body: 'good enough' }, { body: 'x' }]) + + refute post.save + assert post.new_record? + assert post.errors.key?(:comments) + assert_equal 0, Comment.count + end + + def test_id_pointing_at_another_parents_child_is_rejected_not_hijacked + post_a = Post.create!(title: 'a') + post_b = Post.create!(title: 'b') + foreign_comment = Comment.create!(body: 'belongs to a', post_id: post_a.id) + + refute post_b.update(comments_attributes: [{ id: foreign_comment.id, body: 'hijacked' }]) + assert_equal 'belongs to a', foreign_comment.reload.body + assert_equal post_a.id, foreign_comment.reload.post_id + end + + def test_id_on_a_brand_new_parent_is_rejected + post = Post.new(title: 'hello', comments_attributes: [{ id: 999, body: 'ghost' }]) + + refute post.save + assert post.errors.key?(:comments) + end + + def test_has_one_nested_attributes_builds_the_single_record + user = User.create!(name: 'ada', account_attributes: { plan: 'pro' }) + + account = Account.find_by(owner_id: user.id) + refute_nil account + assert_equal 'pro', account.plan + end + + def test_has_one_nested_attributes_updates_the_existing_record + user = User.create!(name: 'ada') + account = Account.create!(plan: 'free', owner_id: user.id) + + user.update!(account_attributes: { id: account.id, plan: 'pro' }) + + assert_equal 'pro', account.reload.plan + end + + def test_does_not_support_associations_other_than_has_many_or_has_one + assert_raises(ArgumentError) do + Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :post, class_name: 'Post', foreign_key: 'post_id' + accepts_nested_attributes_for :post + end + end + end +end From c7b1f948c705c8f1640c212aaba9e287a937be59 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 20:59:36 +0200 Subject: [PATCH 210/245] test: cover OrmOwnedRecordsPendingIndexTest and teardown --- test/test_orm_owned_records_pending_index.rb | 131 +++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 test/test_orm_owned_records_pending_index.rb diff --git a/test/test_orm_owned_records_pending_index.rb b/test/test_orm_owned_records_pending_index.rb new file mode 100644 index 0000000..8e7abf1 --- /dev/null +++ b/test/test_orm_owned_records_pending_index.rb @@ -0,0 +1,131 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# The shape AccountController#moderation actually issues (examples/blog): find +# the ids of every record a user owns (an equality lookup on a foreign key, +# like Store#lookup_by_index), then filter a second table down to just the +# ones whose foreign key is IN that id list AND some other column equals a +# fixed value (Comment.pending.where(post_id: owned_post_ids) in the real +# app). That second query is an AndNode of a HashNode Array (IN-list) and a +# HashNode scalar (equality) - ConditionNode#indexed_candidates only tries +# lookup_by_index_in for a column actually present in #indexable_in_lists, +# so it is the post_id side (an Array) that must be indexed for this to +# avoid a scan; the leftover status equality is filtered from that already- +# narrow candidate set in memory afterward by Relation#rows, exactly the way +# OrmHasManyPolymorphicForeignKeyIndexTest proves for a two-column equality. +class OrmOwnedRecordsPendingIndexTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-owned-pending-index-store') + @store = CountingOwnedPendingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:mod_owners, %w[id name]) + @store.create_table(:mod_posts, %w[id user_id title]) + @store.create_table(:mod_comments, %w[id post_id status body]) + + owner_class = Class.new(Prism::Orm::Model) do + table_name :mod_owners + has_many :posts, class_name: 'ModPost', foreign_key: 'user_id' + end + post_class = Class.new(Prism::Orm::Model) { table_name :mod_posts } + comment_class = Class.new(Prism::Orm::Model) { table_name :mod_comments } + [owner_class, post_class, comment_class].each { |klass| klass.store = @store } + Object.const_set(:ModOwner, owner_class) + Object.const_set(:ModPost, post_class) + Object.const_set(:ModComment, comment_class) + + @alice = ModOwner.create!(name: 'alice') + @bob = ModOwner.create!(name: 'bob') + @alice_post = ModPost.create!(user_id: @alice.id, title: 'alices post') + @bob_post = ModPost.create!(user_id: @bob.id, title: 'bobs post') + + # 0 = pending, 1 = approved - plain integers here (not a real enum + # module) since this test is only about index selection, not Enum's + # label<->value translation, which already has its own coverage. + ModComment.create!(post_id: @alice_post.id, status: 0, body: 'waiting on alice') + ModComment.create!(post_id: @alice_post.id, status: 1, body: 'already approved') + ModComment.create!(post_id: @bob_post.id, status: 0, body: 'waiting on bob') + end + + def teardown + FileUtils.remove_entry(@dir) + %i[ModOwner ModPost ModComment].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_without_a_post_id_index_the_pending_lookup_falls_back_to_a_scan + @store.reset_counts! + bodies = ModComment.where(status: 0).where(post_id: [@alice_post.id]).to_a.map { |c| c['body'] } + assert_equal ['waiting on alice'], bodies + assert_equal 1, @store.scan_count + assert_equal 0, @store.in_list_hits + end + + def test_indexed_post_id_answers_the_in_list_and_filters_status_in_memory + @store.add_index(:mod_comments, :post_id) + + @store.reset_counts! + bodies = ModComment.where(status: 0).where(post_id: [@alice_post.id, @bob_post.id]).to_a.map { |c| c['body'] }.sort + assert_equal ['waiting on alice', 'waiting on bob'], bodies, + 'the already-approved comment on the same post must be filtered out, not just deduped away' + assert_equal 0, @store.scan_count + assert_equal 1, @store.in_list_hits + end + + def test_an_owner_with_no_posts_yields_an_empty_in_list_without_scanning + @store.add_index(:mod_posts, :user_id) + @store.add_index(:mod_comments, :post_id) + carol = ModOwner.create!(name: 'carol') + + @store.reset_counts! + owned_ids = carol.posts.pluck('id') + assert_equal [], owned_ids + assert_equal 0, @store.scan_count, 'carol owns no posts, but that is still an index hit (zero rows), not a scan' + + bodies = ModComment.where(status: 0).where(post_id: owned_ids).to_a + assert_equal [], bodies, + 'lookup_by_index_in on an empty Array is still an index hit (zero rows), not a fallback scan' + assert_equal 0, @store.scan_count + end + + def test_both_steps_of_the_real_controller_query_stay_index_backed_once_both_columns_are_indexed + @store.add_index(:mod_posts, :user_id) + @store.add_index(:mod_comments, :post_id) + + @store.reset_counts! + owned_ids = @alice.posts.pluck('id') + assert_equal [@alice_post.id], owned_ids + + bodies = ModComment.where(status: 0).where(post_id: owned_ids).to_a.map { |c| c['body'] } + assert_equal ['waiting on alice'], bodies + assert_equal 0, @store.scan_count, 'neither the owned-posts lookup nor the pending-comments lookup should scan' + end +end + +# Same wrapper shape as CountingFkIndexStore (test_orm_has_many_foreign_key_ +# index.rb), extended with an IN-list hit counter since this file is +# specifically exercising Store#lookup_by_index_in rather than plain +# Store#lookup_by_index. +class CountingOwnedPendingIndexStore < Prism::Orm::Store + attr_reader :scan_count, :in_list_hits + + def initialize(*args) + super + @scan_count = 0 + @in_list_hits = 0 + end + + def all(table) + @scan_count += 1 + super + end + + def lookup_by_index_in(table, column, values) + result = super + @in_list_hits += 1 unless result.nil? + result + end + + def reset_counts! + @scan_count = 0 + @in_list_hits = 0 + end +end From 132886ac67eb35df695028bd6c6e955365f3f550 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 21:13:21 +0200 Subject: [PATCH 211/245] test: cover OrmPaginationTest and teardown --- test/test_orm_pagination.rb | 97 +++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/test_orm_pagination.rb diff --git a/test/test_orm_pagination.rb b/test/test_orm_pagination.rb new file mode 100644 index 0000000..76d4c25 --- /dev/null +++ b/test/test_orm_pagination.rb @@ -0,0 +1,97 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmPaginationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-pagination-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name position]) + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + end + widget_class.store = @store + Object.const_set(:Widget, widget_class) + + 25.times { |i| Widget.create!(name: "widget-#{i}", position: i) } + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Widget) if Object.const_defined?(:Widget) + end + + def test_default_per_page_is_twenty_five + relation = Widget.order(:position).page(1) + assert_equal 25, relation.to_a.size + assert_equal 1, relation.total_pages + end + + def test_per_page_limits_the_page_size + relation = Widget.order(:position).page(1).per(10) + assert_equal 10, relation.to_a.size + assert_equal 'widget-0', relation.to_a.first.name + assert_equal 'widget-9', relation.to_a.last.name + end + + def test_second_page_offsets_correctly + relation = Widget.order(:position).page(2).per(10) + assert_equal 'widget-10', relation.to_a.first.name + assert_equal 'widget-19', relation.to_a.last.name + end + + def test_last_partial_page_has_the_remainder + relation = Widget.order(:position).page(3).per(10) + assert_equal 5, relation.to_a.size + end + + def test_total_count_ignores_the_page_window + relation = Widget.page(1).per(10) + assert_equal 25, relation.total_count + assert_equal 3, relation.total_pages + end + + def test_current_page_and_navigation_helpers + relation = Widget.page(2).per(10) + assert_equal 2, relation.current_page + assert_equal 1, relation.prev_page + assert_equal 3, relation.next_page + refute relation.first_page? + refute relation.last_page? + end + + def test_first_and_last_page_edges + first = Widget.page(1).per(10) + assert first.first_page? + assert_nil first.prev_page + + last = Widget.page(3).per(10) + assert last.last_page? + assert_nil last.next_page + end + + def test_page_number_is_clamped_to_at_least_one + relation = Widget.page(0).per(10) + assert_equal 1, relation.current_page + end + + def test_out_of_range_page_returns_no_records_but_reports_totals + relation = Widget.page(99).per(10) + assert_empty relation.to_a + assert relation.out_of_range? + assert_equal 25, relation.total_count + end + + def test_page_composes_with_where_and_order + relation = Widget.where(position: (5..14).to_a).order(:position).page(1).per(5) + assert_equal %w[widget-5 widget-6 widget-7 widget-8 widget-9], relation.to_a.map(&:name) + assert_equal 10, relation.total_count + assert_equal 2, relation.total_pages + end + + def test_per_before_page_and_page_before_per_are_equivalent + a = Widget.order(:position).per(4).page(2) + b = Widget.order(:position).page(2).per(4) + assert_equal a.to_a.map(&:id), b.to_a.map(&:id) + end +end From 36c3bd517c6c2dd02201ee98de7257562f0644f9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 21:26:58 +0200 Subject: [PATCH 212/245] test: cover OrmParanoiaTest and teardown --- test/test_orm_paranoia.rb | 158 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 test/test_orm_paranoia.rb diff --git a/test/test_orm_paranoia.rb b/test/test_orm_paranoia.rb new file mode 100644 index 0000000..75c227d --- /dev/null +++ b/test/test_orm_paranoia.rb @@ -0,0 +1,158 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmParanoiaTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-paranoia-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name deleted_at]) + @store.create_table(:plain_things, %w[id name]) + @store.create_table(:locked_widgets, %w[id name deleted_at lock_version]) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id deleted_at]) + @store.create_table(:blogs, %w[id name deleted_at entries_count]) + @store.create_table(:entries, %w[id title blog_id deleted_at]) + + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + acts_as_paranoid + end + plain_class = Class.new(Prism::Orm::Model) { table_name :plain_things } + locked_widget_class = Class.new(Prism::Orm::Model) do + table_name :locked_widgets + acts_as_paranoid + end + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id', dependent: :destroy + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + acts_as_paranoid + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + + blog_class = Class.new(Prism::Orm::Model) do + table_name :blogs + acts_as_paranoid + has_many :entries, class_name: 'Entry', foreign_key: 'blog_id', dependent: :destroy + end + entry_class = Class.new(Prism::Orm::Model) do + table_name :entries + acts_as_paranoid + belongs_to :blog, class_name: 'Blog', foreign_key: 'blog_id', counter_cache: true + end + + [widget_class, plain_class, locked_widget_class, author_class, post_class, blog_class, entry_class].each do |klass| + klass.store = @store + end + Object.const_set(:Widget, widget_class) + Object.const_set(:PlainThing, plain_class) + Object.const_set(:LockedWidget, locked_widget_class) + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + Object.const_set(:Blog, blog_class) + Object.const_set(:Entry, entry_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Widget PlainThing LockedWidget Author Post Blog Entry].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name) + end + end + + def test_plain_model_is_not_paranoid_and_destroy_removes_the_row_for_real + refute PlainThing.paranoid? + thing = PlainThing.create!(name: 'gizmo') + thing.destroy + assert_raises(Prism::Orm::RecordNotFound) { PlainThing.find(thing.id) } + end + + def test_paranoid_destroy_stamps_the_column_instead_of_deleting + widget = Widget.create!(name: 'gizmo') + widget.destroy + + assert widget.destroyed? + row = @store.find('widgets', widget.id) + refute_nil row, 'the row must still physically exist' + refute_nil row['deleted_at'] + end + + def test_paranoid_destroy_hides_the_record_from_find_all_and_count + widget = Widget.create!(name: 'gizmo') + widget.destroy + + assert_raises(Prism::Orm::RecordNotFound) { Widget.find(widget.id) } + assert_empty Widget.all.to_a + assert_equal 0, Widget.count + end + + def test_paranoid_destroy_still_runs_before_and_after_destroy_callbacks + log = [] + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + acts_as_paranoid + before_destroy { log << :before_destroy } + after_destroy { log << :after_destroy } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + + widget.destroy + + assert_equal %i[before_destroy after_destroy], log + end + + def test_before_destroy_abort_leaves_the_record_undeleted + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + acts_as_paranoid + before_destroy { throw :abort } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + + widget.destroy + + refute widget.destroyed? + assert widget_class.find(widget.id) + end + + def test_deleted_predicate + widget = Widget.create!(name: 'gizmo') + refute widget.deleted? + widget.destroy + assert widget.deleted? + end + + def test_soft_deleting_predicate_is_true_inside_before_destroy_for_a_plain_soft_delete + seen = nil + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + acts_as_paranoid + before_destroy { seen = soft_deleting? } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + + widget.destroy + + assert_equal true, seen + assert widget.soft_deleting?, 'still true after #destroy returns, not just mid-callback' + end + + def test_soft_deleting_predicate_is_false_for_really_destroy_bang + seen = nil + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + acts_as_paranoid + before_destroy { seen = soft_deleting? } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + + widget.really_destroy! + From af5303247c418e32e52cd2e12d6d506cafb2d053 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 21:32:44 +0200 Subject: [PATCH 213/245] test: cover test_soft_deleting_predicate_is_false_for_a_non_paranoid_... --- test/test_orm_paranoia.rb | 188 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/test/test_orm_paranoia.rb b/test/test_orm_paranoia.rb index 75c227d..6a5fb8b 100644 --- a/test/test_orm_paranoia.rb +++ b/test/test_orm_paranoia.rb @@ -156,3 +156,191 @@ class OrmParanoiaTest < Minitest::Test widget.really_destroy! + assert_equal false, seen + end + + def test_soft_deleting_predicate_is_false_for_a_non_paranoid_model + seen = nil + plain_class = Class.new(Prism::Orm::Model) do + table_name :plain_things + before_destroy { seen = soft_deleting? } + end + plain_class.store = @store + thing = plain_class.create!(name: 'gizmo') + + thing.destroy + + assert_equal false, seen + end + + def test_restore_undoes_a_soft_delete + widget = Widget.create!(name: 'gizmo') + widget.destroy + refute_includes Widget.all.map(&:id), widget.id + + widget.restore + + refute widget.destroyed? + refute widget.deleted? + assert_includes Widget.all.map(&:id), widget.id + assert_nil Widget.find(widget.id).deleted_at + end + + def test_restore_on_a_record_that_was_never_deleted_is_a_no_op + widget = Widget.create!(name: 'gizmo') + widget.restore + refute widget.deleted? + end + + def test_with_deleted_sees_both_live_and_soft_deleted_records + live = Widget.create!(name: 'live') + dead = Widget.create!(name: 'dead') + dead.destroy + + ids = Widget.with_deleted.map(&:id) + assert_includes ids, live.id + assert_includes ids, dead.id + assert_equal [dead.id], Widget.only_deleted.map(&:id) + end + + def test_really_destroy_bang_removes_the_row_for_real + widget = Widget.create!(name: 'gizmo') + widget.destroy + assert_includes Widget.only_deleted.map(&:id), widget.id + + widget.really_destroy! + + assert_nil @store.find('widgets', widget.id) + refute_includes Widget.with_deleted.map(&:id), widget.id + end + + def test_really_destroy_bang_on_a_non_paranoid_model_behaves_like_plain_destroy + thing = PlainThing.create!(name: 'gizmo') + thing.really_destroy! + assert_raises(Prism::Orm::RecordNotFound) { PlainThing.find(thing.id) } + end + + def test_dependent_destroy_cascades_a_soft_delete_to_paranoid_children + author = Author.create!(name: 'foksiu') + post = Post.create!(title: 'hello', author_id: author.id) + + author.destroy + + refute_nil @store.find('posts', post.id), 'child row must still physically exist' + assert_empty Post.all.to_a + assert_includes Post.only_deleted.map(&:id), post.id + end + + def test_paranoid_destroy_still_respects_optimistic_locking + original = LockedWidget.create!(name: 'a') + stale_copy = LockedWidget.find(original.id) + original.update!(name: 'changed') + + error = assert_raises(Prism::Orm::StaleObjectError) { stale_copy.destroy } + assert_equal :destroy, error.attempted_action + refute stale_copy.destroyed? + refute_nil LockedWidget.find(original.id), 'stale soft-delete must not have stamped the row' + end + + def test_paranoid_column_can_be_customized + @store.create_table(:archivables, %w[id name archived_at]) + archivable_class = Class.new(Prism::Orm::Model) do + table_name :archivables + acts_as_paranoid column: 'archived_at' + end + archivable_class.store = @store + + record = archivable_class.create!(name: 'a') + record.destroy + + assert_equal 'archived_at', archivable_class.paranoid_column + assert_empty archivable_class.all.to_a + refute_nil @store.find('archivables', record.id)['archived_at'] + end + + def test_paranoid_setting_is_inherited_by_subclasses + subclass = Class.new(Widget) { table_name :widgets } + subclass.store = @store + + assert subclass.paranoid? + assert_equal 'deleted_at', subclass.paranoid_column + + record = subclass.create!(name: 'a') + record.destroy + assert_empty subclass.all.to_a + end + + def test_plain_restore_does_not_bring_back_dependent_destroyed_children + blog = Blog.create!(name: 'main', entries_count: 0) + entry = Entry.create!(title: 'first', blog_id: blog.id) + blog.destroy + assert_includes Entry.only_deleted.map(&:id), entry.id + + blog.restore + + refute blog.deleted? + assert_includes Entry.only_deleted.map(&:id), entry.id, 'a non-recursive restore must leave children trashed' + end + + def test_recursive_restore_brings_back_paranoid_dependent_children + blog = Blog.create!(name: 'main', entries_count: 0) + e1 = Entry.create!(title: 'first', blog_id: blog.id) + e2 = Entry.create!(title: 'second', blog_id: blog.id) + blog.destroy + assert_empty Entry.all.to_a + + blog.restore(recursive: true) + + ids = Entry.all.map(&:id) + assert_includes ids, e1.id + assert_includes ids, e2.id + assert_equal 2, blog.reload.entries_count, 'counter_cache must be restored along with each child' + end + + def test_recursive_restore_recurses_into_grandchildren + @store.create_table(:pages, %w[id title entry_id deleted_at]) + page_class = Class.new(Prism::Orm::Model) do + table_name :pages + acts_as_paranoid + belongs_to :entry, class_name: 'Entry', foreign_key: 'entry_id' + end + page_class.store = @store + Object.const_set(:Page, page_class) + Entry.has_many :pages, class_name: 'Page', foreign_key: 'entry_id', dependent: :destroy + + blog = Blog.create!(name: 'main', entries_count: 0) + entry = Entry.create!(title: 'first', blog_id: blog.id) + page = Page.create!(title: 'sub', entry_id: entry.id) + blog.destroy + assert_includes Page.only_deleted.map(&:id), page.id + + blog.restore(recursive: true) + + assert_includes Page.all.map(&:id), page.id + ensure + Object.send(:remove_const, :Page) if Object.const_defined?(:Page) + end + + def test_restore_runs_before_and_after_restore_callbacks_in_order + calls = [] + Widget.before_restore { calls << :before } + Widget.after_restore { calls << :after } + widget = Widget.create!(name: 'gizmo') + widget.destroy + + widget.restore + + assert_equal %i[before after], calls + end + + def test_before_restore_abort_leaves_the_record_soft_deleted + Widget.before_restore { throw :abort } + widget = Widget.create!(name: 'gizmo') + widget.destroy + + widget.restore + + assert widget.deleted? + assert_includes Widget.only_deleted.map(&:id), widget.id + end +end From b68c943485dc93928e853d391c1d99dd5bcde458 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 22:01:16 +0200 Subject: [PATCH 214/245] test: cover OrmParanoiaIndexTest and teardown --- test/test_orm_paranoia_index.rb | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test/test_orm_paranoia_index.rb diff --git a/test/test_orm_paranoia_index.rb b/test/test_orm_paranoia_index.rb new file mode 100644 index 0000000..419dc64 --- /dev/null +++ b/test/test_orm_paranoia_index.rb @@ -0,0 +1,86 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Store has no concept of paranoia at all (see Paranoia#paranoid_scope - it is +# just a plain `relation.where(deleted_at: nil)` bolted on by Model.all/.count). +# Store#lookup_by_index only ever requires an index's OWN columns to be present +# in the equality Hash it is given, never the reverse, so a `status`-only index +# is used even when the real condition tree also carries `deleted_at: nil` from +# the paranoid scope - the index itself returns every row with that status, +# soft-deleted rows included, and Relation#rows re-checks the FULL condition +# against every index-returned candidate before returning (see +# ConditionNode#indexed_candidates). Store#add_column's backfill +# (rebuild_indexes_covering) iterates every row in definition[:rows], which +# still holds soft-deleted rows too (paranoia never physically removes them), +# so a column added after some rows were already trashed still gets those rows +# correctly indexed. None of this required a code change - it was already true +# by construction - but nothing had exercised paranoia and secondary indexes +# together until now (see prism-postep.md session 82 "kierunki dalsze"). +class OrmParanoiaIndexTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-paranoia-index') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:tickets, %w[id title status deleted_at]) + + ticket_class = Class.new(Prism::Orm::Model) do + table_name :tickets + acts_as_paranoid + end + ticket_class.store = @store + Object.const_set(:ParanoidTicket, ticket_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :ParanoidTicket) if Object.const_defined?(:ParanoidTicket) + end + + def test_indexed_where_on_a_paranoid_model_still_excludes_soft_deleted_rows + @store.add_index(:tickets, :status) + ParanoidTicket.create!(title: 'a', status: 'open') + trashed = ParanoidTicket.create!(title: 'b', status: 'open') + trashed.destroy + + visible = ParanoidTicket.where(status: 'open').to_a + assert_equal ['a'], visible.map { |ticket| ticket['title'] } + + # The raw index is paranoia-blind by design: both rows come back for + # status "open", the soft-deleted one included. Correctness comes from + # Relation#rows re-filtering index candidates through the whole + # condition (deleted_at: nil included), not from the index excluding it. + raw_hits = @store.lookup_by_index(:tickets, { 'status' => 'open' }) + assert_equal ['a', 'b'], raw_hits.map { |row| row['title'] }.sort + end + + def test_add_column_backfill_indexes_a_soft_deleted_row_the_same_as_a_live_one + @store.remove_column(:tickets, :status) + + id_live = @store.insert(:tickets, title: 'a') + id_trashed = @store.insert(:tickets, title: 'b') + @store.update(:tickets, id_trashed, 'deleted_at' => Time.now.utc) + # Index the column BEFORE it exists (like OrmIndexColumnMigrationTest's + # own add_column-backfill tests) so add_column's rebuild is the only + # thing that can possibly populate this index, for either row. + @store.add_index(:tickets, :status) + + @store.add_column(:tickets, :status, default: 'open') + + raw_hits = @store.lookup_by_index(:tickets, { 'status' => 'open' }) + assert_equal [id_live, id_trashed].sort, raw_hits.map { |row| row['id'] }.sort, + 'add_column backfill must reindex every existing row, deleted or not' + assert_equal ['a'], ParanoidTicket.where(status: 'open').to_a.map { |ticket| ticket['title'] }, + 'the paranoid scope on top of that same index lookup must still hide the trashed row' + end + + def test_restore_brings_a_row_back_into_the_paranoid_scoped_index_lookup + @store.add_index(:tickets, :status) + trashed = ParanoidTicket.create!(title: 'b', status: 'open') + trashed.destroy + assert_equal [], ParanoidTicket.where(status: 'open').to_a + + trashed.restore + + assert_equal ['b'], ParanoidTicket.where(status: 'open').to_a.map { |ticket| ticket['title'] } + end +end From 9efcddd66a356745164803f0aa90b8fd3acde2a5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 22:28:33 +0200 Subject: [PATCH 215/245] test: cover OrmPessimisticTest and teardown --- test/test_orm_pessimistic.rb | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/test_orm_pessimistic.rb diff --git a/test/test_orm_pessimistic.rb b/test/test_orm_pessimistic.rb new file mode 100644 index 0000000..a5b6ea0 --- /dev/null +++ b/test/test_orm_pessimistic.rb @@ -0,0 +1,161 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmPessimisticTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-pessimistic') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:counters, %w[id value]) + + counter_class = Class.new(Prism::Orm::Model) { table_name :counters } + counter_class.store = @store + Object.const_set(:Counter, counter_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Counter) if Object.const_defined?(:Counter) + end + + def test_lock_bang_raises_on_a_record_that_was_never_saved + counter = Counter.new(value: 0) + assert_raises(Prism::Orm::Pessimistic::UnpersistedRecordError) { counter.lock! } + end + + def test_lock_bang_raises_on_a_record_with_unsaved_changes + counter = Counter.create!(value: 0) + counter.value = 5 + assert_raises(Prism::Orm::Pessimistic::DirtyRecordError) { counter.lock! } + end + + def test_lock_bang_reloads_data_written_by_another_handle_in_the_meantime + counter = Counter.create!(value: 0) + Counter.find(counter.id).update!(value: 41) + + counter.lock! + + assert_equal 41, counter.value + end + + def test_lock_bang_returns_self + counter = Counter.create!(value: 0) + assert_same counter, counter.lock! + end + + def test_with_lock_yields_the_record_with_freshly_reloaded_data + counter = Counter.create!(value: 0) + Counter.find(counter.id).update!(value: 9) + + yielded = nil + counter.with_lock { |c| yielded = c } + + assert_same counter, yielded + assert_equal 9, counter.value + end + + def test_with_lock_commits_a_write_made_inside_the_block + counter = Counter.create!(value: 1) + counter.with_lock { |c| c.update!(value: c.value + 1) } + assert_equal 2, Counter.find(counter.id).value + end + + def test_with_lock_rolls_back_a_write_made_inside_the_block_on_exception + counter = Counter.create!(value: 1) + assert_raises(RuntimeError) do + counter.with_lock do |c| + c.update!(value: 99) + raise 'boom' + end + end + assert_equal 1, Counter.find(counter.id).value + end + + def test_with_lock_nests_inside_an_already_open_transaction_without_deadlocking + counter = Counter.create!(value: 1) + Counter.transaction do + counter.with_lock { |c| c.update!(value: c.value + 1) } + end + assert_equal 2, Counter.find(counter.id).value + end + + # Everything below forks real child OS processes sharing nothing but the + # PStore file on disk - Store#within_transaction opens a brand new file + # handle and takes a real File::LOCK_EX/LOCK_SH on it for every single + # transaction (stdlib pstore.rb, not something this codebase controls), + # so this is the same cross-process guarantee two independent `prism` + # server processes pointed at the same database file would get, not a + # simulation of it via threads. `exit!` (not `exit`) is required in every + # forked child: minitest/autorun installs an at_exit hook, and a plain + # `exit` would let the child fall into that hook and re-run the entire + # suite a second time from inside the fork. + def test_two_processes_updating_without_a_lock_lose_an_update + skip 'fork not supported on this platform' unless Process.respond_to?(:fork) + + counter = Counter.create!(value: 0) + a_read, a_wrote_read_signal = IO.pipe + b_read, b_wrote_read_signal = IO.pipe + + # Two pipes force the full ordering a lost update needs: A reads, THEN + # B reads (while the value is still whatever A saw, because A is + # blocked waiting on B before it writes anything), THEN both write back + # "their own read + 1" as two independent transactions. Only the READS + # are ordered here - deliberately NOT the writes, since a real lost + # update does not care which write lands last, only that both were + # computed from the same stale value. + pid_a = Process.fork do + a_read.close + b_wrote_read_signal.close + value = Counter.find(counter.id).value + a_wrote_read_signal.write('r') + a_wrote_read_signal.close + b_read.read(1) + b_read.close + Counter.find(counter.id).update!(value: value + 1) + exit!(0) + end + + pid_b = Process.fork do + a_wrote_read_signal.close + b_read.close + a_read.read(1) + a_read.close + value = Counter.find(counter.id).value + b_wrote_read_signal.write('r') + b_wrote_read_signal.close + Counter.find(counter.id).update!(value: value + 1) + 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) + + # Both processes read 0 (forced ordering above only sequences the + # reads), so both independently write 1 back - the second write just + # clobbers the first with the exact same value, silently losing A's + # increment no matter which of the two writes lands last. + assert_equal 1, Counter.find(counter.id).value + end + + def test_two_processes_incrementing_through_with_lock_never_lose_an_update + skip 'fork not supported on this platform' unless Process.respond_to?(:fork) + + counter = Counter.create!(value: 0) + increments_per_process = 5 + + pids = Array.new(2) do + Process.fork do + increments_per_process.times do + Counter.find(counter.id).with_lock { |c| c.update!(value: c.value + 1) } + end + exit!(0) + end + end + + pids.each { |pid| Process.waitpid(pid) } + + assert_equal 2 * increments_per_process, Counter.find(counter.id).value + end +end From 84db98ce5813d145a21065e3f7d10418018703cb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 23:11:33 +0200 Subject: [PATCH 216/245] test: cover OrmPolymorphicTest and teardown --- test/test_orm_polymorphic.rb | 174 +++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 test/test_orm_polymorphic.rb diff --git a/test/test_orm_polymorphic.rb b/test/test_orm_polymorphic.rb new file mode 100644 index 0000000..beb2c61 --- /dev/null +++ b/test/test_orm_polymorphic.rb @@ -0,0 +1,174 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmPolymorphicTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-polymorphic-store') + @store = CountingPolymorphicStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + @store.create_table(:photos, %w[id caption]) + @store.create_table(:likes, %w[id likeable_id likeable_type user_id]) + @store.create_table(:previews, %w[id previewable_id previewable_type url]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :likes, class_name: 'Like', as: :likeable, dependent: :destroy + has_one :preview, class_name: 'Preview', as: :previewable, dependent: :nullify + end + photo_class = Class.new(Prism::Orm::Model) do + table_name :photos + has_many :likes, class_name: 'Like', as: :likeable, dependent: :destroy + end + like_class = Class.new(Prism::Orm::Model) do + table_name :likes + belongs_to :likeable, polymorphic: true + end + preview_class = Class.new(Prism::Orm::Model) do + table_name :previews + belongs_to :previewable, polymorphic: true + end + [post_class, photo_class, like_class, preview_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Photo, photo_class) + Object.const_set(:Like, like_class) + Object.const_set(:Preview, preview_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Photo Like Preview].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_polymorphic_belongs_to_resolves_the_right_class + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + like_on_post = Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + like_on_photo = Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 1) + + assert_instance_of Post, like_on_post.likeable + assert_equal post.id, like_on_post.likeable.id + assert_instance_of Photo, like_on_photo.likeable + assert_equal photo.id, like_on_photo.likeable.id + end + + def test_polymorphic_belongs_to_setter_stamps_both_columns + post = Post.create!(title: 'hello') + like = Like.new(user_id: 1) + like.likeable = post + like.save! + + assert_equal post.id, like.likeable_id + assert_equal 'Post', like.likeable_type + end + + def test_polymorphic_belongs_to_returns_nil_without_a_target + like = Like.create!(user_id: 1) + assert_nil like.likeable + end + + def test_polymorphic_has_many_scopes_by_type_not_just_id + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + # Deliberately share the same numeric id across two different owner + # types - the whole point of the type column is that this must not + # cause cross-talk between Post#likes and Photo#likes. + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: post.id, likeable_type: 'Photo', user_id: 2) + + assert_equal 1, post.likes.count + assert_equal 1, post.likes.first.user_id + end + + def test_polymorphic_has_many_dependent_destroy_only_removes_own_type + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 1) + + post.destroy + + assert_equal 1, Like.all.count + assert_equal 'Photo', Like.all.first.likeable_type + end + + def test_polymorphic_has_one_dependent_nullify_clears_both_columns + post = Post.create!(title: 'hello') + preview = Preview.create!(previewable_id: post.id, previewable_type: 'Post', url: '/p.png') + + post.destroy + + reloaded = Preview.find(preview.id) + assert_nil reloaded.previewable_id + assert_nil reloaded.previewable_type + end + + def test_polymorphic_has_one_setter_stamps_both_columns + post = Post.create!(title: 'hello') + preview = Preview.create!(url: '/p.png') + post.preview = preview + + reloaded = Preview.find(preview.id) + assert_equal post.id, reloaded.previewable_id + assert_equal 'Post', reloaded.previewable_type + end + + def test_includes_polymorphic_belongs_to_batches_across_mixed_types + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 2) + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 3) + + @store.reset_query_count! + likes = Like.all.includes(:likeable).to_a + baseline = @store.query_count + + targets = likes.map { |like| like.likeable.class.name }.sort + assert_equal baseline, @store.query_count, + "expected preloaded #likeable to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal %w[Photo Post Post], targets + end + + def test_includes_polymorphic_has_many_avoids_n_plus_one_queries + 3.times do |i| + post = Post.create!(title: "post#{i}") + 2.times { |j| Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: j) } + end + Photo.create!(caption: 'unrelated') + + @store.reset_query_count! + posts = Post.all.includes(:likes).to_a + baseline = @store.query_count + + total = posts.sum { |post| post.likes.size } + assert_equal baseline, @store.query_count, + "expected preloaded #likes to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal 6, total + end +end + +# Mirrors CountingStore/CountingThroughStore from the eager-loading tests - +# a small local double so this file has no load-order dependency on them. +class CountingPolymorphicStore < Prism::Orm::Store + def initialize(*) + super + @query_count = 0 + end + + attr_reader :query_count + + def reset_query_count! + @query_count = 0 + end + + def all(table) + @query_count += 1 + super + end + + def find(table, id) + @query_count += 1 + super + end +end From 44ef87d4a39555917fe1ee016c502a8693584cfa Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 23:38:12 +0200 Subject: [PATCH 217/245] test: cover OrmPolymorphicCounterCacheTest and teardown --- test/test_orm_polymorphic_counter_cache.rb | 113 +++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/test_orm_polymorphic_counter_cache.rb diff --git a/test/test_orm_polymorphic_counter_cache.rb b/test/test_orm_polymorphic_counter_cache.rb new file mode 100644 index 0000000..f002716 --- /dev/null +++ b/test/test_orm_polymorphic_counter_cache.rb @@ -0,0 +1,113 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmPolymorphicCounterCacheTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-polymorphic-counter-cache-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title likes_count]) + @store.create_table(:photos, %w[id caption likes_count]) + @store.create_table(:likes, %w[id likeable_id likeable_type user_id deleted_at]) + @store.create_table(:reactions, %w[id reactable_id reactable_type score]) + @store.create_table(:posts_with_tally, %w[id title reactions_tally]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + end + photo_class = Class.new(Prism::Orm::Model) do + table_name :photos + end + like_class = Class.new(Prism::Orm::Model) do + table_name :likes + acts_as_paranoid + belongs_to :likeable, polymorphic: true, counter_cache: true + end + tally_post_class = Class.new(Prism::Orm::Model) do + table_name :posts_with_tally + end + reaction_class = Class.new(Prism::Orm::Model) do + table_name :reactions + belongs_to :reactable, polymorphic: true, counter_cache: :reactions_tally + end + [post_class, photo_class, like_class, tally_post_class, reaction_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Photo, photo_class) + Object.const_set(:Like, like_class) + Object.const_set(:PostWithTally, tally_post_class) + Object.const_set(:Reaction, reaction_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Photo Like PostWithTally Reaction].each do |name| + Object.send(:remove_const, name) if Object.const_defined?(name) + end + end + + def test_create_increments_the_default_column_on_the_right_owner_type + post = Post.create!(title: 'hello', likes_count: 0) + photo = Photo.create!(caption: 'sunset', likes_count: 0) + + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 1) + Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 2) + + assert_equal 1, post.reload.likes_count + assert_equal 2, photo.reload.likes_count + end + + def test_destroy_decrements_the_owner_of_the_recorded_type + post = Post.create!(title: 'hello', likes_count: 0) + like = Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + + like.destroy + + assert_equal 0, post.reload.likes_count + end + + def test_restoring_a_soft_deleted_like_reincrements_the_original_owner_type + photo = Photo.create!(caption: 'sunset', likes_count: 0) + like = Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 1) + like.destroy + assert_equal 0, photo.reload.likes_count + + like.restore + + assert_equal 1, photo.reload.likes_count + end + + def test_reassigning_across_owner_types_moves_the_count_from_one_table_to_the_other + post = Post.create!(title: 'hello', likes_count: 0) + photo = Photo.create!(caption: 'sunset', likes_count: 0) + like = Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + + like.likeable = photo + like.save! + + assert_equal 0, post.reload.likes_count + assert_equal 1, photo.reload.likes_count + end + + def test_reassigning_within_the_same_owner_type_moves_the_count + post_a = Post.create!(title: 'a', likes_count: 0) + post_b = Post.create!(title: 'b', likes_count: 0) + like = Like.create!(likeable_id: post_a.id, likeable_type: 'Post', user_id: 1) + + like.update!(likeable_id: post_b.id) + + assert_equal 0, post_a.reload.likes_count + assert_equal 1, post_b.reload.likes_count + end + + def test_creating_without_an_owner_does_not_raise_or_touch_anything + Like.create!(likeable_id: nil, likeable_type: nil, user_id: 1) + end + + def test_custom_column_name_is_used_when_given_a_symbol + post = PostWithTally.create!(title: 'hello', reactions_tally: 0) + Reaction.create!(reactable_id: post.id, reactable_type: 'PostWithTally', score: 5) + + assert_equal 1, post.reload.reactions_tally + end +end From cec2ef0e0b1326f45bf068788b72199e2386d83c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 11:19:59 +0200 Subject: [PATCH 218/245] test: cover OrmPolymorphicTouchTest, teardown --- test/test_orm_polymorphic_touch.rb | 110 ++++++++++++++++++++++++++++ test/test_orm_query_aggregations.rb | 90 +++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 test/test_orm_polymorphic_touch.rb create mode 100644 test/test_orm_query_aggregations.rb diff --git a/test/test_orm_polymorphic_touch.rb b/test/test_orm_polymorphic_touch.rb new file mode 100644 index 0000000..99bf6ad --- /dev/null +++ b/test/test_orm_polymorphic_touch.rb @@ -0,0 +1,110 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmPolymorphicTouchTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-polymorphic-touch-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title updated_at]) + @store.create_table(:photos, %w[id caption updated_at reviewed_at]) + @store.create_table(:comments, %w[id body commentable_id commentable_type updated_at]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :comments, class_name: 'Comment', as: :commentable, dependent: :destroy + end + photo_class = Class.new(Prism::Orm::Model) do + table_name :photos + has_many :comments, class_name: 'Comment', as: :commentable, dependent: :destroy + end + comment_class = Class.new(Prism::Orm::Model) do + table_name :comments + belongs_to :commentable, polymorphic: true, touch: true + end + [post_class, photo_class, comment_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Photo, photo_class) + Object.const_set(:Comment, comment_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Photo Comment].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_creating_a_child_touches_the_polymorphic_owner + post = Post.create!(title: 'hello') + original = post.updated_at + sleep 0.01 + + Comment.create!(body: 'first', commentable_id: post.id, commentable_type: 'Post') + + assert post.reload.updated_at > original + end + + def test_touches_the_correct_owner_type_when_two_types_share_ids + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + original_post = post.reload.updated_at + original_photo = photo.reload.updated_at + sleep 0.01 + + Comment.create!(body: 'on the photo', commentable_id: photo.id, commentable_type: 'Photo') + + assert_equal original_post, post.reload.updated_at + assert photo.reload.updated_at > original_photo + end + + def test_updating_a_child_touches_the_owner_even_without_changing_the_association + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'first', commentable_id: post.id, commentable_type: 'Post') + original = post.reload.updated_at + sleep 0.01 + + comment.update!(body: 'first, edited') + + assert post.reload.updated_at > original + end + + def test_destroying_a_child_touches_the_owner + post = Post.create!(title: 'hello') + comment = Comment.create!(body: 'first', commentable_id: post.id, commentable_type: 'Post') + original = post.reload.updated_at + sleep 0.01 + + comment.destroy + + assert post.reload.updated_at > original + end + + def test_reassigning_across_owner_types_touches_both_the_old_and_new_owner + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + comment = Comment.create!(body: 'wandering', commentable_id: post.id, commentable_type: 'Post') + original_post = post.reload.updated_at + original_photo = photo.reload.updated_at + sleep 0.01 + + comment.commentable = photo + comment.save! + + assert post.reload.updated_at > original_post + assert photo.reload.updated_at > original_photo + end + + def test_destroying_the_owner_via_dependent_destroy_cascade_does_not_re_raise + post = Post.create!(title: 'hello') + Comment.create!(body: 'first', commentable_id: post.id, commentable_type: 'Post') + Comment.create!(body: 'second', commentable_id: post.id, commentable_type: 'Post') + + post.destroy + + assert post.destroyed? + assert_nil Post.find_by(id: post.id) + end + + def test_creating_a_child_without_an_owner_does_not_raise + Comment.create!(body: 'orphan', commentable_id: nil, commentable_type: nil) + end +end diff --git a/test/test_orm_query_aggregations.rb b/test/test_orm_query_aggregations.rb new file mode 100644 index 0000000..2859bd0 --- /dev/null +++ b/test/test_orm_query_aggregations.rb @@ -0,0 +1,90 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmQueryAggregationsTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-query-aggregations') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:orders, %w[id customer amount]) + order_class = Class.new(Prism::Orm::Model) do + table_name :orders + end + order_class.store = @store + Object.const_set(:Order, order_class) + + Order.create!(customer: 'alice', amount: 10) + Order.create!(customer: 'alice', amount: 30) + Order.create!(customer: 'bob', amount: 5) + Order.create!(customer: 'bob', amount: 15) + Order.create!(customer: 'carol', amount: nil) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Order) if Object.const_defined?(:Order) + end + + def test_sum + assert_equal 40, Order.where(customer: 'alice').sum(:amount) + end + + def test_sum_ignores_nil_values + assert_equal 60, Order.sum(:amount) + end + + + def test_average + assert_equal 20.0, Order.where(customer: 'alice').average(:amount) + end + + def test_average_on_empty_relation_is_nil + assert_nil Order.where(customer: 'nobody').average(:amount) + end + + def test_minimum_and_maximum + assert_equal 5, Order.minimum(:amount) + assert_equal 30, Order.maximum(:amount) + end + + def test_group_returns_records_bucketed_by_column + grouped = Order.group(:customer) + assert_equal %w[alice bob carol].sort, grouped.keys.sort + assert_equal 2, grouped['alice'].size + assert_kind_of Order, grouped['alice'].first + end + + def test_group_count + counts = Order.all.group_count(:customer) + assert_equal 2, counts['alice'] + assert_equal 2, counts['bob'] + assert_equal 1, counts['carol'] + end + + def test_pick_single_column + assert_equal 10, Order.where(customer: 'alice').order(:amount).pick(:amount) + end + + def test_pick_multiple_columns + row = Order.where(customer: 'bob').order(:amount).pick(:customer, :amount) + assert_equal ['bob', 5], row + end + + def test_ids_plucks_primary_keys + ids = Order.where(customer: 'alice').ids + assert_equal 2, ids.size + assert(ids.all? { |id| id.is_a?(Integer) }) + end + + def test_distinct_dedupes_plucked_column_values + Order.create!(customer: 'alice', amount: 10) + distinct_amounts = Order.where(customer: 'alice').distinct.pluck(:amount) + assert_equal [10, 30], distinct_amounts.sort + end + + def test_without_distinct_pluck_keeps_duplicates + Order.create!(customer: 'alice', amount: 10) + amounts = Order.where(customer: 'alice').pluck(:amount).sort + assert_equal [10, 10, 30], amounts + end +end From ac7e535986396f2c1357e417a2c92dab620943cb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 11:44:11 +0200 Subject: [PATCH 219/245] test: cover OrmQueryBatchesTest, teardown and OrmQueryCombinatorsTest --- test/test_orm_query_batches.rb | 48 ++++++++++++++++ test/test_orm_query_combinators.rb | 64 ++++++++++++++++++++++ test/test_orm_query_predicates.rb | 88 ++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 test/test_orm_query_batches.rb create mode 100644 test/test_orm_query_combinators.rb create mode 100644 test/test_orm_query_predicates.rb diff --git a/test/test_orm_query_batches.rb b/test/test_orm_query_batches.rb new file mode 100644 index 0000000..d882634 --- /dev/null +++ b/test/test_orm_query_batches.rb @@ -0,0 +1,48 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmQueryBatchesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-query-batches') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:records, %w[id seq]) + record_class = Class.new(Prism::Orm::Model) do + table_name :records + end + record_class.store = @store + Object.const_set(:Rec, record_class) + + 25.times { |i| Rec.create!(seq: i) } + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Rec) if Object.const_defined?(:Rec) + end + + def test_find_each_yields_every_record_once + seen = [] + Rec.order(:seq).find_each(batch_size: 7) { |record| seen << record.seq } + assert_equal (0..24).to_a, seen + end + + def test_find_each_without_block_returns_enumerator + enum = Rec.order(:seq).find_each + assert_kind_of Enumerator, enum + assert_equal 25, enum.to_a.size + end + + def test_find_in_batches_respects_batch_size + batch_sizes = [] + Rec.order(:seq).find_in_batches(batch_size: 10) { |batch| batch_sizes << batch.size } + assert_equal [10, 10, 5], batch_sizes + end + + def test_find_in_batches_default_size_yields_single_batch_for_small_sets + batches = [] + Rec.order(:seq).find_in_batches { |batch| batches << batch } + assert_equal 1, batches.size + assert_equal 25, batches.first.size + end +end diff --git a/test/test_orm_query_combinators.rb b/test/test_orm_query_combinators.rb new file mode 100644 index 0000000..dfbd2a5 --- /dev/null +++ b/test/test_orm_query_combinators.rb @@ -0,0 +1,64 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmQueryCombinatorsTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-query-combinators') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:tickets, %w[id title status priority]) + ticket_class = Class.new(Prism::Orm::Model) do + table_name :tickets + end + ticket_class.store = @store + Object.const_set(:Ticket, ticket_class) + + Ticket.create!(title: 'a', status: 'open', priority: 'low') + Ticket.create!(title: 'b', status: 'open', priority: 'high') + Ticket.create!(title: 'c', status: 'closed', priority: 'high') + Ticket.create!(title: 'd', status: 'closed', priority: 'low') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Ticket) if Object.const_defined?(:Ticket) + end + + def test_not_excludes_matching_rows + titles = Ticket.where(status: 'open').not(priority: 'low').pluck(:title) + assert_equal ['b'], titles + end + + def test_model_level_not_shorthand + titles = Ticket.not(status: 'open').order(:title).pluck(:title) + assert_equal %w[c d], titles + end + + def test_or_combines_two_relations + titles = Ticket.where(status: 'open').or(Ticket.where(priority: 'high')).order(:title).pluck(:title) + assert_equal %w[a b c], titles + end + + def test_or_with_plain_hash + titles = Ticket.where(status: 'closed').or(priority: 'high').order(:title).pluck(:title) + assert_equal %w[b c d], titles + end + + def test_chained_and_or_and_not + titles = Ticket.where(status: 'open').not(priority: 'low').or(Ticket.where(status: 'closed').not(priority: 'high')) + .order(:title).pluck(:title) + assert_equal %w[b d], titles + end + + def test_none_returns_empty_relation_without_touching_store + relation = Ticket.none + assert_equal 0, relation.count + assert_equal [], relation.to_a + refute relation.exists? + end + + def test_where_still_ands_across_chained_calls + titles = Ticket.where(status: 'open').where(priority: 'high').pluck(:title) + assert_equal ['b'], titles + end +end diff --git a/test/test_orm_query_predicates.rb b/test/test_orm_query_predicates.rb new file mode 100644 index 0000000..2a03c5c --- /dev/null +++ b/test/test_orm_query_predicates.rb @@ -0,0 +1,88 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmQueryPredicatesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-query-predicates') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:products, %w[id name price stock]) + product_class = Class.new(Prism::Orm::Model) do + table_name :products + end + product_class.store = @store + Object.const_set(:Product, product_class) + + Product.create!(name: 'Widget', price: 10, stock: 5) + Product.create!(name: 'Gadget', price: 25, stock: 0) + Product.create!(name: 'Gizmo', price: 40, stock: 12) + Product.create!(name: nil, price: 15, stock: 3) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Product) if Object.const_defined?(:Product) + end + + def test_gt_operator + assert_equal %w[Gadget Gizmo], Product.where(price: { gt: 15 }).order(:name).pluck(:name) + end + + def test_gte_operator + assert_equal %w[Gadget Gizmo], Product.where(price: { gte: 25 }).order(:name).pluck(:name) + end + + def test_lt_operator + assert_equal ['Widget'], Product.where(price: { lt: 15 }).pluck(:name) + end + + def test_lte_operator + assert_equal %w[Widget], Product.where(price: { lte: 10 }).pluck(:name) + end + + def test_ne_operator + result = Product.where(stock: { ne: 0 }).count + assert_equal 3, result + end + + def test_combined_operators_in_same_hash + matches = Product.where(price: { gt: 15, lt: 40 }).order(:name).pluck(:name) + assert_equal %w[Gadget], matches + end + + def test_in_operator + assert_equal %w[Gadget Widget], Product.where(price: { in: [10, 25] }).order(:name).pluck(:name) + end + + def test_not_in_operator + assert_equal %w[Gizmo], Product.where(price: { not_in: [10, 25, 15] }).pluck(:name) + end + + def test_like_operator_supports_percent_wildcard + assert_equal %w[Gadget Gizmo], Product.where(name: { like: 'g%' }).order(:name).pluck(:name) + end + + def test_like_operator_is_case_insensitive + assert_equal %w[Widget], Product.where(name: { like: 'WIDGET' }).pluck(:name) + end + + def test_not_like_operator + names = Product.where(name: { not_like: 'g%' }).pluck(:name) + assert_equal %w[Widget], names + end + + def test_is_null_true + assert_equal 1, Product.where(name: { is_null: true }).count + end + + def test_is_null_false + assert_equal 3, Product.where(name: { is_null: false }).count + end + + def test_unknown_operator_raises + error = assert_raises(Prism::Orm::Query::Predicates::UnknownOperator) do + Product.where(price: { bogus: 1 }).to_a + end + assert_match(/bogus/, error.message) + end +end From 71db0110afe857a7c67e8e746e0a4aee9e8a2744 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 13:56:55 +0200 Subject: [PATCH 220/245] test: cover OrmQueryScopesTest, teardown and OrmSecurePasswordTest --- test/test_orm_query_scopes.rb | 72 +++++++++++++++++++++++++++ test/test_orm_secure_password.rb | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 test/test_orm_query_scopes.rb create mode 100644 test/test_orm_secure_password.rb diff --git a/test/test_orm_query_scopes.rb b/test/test_orm_query_scopes.rb new file mode 100644 index 0000000..64e010c --- /dev/null +++ b/test/test_orm_query_scopes.rb @@ -0,0 +1,72 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmQueryScopesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-query-scopes') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:articles, %w[id title published views]) + @article_class = Class.new(Prism::Orm::Model) do + table_name :articles + scope :published, -> { where(published: true) } + scope :popular, ->(threshold) { where(views: { gte: threshold }) } + end + Object.const_set(:Article, @article_class) + Article.store = @store + + Article.create!(title: 'a', published: true, views: 100) + Article.create!(title: 'b', published: false, views: 500) + Article.create!(title: 'c', published: true, views: 5) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Article) if Object.const_defined?(:Article) + end + + def test_scope_without_arguments + titles = Article.published.order(:title).pluck(:title) + assert_equal %w[a c], titles + end + + def test_scope_with_argument + titles = Article.popular(50).order(:title).pluck(:title) + assert_equal %w[a b], titles + end + + def test_scopes_are_chainable_with_relation_methods + titles = Article.published.popular(10).pluck(:title) + assert_equal ['a'], titles + end + + def test_scopes_return_relation_not_array + assert_kind_of Prism::Orm::Relation, Article.published + end + + def test_default_scope_applies_to_all_and_where + Object.send(:remove_const, :Article) + scoped_class = Class.new(Prism::Orm::Model) do + table_name :articles + default_scope { where(published: true) } + end + Object.const_set(:Article, scoped_class) + Article.store = @store + + assert_equal %w[a c], Article.all.order(:title).pluck(:title) + assert_equal 2, Article.count + assert_equal %w[a], Article.where(title: 'a').pluck(:title) + end + + def test_unscoped_bypasses_default_scope + Object.send(:remove_const, :Article) + scoped_class = Class.new(Prism::Orm::Model) do + table_name :articles + default_scope { where(published: true) } + end + Object.const_set(:Article, scoped_class) + Article.store = @store + + assert_equal 3, Article.unscoped.count + end +end diff --git a/test/test_orm_secure_password.rb b/test/test_orm_secure_password.rb new file mode 100644 index 0000000..ffd4eaf --- /dev/null +++ b/test/test_orm_secure_password.rb @@ -0,0 +1,83 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmSecurePasswordTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-secure-password-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:accounts, %w[id email password_digest pin_digest]) + account_class = Class.new(Prism::Orm::Model) do + table_name :accounts + validates :email, presence: true + has_secure_password + has_secure_password :pin, validations: false + end + account_class.store = @store + Object.const_set(:SecureAccount, account_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :SecureAccount) if Object.const_defined?(:SecureAccount) + end + + def test_setting_password_populates_the_digest_column + account = SecureAccount.new(email: 'a@b.com', password: 'hunter2') + refute_nil account[:password_digest] + refute_equal 'hunter2', account[:password_digest] + end + + def test_authenticate_returns_the_record_on_a_correct_password + account = SecureAccount.create!(email: 'a@b.com', password: 'hunter2') + assert_equal account, account.authenticate('hunter2') + end + + def test_authenticate_returns_false_on_a_wrong_password + account = SecureAccount.create!(email: 'a@b.com', password: 'hunter2') + refute account.authenticate('wrong') + end + + def test_authenticate_returns_false_when_no_password_was_ever_set + account = SecureAccount.new(email: 'a@b.com') + account[:password_digest] = nil + refute account.authenticate('anything') + end + + def test_two_digests_from_the_same_password_are_different_but_both_verify + first = Prism::Orm::SecurePassword.digest('hunter2') + second = Prism::Orm::SecurePassword.digest('hunter2') + refute_equal first, second + assert Prism::Orm::SecurePassword.verify(first, 'hunter2') + assert Prism::Orm::SecurePassword.verify(second, 'hunter2') + end + + def test_presence_validation_requires_a_password_on_create + account = SecureAccount.new(email: 'a@b.com') + refute account.valid? + assert_includes account.errors[:password], 'must be present' + end + + def test_confirmation_mismatch_is_rejected + account = SecureAccount.new(email: 'a@b.com', password: 'hunter2', password_confirmation: 'other') + refute account.valid? + assert_includes account.errors[:password], "doesn't match confirmation" + end + + def test_confirmation_match_is_accepted + account = SecureAccount.new(email: 'a@b.com', password: 'hunter2', password_confirmation: 'hunter2') + assert account.valid? + end + + def test_updating_an_already_persisted_record_without_touching_the_password_keeps_it_valid + account = SecureAccount.create!(email: 'a@b.com', password: 'hunter2') + account.update!(email: 'new@b.com') + assert account.authenticate('hunter2') + end + + def test_custom_attribute_name_defines_its_own_authenticate_method + account = SecureAccount.create!(email: 'a@b.com', password: 'hunter2', pin: '1234') + assert_equal account, account.authenticate_pin('1234') + refute account.authenticate_pin('0000') + end +end From c791df50e0b07b3397e530a6716342cf6f28f70d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 16:33:10 +0200 Subject: [PATCH 221/245] test: cover OrmSecureTokenTest and teardown --- test/test_orm_secure_token.rb | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/test_orm_secure_token.rb diff --git a/test/test_orm_secure_token.rb b/test/test_orm_secure_token.rb new file mode 100644 index 0000000..32e90d2 --- /dev/null +++ b/test/test_orm_secure_token.rb @@ -0,0 +1,72 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmSecureTokenTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-secure-token-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:api_keys, %w[id name token auth_token]) + api_key_class = Class.new(Prism::Orm::Model) do + table_name :api_keys + has_secure_token + has_secure_token :auth_token, length: 6 + end + api_key_class.store = @store + Object.const_set(:SecureTokenKey, api_key_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :SecureTokenKey) if Object.const_defined?(:SecureTokenKey) + end + + def test_token_is_generated_on_create + key = SecureTokenKey.create!(name: 'ci') + refute_nil key.token + refute_empty key.token + end + + def test_two_records_get_different_tokens + first = SecureTokenKey.create!(name: 'a') + second = SecureTokenKey.create!(name: 'b') + refute_equal first.token, second.token + end + + def test_an_explicitly_assigned_token_is_kept_on_create + key = SecureTokenKey.create!(name: 'ci', token: 'my-own-token') + assert_equal 'my-own-token', key.token + end + + def test_token_survives_a_reload + key = SecureTokenKey.create!(name: 'ci') + token = key.token + reloaded = SecureTokenKey.find(key.id) + assert_equal token, reloaded.token + end + + def test_regenerate_bang_persists_a_new_token + key = SecureTokenKey.create!(name: 'ci') + original = key.token + key.regenerate_token! + refute_equal original, key.token + assert_equal key.token, SecureTokenKey.find(key.id).token + end + + def test_second_attribute_on_the_same_model_gets_its_own_independent_token + key = SecureTokenKey.create!(name: 'ci') + refute_equal key.token, key.auth_token + original_auth_token = key.auth_token + key.regenerate_token! + assert_equal original_auth_token, key.auth_token + end + + def test_generate_produces_a_urlsafe_string_of_the_requested_byte_length + token = Prism::Orm::SecureToken.generate(6) + assert_match(/\A[A-Za-z0-9_-]+\z/, token) + end + + def test_secure_token_attributes_lists_every_declared_attribute + assert_equal %i[token auth_token], SecureTokenKey.secure_token_attributes + end +end From 25beb85254186dde54f45d109338c771e49eb05f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 16:57:34 +0200 Subject: [PATCH 222/245] test: cover OrmSerializationTest, greeting --- test/test_orm_serialization.rb | 145 +++++++++++++++++++++++++++++++++ test/test_orm_store.rb | 46 +++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/test_orm_serialization.rb diff --git a/test/test_orm_serialization.rb b/test/test_orm_serialization.rb new file mode 100644 index 0000000..eb6c784 --- /dev/null +++ b/test/test_orm_serialization.rb @@ -0,0 +1,145 @@ +require 'minitest/autorun' +require 'json' +require 'prism' +require 'tmpdir' + +class OrmSerializationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-serialization-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authors, %w[id name email password_digest created_at]) + @store.create_table(:articles, %w[id author_id title body]) + + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :articles, class_name: 'Article', foreign_key: 'author_id' + has_secure_password validations: false + + def greeting + "hi #{name}" + end + end + article_class = Class.new(Prism::Orm::Model) do + table_name :articles + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [author_class, article_class].each { |klass| klass.store = @store } + Object.const_set(:Author, author_class) + Object.const_set(:Article, article_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Author Article].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_serializable_hash_returns_all_attributes_by_default + author = Author.create!(name: 'Ada', email: 'ada@example.com') + hash = author.serializable_hash + assert_equal 'Ada', hash['name'] + assert_equal 'ada@example.com', hash['email'] + assert_equal author.id, hash['id'] + end + + def test_only_filters_to_named_attributes + author = Author.create!(name: 'Ada', email: 'ada@example.com') + hash = author.serializable_hash(only: :name) + assert_equal({ 'name' => 'Ada' }, hash) + end + + def test_except_removes_named_attributes + author = Author.create!(name: 'Ada', email: 'ada@example.com') + hash = author.serializable_hash(except: [:email, :created_at, :updated_at]) + assert_equal %w[id name], hash.keys.sort + end + + def test_methods_option_adds_computed_values + author = Author.create!(name: 'Ada', email: 'ada@example.com') + hash = author.serializable_hash(only: :name, methods: :greeting) + assert_equal 'hi Ada', hash['greeting'] + end + + def test_password_digest_is_hidden_from_json_by_default + author = Author.create!(name: 'Ada', email: 'ada@example.com', password: 'sekret123') + refute_nil author['password_digest'] + hash = author.serializable_hash + refute hash.key?('password_digest'), 'password_digest must not leak through default serialization' + end + + def test_explicit_only_can_override_the_default_hide + author = Author.create!(name: 'Ada', email: 'ada@example.com', password: 'sekret123') + hash = author.serializable_hash(only: %i[name password_digest]) + assert hash.key?('password_digest'), 'an explicit only: is a deliberate opt-in, it must win' + end + + def test_as_json_stringifies_time_columns + author = Author.create!(name: 'Ada', email: 'ada@example.com') + assert_kind_of Time, author['created_at'], 'sanity check: raw attribute is a Time' + json = author.as_json + assert_kind_of String, json['created_at'] + assert_equal author['created_at'].iso8601, json['created_at'] + end + + def test_to_json_round_trips_through_json_generate + author = Author.create!(name: 'Ada', email: 'ada@example.com') + parsed = JSON.parse(author.to_json) + assert_equal 'Ada', parsed['name'] + refute parsed.key?('password_digest') + end + + def test_json_generate_on_a_bare_record_uses_to_json + author = Author.create!(name: 'Ada', email: 'ada@example.com') + parsed = JSON.parse(JSON.generate(author)) + assert_equal 'Ada', parsed['name'] + end + + def test_include_serializes_a_belongs_to_association + author = Author.create!(name: 'Ada', email: 'ada@example.com') + article = Article.create!(author_id: author.id, title: 'Hello', body: 'World') + hash = article.serializable_hash(include: :author) + assert_equal 'Ada', hash['author']['name'] + end + + def test_include_serializes_a_has_many_collection + author = Author.create!(name: 'Ada', email: 'ada@example.com') + Article.create!(author_id: author.id, title: 'One', body: 'x') + Article.create!(author_id: author.id, title: 'Two', body: 'y') + hash = author.serializable_hash(only: :name, include: { articles: { only: :title } }) + titles = hash['articles'].map { |row| row['title'] } + assert_equal %w[One Two], titles.sort + end + + def test_include_with_nil_belongs_to_yields_nil + orphan = Article.create!(author_id: nil, title: 'Orphan', body: 'x') + hash = orphan.serializable_hash(include: :author) + assert_nil hash['author'] + end + + def test_relation_as_json_serializes_each_record + Author.create!(name: 'Ada', email: 'a@example.com') + Author.create!(name: 'Bea', email: 'b@example.com') + rows = Author.all.as_json(only: :name) + assert_equal [{ 'name' => 'Ada' }, { 'name' => 'Bea' }], rows.sort_by { |row| row['name'] } + end + + def test_relation_to_json_produces_a_json_array_without_leaking_hidden_columns + Author.create!(name: 'Ada', email: 'a@example.com', password: 'sekret123') + parsed = JSON.parse(Author.all.to_json) + assert_equal 1, parsed.size + refute parsed.first.key?('password_digest') + end + + def test_hide_from_json_is_inherited_by_subclasses + base_class = Class.new(Prism::Orm::Model) { table_name :authors } + base_class.hide_from_json(:email) + Object.const_set(:BaseAuthor, base_class) + sub_class = Class.new(base_class) { table_name :authors } + Object.const_set(:SubAuthor, sub_class) + sub_class.store = @store + + record = sub_class.create!(name: 'Ada', email: 'ada@example.com') + refute record.serializable_hash.key?('email') + ensure + %i[BaseAuthor SubAuthor].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end +end diff --git a/test/test_orm_store.rb b/test/test_orm_store.rb index 1384817..5d5c704 100644 --- a/test/test_orm_store.rb +++ b/test/test_orm_store.rb @@ -104,6 +104,52 @@ class OrmStoreTest < Minitest::Test assert_empty @store.applied_migrations end + def test_insert_emits_sql_prism_event + @store.create_table(:users, %w[id name]) + events = [] + subscriber = Prism::Instrumentation.subscribe('sql.prism') { |event| events << event } + begin + @store.insert(:users, name: 'foksiu') + ensure + Prism::Instrumentation.unsubscribe(subscriber) + end + + assert_equal 1, events.size + assert_equal 'INSERT', events.first.payload[:operation] + assert_equal 'users', events.first.payload[:table] + end + + def test_find_and_all_and_count_emit_select_events + @store.create_table(:users, %w[id name]) + id = @store.insert(:users, name: 'foksiu') + events = [] + subscriber = Prism::Instrumentation.subscribe('sql.prism') { |event| events << event } + begin + @store.find(:users, id) + @store.all(:users) + @store.count(:users) + ensure + Prism::Instrumentation.unsubscribe(subscriber) + end + + assert_equal %w[SELECT SELECT], events.first(2).map { |e| e.payload[:operation] } + assert_equal 'SELECT COUNT', events.last.payload[:operation] + end + + def test_update_missing_record_still_emits_event_with_exception + @store.create_table(:users, %w[id name]) + events = [] + subscriber = Prism::Instrumentation.subscribe('sql.prism') { |event| events << event } + begin + assert_raises(Prism::Orm::Store::RecordNotFound) { @store.update(:users, 999, name: 'nope') } + ensure + Prism::Instrumentation.unsubscribe(subscriber) + end + + assert_equal 1, events.size + assert_equal 'Prism::Orm::Store::RecordNotFound', events.first.payload[:exception].first + end + def test_data_survives_reopening_the_store @store.create_table(:users, %w[id name]) @store.insert(:users, name: 'persisted-foksiu') From d900376235d08983be57207b746434870f5c4204 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 17:13:02 +0200 Subject: [PATCH 223/245] test: cover OrmTokenForTest and teardown --- test/test_orm_token_for.rb | 110 +++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 test/test_orm_token_for.rb diff --git a/test/test_orm_token_for.rb b/test/test_orm_token_for.rb new file mode 100644 index 0000000..f685673 --- /dev/null +++ b/test/test_orm_token_for.rb @@ -0,0 +1,110 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmTokenForTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-token-for') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:token_for_users, %w[id email password_digest]) + + Prism::Orm::TokenFor.primary_secret = 'primary-secret' + Prism::Orm::TokenFor.previous_secrets = [] + + user_class = Class.new(Prism::Orm::Model) do + table_name :token_for_users + has_secure_password + generates_token_for(:password_reset, expires_in: 900) { password_digest } + generates_token_for(:email_confirmation) + # negative expires_in => already in the past the instant it is minted, + # letting the expiry path be tested without stubbing the clock. + generates_token_for(:already_expiring, expires_in: -1) + end + user_class.store = @store + Object.const_set(:TokenForUser, user_class) + + @user = TokenForUser.create!(email: 'foksiu@example.com', password: 'hunter2') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :TokenForUser) if Object.const_defined?(:TokenForUser) + Prism::Orm::TokenFor.primary_secret = nil + Prism::Orm::TokenFor.previous_secrets = [] + end + + def test_token_round_trips_to_the_record + token = @user.generate_token_for(:password_reset) + found = TokenForUser.find_by_token_for(:password_reset, token) + assert_equal @user.id, found&.id + end + + def test_token_is_scoped_to_its_own_purpose + token = @user.generate_token_for(:password_reset) + assert_nil TokenForUser.find_by_token_for(:email_confirmation, token) + end + + def test_expired_token_is_rejected + token = @user.generate_token_for(:already_expiring) + assert_nil TokenForUser.find_by_token_for(:already_expiring, token) + end + + def test_token_invalidated_by_changing_fingerprint_after_generation + token = @user.generate_token_for(:password_reset) + @user.password = 'a completely different passphrase' + @user.save! + + assert_nil TokenForUser.find_by_token_for(:password_reset, token) + end + + def test_token_without_a_fingerprint_block_is_unaffected_by_record_changes + token = @user.generate_token_for(:email_confirmation) + @user.update!(email: 'someone-else@example.com') + + found = TokenForUser.find_by_token_for(:email_confirmation, token) + assert_equal @user.id, found&.id + end + + def test_garbage_token_is_rejected + assert_nil TokenForUser.find_by_token_for(:password_reset, 'not-a-real-token') + assert_nil TokenForUser.find_by_token_for(:password_reset, nil) + end + + def test_token_for_a_deleted_record_no_longer_resolves + token = @user.generate_token_for(:password_reset) + @store.delete(:token_for_users, @user.id) + + assert_nil TokenForUser.find_by_token_for(:password_reset, token) + end + + def test_unknown_purpose_raises + assert_raises(Prism::Orm::TokenFor::Error) { @user.generate_token_for(:not_declared) } + assert_raises(Prism::Orm::TokenFor::Error) { TokenForUser.find_by_token_for(:not_declared, 'x') } + end + + def test_token_minted_under_a_retired_secret_still_verifies_after_rotation + token = @user.generate_token_for(:email_confirmation) + + Prism::Orm::TokenFor.previous_secrets = ['primary-secret'] + Prism::Orm::TokenFor.primary_secret = 'rotated-secret' + + found = TokenForUser.find_by_token_for(:email_confirmation, token) + assert_equal @user.id, found&.id + end + + def test_token_cannot_be_replayed_across_two_unrelated_models + @store.create_table(:token_for_widgets, %w[id name]) + widget_class = Class.new(Prism::Orm::Model) do + table_name :token_for_widgets + generates_token_for(:password_reset, expires_in: 900) + end + widget_class.store = @store + Object.const_set(:TokenForWidget, widget_class) + + token = @user.generate_token_for(:password_reset) + assert_nil TokenForWidget.find_by_token_for(:password_reset, token) + ensure + Object.send(:remove_const, :TokenForWidget) if Object.const_defined?(:TokenForWidget) + end +end From b65bbaf352b42001959947fc93655fd123e713f8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 17:39:15 +0200 Subject: [PATCH 224/245] test: cover OrmTouchTest and teardown --- test/test_orm_touch.rb | 148 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 test/test_orm_touch.rb diff --git a/test/test_orm_touch.rb b/test/test_orm_touch.rb new file mode 100644 index 0000000..442c661 --- /dev/null +++ b/test/test_orm_touch.rb @@ -0,0 +1,148 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmTouchTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-touch-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name created_at updated_at published_at]) + @store.create_table(:gadgets, %w[id name]) + + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + end + widget_class.store = @store + Object.const_set(:Widget, widget_class) + + gadget_class = Class.new(Prism::Orm::Model) do + table_name :gadgets + end + gadget_class.store = @store + Object.const_set(:Gadget, gadget_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Widget Gadget].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_touch_bumps_updated_at_on_the_stored_row + widget = Widget.create!(name: 'gizmo') + original = widget.updated_at + sleep 0.01 + widget.touch + + assert widget.updated_at > original + assert_equal widget.updated_at, widget.reload.updated_at + end + + def test_touch_does_not_run_validations + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + validates :name, presence: true + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + widget.name = nil + refute widget.valid? + + widget.touch + + assert_equal 'gizmo', widget.reload.name + end + + def test_touch_accepts_extra_attribute_names + widget = Widget.create!(name: 'gizmo') + refute widget.published_at + + widget.touch(:published_at) + + assert widget.published_at + assert_equal widget.published_at, widget.reload.published_at + end + + def test_touch_runs_after_touch_callback + log = [] + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + after_touch { log << :after_touch } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + + widget.touch + + assert_equal [:after_touch], log + end + + def test_touch_does_not_run_before_save_callbacks + log = [] + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + before_save { log << :before_save } + after_save { log << :after_save } + end + widget_class.store = @store + widget = widget_class.create!(name: 'gizmo') + log.clear + + widget.touch + + assert_empty log + end + + def test_touch_on_a_new_record_raises + widget = Widget.new(name: 'gizmo') + assert_raises(Prism::Orm::RecordNotFound) { widget.touch } + end + + def test_touch_on_a_destroyed_record_raises + widget = Widget.create!(name: 'gizmo') + widget.destroy + assert_raises(Prism::Orm::RecordNotFound) { widget.touch } + end + + def test_touch_without_updated_at_column_and_no_names_is_a_no_op + gadget = Gadget.create!(name: 'thing') + assert_same gadget, gadget.touch + end + + def test_touch_busts_cache_key + widget = Widget.create!(name: 'gizmo') + original_key = widget.cache_key + sleep 1.01 # cache_key truncates to whole seconds (Time#to_i) + + widget.touch + + refute_equal original_key, widget.cache_key + end + + def test_touch_bumps_lock_version_when_optimistic_locking_is_enabled + @store.create_table(:locked_widgets, %w[id name updated_at lock_version]) + locked_class = Class.new(Prism::Orm::Model) do + table_name :locked_widgets + end + locked_class.store = @store + widget = locked_class.create!(name: 'gizmo') + assert_equal 0, widget.lock_version + + widget.touch + + assert_equal 1, widget.lock_version + assert_equal 1, widget.reload.lock_version + end + + def test_touch_raises_stale_object_error_on_lock_version_mismatch + @store.create_table(:locked_widgets, %w[id name updated_at lock_version]) + locked_class = Class.new(Prism::Orm::Model) do + table_name :locked_widgets + end + locked_class.store = @store + widget = locked_class.create!(name: 'gizmo') + stale_copy = locked_class.find(widget.id) + widget.touch + + assert_raises(Prism::Orm::StaleObjectError) { stale_copy.touch } + end +end From 7388c82502bac6b3dc9e88d6e60beed6ff8bf383 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 18:18:31 +0200 Subject: [PATCH 225/245] test: cover OrmTypeCastingTest, test_nil_stays_nil_for_every_type --- test/test_orm_type_casting.rb | 107 ++++++++++++++++++++++++++++++ test/test_orm_uniqueness_scope.rb | 73 ++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 test/test_orm_type_casting.rb create mode 100644 test/test_orm_uniqueness_scope.rb diff --git a/test/test_orm_type_casting.rb b/test/test_orm_type_casting.rb new file mode 100644 index 0000000..d9624c5 --- /dev/null +++ b/test/test_orm_type_casting.rb @@ -0,0 +1,107 @@ +require 'minitest/autorun' +require 'prism' + +class OrmTypeCastingTest < Minitest::Test + Cast = Prism::Orm::TypeCasting + + def test_nil_stays_nil_for_every_type + %i[string text integer float boolean datetime].each do |type| + assert_nil Cast.cast(type, nil), "expected nil to survive :#{type}" + end + end + + def test_unknown_type_raises_argument_error + assert_raises(ArgumentError) { Cast.cast(:money, '5') } + end + + def test_string_and_text_stringify_non_strings + assert_equal '5', Cast.cast(:string, 5) + assert_equal '5', Cast.cast(:text, 5) + assert_equal 'already', Cast.cast(:string, 'already') + end + + def test_integer_parses_numeric_strings + assert_equal 42, Cast.cast(:integer, '42') + assert_equal(-3, Cast.cast(:integer, '-3')) + assert_equal 7, Cast.cast(:integer, ' 7 ') + end + + def test_integer_passes_through_actual_integers_and_numerics + assert_equal 42, Cast.cast(:integer, 42) + assert_equal 3, Cast.cast(:integer, 3.9) + end + + def test_integer_blank_string_is_nil_not_zero + assert_nil Cast.cast(:integer, '') + assert_nil Cast.cast(:integer, ' ') + end + + def test_integer_garbage_raises_invalid_cast + error = assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Cast.cast(:integer, 'abc') } + assert_match(/abc/, error.message) + end + + def test_integer_rejects_float_looking_strings + assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Cast.cast(:integer, '4.5') } + end + + def test_float_parses_numeric_strings + assert_in_delta 4.5, Cast.cast(:float, '4.5') + assert_in_delta 4.0, Cast.cast(:float, '4') + end + + def test_float_blank_string_is_nil + assert_nil Cast.cast(:float, '') + end + + def test_float_garbage_raises_invalid_cast + assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Cast.cast(:float, 'nope') } + end + + def test_boolean_recognizes_common_true_strings + %w[1 t true y yes on TRUE Yes On].each do |value| + assert_equal true, Cast.cast(:boolean, value), "expected #{value.inspect} to cast true" + end + end + + def test_boolean_recognizes_common_false_strings + %w[0 f false n no off FALSE No Off].each do |value| + assert_equal false, Cast.cast(:boolean, value), "expected #{value.inspect} to cast false" + end + end + + def test_boolean_passes_through_actual_booleans + assert_equal true, Cast.cast(:boolean, true) + assert_equal false, Cast.cast(:boolean, false) + end + + def test_boolean_garbage_raises_invalid_cast_instead_of_guessing_truthy + error = assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Cast.cast(:boolean, 'banana') } + assert_match(/banana/, error.message) + end + + def test_datetime_passes_through_time + now = Time.now + assert_same now, Cast.cast(:datetime, now) + end + + def test_datetime_parses_iso8601_strings + result = Cast.cast(:datetime, '2026-01-15T10:30:00Z') + assert_kind_of Time, result + assert_equal 2026, result.year + assert_equal 1, result.month + assert_equal 15, result.day + end + + def test_datetime_converts_date + result = Cast.cast(:datetime, Date.new(2026, 3, 4)) + assert_kind_of Time, result + assert_equal 2026, result.year + assert_equal 3, result.month + assert_equal 4, result.day + end + + def test_datetime_garbage_raises_invalid_cast + assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Cast.cast(:datetime, 'not a date') } + end +end diff --git a/test/test_orm_uniqueness_scope.rb b/test/test_orm_uniqueness_scope.rb new file mode 100644 index 0000000..d2b6e45 --- /dev/null +++ b/test/test_orm_uniqueness_scope.rb @@ -0,0 +1,73 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmUniquenessScopeTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-uniqueness-scope-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:likes, %w[id likeable_id likeable_type user_id]) + + like_class = Class.new(Prism::Orm::Model) do + table_name :likes + validates :user_id, presence: true, uniqueness: { scope: %i[likeable_id likeable_type] } + end + like_class.store = @store + Object.const_set(:Like, like_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Like) if Object.const_defined?(:Like) + end + + def test_rejects_a_second_like_from_the_same_user_on_the_same_target + Like.create!(user_id: 1, likeable_id: 5, likeable_type: 'Post') + duplicate = Like.new(user_id: 1, likeable_id: 5, likeable_type: 'Post') + + refute duplicate.valid? + assert_includes duplicate.errors[:user_id], 'must be unique' + end + + def test_allows_the_same_user_to_like_a_different_target + Like.create!(user_id: 1, likeable_id: 5, likeable_type: 'Post') + other_target = Like.new(user_id: 1, likeable_id: 6, likeable_type: 'Post') + + assert other_target.valid? + end + + def test_allows_the_same_user_to_like_the_same_id_on_a_different_type + Like.create!(user_id: 1, likeable_id: 5, likeable_type: 'Post') + other_type = Like.new(user_id: 1, likeable_id: 5, likeable_type: 'Photo') + + assert other_type.valid? + end + + def test_allows_a_different_user_to_like_the_same_target + Like.create!(user_id: 1, likeable_id: 5, likeable_type: 'Post') + other_user = Like.new(user_id: 2, likeable_id: 5, likeable_type: 'Post') + + assert other_user.valid? + end + + def test_saving_the_same_record_twice_is_not_a_conflict_with_itself + like = Like.create!(user_id: 1, likeable_id: 5, likeable_type: 'Post') + like.user_id = 1 + + assert like.valid? + end + + def test_unscoped_uniqueness_still_works_without_a_scope_option + scoreless_store = Prism::Orm::Store.new(File.join(@dir, 'scoreless.pstore')) + scoreless_store.create_table(:tags, %w[id name]) + tag_class = Class.new(Prism::Orm::Model) do + table_name :tags + validates :name, presence: true, uniqueness: true + end + tag_class.store = scoreless_store + tag_class.create!(name: 'ruby') + + duplicate = tag_class.new(name: 'ruby') + refute duplicate.valid? + end +end From a51e8536c43c805aa0bd940db0506ea51c6238d9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 18:22:08 +0200 Subject: [PATCH 226/245] test: cover OrmValidatesEncryptedAttributeTest, teardown --- .../test_orm_validates_encrypted_attribute.rb | 73 ++++++++++++ test/test_orm_validations_extended.rb | 105 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 test/test_orm_validates_encrypted_attribute.rb create mode 100644 test/test_orm_validations_extended.rb diff --git a/test/test_orm_validates_encrypted_attribute.rb b/test/test_orm_validates_encrypted_attribute.rb new file mode 100644 index 0000000..8f587a8 --- /dev/null +++ b/test/test_orm_validates_encrypted_attribute.rb @@ -0,0 +1,73 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Regression coverage for a real bug found while wiring `encrypts` into +# examples/blog's User#email: every validator in Validatable read the raw +# stored value through `self[attribute]` instead of the attribute's own +# reader method. For a plain column that is the same value either way, so +# the bug was invisible until a column had a reader that DIFFERS from raw +# storage - exactly what `encrypts` defines (decrypts on read, and +# deterministic columns re-encrypt on query). Two concrete failures this +# caused before the fix: +# 1. `format`/`length`/`inclusion` validators matched the CIPHERTEXT, +# not the plaintext the user typed - a syntactically valid email was +# rejected as "invalid" (ciphertext never contains '@'). +# 2. `uniqueness` built `where(attribute => ciphertext)`, and +# ConditionNode's deterministic-encryption cast re-encrypts whatever +# it is given - so the already-ciphertext value got encrypted AGAIN, +# never matching the single-encrypted value already on disk. Two +# records with the IDENTICAL plaintext silently passed uniqueness. +class OrmValidatesEncryptedAttributeTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-validates-encrypted-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:contacts, %w[id email]) + Prism::Orm::Encryption.primary_key = 'test-primary-key-do-not-use-in-prod' + + contact_class = Class.new(Prism::Orm::Model) do + table_name :contacts + encrypts :email, deterministic: true + validates :email, presence: true, uniqueness: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ } + end + contact_class.store = @store + Object.const_set(:EncryptedContact, contact_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Orm::Encryption.primary_key = nil + Object.send(:remove_const, :EncryptedContact) if Object.const_defined?(:EncryptedContact) + end + + def test_format_validation_checks_the_decrypted_plaintext_not_the_ciphertext + contact = EncryptedContact.new(email: 'alice@example.com') + assert contact.valid?, "expected a well-formed plaintext email to pass format validation, got: #{contact.errors.full_messages}" + end + + def test_format_validation_still_rejects_a_genuinely_malformed_plaintext + contact = EncryptedContact.new(email: 'not-an-email') + refute contact.valid? + assert_includes contact.errors.full_messages.join, 'invalid' + end + + def test_uniqueness_catches_a_real_duplicate_plaintext_behind_deterministic_encryption + EncryptedContact.create!(email: 'bob@example.com') + duplicate = EncryptedContact.new(email: 'bob@example.com') + refute duplicate.valid?, 'expected the second record with the same plaintext email to fail uniqueness' + assert_includes duplicate.errors.full_messages.join, 'unique' + end + + def test_uniqueness_allows_a_genuinely_different_plaintext + EncryptedContact.create!(email: 'carol@example.com') + other = EncryptedContact.new(email: 'dave@example.com') + assert other.valid?, "expected a different plaintext email to pass uniqueness, got: #{other.errors.full_messages}" + end + + def test_the_stored_column_is_still_ciphertext_despite_validation_seeing_plaintext + contact = EncryptedContact.create!(email: 'erin@example.com') + raw_row = @store.find(:contacts, contact.id) + refute_equal 'erin@example.com', raw_row['email'] + refute_includes raw_row['email'], '@' + end +end diff --git a/test/test_orm_validations_extended.rb b/test/test_orm_validations_extended.rb new file mode 100644 index 0000000..c655778 --- /dev/null +++ b/test/test_orm_validations_extended.rb @@ -0,0 +1,105 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmValidationsExtendedTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-validation-ext-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:products, %w[id name price quantity status]) + + @product_class = Class.new(Prism::Orm::Model) do + table_name :products + validates_numericality_of :price, greater_than: 0 + validates_numericality_of :quantity, only_integer: true, greater_than_or_equal_to: 0 + validates_inclusion_of :status, in: %w[draft published archived] + + validate :name_must_not_be_shouting + + def name_must_not_be_shouting + return unless name && name == name.upcase && name.match?(/[A-Z]/) + errors.add(:name, 'must not be all caps') + end + end + @product_class.store = @store + Object.const_set(:Product, @product_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Product) if Object.const_defined?(:Product) + end + + def test_numericality_rejects_non_numbers + product = Product.new(name: 'widget', price: 'free', quantity: 1, status: 'draft') + refute product.valid? + assert_includes product.errors[:price], 'is not a number' + end + + def test_numericality_enforces_greater_than + product = Product.new(name: 'widget', price: 0, quantity: 1, status: 'draft') + refute product.valid? + assert_includes product.errors[:price], 'must be greater than 0' + end + + def test_numericality_only_integer + product = Product.new(name: 'widget', price: 5, quantity: 1.5, status: 'draft') + refute product.valid? + assert_includes product.errors[:quantity], 'must be an integer' + end + + def test_numericality_passes_for_valid_number + product = Product.new(name: 'widget', price: 9.99, quantity: 3, status: 'draft') + assert product.valid?, product.errors.full_messages.join(', ') + end + + def test_inclusion_rejects_value_outside_set + product = Product.new(name: 'widget', price: 1, quantity: 1, status: 'deleted') + refute product.valid? + assert_includes product.errors[:status], 'is not included in the list' + end + + def test_inclusion_accepts_value_in_set + product = Product.new(name: 'widget', price: 1, quantity: 1, status: 'published') + assert product.valid?, product.errors.full_messages.join(', ') + end + + def test_exclusion_rejects_reserved_values + klass = Class.new(Prism::Orm::Model) do + table_name :products + validates_exclusion_of :name, in: %w[admin root] + end + klass.store = @store + reserved = klass.new(name: 'admin') + refute reserved.valid? + assert_includes reserved.errors[:name], 'is reserved' + end + + def test_custom_validator_method_adds_error + product = Product.new(name: 'SHOUT', price: 1, quantity: 1, status: 'draft') + refute product.valid? + assert_includes product.errors[:name], 'must not be all caps' + end + + def test_custom_validator_block + klass = Class.new(Prism::Orm::Model) do + table_name :products + validate { errors.add(:base, 'always invalid') if name == 'reject-me' } + end + klass.store = @store + record = klass.new(name: 'reject-me') + refute record.valid? + assert_includes record.errors[:base], 'always invalid' + end + + def test_custom_message_on_numericality + klass = Class.new(Prism::Orm::Model) do + table_name :products + validates_numericality_of :price, greater_than: 0, message: 'needs a positive price' + end + klass.store = @store + record = klass.new(name: 'x', price: -1) + refute record.valid? + assert_includes record.errors[:price], 'needs a positive price' + end +end From d23fb5fec37f462110abc140d3db610d8c83a717 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 18:46:37 +0200 Subject: [PATCH 227/245] test: cover PaginationHelpersTest, teardown and ParamsNestedTest --- test/test_pagination_helpers.rb | 74 +++++++++++++++++++++++++++++++ test/test_params_nested.rb | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 test/test_pagination_helpers.rb create mode 100644 test/test_params_nested.rb diff --git a/test/test_pagination_helpers.rb b/test/test_pagination_helpers.rb new file mode 100644 index 0000000..6ac4a68 --- /dev/null +++ b/test/test_pagination_helpers.rb @@ -0,0 +1,74 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class PaginationHelpersTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-pagination-helpers') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id name position]) + widget_class = Class.new(Prism::Orm::Model) { table_name :widgets } + widget_class.store = @store + Object.const_set(:PaginatedWidget, widget_class) + 25.times { |i| PaginatedWidget.create!(name: "widget-#{i}", position: i) } + + @context = Prism::Template::Context.new({}) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :PaginatedWidget) if Object.const_defined?(:PaginatedWidget) + end + + def path_for + ->(page) { "/widgets?page=#{page}" } + end + + def test_returns_blank_when_everything_fits_on_one_page + relation = PaginatedWidget.page(1) + assert_equal '', @context.paginate(relation, path: path_for) + end + + def test_renders_a_link_per_page_within_the_window + relation = PaginatedWidget.order(:position).page(1).per(5) + html = @context.paginate(relation, path: path_for) + assert_includes html, 'href="/widgets?page=2"' + assert_includes html, '>2<' + end + + def test_current_page_link_carries_the_current_class + relation = PaginatedWidget.order(:position).page(3).per(5) + html = @context.paginate(relation, path: path_for) + assert_includes html, 'href="/widgets?page=3" class="page-link current"' + end + + def test_first_page_disables_the_previous_link + relation = PaginatedWidget.order(:position).page(1).per(5) + html = @context.paginate(relation, path: path_for) + assert_includes html, 'Previous' + end + + def test_last_page_disables_the_next_link + relation = PaginatedWidget.order(:position).page(5).per(5) + html = @context.paginate(relation, path: path_for) + assert_includes html, 'Next' + end + + def test_far_apart_pages_are_separated_by_a_gap_marker + relation = PaginatedWidget.order(:position).page(1).per(1) + html = @context.paginate(relation, path: path_for) + assert_includes html, '...' + end + + def test_renders_through_a_real_erb_template + views_dir = File.join(@dir, 'views', 'widgets') + FileUtils.mkdir_p(views_dir) + File.write(File.join(views_dir, 'index.erb'), '<%== paginate(widgets, path: ->(n) { "/widgets?page=#{n}" }) %>') + + view = Prism::View.new(File.join(@dir, 'views')) + output = view.render('widgets/index', locals: { widgets: PaginatedWidget.order(:position).page(1).per(5) }) + assert_includes output, '