diff --git a/src/app.rs b/src/app.rs index 635000e..a2c4864 100644 --- a/src/app.rs +++ b/src/app.rs @@ -154,293 +154,3 @@ async fn get_translated_pokemon( struct HealthResponse { status: &'static str, } - -#[cfg(test)] -mod tests { - use super::{AppState, create_app}; - use crate::{ - pokemon::PokeApiClient, rate_limit::RateLimiter, service::PokedexService, - telemetry::Telemetry, translation::TranslationClient, - }; - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header::HeaderName}, - }; - 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") - } -} diff --git a/tests/app.rs b/tests/app.rs new file mode 100644 index 0000000..23e9cad --- /dev/null +++ b/tests/app.rs @@ -0,0 +1,287 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header::HeaderName}, +}; +use pokedex_api::{ + app::{AppState, create_app}, + pokemon::PokeApiClient, + rate_limit::RateLimiter, + 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") +}