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" + ); + } }