tests: minitest + rack-test suite, bin/test with ephemeral Postgres

This commit is contained in:
Sergei Poljanski 2026-07-02 17:32:14 +04:00
commit 43bed98329
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
13 changed files with 456 additions and 2 deletions

40
test/admin_auth_test.rb Normal file
View file

@ -0,0 +1,40 @@
require_relative 'test_helper'
require 'base64'
class AdminAuthTest < Minitest::Test
OK_APP = ->(_env) { [200, {}, ['ok']] }
def setup
@auth = AdminAuth.new(OK_APP)
end
def env_for(path, user: nil, pass: nil)
env = { 'PATH_INFO' => path }
if user
env['HTTP_AUTHORIZATION'] = "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"
end
env
end
def test_correct_credentials_pass
assert_equal 200, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'test-password'))[0]
end
def test_wrong_password_rejected
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'nope'))[0]
end
def test_wrong_user_rejected
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'other', pass: 'test-password'))[0]
end
def test_missing_header_rejected
assert_equal 401, @auth.call(env_for('/admin'))[0]
end
def test_sibling_paths_not_gated
assert_equal 200, @auth.call(env_for('/'))[0]
assert_equal 200, @auth.call(env_for('/admin-invoice-form.js'))[0]
end
end

174
test/app_test.rb Normal file
View file

@ -0,0 +1,174 @@
require_relative 'test_helper'
class AppTest < Minitest::Test
include Rack::Test::Methods
def app
AsxpioWeb
end
def setup
Mail::TestMailer.deliveries.clear
TestDb.clean! if TestDb.available?
end
# Each test that passes contact validation must use a fresh IP: the app-level
# rate limiter (5/hour) is shared process state, so the counter must be
# unique across the whole run, not per test.
@@ip_counter = 0
def fresh_ip
@@ip_counter += 1
"10.9.#{@@ip_counter / 250}.#{@@ip_counter % 250}"
end
def csrf_token_from(path, env = {})
get path, {}, env
assert last_response.ok?, "GET #{path} failed: #{last_response.status}"
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] ||
flunk("no CSRF token found on #{path}")
end
def admin_env(extra = {})
{ 'HTTP_AUTHORIZATION' =>
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }.merge(extra)
end
# --- public pages ---------------------------------------------------------
def test_index_renders
get '/'
assert last_response.ok?
end
# --- contact form ---------------------------------------------------------
def contact_params(over = {})
{ name: 'Visitor', email: 'visitor@example.com', subject: 'Hello',
message: 'A message.', website: '' }.merge(over)
end
def test_contact_without_csrf_token_forbidden
post '/contact', contact_params
assert_equal 403, last_response.status
end
def test_contact_honeypot_pretends_success_and_sends_nothing
token = csrf_token_from('/')
post '/contact', contact_params(website: 'spam', authenticity_token: token)
assert_equal 302, last_response.status
assert_match %r{/thanks}, last_response.location
assert_empty Mail::TestMailer.deliveries
end
def test_contact_invalid_email_rejected
token = csrf_token_from('/')
post '/contact', contact_params(email: 'not-an-email', authenticity_token: token)
assert_equal 422, last_response.status
assert_empty Mail::TestMailer.deliveries
end
def test_contact_valid_submission_sends_two_mails
token = csrf_token_from('/')
post '/contact', contact_params(authenticity_token: token),
'HTTP_X_FORWARDED_FOR' => fresh_ip
assert_equal 302, last_response.status
assert_equal 2, Mail::TestMailer.deliveries.size
to_owner, to_visitor = Mail::TestMailer.deliveries
assert_includes to_owner.to, ENV['MAIL_TO']
assert_includes to_visitor.to, 'visitor@example.com'
end
def test_contact_rate_limited_after_five
ip = fresh_ip
token = csrf_token_from('/')
5.times do
post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
assert_equal 302, last_response.status
end
post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
assert_equal 429, last_response.status
end
# --- admin ------------------------------------------------------------
def test_admin_requires_auth
get '/admin/invoices'
assert_equal 401, last_response.status
end
def test_admin_list_with_auth
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
get '/admin/invoices', {}, admin_env
assert last_response.ok?
end
def invoice_form_params(token)
{ authenticity_token: token,
client_name: 'ACME', client_email: 'billing@example.com', client_address: '',
currency: 'EUR', gel_rate: '3.05',
issued_on: Date.today.to_s, due_on: (Date.today + 14).to_s, notes: '',
ltc_address: '', ltc_rate: '', ltc_amount: '',
items: { '0' => { 'description' => 'Engineering — test', 'qty' => '2', 'unit_price' => '100.50' } } }
end
def test_create_invoice_end_to_end
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
token = csrf_token_from('/admin/invoices/new', admin_env)
post '/admin/invoices', invoice_form_params(token), admin_env
assert_equal 302, last_response.status, last_response.body
invoice = Invoice.first
refute_nil invoice
assert_equal BigDecimal('201'), invoice.total
assert_equal "invoices/#{invoice.number}-#{invoice.uuid}.pdf", invoice.pdf_key
assert_match %r{/admin/invoices/#{invoice.uuid}}, last_response.location
end
def test_create_invoice_validation_failure_rerenders_form
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
token = csrf_token_from('/admin/invoices/new', admin_env)
params = invoice_form_params(token)
params[:items]['0']['qty'] = '1,5'
post '/admin/invoices', params, admin_env
assert_equal 422, last_response.status
assert_nil Invoice.first
end
# --- public invoice pages -----------------------------------------------
def create_invoice!
inv = Invoice.build(
client_name: 'ACME', client_email: 'billing@example.com',
currency: 'EUR', gel_rate: '3.05',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
)
inv.pdf_key = "invoices/#{inv.number}-#{inv.uuid}.pdf"
inv.save_changes
inv
end
def test_public_landing_page
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
inv = create_invoice!
get "/i/#{inv.uuid}"
assert last_response.ok?
assert_includes last_response.body, inv.number
assert_includes last_response.body, 'ACME'
end
def test_public_pdf_redirects_to_presigned_url
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
inv = create_invoice!
get "/i/#{inv.uuid}/pdf"
assert_equal 302, last_response.status
assert_includes last_response.location, 'X-Amz-Signature'
end
def test_unknown_invoice_404s
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
get "/i/#{SecureRandom.uuid}"
assert_equal 404, last_response.status
end
end

115
test/invoice_test.rb Normal file
View file

@ -0,0 +1,115 @@
require_relative 'test_helper'
class InvoiceTest < Minitest::Test
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
TestDb.clean!
@year = Date.today.year
end
def insert_number(num)
DB.connection[:invoices].insert(
uuid: SecureRandom.uuid, number: num, client_name: 'c', client_email: 'c@example.com',
currency: 'EUR', gel_rate: 3, subtotal: 1,
items: [{ 'description' => 'x', 'qty' => '1', 'unit_price' => '1' }].to_json,
issued_on: Date.today, due_on: Date.today + 14, pdf_key: 'k', created_at: Time.now.utc
)
end
def test_allocate_number_starts_at_one
assert_equal "INV-#{@year}-0001", Invoice.allocate_number
end
def test_allocate_number_increments
insert_number("INV-#{@year}-0001")
insert_number("INV-#{@year}-0002")
assert_equal "INV-#{@year}-0003", Invoice.allocate_number
end
def test_allocate_number_sorts_numerically_past_9999
insert_number("INV-#{@year}-9999")
insert_number("INV-#{@year}-10000")
assert_equal "INV-#{@year}-10001", Invoice.allocate_number
end
def test_allocate_number_ignores_other_years
insert_number("INV-#{@year - 1}-0500")
assert_equal "INV-#{@year}-0001", Invoice.allocate_number
end
def test_duplicate_number_raises_unique_violation
insert_number("INV-#{@year}-0001")
assert_raises(Sequel::UniqueConstraintViolation) { insert_number("INV-#{@year}-0001") }
end
def test_build_round_trip
inv = Invoice.build(
client_name: 'ACME', client_email: 'billing@example.com', client_address: '',
currency: 'EUR', gel_rate: '3.05', issued_on: '', due_on: '', notes: '',
items: [{ 'description' => 'work', 'qty' => '2', 'unit_price' => '100.50' }]
)
inv.pdf_key = "invoices/#{inv.number}-#{inv.uuid}.pdf"
inv.save_changes
saved = Invoice[uuid: inv.uuid]
assert_equal BigDecimal('201'), saved.total
assert_equal BigDecimal('613.05'), saved.total_gel
assert_equal 'pending', saved.status
refute saved.ltc?
end
def test_build_derives_ltc_amount_from_rate
inv = Invoice.build(
client_name: 'ACME', client_email: 'billing@example.com',
currency: 'EUR', gel_rate: '3.05',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }],
ltc_address: 'ltc1qexampleexampleexampleexample', ltc_rate: '80', ltc_amount: ''
)
assert inv.ltc?
assert_equal BigDecimal('1.25'), inv.ltc_amount_due
end
end
class InvoiceValidationTest < Minitest::Test
BASE = {
client_name: 'ACME', client_email: 'billing@example.com', currency: 'EUR',
gel_rate: '3.05', ltc_address: '', ltc_rate: '', ltc_amount: '',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
}.freeze
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
@app = AsxpioWeb.new!
end
def validate(over = {})
@app.send(:validate_invoice_params, BASE.merge(over).dup)
end
def test_valid_params_pass
assert_empty validate
end
def test_gel_rate_must_be_positive
assert_includes validate(gel_rate: '0'), :gel_rate
assert_includes validate(gel_rate: '-2.9'), :gel_rate
refute_includes validate(gel_rate: '2.95'), :gel_rate
end
def test_item_numbers_must_parse
assert_includes validate(items: [{ 'description' => 'w', 'qty' => '1,5', 'unit_price' => '100' }]), :items
assert_includes validate(items: [{ 'description' => 'w', 'qty' => '1', 'unit_price' => 'abc' }]), :items
end
def test_blank_item_numbers_allowed
assert_empty validate(items: [{ 'description' => 'w', 'qty' => '', 'unit_price' => '' }])
end
def test_at_least_one_item_required
assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items
end
def test_ltc_address_format
assert_includes validate(ltc_address: 'not-an-address'), :ltc_address
end
end

35
test/rate_limit_test.rb Normal file
View file

@ -0,0 +1,35 @@
require_relative 'test_helper'
class RateLimitTest < Minitest::Test
def test_allows_up_to_limit_then_denies
rl = RateLimit.new(limit: 5, window: 3600)
assert (1..5).all? { rl.allow?('1.2.3.4') }
refute rl.allow?('1.2.3.4')
end
def test_keys_are_independent
rl = RateLimit.new(limit: 1, window: 3600)
assert rl.allow?('a')
refute rl.allow?('a')
assert rl.allow?('b')
end
def test_sweep_removes_stale_keys
rl = RateLimit.new(limit: 5, window: 3600)
hits = rl.instance_variable_get(:@hits)
old = Time.now.to_i - 7200
50.times { |i| hits["stale-#{i}"] << old }
rl.sweep!
assert_empty hits.keys.grep(/\Astale-/)
end
def test_allow_prunes_opportunistically
rl = RateLimit.new(limit: 5, window: 3600)
hits = rl.instance_variable_get(:@hits)
old = Time.now.to_i - 7200
50.times { |i| hits["stale-#{i}"] << old }
RateLimit::SWEEP_EVERY.times { rl.allow?('fresh') }
assert_empty hits.keys.grep(/\Astale-/)
assert hits.key?('fresh')
end
end

43
test/test_helper.rb Normal file
View file

@ -0,0 +1,43 @@
ENV['RACK_ENV'] = 'test'
# Dummy env so the app class loads; nothing here reaches a real service.
ENV['SESSION_SECRET'] ||= 'test-secret-0000000000000000000000000000000000000000000000000000'
ENV['SMTP_ADDR'] ||= 'localhost'
ENV['SMTP_PORT'] ||= '587'
ENV['SMTP_USER'] ||= 'test'
ENV['SMTP_PASSWORD'] ||= 'test'
ENV['MAIL_FROM'] ||= 'Test <test@example.com>'
ENV['MAIL_TO'] ||= 'owner@example.com'
ENV['ADMIN_USER'] ||= 'admin'
ENV['ADMIN_PASSWORD'] ||= 'test-password'
# DB-backed tests run only against an explicit test database (bin/test
# provisions an ephemeral one). Never fall through to a developer DATABASE_URL.
if ENV['TEST_DATABASE_URL']
ENV['DATABASE_URL'] = ENV['TEST_DATABASE_URL']
ENV['S3_ENDPOINT'] ||= 'http://127.0.0.1:1'
ENV['S3_PUBLIC_ENDPOINT'] ||= 'http://127.0.0.1:1'
ENV['S3_ACCESS_KEY'] ||= 'test'
ENV['S3_SECRET_KEY'] ||= 'test'
ENV['S3_BUCKET'] ||= 'test-bucket'
require 'aws-sdk-s3'
Aws.config[:s3] = { stub_responses: true } # no S3 call leaves the process
else
ENV.delete('DATABASE_URL')
end
require_relative '../asxpio'
require 'minitest/autorun'
require 'rack/test'
Mail.defaults { delivery_method :test }
module TestDb
def self.available?
!ENV['DATABASE_URL'].nil?
end
def self.clean!
DB.connection[:invoices].delete
end
end