// Package bot — Milestone 3 deterministic transport tests for the // FastAPI agent service client. // // These tests use httptest.NewServer ONLY for the deterministic cases // plan §10 M3 carves out: timeouts, malformed JSON, status-code retry // matrix, context cancellation, and the application-error path. Live // application-level success tests live in fastapi_live_test.go and are // gated by CHATBOT_LOCAL_FASTAPI_URL. // // Compile contract: this file references bot.NewFastAPIClient, // bot.FastAPIClient, bot.ChatRequest, bot.ChatResponse, bot.ChatScope, // bot.ChatCallResult, bot.HealthResponse, bot.ConversationMessage, // bot.Metadata, bot.AgentTelemetry, bot.Answer, bot.ErrorPayload, // bot.WithBaseURL, bot.WithHTTPClient, bot.AttemptCounter, // bot.ErrAgentApplicationError, bot.ErrAgentMissingAnswer, // bot.ErrAgentInvalidResponse, bot.ErrHealthStatusNotOK, // bot.ErrHealthMissingService — none of which exist yet. Backend-eng // adds fastapi.go and the package compiles. Until then the file fails // to compile — the failing state plan §10 M3 endorses. // // Plan reference: plans/chatbot_plan_codex.v10.md §7 (FastAPI Integration). // // Retry policy verbatim from §7: // - Up to 3 attempts. // - Retry HTTP 429, 500, 502, 503, 504, and transport timeouts. // - Do not retry other 4xx responses. // - Do not retry HTTP 200 with status:"error". // - Reuse the same request_id for every attempt. // - One parent context enforces total wall time. package bot_test import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "sync" "sync/atomic" "testing" "time" "queryorchestration/internal/bot" "queryorchestration/internal/serviceconfig/chatbot" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // fastAPITestRequestID is the deterministic request_id every test sends // so the captured incoming-request assertions are reproducible. Plan §7: // request_id format is "{sessionId}:{ordinal}" — the exact session/ordinal // values do not matter at the client layer; the client must reuse the // caller-supplied value verbatim across retries. const fastAPITestRequestID = "00000000-0000-0000-0000-000000000001:1" // shortTimeoutCfg returns a ChatbotConfig whose RequestTimeoutSeconds is // 1 so timeout tests do not have to wait the production default of 60s. // Tests that exercise the retry matrix without timeout pressure can use // the same config; per-attempt timeout is a ceiling, not a floor. func shortTimeoutCfg(baseURL string) chatbot.ChatbotConfig { return chatbot.ChatbotConfig{ ServiceURL: baseURL, APIKey: `{"AGENT_SERVICE_API_KEY":"value"}`, RequestTimeoutSeconds: 1, MaxTurnChars: 2000, RecentTurns: 5, } } // validChatRequest returns a ChatRequest that satisfies the swagger's // required-field set. Used by retry tests where the response shape is // what we are exercising, not the request body. func validChatRequest() bot.ChatRequest { return bot.ChatRequest{ RequestID: fastAPITestRequestID, UserID: "test-user", SessionID: "00000000-0000-0000-0000-000000000001", Message: "hello", ConversationHistory: []bot.ConversationMessage{}, Scope: bot.ChatScope{ DocumentIDs: []string{}, FolderIDs: []string{}, }, } } // successChatResponseBody returns a minimally-valid 200 response body // matching the swagger's "Successful Response" shape. func successChatResponseBody(t testing.TB, requestID string) []byte { t.Helper() body := bot.ChatResponse{ RequestID: requestID, Status: "success", Answer: &bot.Answer{ Text: "the answer", ResultType: "summary", Confidence: 0.95, FollowUpSuggestions: []string{}, }, } out, err := json.Marshal(body) require.NoError(t, err) return out } // errorChatResponseBody returns a 200 response body whose status is // "error" — which per plan §7 must NOT be retried. func errorChatResponseBody(t testing.TB, requestID, code, message string) []byte { t.Helper() body := bot.ChatResponse{ RequestID: requestID, Status: "error", Error: &bot.ErrorPayload{ Code: code, Message: message, }, } out, err := json.Marshal(body) require.NoError(t, err) return out } // flakeServer returns an httptest.Server whose /agent/chat endpoint // emits the responses in order and increments a request counter so // tests can read AttemptCount and the captured request bodies. type flakeServer struct { server *httptest.Server receivedBodies []bot.ChatRequest receivedHeader []http.Header mu sync.Mutex hits int32 } // newFlakeServer constructs a httptest.Server that emits the supplied // (status, body) pairs in order. After the last pair the server keeps // returning the final pair so a buggy retry loop is visible as // AttemptCount > len(pairs). func newFlakeServer(t testing.TB, responses []flakeResponse) *flakeServer { t.Helper() fs := &flakeServer{} fs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { idx := int(atomic.AddInt32(&fs.hits, 1)) - 1 var capture bot.ChatRequest bodyBytes, _ := io.ReadAll(r.Body) _ = r.Body.Close() _ = json.Unmarshal(bodyBytes, &capture) fs.mu.Lock() fs.receivedBodies = append(fs.receivedBodies, capture) fs.receivedHeader = append(fs.receivedHeader, r.Header.Clone()) fs.mu.Unlock() resp := responses[idx] if idx >= len(responses) { resp = responses[len(responses)-1] } if resp.delay > 0 { select { case <-time.After(resp.delay): case <-r.Context().Done(): return } } w.WriteHeader(resp.status) _, _ = w.Write(resp.body) })) t.Cleanup(fs.server.Close) return fs } // flakeResponse is one entry in the staged-response list a flakeServer // returns. delay simulates a slow upstream so timeout tests do not need // a separate harness. type flakeResponse struct { status int body []byte delay time.Duration } // asAttemptCounter returns the AttemptCount embedded in the err if it // implements bot.AttemptCounter. Tests use this to assert the retry // loop exhausted (or did not exhaust) the configured attempts. func asAttemptCounter(t testing.TB, err error) int { t.Helper() require.Error(t, err, "expected AttemptCounter-bearing error, got nil") var counter bot.AttemptCounter if !errors.As(err, &counter) { t.Fatalf("error %T does not implement bot.AttemptCounter: %v", err, err) } return counter.AttemptCount() } // ---------- retry matrix ---------- // TestFastAPIClient_Retry429ThenSuccess: 2x429 then 200 -> success on // the third attempt. AttemptCount=3. func TestFastAPIClient_Retry429ThenSuccess(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusTooManyRequests, body: []byte(`{"error":"slow"}`)}, {status: http.StatusTooManyRequests, body: []byte(`{"error":"slow"}`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) require.NotNil(t, result.Response) assert.Equal(t, "success", result.Response.Status) assert.Equal(t, 3, result.AttemptCount, "two 429s then 200 -> 3 attempts") } // TestFastAPIClient_Retry500ThenSuccess: 1x500 then 200 -> AttemptCount=2. func TestFastAPIClient_Retry500ThenSuccess(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) assert.Equal(t, 2, result.AttemptCount) } // TestFastAPIClient_Retry502ThenSuccess: 1x502 then 200 -> AttemptCount=2. func TestFastAPIClient_Retry502ThenSuccess(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusBadGateway, body: []byte(`bad gateway`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) assert.Equal(t, 2, result.AttemptCount) } // TestFastAPIClient_Retry503ExhaustsAttempts: 3x503 -> final transport // error after exhausting attempts. AttemptCount=3. func TestFastAPIClient_Retry503ExhaustsAttempts(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusServiceUnavailable, body: []byte(`unavailable`)}, {status: http.StatusServiceUnavailable, body: []byte(`unavailable`)}, {status: http.StatusServiceUnavailable, body: []byte(`unavailable`)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err, "exhausted retries must surface as a non-nil error") assert.Equal(t, 3, asAttemptCounter(t, err), "plan §7: max attempts is 3") } // TestFastAPIClient_Retry504ThenSuccess: 1x504 then 200 -> AttemptCount=2. func TestFastAPIClient_Retry504ThenSuccess(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusGatewayTimeout, body: []byte(`timeout`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) assert.Equal(t, 2, result.AttemptCount) } // TestFastAPIClient_TimeoutRetried: server hangs longer than the // per-request timeout (cfg.RequestTimeoutSeconds=1). Each attempt fires // the per-attempt timeout; the retry loop attempts up to the limit and // fails with AttemptCount=3. func TestFastAPIClient_TimeoutRetried(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID), delay: 5 * time.Second}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID), delay: 5 * time.Second}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID), delay: 5 * time.Second}, }) // Parent context budget: 10s, well above the aggregate of three 1s // per-attempt timeouts. The client respects the per-attempt // timeout, not the parent ctx, for retry decisions. parentCtx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(parentCtx, validChatRequest()) require.Error(t, err, "all attempts time out -> non-nil error") assert.Equal(t, 3, asAttemptCounter(t, err)) } // TestFastAPIClient_4xxNotRetried covers 400/401/403/404. Each must // return immediately with AttemptCount=1. func TestFastAPIClient_4xxNotRetried(t *testing.T) { cases := []struct { name string status int }{ {name: "400_bad_request", status: http.StatusBadRequest}, {name: "401_unauthorized", status: http.StatusUnauthorized}, {name: "403_forbidden", status: http.StatusForbidden}, {name: "404_not_found", status: http.StatusNotFound}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: c.status, body: []byte(`{"error":"nope"}`)}, // A second successful entry so a buggy retry would be // caught by AttemptCount > 1. {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err) assert.Equal(t, 1, asAttemptCounter(t, err), "plan §7: do not retry %d", c.status) }) } } // TestFastAPIClient_RequestIDStableAcrossAttempts: same request_id used // on every attempt. Captures every incoming request via the test server // and asserts the request_id matches across all of them. func TestFastAPIClient_RequestIDStableAcrossAttempts(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusServiceUnavailable, body: []byte(`unavailable`)}, {status: http.StatusServiceUnavailable, body: []byte(`unavailable`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) req := validChatRequest() req.RequestID = "session-abc:7" _, err := client.Chat(t.Context(), req) require.NoError(t, err) require.Len(t, fs.receivedBodies, 3, "must observe 3 attempts") for i, captured := range fs.receivedBodies { assert.Equal(t, "session-abc:7", captured.RequestID, "plan §7: same request_id on every attempt; attempt %d had %q", i+1, captured.RequestID) } } // TestFastAPIClient_ContextCancelStopsRetry: parent ctx canceled // mid-retry -> no further attempts; error contains context.Canceled. func TestFastAPIClient_ContextCancelStopsRetry(t *testing.T) { // Each response delays 200ms so we have a window to cancel the ctx // between the first and second attempts. fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusServiceUnavailable, body: []byte(`unavailable`), delay: 200 * time.Millisecond}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) parentCtx, cancel := context.WithCancel(t.Context()) // Cancel while the first attempt is in flight. go func() { time.Sleep(50 * time.Millisecond) cancel() }() client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(parentCtx, validChatRequest()) require.Error(t, err, "canceled parent ctx must surface as a non-nil error") assert.True(t, errors.Is(err, context.Canceled), "expected wrapped context.Canceled, got %v", err) hits := atomic.LoadInt32(&fs.hits) assert.LessOrEqualf(t, hits, int32(2), "cancel must stop the retry loop; saw %d attempts", hits) } // TestFastAPIClient_TotalWallTimeBounded: parent ctx deadline shorter // than aggregate retry budget -> client respects the deadline; the // final AttemptCount is below the max. func TestFastAPIClient_TotalWallTimeBounded(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusServiceUnavailable, body: []byte(`unavailable`), delay: 800 * time.Millisecond}, {status: http.StatusServiceUnavailable, body: []byte(`unavailable`), delay: 800 * time.Millisecond}, {status: http.StatusServiceUnavailable, body: []byte(`unavailable`), delay: 800 * time.Millisecond}, }) // Total budget is 600ms — less than even one 800ms attempt. The // client must not loop past the parent deadline. parentCtx, cancel := context.WithTimeout(t.Context(), 600*time.Millisecond) defer cancel() client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(parentCtx, validChatRequest()) require.Error(t, err, "deadline-exceeded parent ctx must propagate") count := asAttemptCounter(t, err) assert.Lessf(t, count, 3, "plan §7: parent ctx caps total wall time; saw AttemptCount=%d", count) } // TestFastAPIClient_MalformedJSONFails: 200 with a non-JSON body maps // to a typed parse error; not retried. func TestFastAPIClient_MalformedJSONFails(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: []byte(`not json`)}, // Second entry would be returned if a buggy client retried. {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err) assert.True(t, errors.Is(err, bot.ErrAgentInvalidResponse), "malformed JSON must wrap ErrAgentInvalidResponse, got %v", err) assert.Equal(t, 1, asAttemptCounter(t, err), "malformed JSON is not retried") } // ---------- application-error paths ---------- // TestFastAPIClient_200StatusErrorNotRetried: 200 with status:"error" // maps to ErrAgentApplicationError; not retried; the FastAPI error JSON // is preserved on the wrapped error so tx2 can persist it. func TestFastAPIClient_200StatusErrorNotRetried(t *testing.T) { body := errorChatResponseBody(t, fastAPITestRequestID, "validation_failed", "missing entity") fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: body}, // Second entry would be a 200/success — observable as // AttemptCount=2 if the client incorrectly retried. {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err) assert.True(t, errors.Is(err, bot.ErrAgentApplicationError), "200 status:error must wrap ErrAgentApplicationError") assert.Equal(t, 1, asAttemptCounter(t, err), "plan §7: 200 status:error is not retried") assert.Contains(t, err.Error(), "validation_failed", "FastAPI error code must surface in the wrapped error") } // TestFastAPIClient_AnswerTextEmpty covers the answer.text validation: // empty / whitespace-only / null answer maps to ErrAgentMissingAnswer // and is not retried. func TestFastAPIClient_AnswerTextEmpty(t *testing.T) { cases := []struct { name string body []byte }{ { name: "empty_string", body: []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success","answer":{"text":"","result_type":"summary","confidence":0.5}}`), }, { name: "whitespace_only", body: []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success","answer":{"text":" \t\n ","result_type":"summary","confidence":0.5}}`), }, { name: "answer_null", body: []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success","answer":null}`), }, { name: "answer_missing", body: []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success"}`), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: c.body}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err) assert.True(t, errors.Is(err, bot.ErrAgentMissingAnswer), "empty/whitespace/null answer must wrap ErrAgentMissingAnswer, got %v", err) assert.Equal(t, 1, asAttemptCounter(t, err), "agent_missing_answer is not retried") }) } } // ---------- metadata + state handling ---------- // TestFastAPIClient_MissingMetadataUsesWallClock: 200 response with no // metadata field. The client always populates SentAt and ReceivedAt on // ChatCallResult so the caller (service layer) can fall back to wall // clock when metadata is absent. func TestFastAPIClient_MissingMetadataUsesWallClock(t *testing.T) { body := []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success","answer":{"text":"ok","result_type":"summary","confidence":0.9}}`) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: body}, }) before := time.Now() client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) after := time.Now() require.NoError(t, err) assert.False(t, result.SentAt.IsZero(), "client must populate SentAt") assert.False(t, result.ReceivedAt.IsZero(), "client must populate ReceivedAt") assert.Truef(t, !result.SentAt.Before(before), "SentAt %v must not be before before=%v", result.SentAt, before) assert.Truef(t, !result.ReceivedAt.After(after), "ReceivedAt %v must not be after after=%v", result.ReceivedAt, after) assert.Truef(t, !result.ReceivedAt.Before(result.SentAt), "ReceivedAt %v must not be before SentAt %v", result.ReceivedAt, result.SentAt) require.NotNil(t, result.Response) assert.Nil(t, result.Response.Metadata, "missing metadata field must deserialize to nil so the service can fall back to wall clock") } // TestFastAPIClient_UpdatedSessionStateNullPreservesPrior: response // updated_session_state == null. The client must distinguish null from // explicit {} so the service layer can apply plan §7's preserve-prior // semantics. func TestFastAPIClient_UpdatedSessionStateNullPreservesPrior(t *testing.T) { body := []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success","answer":{"text":"ok","result_type":"summary","confidence":0.9},"updated_session_state":null}`) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: body}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) require.NotNil(t, result.Response) assert.Nil(t, result.Response.UpdatedSessionState, "explicit null must deserialize to a nil UpdatedSessionState so the caller can preserve prior state") } // TestFastAPIClient_UpdatedSessionStateExplicitEmptyOverwrites: response // updated_session_state == {}. Distinguishable from null; service layer // will treat as overwrite. func TestFastAPIClient_UpdatedSessionStateExplicitEmptyOverwrites(t *testing.T) { body := []byte(`{"request_id":"` + fastAPITestRequestID + `","status":"success","answer":{"text":"ok","result_type":"summary","confidence":0.9},"updated_session_state":{}}`) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: body}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) result, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) require.NotNil(t, result.Response) assert.NotNil(t, result.Response.UpdatedSessionState, "explicit empty object must deserialize to a non-nil UpdatedSessionState") } // ---------- header + auth ---------- // TestFastAPIClient_SendsAPIKeyHeader: every request carries the // configured X-API-Key header verbatim. Plan §7 ("API key header") + // plan §11 ("Header literal: X-API-Key: ..."). func TestFastAPIClient_SendsAPIKeyHeader(t *testing.T) { fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) const apiKeyValue = `{"AGENT_SERVICE_API_KEY":"value"}` cfg := shortTimeoutCfg(fs.server.URL) cfg.APIKey = apiKeyValue client := bot.NewFastAPIClient(cfg) _, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) require.Len(t, fs.receivedHeader, 1) assert.Equal(t, apiKeyValue, fs.receivedHeader[0].Get("X-API-Key"), "every request must carry X-API-Key with the configured value verbatim") } // ---------- health smoke ---------- // TestFastAPIClient_HealthSmokeOK: /health returns 200 + ok shape -> nil. func TestFastAPIClient_HealthSmokeOK(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok","service":"aaria-chatbot","version":"0.1.0"}`)) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) client := bot.NewFastAPIClient(shortTimeoutCfg(server.URL)) require.NoError(t, client.Health(t.Context())) } // TestFastAPIClient_HealthSmokeStatusNotOK: non-ok status -> typed error. func TestFastAPIClient_HealthSmokeStatusNotOK(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"degraded","service":"aaria-chatbot","version":"0.1.0"}`)) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) client := bot.NewFastAPIClient(shortTimeoutCfg(server.URL)) err := client.Health(t.Context()) require.Error(t, err) assert.True(t, errors.Is(err, bot.ErrHealthStatusNotOK), "non-ok status must wrap ErrHealthStatusNotOK, got %v", err) } // TestFastAPIClient_HealthSmokeMissingService: empty service or version // -> typed error. Plan §7: "service and version must be non-empty". func TestFastAPIClient_HealthSmokeMissingService(t *testing.T) { cases := []struct { name string body string }{ {name: "empty_service", body: `{"status":"ok","service":"","version":"0.1.0"}`}, {name: "empty_version", body: `{"status":"ok","service":"aaria-chatbot","version":""}`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(c.body)) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) client := bot.NewFastAPIClient(shortTimeoutCfg(server.URL)) err := client.Health(t.Context()) require.Error(t, err) assert.True(t, errors.Is(err, bot.ErrHealthMissingService), "empty service or version must wrap ErrHealthMissingService, got %v", err) }) } }