@ -581,6 +581,157 @@ async fn report_handler(
}
}
}
}
/// Query parameters for `GET /api/admin/reports`.
#[ derive(Debug, Deserialize) ]
struct AdminReportsParams {
/// 1-based page number. Defaults to 1.
#[ serde(default = " default_page " ) ]
page : u32 ,
}
/// `GET /api/admin/reports` — paginated list of reported quotes.
///
/// Returns a [`ReportListResult`] with 10 entries per page. Each entry
/// contains the quote ID, truncated text, author, total report count, and
/// the timestamp of the most recent report.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn list_reports_handler (
State ( repo ) : State < Repo > ,
headers : HeaderMap ,
Query ( params ) : Query < AdminReportsParams > ,
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
match repo . list_reports ( params . page ) . await {
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
Err ( e ) = > db_error_response ( e ) ,
}
}
/// `GET /api/admin/reports/:quote_id` — full quote and all reports for it.
///
/// Returns a JSON object with `quote` and `reports` fields. Reports are
/// ordered oldest first.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn get_quote_reports_handler (
State ( repo ) : State < Repo > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
match repo . get_reports_for_quote ( & quote_id ) . await {
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
}
Err ( e ) = > db_error_response ( e ) ,
}
}
/// `DELETE /api/admin/reports/:quote_id/quote` — delete a quote as admin.
///
/// Deletes the quote unconditionally (no per-quote auth code required).
/// Tags and reports are removed automatically via `ON DELETE CASCADE`.
/// Returns `204 No Content` on success.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn admin_delete_quote_handler (
State ( repo ) : State < Repo > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
match repo . admin_delete_quote ( & quote_id ) . await {
Ok ( ( ) ) = > StatusCode ::NO_CONTENT . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
}
Err ( e ) = > db_error_response ( e ) ,
}
}
/// `POST /api/admin/reports/:quote_id/hide` — hide a quote.
///
/// Sets `hidden = 1` on the quote so it is excluded from public listing.
/// Returns `200 OK` with `{"hidden": true}` on success.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn hide_quote_handler (
State ( repo ) : State < Repo > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
match repo . hide_quote ( & quote_id ) . await {
Ok ( ( ) ) = > Json ( serde_json ::json ! ( { "hidden" : true } ) ) . into_response ( ) ,
Err ( crate ::db ::DbError ::NotFound ) = > {
error_response ( StatusCode ::NOT_FOUND , "quote not found" )
}
Err ( e ) = > db_error_response ( e ) ,
}
}
/// `DELETE /api/admin/reports/:quote_id/reports` — clear all reports for a quote.
///
/// Removes all report rows for the given quote without deleting the quote
/// itself. Returns `204 No Content` on success.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn clear_reports_handler (
State ( repo ) : State < Repo > ,
Path ( quote_id ) : Path < String > ,
headers : HeaderMap ,
) -> Response {
let Some ( code ) = extract_admin_code ( & headers ) else {
return error_response ( StatusCode ::FORBIDDEN , "X-Admin-Code header is required" ) ;
} ;
if ! verify_admin_code ( & repo , & code ) . await {
return error_response ( StatusCode ::FORBIDDEN , "invalid admin code" ) ;
}
match repo . clear_reports ( & quote_id ) . await {
Ok ( ( ) ) = > StatusCode ::NO_CONTENT . 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.
@ -604,6 +755,24 @@ 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 ) )
// Admin moderation endpoints — report management.
. route ( "/api/admin/reports" , get ( list_reports_handler ) )
. route (
"/api/admin/reports/{quote_id}" ,
get ( get_quote_reports_handler ) ,
)
. route (
"/api/admin/reports/{quote_id}/quote" ,
delete ( admin_delete_quote_handler ) ,
)
. route (
"/api/admin/reports/{quote_id}/hide" ,
post ( hide_quote_handler ) ,
)
. route (
"/api/admin/reports/{quote_id}/reports" ,
delete ( clear_reports_handler ) ,
)
// IMPORTANT: /random and /{id}/report must be registered before /{id}
// IMPORTANT: /random and /{id}/report must be registered before /{id}
// so static segments win over the dynamic capture.
// so static segments win over the dynamic capture.
. route ( "/api/quotes/random" , get ( random_handler ) )
. route ( "/api/quotes/random" , get ( random_handler ) )
@ -843,6 +1012,62 @@ mod tests {
Err ( DbError ::NotFound )
Err ( DbError ::NotFound )
}
}
}
}
async fn list_reports ( & self , page : u32 ) -> Result < crate ::db ::ReportListResult , DbError > {
Ok ( crate ::db ::ReportListResult {
reports : vec ! [ ] ,
page ,
total_pages : 0 ,
total_count : 0 ,
} )
}
async fn get_reports_for_quote (
& self ,
quote_id : & str ,
) -> Result < crate ::db ::QuoteReports , DbError > {
let quotes = self . quotes . lock ( ) . unwrap ( ) ;
let maybe = quotes . iter ( ) . find ( | ( q , _ ) | q . id = = quote_id ) ;
match maybe {
None = > Err ( DbError ::NotFound ) ,
Some ( ( q , _ ) ) = > Ok ( crate ::db ::QuoteReports {
quote : q . clone ( ) ,
reports : vec ! [ ] ,
} ) ,
}
}
async fn admin_delete_quote ( & self , quote_id : & str ) -> Result < ( ) , DbError > {
let mut quotes = self . quotes . lock ( ) . unwrap ( ) ;
let pos = quotes . iter ( ) . position ( | ( q , _ ) | q . id = = quote_id ) ;
match pos {
None = > Err ( DbError ::NotFound ) ,
Some ( i ) = > {
quotes . remove ( i ) ;
Ok ( ( ) )
}
}
}
async fn hide_quote ( & self , quote_id : & str ) -> Result < ( ) , DbError > {
let mut quotes = self . quotes . lock ( ) . unwrap ( ) ;
match quotes . iter_mut ( ) . find ( | ( q , _ ) | q . id = = quote_id ) {
None = > Err ( DbError ::NotFound ) ,
Some ( ( q , _ ) ) = > {
q . hidden = true ;
Ok ( ( ) )
}
}
}
async fn clear_reports ( & self , quote_id : & 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 {
@ -1526,6 +1751,325 @@ mod tests {
"response must include auth_code after second reset"
"response must include auth_code after second reset"
) ;
) ;
}
}
// ── GET /api/admin/reports handler tests ──────────────────────────────────
/// `GET /api/admin/reports` with a valid admin code returns `200` and a
/// [`ReportListResult`] JSON body (empty list since MockRepo returns no rows).
#[ tokio::test ]
async fn test_list_reports_correct_code_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , body ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::OK ) ;
let v : serde_json ::Value = serde_json ::from_str ( & body ) . unwrap ( ) ;
assert_eq! ( v [ "total_count" ] , 0 ) ;
assert! ( v [ "reports" ] . is_array ( ) ) ;
}
/// `GET /api/admin/reports` with no `X-Admin-Code` header returns `403`.
#[ tokio::test ]
async fn test_list_reports_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
/// `GET /api/admin/reports` with a wrong admin code returns `403`.
#[ tokio::test ]
async fn test_list_reports_wrong_code_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "real-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. header ( "X-Admin-Code" , "wrong-code" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
// ── GET /api/admin/reports/:quote_id handler tests ────────────────────────
/// `GET /api/admin/reports/:quote_id` with a valid code and existing quote
/// returns `200` with the quote and an empty reports list.
#[ tokio::test ]
async fn test_get_quote_reports_found_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
// Seed a quote into the mock.
repo . create_quote ( quotesdb ::CreateQuoteInput {
text : "Test" . to_owned ( ) ,
author : "Author" . to_owned ( ) ,
source : None ,
date : None ,
tags : vec ! [ ] ,
auth_code : Some ( "auth" . to_owned ( ) ) ,
cf_turnstile_token : None ,
} )
. await
. unwrap ( ) ;
// Retrieve the quote id.
let quotes = repo . quotes . lock ( ) . unwrap ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/admin/reports/{quote_id}" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , body ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::OK ) ;
let v : serde_json ::Value = serde_json ::from_str ( & body ) . unwrap ( ) ;
assert_eq! ( v [ "quote" ] [ "id" ] , quote_id . as_str ( ) ) ;
assert! ( v [ "reports" ] . is_array ( ) ) ;
}
/// `GET /api/admin/reports/:quote_id` for a nonexistent quote returns `404`.
#[ tokio::test ]
async fn test_get_quote_reports_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports/nonexistent" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
}
/// `GET /api/admin/reports/:quote_id` with no admin code returns `403`.
#[ tokio::test ]
async fn test_get_quote_reports_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports/any-id" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
// ── DELETE /api/admin/reports/:quote_id/quote handler tests ───────────────
/// `DELETE /api/admin/reports/:quote_id/quote` with a valid code deletes
/// the quote and returns `204 No Content`.
#[ tokio::test ]
async fn test_admin_delete_quote_returns_204 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
repo . create_quote ( quotesdb ::CreateQuoteInput {
text : "Delete me" . to_owned ( ) ,
author : "Author" . to_owned ( ) ,
source : None ,
date : None ,
tags : vec ! [ ] ,
auth_code : Some ( "auth" . to_owned ( ) ) ,
cf_turnstile_token : None ,
} )
. await
. unwrap ( ) ;
let quotes = repo . quotes . lock ( ) . unwrap ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/admin/reports/{quote_id}/quote" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NO_CONTENT ) ;
// Verify the quote is gone.
let quotes = repo . quotes . lock ( ) . unwrap ( ) ;
assert! ( quotes . is_empty ( ) , "quote should have been deleted" ) ;
}
/// `DELETE /api/admin/reports/:quote_id/quote` for a nonexistent quote
/// returns `404`.
#[ tokio::test ]
async fn test_admin_delete_quote_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/nonexistent/quote" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
}
/// `DELETE /api/admin/reports/:quote_id/quote` with no admin code returns
/// `403`.
#[ tokio::test ]
async fn test_admin_delete_quote_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/any-id/quote" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
// ── POST /api/admin/reports/:quote_id/hide handler tests ──────────────────
/// `POST /api/admin/reports/:quote_id/hide` with a valid code sets the
/// quote hidden and returns `200` with `{"hidden": true}`.
#[ tokio::test ]
async fn test_hide_quote_returns_200 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
repo . create_quote ( quotesdb ::CreateQuoteInput {
text : "Hide me" . to_owned ( ) ,
author : "Author" . to_owned ( ) ,
source : None ,
date : None ,
tags : vec ! [ ] ,
auth_code : Some ( "auth" . to_owned ( ) ) ,
cf_turnstile_token : None ,
} )
. await
. unwrap ( ) ;
let quotes = repo . quotes . lock ( ) . unwrap ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/admin/reports/{quote_id}/hide" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , body ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::OK ) ;
let v : serde_json ::Value = serde_json ::from_str ( & body ) . unwrap ( ) ;
assert_eq! ( v [ "hidden" ] , true ) ;
// Verify the quote is now hidden in the mock.
let quotes = repo . quotes . lock ( ) . unwrap ( ) ;
assert! ( quotes [ 0 ] . 0. hidden , "quote should be marked hidden" ) ;
}
/// `POST /api/admin/reports/:quote_id/hide` for a nonexistent quote
/// returns `404`.
#[ tokio::test ]
async fn test_hide_quote_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reports/nonexistent/hide" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
}
/// `POST /api/admin/reports/:quote_id/hide` with no admin code returns
/// `403`.
#[ tokio::test ]
async fn test_hide_quote_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/admin/reports/any-id/hide" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
// ── DELETE /api/admin/reports/:quote_id/reports handler tests ─────────────
/// `DELETE /api/admin/reports/:quote_id/reports` with a valid code clears
/// all reports and returns `204 No Content`.
#[ tokio::test ]
async fn test_clear_reports_returns_204 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
repo . create_quote ( quotesdb ::CreateQuoteInput {
text : "Reported" . to_owned ( ) ,
author : "Author" . to_owned ( ) ,
source : None ,
date : None ,
tags : vec ! [ ] ,
auth_code : Some ( "auth" . to_owned ( ) ) ,
cf_turnstile_token : None ,
} )
. await
. unwrap ( ) ;
let quotes = repo . quotes . lock ( ) . unwrap ( ) ;
let quote_id = quotes [ 0 ] . 0. id . clone ( ) ;
drop ( quotes ) ;
let app = router ( Arc ::clone ( & repo ) as Repo ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/admin/reports/{quote_id}/reports" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NO_CONTENT ) ;
}
/// `DELETE /api/admin/reports/:quote_id/reports` for a nonexistent quote
/// returns `404`.
#[ tokio::test ]
async fn test_clear_reports_not_found_returns_404 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/nonexistent/reports" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
}
/// `DELETE /api/admin/reports/:quote_id/reports` with no admin code returns
/// `403`.
#[ tokio::test ]
async fn test_clear_reports_missing_header_returns_403 ( ) {
let repo = MockRepo ::with_admin_code ( "admin-secret" ) ;
let app = router ( repo ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( "/api/admin/reports/any-id/reports" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let ( status , _ ) = send ( app , req ) . await ;
assert_eq! ( status , StatusCode ::FORBIDDEN ) ;
}
}
}
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────
@ -2378,4 +2922,235 @@ mod integration_tests {
. unwrap ( ) ;
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::BAD_REQUEST ) ;
assert_eq! ( resp . status ( ) , StatusCode ::BAD_REQUEST ) ;
}
}
// ── Admin moderation endpoint integration tests (ticket 6c5904) ───────────
/// Build a `test_router` and seed a known admin auth code so integration
/// tests can authenticate without reading the printed code.
async fn test_router_with_admin ( admin_code : & str ) -> ( Router , NamedTempFile ) {
let f = NamedTempFile ::new ( ) . expect ( "failed to create temp db file" ) ;
let repo = connection ::open ( f . path ( ) . to_str ( ) . expect ( "non-utf8 temp path" ) )
. await
. expect ( "failed to open test database" ) ;
repo . run_migrations ( ) . await . expect ( "migrations failed" ) ;
repo . seed_admin_auth_code ( admin_code )
. await
. expect ( "failed to seed admin code" ) ;
repo . seed_submissions_locked ( )
. await
. expect ( "failed to seed submissions lock" ) ;
let repo : Arc < dyn QuoteRepository + Send + Sync > = Arc ::new ( repo ) ;
( router ( repo ) , f )
}
/// Submit a report for a quote via `POST /api/quotes/:id/report`.
async fn report_quote ( app : Router , quote_id : & str ) -> Router {
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/quotes/{quote_id}/report" ) )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( r#"{"reason":"spam"}"# ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! (
resp . status ( ) ,
StatusCode ::CREATED ,
"report_quote: unexpected status"
) ;
app
}
/// `GET /api/admin/reports` returns `200` with an empty list when there
/// are no reported quotes.
#[ tokio::test ]
async fn integration_list_reports_empty_returns_200 ( ) {
let ( app , _f ) = test_router_with_admin ( "admin-secret" ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "total_count" ] , 0 ) ;
assert_eq! ( v [ "reports" ] . as_array ( ) . unwrap ( ) . len ( ) , 0 ) ;
}
/// `GET /api/admin/reports` returns a summary entry after a report has
/// been submitted for a quote.
#[ tokio::test ]
async fn integration_list_reports_with_report_returns_entry ( ) {
let ( app , _f ) = test_router_with_admin ( "admin-secret" ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "Reported quote" , "Author" , & [ ] ) . await ;
let quote_id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let app = report_quote ( app , & quote_id ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "total_count" ] , 1 ) ;
let entry = & v [ "reports" ] [ 0 ] ;
assert_eq! ( entry [ "quote_id" ] , quote_id . as_str ( ) ) ;
assert_eq! ( entry [ "report_count" ] , 1 ) ;
}
/// `GET /api/admin/reports/:quote_id` returns `200` with the full quote
/// and all report rows.
#[ tokio::test ]
async fn integration_get_quote_reports_returns_full_detail ( ) {
let ( app , _f ) = test_router_with_admin ( "admin-secret" ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "Flagged" , "Author" , & [ ] ) . await ;
let quote_id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let app = report_quote ( app , & quote_id ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/admin/reports/{quote_id}" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "quote" ] [ "id" ] , quote_id . as_str ( ) ) ;
assert_eq! ( v [ "reports" ] . as_array ( ) . unwrap ( ) . len ( ) , 1 ) ;
assert_eq! ( v [ "reports" ] [ 0 ] [ "reason" ] , "spam" ) ;
}
/// `DELETE /api/admin/reports/:quote_id/quote` deletes the quote; a
/// subsequent `GET /api/quotes/:id` returns `404`.
#[ tokio::test ]
async fn integration_admin_delete_quote_removes_quote ( ) {
let ( app , _f ) = test_router_with_admin ( "admin-secret" ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "To delete" , "Author" , & [ ] ) . await ;
let quote_id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let app = report_quote ( app , & quote_id ) . await ;
// Delete via admin endpoint.
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/admin/reports/{quote_id}/quote" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NO_CONTENT ) ;
// Confirm the quote is gone.
let req2 = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/quotes/{quote_id}" ) )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp2 = ServiceExt ::< Request < Body > > ::oneshot ( app , req2 )
. await
. unwrap ( ) ;
assert_eq! ( resp2 . status ( ) , StatusCode ::NOT_FOUND ) ;
}
/// `POST /api/admin/reports/:quote_id/hide` sets `hidden = true`; the
/// `hidden` field on the quote is `true` when fetched via GET afterward.
#[ tokio::test ]
async fn integration_hide_quote_sets_hidden_flag ( ) {
let ( app , _f ) = test_router_with_admin ( "admin-secret" ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "Hide me" , "Author" , & [ ] ) . await ;
let quote_id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let app = report_quote ( app , & quote_id ) . await ;
// Hide the quote.
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/admin/reports/{quote_id}/hide" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "hidden" ] , true ) ;
// Verify via GET.
let req2 = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/quotes/{quote_id}" ) )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp2 = ServiceExt ::< Request < Body > > ::oneshot ( app , req2 )
. await
. unwrap ( ) ;
assert_eq! ( resp2 . status ( ) , StatusCode ::OK ) ;
let v2 = body_json ( resp2 ) . await ;
assert_eq! ( v2 [ "hidden" ] , true ) ;
}
/// `DELETE /api/admin/reports/:quote_id/reports` clears reports; the list
/// is empty afterward.
#[ tokio::test ]
async fn integration_clear_reports_empties_report_list ( ) {
let ( app , _f ) = test_router_with_admin ( "admin-secret" ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "Spammy" , "Author" , & [ ] ) . await ;
let quote_id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let app = report_quote ( app , & quote_id ) . await ;
// Confirm there is one report before clearing.
let check_req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let check_resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , check_req )
. await
. unwrap ( ) ;
let v = body_json ( check_resp ) . await ;
assert_eq! (
v [ "total_count" ] , 1 ,
"should have one report before clearing"
) ;
// Clear reports.
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/admin/reports/{quote_id}/reports" ) )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NO_CONTENT ) ;
// Confirm no reports remain.
let check2_req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/admin/reports" )
. header ( "X-Admin-Code" , "admin-secret" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let check2_resp = ServiceExt ::< Request < Body > > ::oneshot ( app , check2_req )
. await
. unwrap ( ) ;
let v2 = body_json ( check2_resp ) . await ;
assert_eq! ( v2 [ "total_count" ] , 0 , "all reports should be cleared" ) ;
}
}
}