632 lines
24 KiB
Go
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)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|