06f48ef9e9
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>
94 lines
3.1 KiB
Rust
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");
|
|
}
|