671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
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
98 lines
3.2 KiB
Rust
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");
|
|
}
|