Files
Jay Brown 72de690894 Merged in feature/chatbot1 (pull request #223)
Chatbot functionality

* baseline working

* missing test file

* more tests
2026-05-07 20:56:18 +00:00

632 lines
24 KiB
Go

// 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(`<html>not json</html>`)},
// 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)
})
}
}