72de690894
Chatbot functionality * baseline working * missing test file * more tests
276 lines
10 KiB
Go
276 lines
10 KiB
Go
// 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)
|
||
}
|
||
}
|