From e6aefaa8045b10015a8db5c7322cabc373c9be66 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 22:03:28 +0200 Subject: [PATCH] test: pin AppError -> HTTP status mapping (incl 501 and 429 Retry-After) Table-driven test over every AppError variant's into_response().status(), closing the unexercised 501 NotImplemented path and asserting a deterministic 429 Retry-After header (audit GAP B). Test-only. Co-Authored-By: Claude Opus 4.8 --- backend/src/error.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/backend/src/error.rs b/backend/src/error.rs index 6253893..3f6927f 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -207,4 +207,68 @@ mod tests { "text_search_not_yet_supported" ); } + + #[test] + fn status_mapping_is_stable() { + // Pin the code -> HTTP status mapping so a refactor of into_response + // can't silently change a status (e.g. 501 -> 500). The 501 NotImplemented + // path and a deterministic 429 Retry-After were previously unexercised + // (audit GAP B). + let cases: Vec<(AppError, StatusCode)> = vec![ + (AppError::NotFound, StatusCode::NOT_FOUND), + (AppError::InvalidInput("x".into()), StatusCode::BAD_REQUEST), + (AppError::Unauthenticated, StatusCode::UNAUTHORIZED), + (AppError::Forbidden, StatusCode::FORBIDDEN), + (AppError::Conflict("x".into()), StatusCode::CONFLICT), + (AppError::PayloadTooLarge("x".into()), StatusCode::PAYLOAD_TOO_LARGE), + ( + AppError::UnsupportedMediaType("x".into()), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ), + ( + AppError::ServiceUnavailable("x".into()), + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + AppError::ValidationFailed { + message: "x".into(), + details: json!({}), + }, + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + AppError::NotImplemented { + code: "c", + message: "m", + }, + StatusCode::NOT_IMPLEMENTED, + ), + ( + AppError::TooManyRequests { + retry_after_secs: None, + }, + StatusCode::TOO_MANY_REQUESTS, + ), + ( + AppError::Other(anyhow::anyhow!("boom")), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ]; + for (err, want) in cases { + let code = err.code(); + assert_eq!(err.into_response().status(), want, "status for code {code}"); + } + + // A 429 with retry_after carries the Retry-After header deterministically + // (without needing a live rate limiter). + let resp = AppError::TooManyRequests { + retry_after_secs: Some(7), + } + .into_response(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + resp.headers().get(axum::http::header::RETRY_AFTER).unwrap(), + "7" + ); + } }