Files
kvitt/server/crates/kvitt-server/tests/skeleton/errors.rs
T
jacobh2 671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical
contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error
codes and a 502 response, examples for every card body and error code, the
match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py
(157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists
match_card_purchase as not ported until K3 ports it (orchestrator's allowance).

Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its
Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions,
transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping),
the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day,
de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk),
CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45,
4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38
and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch
log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub
(tests/cards_api/, 34 tests); the real API is never called.

Infra: Pi compose mounts secrets/cards read-only and passes the two application ids;
.env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback
gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases".

Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail
in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin
the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design
(contract-first landing; K2 lands next). Server, contracts-check and web gates are green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-24 16:43:26 +02:00

98 lines
3.2 KiB
Rust

//! Every error code of SPEC §14.3.2 serialises like `contracts/examples/errors/<code>.json`, with its HTTP status.
use std::path::PathBuf;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use http_body_util::BodyExt;
use kvitt_server::error::{AppError, ErrorBody, ErrorCode};
use serde_json::Value;
const STATUS: &[(&str, u16)] = &[
("invalid_request", 400),
("invite_invalid", 400),
("unauthorized", 401),
("device_revoked", 401),
("forbidden", 403),
("not_found", 404),
("household_full", 409),
("household_incomplete", 409),
("name_taken", 409),
("account_not_empty", 409),
("category_in_use", 409),
("category_is_default", 409),
("cannot_revoke_current_device", 409),
("no_accounts", 409),
("cursor_ahead", 409),
("cards_unavailable", 409),
("card_consent_expired", 409),
("expense_already_linked", 409),
("payload_too_large", 413),
("unsupported_media_type", 415),
("validation_failed", 422),
("image_invalid", 422),
("batch_too_large", 422),
("protocol_unsupported", 426),
("rate_limited", 429),
("internal", 500),
("card_bank_unavailable", 502),
("not_implemented", 501),
("unavailable", 503),
];
#[test]
fn every_code_has_the_status_of_the_spec_table() {
assert_eq!(ErrorCode::ALL.len(), STATUS.len());
for (code, (token, status)) in ErrorCode::ALL.iter().zip(STATUS) {
assert_eq!(code.as_str(), *token);
assert_eq!(code.status().as_u16(), *status, "{token}");
}
}
#[tokio::test]
async fn every_error_example_round_trips_through_app_error() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/examples/errors");
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
let example: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let body: ErrorBody = serde_json::from_value(example.clone()).unwrap();
let stem = path.file_stem().unwrap().to_string_lossy().into_owned();
assert_eq!(
body.code.as_str(),
stem,
"{}: file name is the code",
path.display()
);
let mut error = AppError::new(body.code, body.message.clone());
if let Some(field) = &body.field {
error = error.with_field(field);
}
let response = error.into_response();
let status = response.status();
assert_eq!(
response.headers()["content-type"],
"application/json; charset=utf-8"
);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json, example, "{stem}");
let expected = STATUS
.iter()
.find(|(t, _)| *t == stem)
.map(|(_, s)| *s)
.unwrap();
assert_eq!(status, StatusCode::from_u16(expected).unwrap());
seen += 1;
}
assert_eq!(seen, ErrorCode::ALL.len(), "one example per error code");
}
#[test]
fn rate_limited_carries_retry_after() {
let response = AppError::rate_limited(30).into_response();
assert_eq!(response.headers()["retry-after"], "30");
}