Files
kvitt/server/crates/kvitt-domain/tests/vectors.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

200 lines
6.1 KiB
Rust

//! Runs every case of every file in `contracts/test-vectors/` (SPEC §14.5.5) against `kvitt-domain`.
//!
//! The directory is read at test time (path from `CARGO_MANIFEST_DIR`), so a new vector file is picked up without a
//! code change; a file for a function this crate does not know fails the test unless it is one of the Kotlin-only
//! functions (SPEC §14.5.4 analytics and the category suggestion).
use std::collections::BTreeMap;
use std::path::PathBuf;
use kvitt_domain::{
AccountPosition, Allocation, BalanceExpense, BalanceSettlement, SplitSpec, compute_balances,
compute_split, over_settled_accounts, propose_allocation, validate_settlement,
};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
/// Functions that exist only in Kotlin (SPEC §14.5.4, the category suggestion); their files are parsed but not run here.
// `match_card_purchase` is Kotlin and web only (SPEC §14.5.8, D-170): the server never suggests matches.
const KOTLIN_ONLY: &[&str] = &[
"spend_series",
"resolve_range",
"suggest_category",
"match_card_purchase",
];
/// Every Rust function of SPEC §14.5.2; each must have a vector file.
const RUST_FUNCTIONS: &[&str] = &[
"compute_split",
"compute_balances",
"propose_allocation",
"validate_settlement",
"over_settled_accounts",
];
#[derive(Deserialize)]
struct VectorFile {
format: u32,
function: String,
cases: Vec<Case>,
}
#[derive(Deserialize)]
struct Case {
name: String,
input: Value,
expected: Value,
}
#[derive(Deserialize)]
struct SplitInput {
amount_ore: i64,
payer_user_id: String,
user_ids: Vec<String>,
spec: SplitSpec,
}
#[derive(Deserialize)]
struct BalancesInput {
user_ids: Vec<String>,
account_ids: Vec<String>,
expenses: Vec<BalanceExpense>,
settlements: Vec<BalanceSettlement>,
}
#[derive(Deserialize)]
struct ProposalInput {
amount_ore: i64,
accounts: Vec<AccountPosition>,
}
#[derive(Deserialize)]
struct SettlementInput {
amount_ore: i64,
from_user_id: String,
to_user_id: String,
user_ids: Vec<String>,
allocations: Vec<Allocation>,
}
#[derive(Deserialize)]
struct OverSettledInput {
allocations: Vec<Allocation>,
accounts: Vec<AccountPosition>,
}
fn vectors_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/test-vectors")
}
fn input<T: DeserializeOwned>(file: &str, case: &Case) -> T {
serde_json::from_value(case.input.clone())
.unwrap_or_else(|e| panic!("{file} / {}: bad input: {e}", case.name))
}
fn outcome<T: serde::Serialize, E: serde::Serialize>(result: Result<T, E>) -> Value {
match result {
Ok(v) => json!({ "ok": v }),
Err(e) => json!({ "error": e }),
}
}
fn run(function: &str, file: &str, case: &Case) -> Value {
match function {
"compute_split" => {
let i: SplitInput = input(file, case);
outcome(compute_split(
i.amount_ore,
&i.payer_user_id,
&i.user_ids,
&i.spec,
))
}
"compute_balances" => {
let i: BalancesInput = input(file, case);
let b = compute_balances(&i.user_ids, &i.account_ids, &i.expenses, &i.settlements);
json!({ "ok": b })
}
"propose_allocation" => {
let i: ProposalInput = input(file, case);
outcome(propose_allocation(i.amount_ore, &i.accounts))
}
"validate_settlement" => {
let i: SettlementInput = input(file, case);
outcome(validate_settlement(
i.amount_ore,
&i.from_user_id,
&i.to_user_id,
&i.user_ids,
&i.allocations,
))
}
"over_settled_accounts" => {
let i: OverSettledInput = input(file, case);
json!({ "ok": over_settled_accounts(&i.allocations, &i.accounts) })
}
other => panic!("{file}: no Rust implementation for function {other}"),
}
}
#[test]
fn every_vector_file_passes() {
let dir = vectors_dir();
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.map(|entry| entry.expect("directory entry").path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
entries.sort();
assert!(!entries.is_empty(), "no vector files in {}", dir.display());
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut failures: Vec<String> = Vec::new();
for path in &entries {
let file = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let text = std::fs::read_to_string(path).expect("read vector file");
let vectors: VectorFile = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{file}: not a vector file (SPEC §14.5.5): {e}"));
assert_eq!(vectors.format, 1, "{file}: unknown format");
assert_eq!(
file,
format!("{}.json", vectors.function),
"{file}: file name must be <function>.json"
);
assert!(!vectors.cases.is_empty(), "{file}: no cases");
if KOTLIN_ONLY.contains(&vectors.function.as_str()) {
seen.insert(vectors.function.clone(), 0);
continue;
}
for case in &vectors.cases {
let actual = run(&vectors.function, &file, case);
if actual != case.expected {
failures.push(format!(
"{file} / {}: expected {}, got {}",
case.name, case.expected, actual
));
}
}
seen.insert(vectors.function.clone(), vectors.cases.len());
}
for function in RUST_FUNCTIONS {
assert!(
seen.get(*function).is_some_and(|n| *n > 0),
"no vector cases for {function}"
);
}
for (function, cases) in &seen {
println!("{function}: {cases} cases run");
}
assert!(
failures.is_empty(),
"{} failing cases:\n{}",
failures.len(),
failures.join("\n")
);
}