package com.ankurm.gateway; import static org.assertj.core.api.Assertions.assertThat; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import com.ankurm.gateway.support.FakeVendors; import com.ankurm.gateway.support.Transcript; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; /** The whole application over HTTP: real beans, real models, a fake at the far end. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class HttpTest { static final FakeVendors VENDORS; static { try { VENDORS = new FakeVendors(); } catch (java.io.IOException e) { throw new ExceptionInInitializerError(e); } } @DynamicPropertySource static void providers(DynamicPropertyRegistry r) { r.add("gateway.providers[0].name", () -> "openai"); r.add("gateway.providers[0].type", () -> "openai"); r.add("gateway.providers[0].base-url", () -> VENDORS.url() + "/v1"); r.add("gateway.providers[0].api-key", () -> "test-key"); r.add("gateway.providers[0].model", () -> "gpt-6.1-sol"); r.add("gateway.providers[0].input-price", () -> "2.00"); r.add("gateway.providers[0].output-price", () -> "10.00"); r.add("gateway.providers[1].name", () -> "anthropic"); r.add("gateway.providers[1].type", () -> "anthropic"); r.add("gateway.providers[1].base-url", () -> VENDORS.url()); r.add("gateway.providers[1].api-key", () -> "test-key"); r.add("gateway.providers[1].model", () -> "claude-sonnet-5-5"); r.add("gateway.providers[1].input-price", () -> "2.00"); r.add("gateway.providers[1].output-price", () -> "10.00"); r.add("gateway.tenants.poor.budget-micros", () -> "1000"); r.add("gateway.tenants.poor.tokens-per-hour", () -> "100000"); } @LocalServerPort int port; private final HttpClient http = HttpClient.newHttpClient(); @BeforeEach void reset() { VENDORS.openai.reset(); VENDORS.anthropic.reset(); } @AfterAll static void stop() { VENDORS.close(); } private HttpResponse post(String tenant, String json) throws Exception { HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/v1/gateway/complete")) .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(json)); if (tenant != null) { b.header("X-Tenant-Id", tenant); } return http.send(b.build(), HttpResponse.BodyHandlers.ofString()); } private static String body(String hint, String extra) { return "{\"featureTag\":\"support\",\"userMessage\":\"What is your return policy?\",\"modelHint\":\"" + hint + "\",\"maxTokens\":256" + extra + "}"; } @Test void statusCodesAndBodiesOverHttp() throws Exception { try (Transcript t = new Transcript("10-http.txt", "The gateway over HTTP (real beans, real models, fake vendors)")) { VENDORS.openai.then(200, FakeVendors.openaiText("30 days.", 47, 52)); HttpResponse ok = post("acme", body("smart", "")); t.line("1. normal call -> %d %s", ok.statusCode(), ok.body()); assertThat(ok.statusCode()).isEqualTo(200); assertThat(ok.body()).contains("\"provider\":\"openai\"").contains("\"costMicros\":614"); reset(); VENDORS.openai.then(503, FakeVendors.error("server_error", "overloaded")); VENDORS.anthropic.then(200, FakeVendors.anthropicText("30 days.", 47, 52)); HttpResponse failover = post("acme", body("smart", "")); t.line("2. openai returns 503 -> %d %s", failover.statusCode(), failover.body()); assertThat(failover.body()).contains("\"provider\":\"anthropic\""); reset(); VENDORS.openai.always(503, FakeVendors.error("server_error", "overloaded")); VENDORS.anthropic.always(529, FakeVendors.error("overloaded_error", "overloaded")); HttpResponse down = post("acme", body("smart", "")); t.line("3. both providers down -> %d Retry-After=%s %s", down.statusCode(), down.headers().firstValue("Retry-After").orElse("-"), down.body()); assertThat(down.statusCode()).isEqualTo(503); reset(); HttpResponse hint = post("acme", body("locla", "")); t.line("4. unknown hint -> %d %s", hint.statusCode(), hint.body()); assertThat(hint.statusCode()).isEqualTo(400); assertThat(VENDORS.openai.requests() + VENDORS.anthropic.requests()).isZero(); HttpResponse tool = post("acme", body("smart", ",\"tools\":[\"drop_tables\"]")); t.line("5. tool not allow-listed -> %d %s", tool.statusCode(), tool.body()); assertThat(tool.statusCode()).isEqualTo(400); HttpResponse poor = post("poor", body("smart", "")); t.line("6. tenant over its cap -> %d %s", poor.statusCode(), poor.body()); assertThat(poor.statusCode()).isEqualTo(429); assertThat(VENDORS.openai.requests() + VENDORS.anthropic.requests()).isZero(); HttpResponse spoof = post("poor", body("smart", ",\"tenantId\":\"acme\"")); t.line("7. body claims tenant acme, header says poor -> %d (the header wins)", spoof.statusCode()); assertThat(spoof.statusCode()).isEqualTo(429); HttpResponse none = post(null, body("smart", "")); t.line("8. no X-Tenant-Id header -> %d", none.statusCode()); assertThat(none.statusCode()).isEqualTo(400); } } }