From ce065c932adb83a886918589ae1d0a6fcc576e28 Mon Sep 17 00:00:00 2001 From: Alberto Moretti Date: Tue, 16 Jun 2026 14:49:22 +0200 Subject: [PATCH] test: add api and provider coverage --- tests/app.rs | 481 ++++++++++++++++++ tests/external_contract.rs | 56 ++ .../funtranslations_shakespeare_pikachu.json | 8 + .../fixtures/funtranslations_yoda_mewtwo.json | 8 + tests/fixtures/pokeapi_mewtwo_species.json | 11 + tests/fixtures/pokeapi_pikachu_species.json | 11 + 6 files changed, 575 insertions(+) create mode 100644 tests/app.rs create mode 100644 tests/external_contract.rs create mode 100644 tests/fixtures/funtranslations_shakespeare_pikachu.json create mode 100644 tests/fixtures/funtranslations_yoda_mewtwo.json create mode 100644 tests/fixtures/pokeapi_mewtwo_species.json create mode 100644 tests/fixtures/pokeapi_pikachu_species.json diff --git a/tests/app.rs b/tests/app.rs new file mode 100644 index 0000000..1f72214 --- /dev/null +++ b/tests/app.rs @@ -0,0 +1,481 @@ +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 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"); +} + +#[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 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/external_contract.rs b/tests/external_contract.rs new file mode 100644 index 0000000..480e150 --- /dev/null +++ b/tests/external_contract.rs @@ -0,0 +1,56 @@ +use pokedex_api::{ + clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient}, + domain::TranslationKind, +}; +use std::time::Duration; + +const POKEAPI_BASE_URL: &str = "https://pokeapi.co/api/v2"; +const FUN_TRANSLATIONS_BASE_URL: &str = "https://api.funtranslations.mercxry.me/v1"; +const CONTRACT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +#[ignore = "nightly external contract test: calls the real PokéAPI"] +async fn pokeapi_species_contract_still_matches_what_we_parse() { + let client = + PokeApiClient::new(POKEAPI_BASE_URL, CONTRACT_TIMEOUT).expect("client should build"); + + let pokemon = client + .get_pokemon_info("mewtwo") + .await + .expect("PokéAPI species contract should still parse"); + + assert_eq!(pokemon.name, "mewtwo"); + assert_eq!(pokemon.habitat, "rare"); + assert!(pokemon.is_legendary); + assert!(!pokemon.description.trim().is_empty()); +} + +#[tokio::test] +#[ignore = "nightly external contract test: calls the real FunTranslations API"] +async fn funtranslations_yoda_contract_still_matches_what_we_parse() { + let client = TranslationClient::new(FUN_TRANSLATIONS_BASE_URL, CONTRACT_TIMEOUT) + .expect("client should build"); + + let translated = client + .translate(TranslationKind::Yoda, "It was created by science.") + .await + .expect("FunTranslations Yoda contract should still parse"); + + assert!(!translated.trim().is_empty()); + assert_ne!(translated, "It was created by science."); +} + +#[tokio::test] +#[ignore = "nightly external contract test: calls the real FunTranslations API"] +async fn funtranslations_shakespeare_contract_still_matches_what_we_parse() { + let client = TranslationClient::new(FUN_TRANSLATIONS_BASE_URL, CONTRACT_TIMEOUT) + .expect("client should build"); + + let translated = client + .translate(TranslationKind::Shakespeare, "You have a great friend.") + .await + .expect("FunTranslations Shakespeare contract should still parse"); + + assert!(!translated.trim().is_empty()); + assert_ne!(translated, "You have a great friend."); +} diff --git a/tests/fixtures/funtranslations_shakespeare_pikachu.json b/tests/fixtures/funtranslations_shakespeare_pikachu.json new file mode 100644 index 0000000..8ae79a7 --- /dev/null +++ b/tests/fixtures/funtranslations_shakespeare_pikachu.json @@ -0,0 +1,8 @@ +{ + "success": { "total": 1 }, + "contents": { + "translated": "Electric cheeks, prithee.", + "text": "Electric cheeks.", + "translation": "shakespeare" + } +} diff --git a/tests/fixtures/funtranslations_yoda_mewtwo.json b/tests/fixtures/funtranslations_yoda_mewtwo.json new file mode 100644 index 0000000..2a53f33 --- /dev/null +++ b/tests/fixtures/funtranslations_yoda_mewtwo.json @@ -0,0 +1,8 @@ +{ + "success": { "total": 1 }, + "contents": { + "translated": "Created by science, it was.", + "text": "Created by science.", + "translation": "yoda" + } +} diff --git a/tests/fixtures/pokeapi_mewtwo_species.json b/tests/fixtures/pokeapi_mewtwo_species.json new file mode 100644 index 0000000..b8c790e --- /dev/null +++ b/tests/fixtures/pokeapi_mewtwo_species.json @@ -0,0 +1,11 @@ +{ + "name": "mewtwo", + "is_legendary": true, + "habitat": { "name": "rare" }, + "flavor_text_entries": [ + { + "flavor_text": "It was created by a scientist after years of horrific gene splicing and DNA engineering experiments.", + "language": { "name": "en" } + } + ] +} diff --git a/tests/fixtures/pokeapi_pikachu_species.json b/tests/fixtures/pokeapi_pikachu_species.json new file mode 100644 index 0000000..a62352b --- /dev/null +++ b/tests/fixtures/pokeapi_pikachu_species.json @@ -0,0 +1,11 @@ +{ + "name": "pikachu", + "is_legendary": false, + "habitat": { "name": "forest" }, + "flavor_text_entries": [ + { + "flavor_text": "Electric cheeks.", + "language": { "name": "en" } + } + ] +}