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:
@@ -0,0 +1,93 @@
|
||||
//! 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");
|
||||
}
|
||||
Reference in New Issue
Block a user