fix: cover api edge cases

This commit is contained in:
2026-06-13 10:44:47 +02:00
parent aa3b678cc5
commit ce959b59bf
15 changed files with 400 additions and 36 deletions

View File

@@ -80,9 +80,11 @@ async fn translated_endpoint_returns_translated_description() {
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare.json"))
.and(matchers::body_string_contains("Electric+cheeks"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Electric cheeks, prithee." }
})))
.expect(1)
.mount(&translations)
.await;
let app = create_app(state_for(&pokeapi, &translations));
@@ -94,8 +96,52 @@ async fn translated_endpoint_returns_translated_description() {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
json_body(response).await["description"],
"Electric cheeks, prithee."
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(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda.json"))
.and(matchers::body_string_contains("Created+by+science"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Created by science, it was." }
})))
.expect(1)
.mount(&translations)
.await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare.json"))
.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
})
);
}
@@ -117,7 +163,47 @@ async fn translated_endpoint_falls_back_to_original_description_when_translation
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["description"], "It has no eyes.");
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.json"))
.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]
@@ -180,6 +266,7 @@ async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
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(
@@ -194,11 +281,11 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
let first = app
.clone()
.oneshot(request("/health"))
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
let second = app
.oneshot(request("/health"))
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
@@ -207,6 +294,43 @@ async fn rate_limit_rejects_requests_over_the_configured_burst() {
assert_eq!(json_body(second).await["error"], "rate limit exceeded");
}
#[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;
@@ -219,12 +343,91 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.clone()
.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");
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);
assert_eq!(
json_body(response).await["error"],
"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);
assert_eq!(
json_body(response).await["error"],
"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(

View File

@@ -1,4 +1,4 @@
use pokedex_api::telemetry::Telemetry;
use pokedex_api::telemetry::{Telemetry, UpstreamRequestOutcome};
use std::time::Duration;
#[test]
@@ -7,7 +7,12 @@ fn records_http_and_upstream_metrics_in_prometheus_text_format() {
let metrics = telemetry.metrics();
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
metrics.record_upstream_request("pokeapi", "pokemon_species", true, Duration::from_millis(5));
metrics.record_upstream_request(
"pokeapi",
"pokemon_species",
UpstreamRequestOutcome::Success,
Duration::from_millis(5),
);
let rendered = telemetry
.render_prometheus()
.expect("metrics should render as text");