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

View file

@ -12,3 +12,6 @@ tmp
flake.nix flake.nix
flake.lock flake.lock
CLAUDE.md CLAUDE.md
test
Rakefile
bin

View file

@ -135,6 +135,7 @@ The hedgehog is the user's pre-IE personal mark; once the IE has its own logo, t
- **Generate a new SESSION_SECRET:** `openssl rand -hex 64` (then update Forgejo secret). - **Generate a new SESSION_SECRET:** `openssl rand -hex 64` (then update Forgejo secret).
- **Generate a new ADMIN_PASSWORD:** `openssl rand -base64 24`. **Save it to a password manager** — Forgejo secrets are write-only. - **Generate a new ADMIN_PASSWORD:** `openssl rand -base64 24`. **Save it to a password manager** — Forgejo secrets are write-only.
- **Local dev:** `nix develop`, then `bundle exec rerun -- rackup -p 3000`. Visit `http://localhost:3000`. Without `DATABASE_URL` the contact form still works; invoicing routes return 503. - **Local dev:** `nix develop`, then `bundle exec rerun -- rackup -p 3000`. Visit `http://localhost:3000`. Without `DATABASE_URL` the contact form still works; invoicing routes return 503.
- **Run tests:** `nix develop -c bin/test` — full suite (minitest + rack-test) against an ephemeral Postgres it provisions and tears down itself. `bundle exec rake test` runs without a DB, skipping DB-backed tests. Mail uses `Mail::TestMailer`; S3 uses aws-sdk stubbed responses; tests never touch real services, and `.env` is deliberately not loaded when `RACK_ENV=test`.
- **Local image build (sanity check):** `docker compose build` - **Local image build (sanity check):** `docker compose build`
- **Production deploy:** automatic via `.forgejo/workflows/deploy.yaml` on push to `main`. Pipeline: - **Production deploy:** automatic via `.forgejo/workflows/deploy.yaml` on push to `main`. Pipeline:
1. Kaniko builds the image and pushes it to the Forgejo registry. 1. Kaniko builds the image and pushes it to the Forgejo registry.

View file

@ -15,7 +15,7 @@ ENV GEM_HOME=/app/bundle
ENV PATH="${GEM_HOME}/bin:${PATH}" ENV PATH="${GEM_HOME}/bin:${PATH}"
COPY --chown=asxpio Gemfile Gemfile.lock* ./ COPY --chown=asxpio Gemfile Gemfile.lock* ./
RUN bundle install --without development RUN bundle install --without development:test
COPY --chown=asxpio . . COPY --chown=asxpio . .

View file

@ -26,3 +26,9 @@ gem 'rexml', '~> 3.4'
group :development do group :development do
gem 'rerun', '~> 0.14' gem 'rerun', '~> 0.14'
end end
group :test do
gem 'minitest', '~> 5.25'
gem 'rack-test', '~> 2.2'
gem 'rake', '~> 13.2'
end

View file

@ -51,6 +51,7 @@ GEM
net-smtp net-smtp
matrix (0.4.3) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.27.0)
multi_json (1.21.1) multi_json (1.21.1)
mustermann (3.1.1) mustermann (3.1.1)
net-imap (0.6.4) net-imap (0.6.4)
@ -87,8 +88,11 @@ GEM
rack-session (2.1.2) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rake (13.4.2)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.11.1) rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
@ -138,6 +142,7 @@ DEPENDENCIES
dotenv (~> 3.1) dotenv (~> 3.1)
erubi (~> 1.13) erubi (~> 1.13)
mail (~> 2.8) mail (~> 2.8)
minitest (~> 5.25)
pg (~> 1.5) pg (~> 1.5)
prawn (~> 2.5) prawn (~> 2.5)
prawn-table (~> 0.2) prawn-table (~> 0.2)
@ -145,7 +150,9 @@ DEPENDENCIES
rack (~> 3.2) rack (~> 3.2)
rack-protection (~> 4.2) rack-protection (~> 4.2)
rack-session (~> 2.1) rack-session (~> 2.1)
rack-test (~> 2.2)
rackup (~> 2.3) rackup (~> 2.3)
rake (~> 13.2)
rerun (~> 0.14) rerun (~> 0.14)
rexml (~> 3.4) rexml (~> 3.4)
rqrcode (~> 2.2) rqrcode (~> 2.2)

9
Rakefile Normal file
View file

@ -0,0 +1,9 @@
require 'rake/testtask'
Rake::TestTask.new(:test) do |t|
t.libs << 'test'
t.pattern = 'test/*_test.rb'
t.warning = false
end
task default: :test

View file

@ -7,7 +7,9 @@ require 'uri'
begin begin
require 'dotenv' require 'dotenv'
Dotenv.load # Tests control ENV themselves; a developer's .env must not leak in (it may
# point DATABASE_URL at a real database).
Dotenv.load unless ENV['RACK_ENV'] == 'test'
rescue LoadError rescue LoadError
end end

19
bin/test Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Full test suite against an ephemeral Postgres. Run inside `nix develop`
# (needs initdb/pg_ctl from the dev shell). Without a database you can still
# run `bundle exec rake test` — DB-backed tests skip.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${TEST_PG_PORT:-54329}"
TMP="$(mktemp -d)"
trap 'pg_ctl -D "$TMP/pg" stop -m immediate >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
initdb -D "$TMP/pg" -A trust -U postgres >/dev/null
printf "unix_socket_directories = ''\nlisten_addresses = '127.0.0.1'\nport = %s\n" "$PORT" \
>> "$TMP/pg/postgresql.conf"
pg_ctl -D "$TMP/pg" -l "$TMP/pg.log" start >/dev/null
createdb -h 127.0.0.1 -p "$PORT" -U postgres asxpio_test
TEST_DATABASE_URL="postgres://postgres@127.0.0.1:$PORT/asxpio_test" \
bundle exec rake test "$@"

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