M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile

Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:25:35 +02:00
parent c277ee37c2
commit 06f48ef9e9
71 changed files with 9187 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
//! 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
//! analytics functions (SPEC §14.5.4).
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); their files are parsed but not run here.
const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range"];
/// 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")
);
}