@ -584,3 +584,725 @@ mod tests {
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
assert_eq! ( status , StatusCode ::NOT_FOUND ) ;
}
}
}
}
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────
//
// These tests spin up the full Axum router backed by a temporary file-based
// SQLite database. Each test gets its own database via `NamedTempFile` so
// there is no cross-test interference.
//
// Tickets covered:
// 789d0f GET /api/ returns OpenAPI JSON
// aa0eab GET /api/quotes/random
// f9f448 GET /api/quotes/:id
// 4a4c26 PUT /api/quotes (create)
// 93f1b6 GET /api/quotes (list + filters + pagination)
// fae330 POST /api/quotes/:id (update)
// 8c87db DELETE /api/quotes/:id
// 893eba Tag operations
// e8f5cf Router ordering (/random not matched as :id)
#[ cfg(test) ]
mod integration_tests {
use super ::* ;
use axum ::http ::Request ;
use axum ::{ body ::Body , http ::Method } ;
use serde_json ::json ;
use tempfile ::NamedTempFile ;
use tower ::util ::ServiceExt ;
use crate ::db ::connection ;
// ── Harness ───────────────────────────────────────────────────────────────
/// Create an Axum router backed by a real, migrated NativeRepository
/// stored in a temporary file. Returns both the router and the temp file
/// handle (which must be kept alive for the duration of the test).
async fn test_router ( ) -> ( 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" ) ;
let repo : Arc < dyn QuoteRepository + Send + Sync > = Arc ::new ( repo ) ;
( router ( repo ) , f )
}
// ── Body helpers ──────────────────────────────────────────────────────────
/// Collect the full response body as raw bytes.
async fn body_bytes ( resp : axum ::response ::Response ) -> Vec < u8 > {
axum ::body ::to_bytes ( resp . into_body ( ) , usize ::MAX )
. await
. expect ( "failed to read response body" )
. to_vec ( )
}
/// Collect the full response body and parse it as a JSON value.
async fn body_json ( resp : axum ::response ::Response ) -> serde_json ::Value {
let bytes = body_bytes ( resp ) . await ;
serde_json ::from_slice ( & bytes ) . expect ( "response is not valid JSON" )
}
// ── Quote creation helper ─────────────────────────────────────────────────
/// Create a quote via PUT /api/quotes and return `(quote_json, auth_code)`.
async fn create_quote_raw (
app : Router ,
text : & str ,
author : & str ,
tags : & [ & str ] ,
) -> ( Router , serde_json ::Value , String ) {
let payload = json ! ( {
"text" : text ,
"author" : author ,
"tags" : tags ,
} ) ;
let req = Request ::builder ( )
. method ( Method ::PUT )
. uri ( "/api/quotes" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! (
resp . status ( ) ,
StatusCode ::CREATED ,
"create_quote_raw: unexpected status"
) ;
let v = body_json ( resp ) . await ;
let auth_code = v [ "auth_code" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let quote = v [ "quote" ] . clone ( ) ;
( app , quote , auth_code )
}
// ── Ticket 789d0f: GET /api/ returns OpenAPI JSON ─────────────────────────
/// GET /api/ must respond 200 with a JSON body containing the keys
/// `openapi`, `info`, and `paths` required by the OpenAPI 3.x spec.
#[ tokio::test ]
async fn integration_openapi_spec_is_valid_json ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/" )
. 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! ( v . get ( "openapi" ) . is_some ( ) , "missing 'openapi' key" ) ;
assert! ( v . get ( "info" ) . is_some ( ) , "missing 'info' key" ) ;
assert! ( v . get ( "paths" ) . is_some ( ) , "missing 'paths' key" ) ;
}
// ── Ticket aa0eab: GET /api/quotes/random ─────────────────────────────────
/// Random endpoint returns 404 when the database contains no quotes.
#[ tokio::test ]
async fn integration_random_empty_db_returns_404 ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes/random" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NOT_FOUND ) ;
}
/// Random endpoint returns 200 with a quote when the database has data.
#[ tokio::test ]
async fn integration_random_with_data_returns_200 ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Cogito ergo sum" , "Descartes" , & [ ] ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes/random" )
. 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! ( v . get ( "id" ) . is_some ( ) , "random quote must have id" ) ;
assert! ( v . get ( "text" ) . is_some ( ) , "random quote must have text" ) ;
assert! ( v . get ( "author" ) . is_some ( ) , "random quote must have author" ) ;
}
// ── Ticket f9f448: GET /api/quotes/:id ────────────────────────────────────
/// GET /api/quotes/:id returns 404 for an ID that does not exist.
#[ tokio::test ]
async fn integration_get_quote_not_found ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes/does-not-exist-at-all" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NOT_FOUND ) ;
}
/// GET /api/quotes/:id returns 200 with the full quote schema.
#[ tokio::test ]
async fn integration_get_quote_returns_correct_schema ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , created , _auth ) =
create_quote_raw ( app , "To be or not to be" , "Shakespeare" , & [ "classic" ] ) . await ;
let id = created [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/quotes/{id}" ) )
. 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 ;
// All required fields must be present
assert_eq! ( v [ "id" ] , id ) ;
assert_eq! ( v [ "text" ] , "To be or not to be" ) ;
assert_eq! ( v [ "author" ] , "Shakespeare" ) ;
assert! ( v . get ( "source" ) . is_some ( ) , "source field must be present" ) ;
assert! ( v . get ( "date" ) . is_some ( ) , "date field must be present" ) ;
assert! ( v . get ( "tags" ) . is_some ( ) , "tags field must be present" ) ;
assert! ( v . get ( "created_at" ) . is_some ( ) , "created_at must be present" ) ;
assert! ( v . get ( "updated_at" ) . is_some ( ) , "updated_at must be present" ) ;
assert_eq! ( v [ "tags" ] , json ! ( [ "classic" ] ) ) ;
}
// ── Ticket 4a4c26: PUT /api/quotes ────────────────────────────────────────
/// Create a quote without providing auth_code; the server auto-generates
/// a 4-word passphrase.
#[ tokio::test ]
async fn integration_create_quote_auto_auth_code ( ) {
let ( app , _f ) = test_router ( ) . await ;
let payload = json ! ( { "text" : "Hello" , "author" : "World" } ) ;
let req = Request ::builder ( )
. method ( Method ::PUT )
. uri ( "/api/quotes" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::CREATED ) ;
let v = body_json ( resp ) . await ;
let auth = v [ "auth_code" ] . as_str ( ) . expect ( "auth_code must be a string" ) ;
// Auto-generated codes have the pattern word-word-word-word
let parts : Vec < & str > = auth . split ( '-' ) . collect ( ) ;
assert_eq! ( parts . len ( ) , 4 , "auto auth_code must be 4 words: {auth}" ) ;
assert! ( v [ "quote" ] [ "id" ] . is_string ( ) , "quote.id must be present" ) ;
}
/// Create a quote with a custom auth_code; it must be echoed back.
#[ tokio::test ]
async fn integration_create_quote_custom_auth_code ( ) {
let ( app , _f ) = test_router ( ) . await ;
let payload = json ! ( {
"text" : "Custom auth" ,
"author" : "Tester" ,
"auth_code" : "my-custom-passphrase-code"
} ) ;
let req = Request ::builder ( )
. method ( Method ::PUT )
. uri ( "/api/quotes" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::CREATED ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "auth_code" ] , "my-custom-passphrase-code" ) ;
}
/// PUT /api/quotes with missing required fields returns 422.
#[ tokio::test ]
async fn integration_create_quote_missing_required_fields ( ) {
let ( app , _f ) = test_router ( ) . await ;
// Missing both `text` and `author`
let payload = json ! ( { "source" : "somewhere" } ) ;
let req = Request ::builder ( )
. method ( Method ::PUT )
. uri ( "/api/quotes" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::UNPROCESSABLE_ENTITY ) ;
}
// ── Ticket 93f1b6: GET /api/quotes ────────────────────────────────────────
/// Page 1 returns at most 10 quotes even when more exist.
#[ tokio::test ]
async fn integration_list_quotes_pagination_page1 ( ) {
let ( app , _f ) = test_router ( ) . await ;
// Insert 12 quotes
let mut current_app = app ;
for i in 0 .. 12 {
let ( next_app , _ , _ ) =
create_quote_raw ( current_app , & format! ( "Quote {i}" ) , "Paginator" , & [ ] ) . await ;
current_app = next_app ;
}
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?page=1" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( current_app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "quotes" ] . as_array ( ) . unwrap ( ) . len ( ) , 10 ) ;
assert_eq! ( v [ "total_count" ] , 12 ) ;
assert_eq! ( v [ "total_pages" ] , 2 ) ;
}
/// A page beyond the last page returns an empty list (not an error).
#[ tokio::test ]
async fn integration_list_quotes_page_beyond_results ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Only one" , "Solo" , & [ ] ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?page=99" )
. 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 [ "quotes" ] . as_array ( ) . unwrap ( ) . len ( ) , 0 ) ;
}
/// `?author=` filter is case-insensitive.
#[ tokio::test ]
async fn integration_list_quotes_author_filter ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Upper Alice" , "Alice" , & [ ] ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Lower alice" , "alice" , & [ ] ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "By Bob" , "Bob" , & [ ] ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?author=alice" )
. 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 ;
// Both "Alice" and "alice" should be returned
assert_eq! ( v [ "total_count" ] , 2 ) ;
}
/// `?tag=` filter returns only quotes that have the specified tag.
#[ tokio::test ]
async fn integration_list_quotes_tag_filter ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Tagged quote" , "A" , & [ "rust" ] ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Untagged quote" , "B" , & [ ] ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?tag=rust" )
. 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 ) ;
assert_eq! ( v [ "quotes" ] [ 0 ] [ "text" ] , "Tagged quote" ) ;
}
/// List on an empty database returns an empty quotes array.
#[ tokio::test ]
async fn integration_list_quotes_empty_db ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes" )
. 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 [ "quotes" ] . as_array ( ) . unwrap ( ) . len ( ) , 0 ) ;
assert_eq! ( v [ "total_count" ] , 0 ) ;
}
// ── Ticket fae330: POST /api/quotes/:id ───────────────────────────────────
/// Update succeeds when auth code is correct; updated fields are reflected.
#[ tokio::test ]
async fn integration_update_quote_success ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , auth ) =
create_quote_raw ( app , "Original text" , "Original Author" , & [ ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let payload = json ! ( { "text" : "Updated text" } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "Content-Type" , "application/json" )
. header ( "X-Auth-Code" , & auth )
. body ( Body ::from ( payload . to_string ( ) ) )
. 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 [ "text" ] , "Updated text" ) ;
// Author should remain unchanged
assert_eq! ( v [ "author" ] , "Original Author" ) ;
}
/// Update returns 403 when the wrong auth code is provided.
#[ tokio::test ]
async fn integration_update_quote_wrong_auth ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "Protected" , "Author" , & [ ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let payload = json ! ( { "text" : "Hacked" } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "Content-Type" , "application/json" )
. header ( "X-Auth-Code" , "definitely-wrong-code" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::FORBIDDEN ) ;
}
/// Update returns 404 for an ID that does not exist.
#[ tokio::test ]
async fn integration_update_quote_not_found ( ) {
let ( app , _f ) = test_router ( ) . await ;
let payload = json ! ( { "text" : "Ghost update" } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( "/api/quotes/no-such-id-anywhere" )
. header ( "Content-Type" , "application/json" )
. header ( "X-Auth-Code" , "any-code" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NOT_FOUND ) ;
}
/// Partial update: only the provided fields change; omitted optional fields
/// (tags in this case) remain unchanged.
#[ tokio::test ]
async fn integration_update_quote_partial_only_text_changes ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , auth ) =
create_quote_raw ( app , "Original" , "AuthorName" , & [ "keep-this-tag" ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
// Only update text; omit tags so they should remain
let payload = json ! ( { "text" : "New text" , "author" : "AuthorName" } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "Content-Type" , "application/json" )
. header ( "X-Auth-Code" , & auth )
. body ( Body ::from ( payload . to_string ( ) ) )
. 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 [ "text" ] , "New text" ) ;
// Tags not provided → tags remain unchanged
assert_eq! ( v [ "tags" ] , json ! ( [ "keep-this-tag" ] ) ) ;
}
/// Setting source to null in the update payload clears the field.
#[ tokio::test ]
async fn integration_update_quote_null_source_clears_it ( ) {
let ( app , _f ) = test_router ( ) . await ;
// Create a quote with a source
let payload = json ! ( {
"text" : "Sourced quote" ,
"author" : "Writer" ,
"source" : "Some Book"
} ) ;
let req = Request ::builder ( )
. method ( Method ::PUT )
. uri ( "/api/quotes" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::CREATED ) ;
let v = body_json ( resp ) . await ;
let id = v [ "quote" ] [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let auth = v [ "auth_code" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
// Now update with source: null to clear it
let update = json ! ( { "source" : null } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "Content-Type" , "application/json" )
. header ( "X-Auth-Code" , & auth )
. body ( Body ::from ( update . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert! (
v [ "source" ] . is_null ( ) ,
"source should be null after clearing"
) ;
}
// ── Ticket 8c87db: DELETE /api/quotes/:id ─────────────────────────────────
/// Delete returns 204 No Content when auth code matches.
#[ tokio::test ]
async fn integration_delete_quote_success ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , auth ) = create_quote_raw ( app , "Delete me" , "Author" , & [ ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "X-Auth-Code" , & auth )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NO_CONTENT ) ;
}
/// Delete returns 403 when auth code is wrong.
#[ tokio::test ]
async fn integration_delete_quote_wrong_auth ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , _auth ) = create_quote_raw ( app , "Protected" , "Author" , & [ ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "X-Auth-Code" , "totally-wrong-code-here" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::FORBIDDEN ) ;
}
/// Delete returns 404 for a non-existent ID.
#[ tokio::test ]
async fn integration_delete_quote_not_found ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( "/api/quotes/ghost-id-not-here" )
. header ( "X-Auth-Code" , "any" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NOT_FOUND ) ;
}
/// After a successful delete, GET /api/quotes/:id returns 404.
#[ tokio::test ]
async fn integration_delete_then_get_returns_404 ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , auth ) = create_quote_raw ( app , "Ephemeral" , "Author" , & [ ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
// Delete
let req = Request ::builder ( )
. method ( Method ::DELETE )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "X-Auth-Code" , & auth )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NO_CONTENT ) ;
// Now GET should 404
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/quotes/{id}" ) )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::NOT_FOUND ) ;
}
// ── Ticket 893eba: Tag operations ─────────────────────────────────────────
/// Tags provided on create appear in the GET response.
#[ tokio::test ]
async fn integration_tags_on_create_appear_in_get ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , _auth ) =
create_quote_raw ( app , "Tagged" , "Tagger" , & [ "alpha" , "beta" , "gamma" ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/quotes/{id}" ) )
. 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 ;
let mut tags : Vec < & str > = v [ "tags" ]
. as_array ( )
. unwrap ( )
. iter ( )
. map ( | t | t . as_str ( ) . unwrap ( ) )
. collect ( ) ;
tags . sort_unstable ( ) ;
assert_eq! ( tags , vec! [ "alpha" , "beta" , "gamma" ] ) ;
}
/// List quotes filtered by tag returns only quotes with that tag.
#[ tokio::test ]
async fn integration_tags_list_filter_by_tag ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Has tag" , "A" , & [ "special" ] ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "No tag" , "B" , & [ ] ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Other tag" , "C" , & [ "other" ] ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?tag=special" )
. 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 ) ;
assert_eq! ( v [ "quotes" ] [ 0 ] [ "text" ] , "Has tag" ) ;
}
/// Updating tags replaces the entire previous tag set.
#[ tokio::test ]
async fn integration_tags_update_replaces_all_previous_tags ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , quote , auth ) =
create_quote_raw ( app , "Retag me" , "Author" , & [ "old1" , "old2" ] ) . await ;
let id = quote [ "id" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let payload = json ! ( { "tags" : [ "new1" , "new2" , "new3" ] } ) ;
let req = Request ::builder ( )
. method ( Method ::POST )
. uri ( format! ( "/api/quotes/{id}" ) )
. header ( "Content-Type" , "application/json" )
. header ( "X-Auth-Code" , & auth )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
// Fetch the quote and verify only new tags are present
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( format! ( "/api/quotes/{id}" ) )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
let v = body_json ( resp ) . await ;
let mut tags : Vec < & str > = v [ "tags" ]
. as_array ( )
. unwrap ( )
. iter ( )
. map ( | t | t . as_str ( ) . unwrap ( ) )
. collect ( ) ;
tags . sort_unstable ( ) ;
assert_eq! ( tags , vec! [ "new1" , "new2" , "new3" ] ) ;
}
// ── Ticket e8f5cf: Router ordering ────────────────────────────────────────
/// GET /api/quotes/random must be dispatched to the random handler, not
/// the get-by-id handler. Verified by populating the DB and confirming a
/// 200 response (the random handler returns 200; get-by-id for the literal
/// string "random" would return 404 since no quote has that ID).
#[ tokio::test ]
async fn integration_router_random_not_matched_as_id ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_raw ( app , "Some quote" , "Some Author" , & [ ] ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes/random" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
// If router order were wrong, this would be 404 (no quote with id="random").
// Correct routing gives 200 because the random handler picks a real quote.
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
// The random handler returns the full Quote, not a CreateResponse
assert! ( v . get ( "id" ) . is_some ( ) , "should be a Quote, not an error" ) ;
}
}