Files
kvitt/server/crates/kvitt-server/tests/skeleton/errors.rs
T
jacobh2 06f48ef9e9 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>
2026-10-17 21:30:20 +02:00

94 lines
3.1 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),
("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),
("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");
}