@ -539,6 +539,48 @@ async fn reset_auth_code(
}
}
}
}
/// Request body for `POST /api/quotes/:id/report`.
///
/// All fields are optional — a report can be submitted without a reason.
#[ derive(Debug, Deserialize) ]
struct ReportInput {
/// Optional human-readable reason for the report. At most 256 characters.
reason : Option < String > ,
}
/// `POST /api/quotes/:id/report` — submit a moderation report for a quote.
///
/// The request body is a JSON object with an optional `reason` field. The body
/// itself is also optional — omitting it entirely (or sending `{}`) is valid.
///
/// Returns `201 Created` on success, `400 Bad Request` if the reason exceeds
/// 256 characters, `404 Not Found` if no quote with the given ID exists, or
/// `500 Internal Server Error` on a database failure.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn report_handler (
State ( repo ) : State < Repo > ,
Path ( id ) : Path < String > ,
body : Option < Json < ReportInput > > ,
) -> Response {
let reason = body . and_then ( | Json ( input ) | input . reason ) ;
// Validate reason length — enforced here before the DB call.
if reason . as_deref ( ) . map ( | r | r . len ( ) ) . unwrap_or ( 0 ) > 256 {
return error_response (
StatusCode ::BAD_REQUEST ,
"reason must be at most 256 characters" ,
) ;
}
match repo . create_report ( & id , reason . as_deref ( ) ) . await {
Ok ( ( ) ) = > StatusCode ::CREATED . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
}
Err ( e ) = > db_error_response ( e ) ,
}
}
// ── Router ────────────────────────────────────────────────────────────────────
// ── Router ────────────────────────────────────────────────────────────────────
/// Build the Axum [`Router`] with all API routes wired to their handlers.
/// Build the Axum [`Router`] with all API routes wired to their handlers.
@ -562,9 +604,10 @@ pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
. route ( "/api/admin/lock" , post ( lock_submissions ) )
. route ( "/api/admin/lock" , post ( lock_submissions ) )
. route ( "/api/admin/unlock" , post ( unlock_submissions ) )
. route ( "/api/admin/unlock" , post ( unlock_submissions ) )
. route ( "/api/admin/reset-auth-code" , post ( reset_auth_code ) )
. route ( "/api/admin/reset-auth-code" , post ( reset_auth_code ) )
// IMPORTANT: /random must be registered before /{id} so the static
// IMPORTANT: /random and /{id}/report must be registered before /{id}
// s egment wins over the dynamic capture.
// s o static s egments win over the dynamic capture.
. route ( "/api/quotes/random" , get ( random_handler ) )
. route ( "/api/quotes/random" , get ( random_handler ) )
. route ( "/api/quotes/{id}/report" , post ( report_handler ) )
. route ( "/api/quotes/{id}" , get ( get_quote_handler ) )
. route ( "/api/quotes/{id}" , get ( get_quote_handler ) )
. route ( "/api/quotes" , get ( list_handler ) )
. route ( "/api/quotes" , get ( list_handler ) )
. route ( "/api/quotes" , put ( create_handler ) )
. route ( "/api/quotes" , put ( create_handler ) )
@ -787,6 +830,19 @@ mod tests {
async fn seed_submissions_locked ( & self ) -> Result < ( ) , DbError > {
async fn seed_submissions_locked ( & self ) -> Result < ( ) , DbError > {
Ok ( ( ) )
Ok ( ( ) )
}
}
async fn create_report (
& self ,
quote_id : & str ,
_reason : Option < & str > ,
) -> Result < ( ) , DbError > {
let quotes = self . quotes . lock ( ) . unwrap ( ) ;
if quotes . iter ( ) . any ( | ( q , _ ) | q . id = = quote_id ) {
Ok ( ( ) )
} else {
Err ( DbError ::NotFound )
}
}
}
}
fn sample_quote ( ) -> Quote {
fn sample_quote ( ) -> Quote {
@ -1363,6 +1419,55 @@ mod tests {
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
}
// ── POST /api/quotes/:id/report handler tests ──────────────────────────────
/// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`.
#[ tokio::test ]
async fn test_report_success ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) ) ;
let body = serde_json ::json ! ( { "reason" : "inappropriate content" } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/quotes/abc-123/report" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::CREATED ) ;
}
/// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`.
#[ tokio::test ]
async fn test_report_quote_not_found ( ) {
let app = router ( MockRepo ::empty ( ) ) ;
let body = serde_json ::json ! ( { } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/quotes/unknown/report" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
}
/// `POST /api/quotes/:id/report` with a reason longer than 256 characters
/// returns `400 Bad Request`.
#[ tokio::test ]
async fn test_report_reason_too_long ( ) {
let app = router ( MockRepo ::with_quote ( sample_quote ( ) , "auth" ) ) ;
let long_reason = "x" . repeat ( 257 ) ;
let body = serde_json ::json ! ( { "reason" : long_reason } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/quotes/abc-123/report" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( body . to_string ( ) ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::BAD_REQUEST ) ;
}
/// After a successful reset, subsequent calls with the old code return `403`
/// After a successful reset, subsequent calls with the old code return `403`
/// and with the new code return `200`.
/// and with the new code return `200`.
#[ tokio::test ]
#[ tokio::test ]