use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header::HeaderName}, }; use pokedex_api::{ http::{AppState, RateLimiter, create_app}, pokemon::PokeApiClient, service::PokedexService, telemetry::Telemetry, translation::TranslationClient, }; use serde_json::Value; use std::time::Duration; use tower::ServiceExt; use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; #[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(&pokeapi, "mewtwo", true, "rare", "Created by science.").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": "Created by science.", "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); assert_eq!( json_body(response).await["error"], "pokemon name must contain only ASCII letters, digits, or hyphens" ); } #[tokio::test] async fn translated_endpoint_returns_translated_description() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/shakespeare.json")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "contents": { "translated": "Electric cheeks, prithee." } }))) .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["description"], "Electric cheeks, prithee." ); } #[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.json")) .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["description"], "It has no eyes."); } #[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; 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("/health")) .await .expect("request should be handled"); let second = app .oneshot(request("/health")) .await .expect("request should be handled"); assert_eq!(first.status(), StatusCode::OK); assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!(json_body(second).await["error"], "rate limit exceeded"); } #[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 .oneshot(request("/pokemon/nope")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(json_body(response).await["error"], "pokemon not found"); } 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") } 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") }