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

276 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package bot — slog observability tests for the FastAPI client.
//
// Plan reference: plans/chatbot.stuff/fix.chat.bugs.1.md §2 (Issue #1).
// Captures slog records from FastAPIClient.Chat via slog.SetDefault
// swap, then asserts the documented level / message / attribute set
// for the `bot.fastapi.attempt`, `bot.fastapi.exhausted`, and
// `bot.fastapi.context_canceled` events.
//
// Tests in this file MUST NOT call t.Parallel: they swap the process
// default slog logger for the duration of the test and restore it on
// cleanup. The other tests in fastapi_test.go also do not run in
// parallel, so the default-logger swap is safe within this package.
package bot_test
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"sync"
"testing"
"time"
"queryorchestration/internal/bot"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingHandler is a slog.Handler that appends every record into an
// in-memory slice. Used to assert which events the FastAPI retry loop
// emits and what attributes they carry.
type recordingHandler struct {
mu sync.Mutex
records []recordedEvent
}
// recordedEvent is one captured slog record. The handler flattens
// attributes into a map[string]any so tests can read them by key.
type recordedEvent struct {
level slog.Level
message string
attrs map[string]any
}
func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true
}
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
attrs := make(map[string]any, r.NumAttrs())
r.Attrs(func(a slog.Attr) bool {
attrs[a.Key] = a.Value.Any()
return true
})
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, recordedEvent{
level: r.Level,
message: r.Message,
attrs: attrs,
})
return nil
}
func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
// snapshot returns a copy of the captured records for assertion.
func (h *recordingHandler) snapshot() []recordedEvent {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]recordedEvent, len(h.records))
copy(out, h.records)
return out
}
// installRecordingLogger replaces slog.Default with a recording handler
// for the duration of the test. The previous default is restored via
// t.Cleanup so other tests are unaffected.
func installRecordingLogger(t *testing.T) *recordingHandler {
t.Helper()
h := &recordingHandler{}
prev := slog.Default()
slog.SetDefault(slog.New(h))
t.Cleanup(func() {
slog.SetDefault(prev)
})
return h
}
// eventsByMessage filters captured records by message name.
func eventsByMessage(records []recordedEvent, message string) []recordedEvent {
out := make([]recordedEvent, 0, len(records))
for _, r := range records {
if r.message == message {
out = append(out, r)
}
}
return out
}
// TestFastAPIClient_SlogAttempt_Success: 200 success → exactly one
// bot.fastapi.attempt event at DEBUG with outcome=success and attempt=1.
// Plan §2.4 case 1.
func TestFastAPIClient_SlogAttempt_Success(t *testing.T) {
rec := installRecordingLogger(t)
fs := newFlakeServer(t, []flakeResponse{
{status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)},
})
client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL))
_, err := client.Chat(t.Context(), validChatRequest())
require.NoError(t, err)
attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt")
require.Len(t, attempts, 1, "expected exactly one bot.fastapi.attempt event on a single success")
ev := attempts[0]
assert.Equal(t, slog.LevelDebug, ev.level,
"plan §8 B2: success-path attempts log at DEBUG")
assert.Equal(t, "success", ev.attrs["outcome"])
assert.EqualValues(t, 1, ev.attrs["attempt"])
assert.EqualValues(t, http.StatusOK, ev.attrs["status_code"])
assert.Equal(t, fastAPITestRequestID, ev.attrs["request_id"])
_, hasError := ev.attrs["error"]
assert.False(t, hasError, "success records must not include an error attr")
assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted"))
assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.context_canceled"))
}
// TestFastAPIClient_SlogAttempt_RetryThenSuccess: 500 → 500 → 200 emits
// three bot.fastapi.attempt events (two retryable_failure WARN, one
// success DEBUG); no exhausted event. Plan §2.4 case 2.
func TestFastAPIClient_SlogAttempt_RetryThenSuccess(t *testing.T) {
rec := installRecordingLogger(t)
fs := newFlakeServer(t, []flakeResponse{
{status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)},
{status: http.StatusInternalServerError, body: []byte(`{"error":"oops again"}`)},
{status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)},
})
client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL))
_, err := client.Chat(t.Context(), validChatRequest())
require.NoError(t, err)
attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt")
require.Len(t, attempts, 3)
assert.Equal(t, slog.LevelWarn, attempts[0].level)
assert.Equal(t, "retryable_failure", attempts[0].attrs["outcome"])
assert.EqualValues(t, http.StatusInternalServerError, attempts[0].attrs["status_code"])
assert.Equal(t, slog.LevelWarn, attempts[1].level)
assert.Equal(t, "retryable_failure", attempts[1].attrs["outcome"])
assert.Equal(t, slog.LevelDebug, attempts[2].level)
assert.Equal(t, "success", attempts[2].attrs["outcome"])
assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted"),
"a successful retry must not emit an exhausted event")
}
// TestFastAPIClient_SlogAttempt_Exhausted: 500×3 emits three attempt
// records and one exhausted record. Plan §2.4 case 3.
func TestFastAPIClient_SlogAttempt_Exhausted(t *testing.T) {
rec := installRecordingLogger(t)
fs := newFlakeServer(t, []flakeResponse{
{status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)},
{status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)},
{status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)},
})
client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL))
_, err := client.Chat(t.Context(), validChatRequest())
require.Error(t, err)
attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt")
assert.Len(t, attempts, 3)
for i, a := range attempts {
assert.Equalf(t, slog.LevelWarn, a.level, "attempt %d level", i+1)
assert.Equalf(t, "retryable_failure", a.attrs["outcome"], "attempt %d outcome", i+1)
}
exhausted := eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted")
require.Len(t, exhausted, 1, "exhausted retry budget must emit exactly one event")
ex := exhausted[0]
assert.Equal(t, slog.LevelError, ex.level)
assert.EqualValues(t, 3, ex.attrs["attempts"])
assert.EqualValues(t, http.StatusInternalServerError, ex.attrs["last_status"])
if errMsg, ok := ex.attrs["last_error"].(string); assert.True(t, ok, "last_error must be string") {
assert.NotEmpty(t, errMsg)
}
}
// TestFastAPIClient_SlogAttempt_NonRetryable: a 400 response yields one
// attempt with outcome=nonretryable_failure at ERROR level; no
// exhausted event. Plan §2.4 case 4.
func TestFastAPIClient_SlogAttempt_NonRetryable(t *testing.T) {
rec := installRecordingLogger(t)
fs := newFlakeServer(t, []flakeResponse{
{status: http.StatusBadRequest, body: []byte(`{"error":"bad request"}`)},
})
client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL))
_, err := client.Chat(t.Context(), validChatRequest())
require.Error(t, err)
attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt")
require.Len(t, attempts, 1)
assert.Equal(t, slog.LevelError, attempts[0].level)
assert.Equal(t, "nonretryable_failure", attempts[0].attrs["outcome"])
assert.EqualValues(t, http.StatusBadRequest, attempts[0].attrs["status_code"])
assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted"),
"non-retryable failures must not emit exhausted events")
}
// TestFastAPIClient_SlogAttempt_TransportTimeout: server hangs past the
// per-attempt timeout; resulting attempt records have outcome=
// retryable_failure and no status_code attribute (no HTTP response was
// received). Plan §2.4 case 5.
func TestFastAPIClient_SlogAttempt_TransportTimeout(t *testing.T) {
rec := installRecordingLogger(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},
})
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)
attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt")
require.NotEmpty(t, attempts, "transport timeouts must still log per-attempt events")
for i, a := range attempts {
assert.Equalf(t, "retryable_failure", a.attrs["outcome"], "attempt %d outcome", i+1)
_, hasStatus := a.attrs["status_code"]
assert.Falsef(t, hasStatus,
"transport timeout attempt %d must omit status_code (no HTTP response received)", i+1)
}
}
// TestFastAPIClient_SlogAttempt_NoAPIKeyLeak: the configured API key
// must never appear as the value of any logged attribute, nor anywhere
// in the rendered attribute payload. Plan §2.5 risk row "accidentally
// logging the API key".
func TestFastAPIClient_SlogAttempt_NoAPIKeyLeak(t *testing.T) {
rec := installRecordingLogger(t)
const secret = `{"AGENT_SERVICE_API_KEY":"sl0g-le4k-c4n4ry"}`
fs := newFlakeServer(t, []flakeResponse{
{status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)},
{status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)},
})
cfg := shortTimeoutCfg(fs.server.URL)
cfg.APIKey = secret
client := bot.NewFastAPIClient(cfg)
_, err := client.Chat(t.Context(), validChatRequest())
require.NoError(t, err)
for _, ev := range rec.snapshot() {
// Stringify all attribute values; substring-search for the API key.
// json.Marshal is convenient and safe — the recordingHandler holds
// only basic Go types coming from a.Value.Any().
raw, mErr := json.Marshal(ev.attrs)
require.NoError(t, mErr)
assert.Falsef(t, strings.Contains(string(raw), secret),
"event %q leaked the API key in attrs: %s", ev.message, raw)
}
}