use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header::HeaderName}, }; use pokedex_api::{ application::PokedexService, clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient}, http::{AppState, RateLimiter, create_app}, telemetry::Telemetry, }; use serde_json::Value; use std::time::Duration; use tower::ServiceExt; use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; const POKEAPI_MEWTWO_SPECIES: &str = include_str!("fixtures/pokeapi_mewtwo_species.json"); const POKEAPI_PIKACHU_SPECIES: &str = include_str!("fixtures/pokeapi_pikachu_species.json"); const FUNTRANSLATIONS_YODA_MEWTWO: &str = include_str!("fixtures/funtranslations_yoda_mewtwo.json"); const FUNTRANSLATIONS_SHAKESPEARE_PIKACHU: &str = include_str!("fixtures/funtranslations_shakespeare_pikachu.json"); #[tokio::test] async fn health_endpoint_reports_ok() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/health")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "status": "ok" }) ); } #[tokio::test] async fn pokemon_endpoint_returns_basic_pokemon_information() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "name": "mewtwo", "description": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.", "habitat": "rare", "isLegendary": true }) ); } #[tokio::test] async fn pokemon_endpoint_rejects_invalid_names_before_calling_upstream() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/foo%3Fbar=baz")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = json_body(response).await; assert_eq!(body["code"], "bad_request"); assert_eq!( body["message"], "pokemon name must contain only ASCII letters, digits, or hyphens" ); assert!(body["requestId"].is_string()); } #[tokio::test] async fn translated_endpoint_returns_translated_description() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species_fixture(&pokeapi, "pikachu", POKEAPI_PIKACHU_SPECIES).await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/shakespeare")) .and(matchers::body_string_contains("Electric cheeks")) .respond_with( ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_SHAKESPEARE_PIKACHU), ) .expect(1) .mount(&translations) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/translated/pikachu")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "name": "pikachu", "description": "Electric cheeks, prithee.", "habitat": "forest", "isLegendary": false }) ); } #[tokio::test] async fn translated_endpoint_uses_yoda_for_legendary_pokemon() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species_fixture(&pokeapi, "mewtwo", POKEAPI_MEWTWO_SPECIES).await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/yoda")) .and(matchers::body_string_contains("horrific gene splicing")) .respond_with(ResponseTemplate::new(200).set_body_string(FUNTRANSLATIONS_YODA_MEWTWO)) .expect(1) .mount(&translations) .await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/shakespeare")) .respond_with(ResponseTemplate::new(200)) .expect(0) .mount(&translations) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/translated/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "name": "mewtwo", "description": "Created by science, it was.", "habitat": "rare", "isLegendary": true }) ); } #[tokio::test] async fn translated_endpoint_falls_back_to_original_description_when_translation_fails() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/yoda")) .respond_with(ResponseTemplate::new(500)) .mount(&translations) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/translated/zubat")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "name": "zubat", "description": "It has no eyes.", "habitat": "cave", "isLegendary": false }) ); } #[tokio::test] async fn translated_endpoint_falls_back_when_translation_is_empty() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/yoda")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "contents": { "translated": " " } }))) .expect(1) .mount(&translations) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/translated/zubat")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "name": "zubat", "description": "It has no eyes.", "habitat": "cave", "isLegendary": false }) ); } #[tokio::test] async fn every_response_has_a_request_id() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/health")) .await .expect("request should be handled"); assert!(response.headers().contains_key("x-request-id")); } #[tokio::test] async fn keeps_incoming_request_id_when_present() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let request = Request::builder() .uri("/health") .header(HeaderName::from_static("x-request-id"), "external-id") .body(Body::empty()) .expect("request should build"); let response = app .oneshot(request) .await .expect("request should be handled"); assert_eq!(response.headers()["x-request-id"], "external-id"); } #[tokio::test] async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .clone() .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); let response = app .oneshot(request("/metrics")) .await .expect("request should be handled"); let body = text_body(response).await; assert!(body.contains("http_server_requests")); assert!(body.contains("upstream_client_requests")); } #[tokio::test] async fn rate_limit_rejects_requests_over_the_configured_burst() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await; let telemetry = Telemetry::new("test-service"); let app = create_app(AppState::new( PokedexService::new( PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"), TranslationClient::new(translations.uri(), Duration::from_secs(1)) .expect("client should build"), telemetry.metrics(), ), telemetry, RateLimiter::new(0.01, 1).expect("rate limiter should build"), )); let first = app .clone() .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); let second = app .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(first.status(), StatusCode::OK); assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); let body = json_body(second).await; assert_eq!(body["code"], "rate_limit_exceeded"); assert_eq!(body["message"], "rate limit exceeded"); assert!(body["requestId"].is_string()); } #[tokio::test] async fn health_and_metrics_are_not_rate_limited() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await; let telemetry = Telemetry::new("test-service"); let app = create_app(AppState::new( PokedexService::new( PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"), TranslationClient::new(translations.uri(), Duration::from_secs(1)) .expect("client should build"), telemetry.metrics(), ), telemetry, RateLimiter::new(0.01, 1).expect("rate limiter should build"), )); let pokemon = app .clone() .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); let health = app .clone() .oneshot(request("/health")) .await .expect("request should be handled"); let metrics = app .oneshot(request("/metrics")) .await .expect("request should be handled"); assert_eq!(pokemon.status(), StatusCode::OK); assert_eq!(health.status(), StatusCode::OK); assert_eq!(metrics.status(), StatusCode::OK); } #[tokio::test] async fn pokemon_endpoint_maps_missing_pokemon_to_404() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/nope")) .respond_with(ResponseTemplate::new(404)) .mount(&pokeapi) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .clone() .oneshot(request_with_request_id("/pokemon/nope", "known-request-id")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.headers()["x-request-id"], "known-request-id"); let error = json_body(response).await; assert_eq!(error["code"], "pokemon_not_found"); assert_eq!(error["message"], "pokemon not found"); assert_eq!(error["requestId"], "known-request-id"); let metrics = app .oneshot(request("/metrics")) .await .expect("request should be handled"); let body = text_body(metrics).await; assert!(body.contains("status=\"not_found\"")); assert!(!body.contains("upstream_client_errors")); } #[tokio::test] async fn pokemon_endpoint_maps_pokeapi_500_to_502() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/mewtwo")) .respond_with(ResponseTemplate::new(500)) .mount(&pokeapi) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = json_body(response).await; assert_eq!(body["code"], "upstream_unavailable"); assert_eq!(body["message"], "upstream service failed"); } #[tokio::test] async fn pokemon_endpoint_maps_invalid_pokeapi_json_to_502() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/mewtwo")) .respond_with(ResponseTemplate::new(200).set_body_string("not-json")) .mount(&pokeapi) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = json_body(response).await; assert_eq!(body["code"], "upstream_invalid_data"); assert_eq!(body["message"], "upstream service returned invalid data"); } #[tokio::test] async fn translated_endpoint_does_not_call_funtranslations_when_pokeapi_fails() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/mewtwo")) .respond_with(ResponseTemplate::new(500)) .mount(&pokeapi) .await; Mock::given(matchers::method("POST")) .respond_with(ResponseTemplate::new(200)) .expect(0) .mount(&translations) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/translated/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } async fn mount_species_fixture(server: &MockServer, name: &str, body: &'static str) { Mock::given(matchers::method("GET")) .and(matchers::path(format!("/pokemon-species/{name}"))) .respond_with(ResponseTemplate::new(200).set_body_string(body)) .mount(server) .await; } async fn mount_species( server: &MockServer, name: &str, is_legendary: bool, habitat: &str, description: &str, ) { Mock::given(matchers::method("GET")) .and(matchers::path(format!("/pokemon-species/{name}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": name, "is_legendary": is_legendary, "habitat": { "name": habitat }, "flavor_text_entries": [ { "flavor_text": description, "language": { "name": "en" } } ] }))) .mount(server) .await; } fn state_for(pokeapi: &MockServer, translations: &MockServer) -> AppState { let telemetry = Telemetry::new("test-service"); AppState::new( PokedexService::new( PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"), TranslationClient::new(translations.uri(), Duration::from_secs(1)) .expect("client should build"), telemetry.metrics(), ), telemetry, RateLimiter::new(100.0, 100).expect("rate limiter should build"), ) } fn request(path: &str) -> Request { Request::builder() .uri(path) .body(Body::empty()) .expect("request should build") } fn request_with_request_id(path: &str, request_id: &str) -> Request { Request::builder() .uri(path) .header(HeaderName::from_static("x-request-id"), request_id) .body(Body::empty()) .expect("request should build") } async fn json_body(response: axum::response::Response) -> Value { let bytes = body_bytes(response).await; serde_json::from_slice(&bytes).expect("body should be valid JSON") } async fn text_body(response: axum::response::Response) -> String { String::from_utf8(body_bytes(response).await.to_vec()).expect("body should be valid UTF-8") } async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes { to_bytes(response.into_body(), 1024 * 1024) .await .expect("body should be readable") }