Files

1298 lines
48 KiB
Go
Raw Permalink Normal View History

// Package queryapi_test — Milestone 4 HTTP-level AddTurn tests.
//
// Routes covered here:
// - POST /client/{clientId}/bot/sessions/{sessionId}/turns
// - GET /client/{clientId}/bot/sessions/{sessionId}/turns
//
// Each test stands up:
// - real Postgres via internal/test.CreateDB
// - a httptest.NewServer playing the FastAPI agent service. The §10 M3
// carve-out for httptest is reused here: AddTurn outcomes depend on
// specific FastAPI status codes and response bodies, and live FastAPI
// cannot drive the deterministic-failure scenarios plan §11 demands.
//
// Compile contract: this file references queryapi.CreateBotTurnRequest,
// queryapi.BotTurnResponse, queryapi.BotTurnListResponse, the
// CreateBotTurn / ListBotTurns controller methods, the BotTurnStatus
// enum, and the M4-only sqlc queries (GetBotTurn, InsertBotTurnPrompt,
// CompleteBotTurn, FailBotTurn, MarkBotTurnSessionDeleted,
// SetBotSessionTitle, ListTurnsForSession). Backend-eng adds the M4
// OpenAPI spec entries, regenerates, and the file compiles. Until
// then it fails to compile — the failing state plan §10 M4 endorses.
//
// Plan reference: plans/chatbot_plan_codex.v10.md §6 (AddTurn algorithm)
// and §11 (AddTurn matrix + GET turns + state-management tests).
package queryapi_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
queryapi "queryorchestration/api/queryAPI"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fastAPIScript is the staged set of (status, body, delay) responses
// the test FastAPI server emits in order. After the last entry the
// server keeps emitting the final entry so a buggy retry loop is
// visible as hits > len(entries).
type fastAPIScript struct {
entries []fastAPIResponse
}
// fastAPIResponse is one staged response.
type fastAPIResponse struct {
status int
body []byte
delay time.Duration
}
// fastAPIRecorder counts requests and surfaces them to assertions.
type fastAPIRecorder struct {
hits int32
server *httptest.Server
}
// hitsCount returns the total number of incoming /agent/chat requests.
func (r *fastAPIRecorder) hitsCount() int {
return int(atomic.LoadInt32(&r.hits))
}
// newFastAPIServer builds a httptest.NewServer that drives /agent/chat
// from the supplied script. /health always returns the canonical OK
// shape so callers do not have to script it. The cleanup hook closes
// the server when the test ends.
func newFastAPIServer(t testing.TB, script fastAPIScript) *fastAPIRecorder {
t.Helper()
rec := &fastAPIRecorder{}
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"}`))
})
mux.HandleFunc("/agent/chat", func(w http.ResponseWriter, r *http.Request) {
idx := int(atomic.AddInt32(&rec.hits, 1)) - 1
if idx >= len(script.entries) {
idx = len(script.entries) - 1
}
entry := script.entries[idx]
if entry.delay > 0 {
select {
case <-time.After(entry.delay):
case <-r.Context().Done():
return
}
}
w.WriteHeader(entry.status)
_, _ = w.Write(entry.body)
})
rec.server = httptest.NewServer(mux)
t.Cleanup(rec.server.Close)
return rec
}
// successAnswerBody returns a 200 chat response body whose answer.text
// is the supplied string. For the simple-completion paths.
func successAnswerBody(t testing.TB, requestID, answerText string) []byte {
t.Helper()
body := map[string]interface{}{
"request_id": requestID,
"status": "success",
"answer": map[string]interface{}{
"text": answerText,
"result_type": "summary",
"confidence": 0.9,
},
}
out, err := json.Marshal(body)
require.NoError(t, err)
return out
}
// successAnswerWithStateBody returns a 200 chat response carrying an
// updated_session_state value. stateLiteral is the raw JSON for the
// updated_session_state field; pass `null` for the preserve-prior path
// or `{}` for explicit overwrite.
func successAnswerWithStateBody(t testing.TB, requestID, answerText, stateLiteral string) []byte {
t.Helper()
tmpl := `{"request_id":%q,"status":"success","answer":{"text":%q,"result_type":"summary","confidence":0.9},"updated_session_state":%s}`
return []byte(fmt.Sprintf(tmpl, requestID, answerText, stateLiteral))
}
// errorChatBody returns a 200 chat response with status:"error".
func errorChatBody(t testing.TB, requestID, code, message string) []byte {
t.Helper()
body := map[string]interface{}{
"request_id": requestID,
"status": "error",
"error": map[string]interface{}{
"code": code,
"message": message,
},
}
out, err := json.Marshal(body)
require.NoError(t, err)
return out
}
// botTurnsContext bundles the per-test fixture: ControllerConfig with
// the test FastAPI URL injected, the Controllers, and the FastAPI
// recorder so tests can assert hits or close early.
type botTurnsContext struct {
cfg *ControllerConfig
cons *queryapi.Controllers
fastAPI *fastAPIRecorder
clientID string
actor string
session queryapi.BotSessionResponse
}
// newBotTurnsContext is the standard fixture. It builds a real DB and
// a placeholder FastAPI server, seeds a fresh session, and returns the
// btc so tests can call setFastAPIScript with the real session id
// embedded in expected request_ids. Tests that should never call
// FastAPI (validation failures) can leave the placeholder script in
// place — its single response would only be reached if the algorithm
// incorrectly skipped validation.
func newBotTurnsContext(t *testing.T, clientID, actor string) *botTurnsContext {
t.Helper()
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
// Placeholder FastAPI: returns a generic 500 so any unintended call
// surfaces as an obviously-failing test rather than silently passing.
rec := newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusInternalServerError, body: []byte(`{"unintended":"call"}`)},
},
})
cfg.ChatbotConfig.ServiceURL = rec.server.URL
// Short timeout so tests do not wait 60s on a hung server. 1s is
// enough for the local httptest round-trip; tests that exercise
// timeout behavior set their own delays inside the script.
cfg.ChatbotConfig.RequestTimeoutSeconds = 1
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "bot-m4-"+clientID)
session := seedBotSessionViaService(t, cons, clientID, actor)
return &botTurnsContext{
cfg: cfg,
cons: cons,
fastAPI: rec,
clientID: clientID,
actor: actor,
session: session,
}
}
// setFastAPIScript swaps the placeholder FastAPI server for one driven
// by the supplied script and rebuilds the Controllers so bot.New
// constructs a FastAPIClient pinned at the new URL. Tests call this
// after newBotTurnsContext when they need a specific FastAPI behavior
// keyed on the (now-known) session id.
func setFastAPIScript(t *testing.T, btc *botTurnsContext, script fastAPIScript) {
t.Helper()
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, script)
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
}
// postTurn drives a single POST /turns request and returns the recorder
// + handler error. The actor argument lets a test override the default
// session owner for cross-user scenarios.
func postTurn(t *testing.T, btc *botTurnsContext, prompt, actor string) (*httptest.ResponseRecorder, error) {
t.Helper()
if actor == "" {
actor = btc.actor
}
body := queryapi.CreateBotTurnRequest{Prompt: prompt}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, rec := newBotContextAs(t, http.MethodPost, path, body, actor)
err := btc.cons.CreateBotTurn(ctx, btc.clientID, btc.session.Id)
return rec, err
}
// fetchTurnRow reads the persisted bot_turns row for assertions.
type persistedTurn struct {
status string
prompt string
completion *string
attemptID uuid.UUID
createdAt time.Time
errorJSON []byte
}
func fetchTurnRow(t *testing.T, btc *botTurnsContext, ordinal int) persistedTurn {
t.Helper()
var row persistedTurn
err := btc.cfg.GetDBPool().QueryRow(t.Context(), `
SELECT status, prompt, completion, attempt_id, created_at, error::text
FROM bot_turns WHERE session_id = $1 AND ordinal = $2
`, uuid.UUID(btc.session.Id), ordinal).
Scan(&row.status, &row.prompt, &row.completion, &row.attemptID, &row.createdAt, &row.errorJSON)
require.NoError(t, err)
return row
}
// seedTurnRow inserts a bot_turns row directly so tests can prime the
// pre-AddTurn state. The status drives which columns get populated.
func seedTurnRow(t *testing.T, btc *botTurnsContext, ordinal int, status, prompt string, attemptStartedAt time.Time) uuid.UUID {
t.Helper()
pool := btc.cfg.GetDBPool()
attemptID := uuid.New()
switch status {
case "in_flight":
_, err := pool.Exec(t.Context(), `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at)
VALUES ($1, $2, $3, 'in_flight', $4, $5, $5)
`, uuid.UUID(btc.session.Id), ordinal, prompt, attemptID, attemptStartedAt)
require.NoError(t, err)
case "completed":
_, err := pool.Exec(t.Context(), `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, attempt_started_at, created_at)
VALUES ($1, $2, $3, 'completed', $4, 'seeded answer', $5, $5)
`, uuid.UUID(btc.session.Id), ordinal, prompt, attemptID, attemptStartedAt)
require.NoError(t, err)
case "errored", "abandoned", "session_deleted":
_, err := pool.Exec(t.Context(), `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error, attempt_started_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
`, uuid.UUID(btc.session.Id), ordinal, prompt, status, attemptID,
`{"code":"x","message":"y"}`, attemptStartedAt)
require.NoError(t, err)
default:
t.Fatalf("seedTurnRow: unknown status %q", status)
}
return attemptID
}
// assertHTTPError asserts the returned err is *echo.HTTPError with the
// expected code and message-substring. The return type is intentionally
// nothing so the linter does not flag the bare calls test-eng wrote.
func assertHTTPError(t *testing.T, err error, wantCode int, wantMessageSubstring string) {
t.Helper()
require.Error(t, err)
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, wantCode, httpErr.Code)
if wantMessageSubstring != "" {
assert.Contains(t, fmt.Sprintf("%v", httpErr.Message), wantMessageSubstring,
"expected message substring %q in %v", wantMessageSubstring, httpErr.Message)
}
}
// ---------- AddTurn matrix ----------
// TestAddTurn_FirstTurnDerivesTitle: ordinal 1 with a long prompt;
// after completion, bot_sessions.title = first 120 runes of prompt;
// turn row is completed; FastAPI was called exactly once. Plan §6.
func TestAddTurn_FirstTurnDerivesTitle(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M4_TITLE"
prompt := strings.Repeat("X", 200)
btc := newBotTurnsContext(t, clientID, testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
setFastAPIScript(t, btc, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "the answer")},
},
})
rec, err := postTurn(t, btc, prompt, "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
var resp queryapi.BotTurnResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, int32(1), resp.Ordinal)
assert.Equal(t, queryapi.BotTurnResponseStatusCompleted, resp.Status)
require.NotNil(t, resp.Completion)
assert.Equal(t, "the answer", *resp.Completion)
// Title is the first 120 runes of the prompt. With prompt = 200 X's,
// title = 120 X's exactly.
var title string
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT title FROM bot_sessions WHERE id = $1`, uuid.UUID(btc.session.Id)).Scan(&title)
require.NoError(t, err)
assert.Equal(t, strings.Repeat("X", 120), title,
"plan §6: title = first 120 runes of first prompt")
assert.Equal(t, 1, btc.fastAPI.hitsCount(), "FastAPI must be called exactly once")
}
// TestAddTurn_NormalNextTurn: ordinal 2 after a completed ordinal 1
// inserts and completes.
func TestAddTurn_NormalNextTurn(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_NEXT", testActorSubject)
seedTurnRow(t, btc, 1, "completed", "first prompt", time.Now().UTC().Add(-1*time.Hour))
expectedRequestID := fmt.Sprintf("%s:2", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "second answer")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
rec, err := postTurn(t, btc, "second prompt", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
var resp queryapi.BotTurnResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, int32(2), resp.Ordinal)
assert.Equal(t, queryapi.BotTurnResponseStatusCompleted, resp.Status)
}
// TestAddTurn_PromptTooLong_400: prompt with rune count > MaxTurnChars
// is rejected. FastAPI is NOT called.
func TestAddTurn_PromptTooLong_400(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_LONG", testActorSubject)
prompt := strings.Repeat("a", btc.cfg.ChatbotConfig.MaxTurnChars+1)
_, err := postTurn(t, btc, prompt, "")
assertHTTPError(t, err, http.StatusBadRequest, "prompt_too_long")
assert.Equal(t, 0, btc.fastAPI.hitsCount(), "FastAPI must not be called for invalid input")
// No bot_turns row should have been inserted.
var count int
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT COUNT(*) FROM bot_turns WHERE session_id = $1`,
uuid.UUID(btc.session.Id)).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
// TestAddTurn_PromptEmpty_400: empty/whitespace-only prompt rejected.
func TestAddTurn_PromptEmpty_400(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cases := []struct {
name string
prompt string
}{
{name: "empty", prompt: ""},
{name: "whitespace", prompt: " \t\n "},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
btc := newBotTurnsContext(t, "BOT_M4_EMPTY_"+c.name, testActorSubject)
_, err := postTurn(t, btc, c.prompt, "")
assertHTTPError(t, err, http.StatusBadRequest, "prompt_empty")
assert.Equal(t, 0, btc.fastAPI.hitsCount())
})
}
}
// TestAddTurn_SameOrdinalStillInflight_409: seed turn N as in_flight;
// resubmit ordinal N → 409 turn_in_flight. Plan §6.
//
// AddTurn API takes (sessionID, ordinal=N, prompt). The handler resolves
// ordinal from the AddTurn algorithm; the wire request body is just
// {prompt}. The test relies on backend-eng's algorithm reading the next
// ordinal from the session state. Seeding at ordinal 1 (in_flight) and
// posting the second request without bumping ordinal locally exercises
// the same-ordinal-in-flight case because the partial unique index will
// reject the next attempt.
func TestAddTurn_SameOrdinalStillInflight_409(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_SAME_INF", testActorSubject)
seedTurnRow(t, btc, 1, "in_flight", "first prompt", time.Now().UTC().Add(-30*time.Second))
_, err := postTurn(t, btc, "second prompt", "")
assertHTTPError(t, err, http.StatusConflict, "turn_in_flight")
}
// TestAddTurn_PriorTurnInflight_409: seed N as in_flight; submit N+1
// (the algorithm picks the next ordinal). The partial unique index
// rejects the new in_flight insert; service maps to 409
// prior_turn_in_flight. Plan §6.
//
// Note: the same-ordinal-inflight test seeds ordinal 1 in_flight and
// resubmits; that case maps to turn_in_flight by the existing-row
// classification path. To get prior_turn_in_flight we need an N where
// the resubmission would naturally target N+1 — that requires the
// algorithm to recognize "no existing row at N+1, but partial unique
// index blocks insert" rather than the existing-row at N path. This
// scenario reproduces by seeding ordinal 1 completed AND ordinal 2
// in_flight, then submitting which picks ordinal 3 (per
// last_seen_ordinal+1 since N=2 is in_flight). The partial unique
// index rejection then maps to prior_turn_in_flight per plan §6.
func TestAddTurn_PriorTurnInflight_409(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_PRIOR_INF", testActorSubject)
seedTurnRow(t, btc, 1, "completed", "first", time.Now().UTC().Add(-1*time.Hour))
seedTurnRow(t, btc, 2, "in_flight", "second", time.Now().UTC().Add(-30*time.Second))
_, err := postTurn(t, btc, "third prompt", "")
assertHTTPError(t, err, http.StatusConflict, "prior_turn_in_flight")
}
// TestAddTurn_GraceExpiredAbandonAndProceed: seed N in_flight with
// attempt_started_at older than 2 * RequestTimeoutSeconds (default
// grace=120s); submit ordinal N+1; expected: N becomes abandoned with
// the documented error JSON, N+1 inserts and completes successfully.
// Plan §6.
func TestAddTurn_GraceExpiredAbandonAndProceed(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_GRACE", testActorSubject)
// Seed N=1 in_flight 600s ago; default RequestTimeoutSeconds=1
// (forced in newBotTurnsContext) so grace = 2s. 600s is well
// outside the grace window.
seedTurnRow(t, btc, 1, "in_flight", "stuck", time.Now().UTC().Add(-600*time.Second))
expectedRequestID := fmt.Sprintf("%s:2", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "answer for new turn")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
rec, err := postTurn(t, btc, "new prompt after grace expired", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
row1 := fetchTurnRow(t, btc, 1)
assert.Equal(t, "abandoned", row1.status)
assert.Contains(t, string(row1.errorJSON), "turn_abandoned_grace_expired")
row2 := fetchTurnRow(t, btc, 2)
assert.Equal(t, "completed", row2.status)
require.NotNil(t, row2.completion)
assert.Equal(t, "answer for new turn", *row2.completion)
}
// TestAddTurn_RetryErroredSamePrompt: seed N as errored with prompt="X";
// resubmit N with prompt="X" → status flips to in_flight via reset,
// FastAPI called, turn completes. attempt_id is different.
func TestAddTurn_RetryErroredSamePrompt(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_RETRY_ERR", testActorSubject)
originalAttempt := seedTurnRow(t, btc, 1, "errored", "X", time.Now().UTC().Add(-1*time.Hour))
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "retry answer")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
rec, err := postTurn(t, btc, "X", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "completed", row.status)
require.NotNil(t, row.completion)
assert.Equal(t, "retry answer", *row.completion)
assert.NotEqual(t, originalAttempt, row.attemptID,
"plan §6: ResetBotTurnForRetry must mint a new attempt_id")
}
// TestAddTurn_RetryAbandonedSamePrompt: same shape, seeded as abandoned.
func TestAddTurn_RetryAbandonedSamePrompt(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_RETRY_ABN", testActorSubject)
seedTurnRow(t, btc, 1, "abandoned", "X", time.Now().UTC().Add(-1*time.Hour))
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "retry answer")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
rec, err := postTurn(t, btc, "X", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "completed", row.status)
}
// TestAddTurn_RetryResetTimestamp: plan §11 explicit — created_at
// after the retry must be greater than the original seeded created_at.
func TestAddTurn_RetryResetTimestamp(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_RETRY_TS", testActorSubject)
originalCreatedAt := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Microsecond)
seedTurnRow(t, btc, 1, "errored", "X", originalCreatedAt)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "retry answer")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err := postTurn(t, btc, "X", "")
require.NoError(t, err)
row := fetchTurnRow(t, btc, 1)
assert.Truef(t, row.createdAt.After(originalCreatedAt),
"plan §11: retry must advance created_at; original=%v after-retry=%v",
originalCreatedAt, row.createdAt)
}
// TestAddTurn_RetryDifferentPrompt_AdvancesToNextOrdinal: errored row
// with prompt="X"; resubmit with a different prompt advances to ordinal
// 2 instead of returning 409 prompt_mismatch. The errored row at
// ordinal 1 stays put. Plan reference: plans/chatbot.stuff/fix.chat.bugs.1.md
// §3 Option A — replaces the wedge-on-error wire behavior from plan v8
// §3 decision 9.
func TestAddTurn_RetryDifferentPrompt_AdvancesToNextOrdinal(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_DIFF_PROMPT", testActorSubject)
seedTurnRow(t, btc, 1, "errored", "X", time.Now().UTC().Add(-1*time.Hour))
expectedRequestID := fmt.Sprintf("%s:2", btc.session.Id)
setFastAPIScript(t, btc, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "second turn answer")},
},
})
rec, err := postTurn(t, btc, "Y", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
turn1 := fetchTurnRow(t, btc, 1)
assert.Equal(t, "errored", turn1.status, "errored ordinal 1 stays errored")
assert.Equal(t, "X", turn1.prompt)
turn2 := fetchTurnRow(t, btc, 2)
assert.Equal(t, "completed", turn2.status, "new prompt lands at ordinal 2")
assert.Equal(t, "Y", turn2.prompt)
}
// TestAddTurn_RetrySamePromptAfterError: errored row at ordinal 1 with
// prompt="X"; resubmitting "X" still triggers a same-ordinal retry
// (FindBotTurnByPrompt finds the errored row, ResetBotTurnForRetry
// rewrites it). Confirms Option A's Issue #2 fix did not regress the
// "exact-prompt retry" affordance.
func TestAddTurn_RetrySamePromptAfterError(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_SAME_RETRY", testActorSubject)
originalCreatedAt := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Microsecond)
seedTurnRow(t, btc, 1, "errored", "X", originalCreatedAt)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
setFastAPIScript(t, btc, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "retry answer")},
},
})
rec, err := postTurn(t, btc, "X", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "completed", row.status, "same-prompt resubmit must reset and complete ordinal 1")
assert.Equal(t, "X", row.prompt)
assert.Truef(t, row.createdAt.After(originalCreatedAt),
"retry must advance created_at; original=%v after-retry=%v",
originalCreatedAt, row.createdAt)
// No row should have appeared at ordinal 2.
var existsAtTwo bool
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT EXISTS(SELECT 1 FROM bot_turns WHERE session_id = $1 AND ordinal = 2)`,
uuid.UUID(btc.session.Id)).Scan(&existsAtTwo)
require.NoError(t, err)
assert.False(t, existsAtTwo, "same-prompt retry must not advance to a new ordinal")
}
// TestAddTurn_TwoErroredTurns_AdvanceToN_Plus_1: ordinals 1 and 2 both
// errored with distinct prompts; a third, distinct prompt advances to
// ordinal 3 (last_terminal_ordinal+1), not ordinal 2. Plan reference:
// plans/chatbot.stuff/fix.chat.bugs.1.md §4.
func TestAddTurn_TwoErroredTurns_AdvanceToN_Plus_1(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_TWO_ERRORED", testActorSubject)
seedTurnRow(t, btc, 1, "errored", "p1", time.Now().UTC().Add(-2*time.Hour))
seedTurnRow(t, btc, 2, "errored", "p2", time.Now().UTC().Add(-1*time.Hour))
expectedRequestID := fmt.Sprintf("%s:3", btc.session.Id)
setFastAPIScript(t, btc, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "third answer")},
},
})
rec, err := postTurn(t, btc, "p3", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
turn3 := fetchTurnRow(t, btc, 3)
assert.Equal(t, "completed", turn3.status)
assert.Equal(t, "p3", turn3.prompt)
}
// TestAddTurn_CompletedOrdinalRetry_409: resubmit completed ordinal →
// 409 already_complete.
func TestAddTurn_CompletedOrdinalRetry_409(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_COMPLETED", testActorSubject)
seedTurnRow(t, btc, 1, "completed", "first", time.Now().UTC().Add(-1*time.Hour))
_, err := postTurn(t, btc, "first", "")
assertHTTPError(t, err, http.StatusConflict, "already_complete")
}
// TestAddTurn_ExistingSessionDeleted_409: session_deleted ordinal
// returns 409 turn_session_deleted. Distinct from already_complete.
func TestAddTurn_ExistingSessionDeleted_409(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_SESSDEL", testActorSubject)
seedTurnRow(t, btc, 1, "session_deleted", "first", time.Now().UTC().Add(-1*time.Hour))
_, err := postTurn(t, btc, "first", "")
assertHTTPError(t, err, http.StatusConflict, "turn_session_deleted")
}
// TestAddTurn_RandomFutureOrdinal_409: existing turns at 1,2; the
// algorithm computes next ordinal as 3, so a request that would land
// at 5 is impossible from the wire. The plan §11 case covers the
// algorithm-level guard: if the algorithm somehow computes an ordinal
// outside (last_seen_ordinal, last_terminal_ordinal+1, last_seen+1),
// it returns 409 ordinal_out_of_range. The test exercises this by
// seeding two completed turns and a third completed turn at ordinal 5
// (skipping 3,4), then submitting; the algorithm classifies the
// next-ordinal computation as out-of-range relative to the existing
// max=5 and returns 409.
func TestAddTurn_RandomFutureOrdinal_409(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_FUTURE", testActorSubject)
seedTurnRow(t, btc, 1, "completed", "p1", time.Now().UTC().Add(-3*time.Hour))
seedTurnRow(t, btc, 2, "completed", "p2", time.Now().UTC().Add(-2*time.Hour))
// Skip ordinals 3 and 4; place a completed at 5. The next ordinal
// the algorithm computes from last_terminal_ordinal+1 = 6 is fine,
// but the row at 5 with a gap means a previously-seeded errored at
// 4 (if we add one) would be reachable for retry. To force the
// out-of-range path, we leave gaps: any algorithm-driven submission
// will compute ordinal 6 (last_seen+1) which is the standard next-
// ordinal path; the AddTurn algorithm rejects N+1 only when N is
// in_flight or N != last_terminal_ordinal+1 for retry. The
// out-of-range path triggers on an existing-row classification at
// an ordinal that does not match last_terminal_ordinal — for
// example, an errored row at ordinal 3 with last_terminal=5: a
// retry at 3 fails out-of-range because 3 != 5.
seedTurnRow(t, btc, 3, "errored", "p3", time.Now().UTC().Add(-1*time.Hour))
seedTurnRow(t, btc, 5, "completed", "p5", time.Now().UTC().Add(-30*time.Minute))
// Submit prompt matching the errored row at ordinal 3; algorithm
// sees existing errored at 3 with last_terminal_ordinal=5 (since 5
// is the latest non-in_flight terminal); reset is rejected because
// 3 != last_terminal_ordinal. Plan §6.
_, err := postTurn(t, btc, "p3", "")
assertHTTPError(t, err, http.StatusConflict, "ordinal_out_of_range")
}
// TestAddTurn_SessionDeletedDuringCall_410: tx1 inserts in_flight;
// during the FastAPI call (delayed), a super-admin DELETE soft-deletes
// the session; FastAPI returns success; tx2 finds is_deleted=true →
// handler returns 410 session_deleted_during_call. The persisted row
// flips to status=session_deleted.
func TestAddTurn_SessionDeletedDuringCall_410(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_DEL_DURING", testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
// FastAPI handler delays 200ms so we have a window to soft-delete
// the session before tx2 runs.
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{
status: http.StatusOK,
body: successAnswerBody(t, expectedRequestID, "answer"),
delay: 200 * time.Millisecond,
},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
// Trigger AddTurn in a goroutine; soft-delete the session while
// the FastAPI call is in flight.
type result struct {
rec *httptest.ResponseRecorder
err error
}
resCh := make(chan result, 1)
go func() {
rec, err := postTurn(t, btc, "prompt", "")
resCh <- result{rec: rec, err: err}
}()
// Wait briefly so tx1 has inserted the in_flight row.
time.Sleep(50 * time.Millisecond)
// Soft-delete via direct SQL (the super-admin DELETE handler is
// the production path; raw SQL avoids dependency on M2 in this
// test's failure surface).
_, err := btc.cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET is_deleted = true WHERE id = $1`,
uuid.UUID(btc.session.Id))
require.NoError(t, err)
res := <-resCh
assertHTTPError(t, res.err, http.StatusGone, "session_deleted_during_call")
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "session_deleted", row.status,
"plan §6: tx2 must MarkBotTurnSessionDeleted when the session disappeared mid-call")
}
// TestAddTurn_FastAPIApplicationError_502: 200 with status:"error" →
// tx2 calls FailBotTurn with error JSON containing attempt count;
// HTTP 502 with reason agent_application_error.
func TestAddTurn_FastAPIApplicationError_502(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_AGENT_ERR", testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: errorChatBody(t, expectedRequestID, "validation_failed", "missing entity")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err := postTurn(t, btc, "prompt", "")
assertHTTPError(t, err, http.StatusBadGateway, "agent_application_error")
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "errored", row.status)
assert.Contains(t, string(row.errorJSON), "validation_failed")
assert.Contains(t, string(row.errorJSON), "attempt",
"plan §7: error JSON must include attempt count")
}
// TestAddTurn_FastAPIEmptyAnswer_502: 200 with answer.text == "" →
// tx2 fails turn; HTTP 502 with reason agent_missing_answer.
func TestAddTurn_FastAPIEmptyAnswer_502(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_EMPTY_ANS", testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err := postTurn(t, btc, "prompt", "")
assertHTTPError(t, err, http.StatusBadGateway, "agent_missing_answer")
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "errored", row.status)
}
// TestAddTurn_FastAPIRetriesThenSuccess: 503 twice then 200; turn
// completes; AttemptCount=3 on the persisted result; HTTP 200/201 with
// the answer.
func TestAddTurn_FastAPIRetriesThenSuccess(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_RETRIES_OK", testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusServiceUnavailable, body: []byte(`unavailable`)},
{status: http.StatusServiceUnavailable, body: []byte(`unavailable`)},
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, "after retries")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
rec, err := postTurn(t, btc, "prompt", "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, 3, btc.fastAPI.hitsCount(),
"plan §7: 503,503,200 -> AttemptCount=3")
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "completed", row.status)
require.NotNil(t, row.completion)
assert.Equal(t, "after retries", *row.completion)
}
// TestAddTurn_FastAPIRetriesExhausted_502: 503 three times; tx2 fails
// turn with error JSON containing attempt:3; HTTP 502.
func TestAddTurn_FastAPIRetriesExhausted_502(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_RETRIES_X", testActorSubject)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusServiceUnavailable, body: []byte(`unavailable`)},
{status: http.StatusServiceUnavailable, body: []byte(`unavailable`)},
{status: http.StatusServiceUnavailable, body: []byte(`unavailable`)},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err := postTurn(t, btc, "prompt", "")
assertHTTPError(t, err, http.StatusBadGateway, "")
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "errored", row.status)
assert.Contains(t, string(row.errorJSON), "attempt",
"error JSON must include attempt count")
assert.Contains(t, string(row.errorJSON), "3",
"plan §7: exhausted retries -> attempt count = 3 in error JSON")
}
// TestAddTurn_CompletionTooLong_502: FastAPI returns answer.text >
// MaxTurnChars; tx2 rejects via the agent_invalid_response or
// agent_missing_answer sentinel (impl-defined which); turn fails;
// HTTP 502.
func TestAddTurn_CompletionTooLong_502(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_LONG_COMP", testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
tooLong := strings.Repeat("a", btc.cfg.ChatbotConfig.MaxTurnChars+1)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerBody(t, expectedRequestID, tooLong)},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err := postTurn(t, btc, "prompt", "")
assertHTTPError(t, err, http.StatusBadGateway, "")
row := fetchTurnRow(t, btc, 1)
assert.Equal(t, "errored", row.status,
"completion exceeding MaxTurnChars must fail the turn")
}
// TestAddTurn_CrossClient_404: posting to clientB's path with a
// session belonging to clientA misses LockBotSessionForOwner.
func TestAddTurn_CrossClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientA = "BOT_M4_X_A"
const clientB = "BOT_M4_X_B"
btc := newBotTurnsContext(t, clientA, testActorSubject)
resetClientForBotTests(t, btc.cfg, clientB)
test.CreateTestClient(t, btc.cfg, clientB, "bot-m4-x-b")
body := queryapi.CreateBotTurnRequest{Prompt: "hello"}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", clientB, btc.session.Id)
ctx, _ := newBotContext(t, http.MethodPost, path, body)
err := btc.cons.CreateBotTurn(ctx, clientB, btc.session.Id)
assertHTTPError(t, err, http.StatusNotFound, "")
}
// TestAddTurn_OtherUserSameClient_404: U2 cannot post to U1's session.
func TestAddTurn_OtherUserSameClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_OTHER_USER", testActorSubject)
_, err := postTurn(t, btc, "hello", botOtherActorSubject)
assertHTTPError(t, err, http.StatusNotFound, "")
}
// TestAddTurn_NoSubject_401: missing Cognito subject.
func TestAddTurn_NoSubject_401(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_NO_SUB", testActorSubject)
body := queryapi.CreateBotTurnRequest{Prompt: "hello"}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, _ := newBotContextAs(t, http.MethodPost, path, body, "")
err := btc.cons.CreateBotTurn(ctx, btc.clientID, btc.session.Id)
assertHTTPError(t, err, http.StatusUnauthorized, "")
}
// TestAddTurn_DeletedSession_404: is_deleted=true session is invisible
// to its owner, so AddTurn returns 404.
func TestAddTurn_DeletedSession_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_DELETED", testActorSubject)
_, err := btc.cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET is_deleted = true WHERE id = $1`,
uuid.UUID(btc.session.Id))
require.NoError(t, err)
_, err = postTurn(t, btc, "hello", "")
assertHTTPError(t, err, http.StatusNotFound, "")
}
// ---------- state management ----------
// TestAddTurn_StatePreservedOnNullUpdate: existing state {"foo":1};
// FastAPI returns updated_session_state == null; persisted state
// remains {"foo":1}.
func TestAddTurn_StatePreservedOnNullUpdate(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_STATE_NULL", testActorSubject)
// Seed initial state on the session.
_, err := btc.cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET state = $1::jsonb WHERE id = $2`,
`{"foo":1}`, uuid.UUID(btc.session.Id))
require.NoError(t, err)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerWithStateBody(t, expectedRequestID, "ok", "null")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err = postTurn(t, btc, "prompt", "")
require.NoError(t, err)
var got string
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT state::text FROM bot_sessions WHERE id = $1`,
uuid.UUID(btc.session.Id)).Scan(&got)
require.NoError(t, err)
assert.JSONEq(t, `{"foo":1}`, got,
"plan §6: null updated_session_state preserves prior state")
}
// TestAddTurn_StateOverwrittenOnExplicitEmpty: existing state {"foo":1};
// FastAPI returns updated_session_state == {}; persisted state is {}.
func TestAddTurn_StateOverwrittenOnExplicitEmpty(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_STATE_EMPTY", testActorSubject)
_, err := btc.cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET state = $1::jsonb WHERE id = $2`,
`{"foo":1}`, uuid.UUID(btc.session.Id))
require.NoError(t, err)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerWithStateBody(t, expectedRequestID, "ok", "{}")},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err = postTurn(t, btc, "prompt", "")
require.NoError(t, err)
var got string
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT state::text FROM bot_sessions WHERE id = $1`,
uuid.UUID(btc.session.Id)).Scan(&got)
require.NoError(t, err)
assert.JSONEq(t, `{}`, got,
"plan §6: explicit {} updated_session_state overwrites prior state")
}
// TestAddTurn_CachedResultsStrippedOnPersist: FastAPI returns state
// with cached_results plus other keys; persisted state has no
// cached_results, other keys preserved.
func TestAddTurn_CachedResultsStrippedOnPersist(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_STRIP", testActorSubject)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
stateLiteral := `{"turn":1,"resolved_entities":{"contracts":[]},"cached_results":{"abc":{"k":"v"}}}`
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerWithStateBody(t, expectedRequestID, "ok", stateLiteral)},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err := postTurn(t, btc, "prompt", "")
require.NoError(t, err)
var got string
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT state::text FROM bot_sessions WHERE id = $1`,
uuid.UUID(btc.session.Id)).Scan(&got)
require.NoError(t, err)
var parsed map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(got), &parsed))
_, hasCached := parsed["cached_results"]
assert.False(t, hasCached, "plan §7: cached_results must be stripped before persist")
assert.EqualValues(t, 1, parsed["turn"], "other keys must be preserved")
require.Contains(t, parsed, "resolved_entities")
}
// TestAddTurn_OversizedStrippedStatePreservesPrior: stripped state
// still > 64KB; existing state is preserved (oversize → log warn +
// preserve prior).
func TestAddTurn_OversizedStrippedStatePreservesPrior(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_OVERSIZE", testActorSubject)
_, err := btc.cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET state = $1::jsonb WHERE id = $2`,
`{"prior":"value"}`, uuid.UUID(btc.session.Id))
require.NoError(t, err)
// Build a state literal whose size after stripping cached_results
// (none here, so post-strip = pre-strip) exceeds 64 KiB.
hugeBlob := strings.Repeat("a", 70*1024)
stateLiteral := fmt.Sprintf(`{"resolved_entities":%q}`, hugeBlob)
expectedRequestID := fmt.Sprintf("%s:1", btc.session.Id)
btc.fastAPI.server.Close()
btc.fastAPI = newFastAPIServer(t, fastAPIScript{
entries: []fastAPIResponse{
{status: http.StatusOK, body: successAnswerWithStateBody(t, expectedRequestID, "ok", stateLiteral)},
},
})
btc.cfg.ChatbotConfig.ServiceURL = btc.fastAPI.server.URL
svc := createControllerServices(btc.cfg)
btc.cons = queryapi.NewControllers(svc, btc.cfg)
_, err = postTurn(t, btc, "prompt", "")
require.NoError(t, err, "AddTurn must still succeed; state oversize is not a request failure")
var got string
err = btc.cfg.GetDBPool().QueryRow(t.Context(),
`SELECT state::text FROM bot_sessions WHERE id = $1`,
uuid.UUID(btc.session.Id)).Scan(&got)
require.NoError(t, err)
assert.JSONEq(t, `{"prior":"value"}`, got,
"plan §6: oversize stripped state must preserve prior state")
}
// ---------- GET turns ----------
// TestGetTurns_OwnerListsAll: 5 completed turns -> GET returns 5
// ordered ASC.
func TestGetTurns_OwnerListsAll(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_GET_ALL", testActorSubject)
now := time.Now().UTC().Add(-1 * time.Hour)
for ord := 1; ord <= 5; ord++ {
seedTurnRow(t, btc, ord, "completed",
fmt.Sprintf("prompt-%d", ord),
now.Add(time.Duration(ord)*time.Second))
}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, btc.cons.ListBotTurns(ctx, btc.clientID, btc.session.Id))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotTurnListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Turns, 5)
for i, turn := range resp.Turns {
assert.Equal(t, int32(i+1), turn.Ordinal, //nolint:gosec // small loop bound
"plan: GET turns ordered ASC by ordinal")
}
}
// TestGetTurns_CrossClient_404: U1's session, GET as the same actor
// but with a different clientId in the path -> 404.
func TestGetTurns_CrossClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientA = "BOT_M4_GET_X_A"
const clientB = "BOT_M4_GET_X_B"
btc := newBotTurnsContext(t, clientA, testActorSubject)
resetClientForBotTests(t, btc.cfg, clientB)
test.CreateTestClient(t, btc.cfg, clientB, "bot-m4-get-x-b")
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", clientB, btc.session.Id)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err := btc.cons.ListBotTurns(ctx, clientB, btc.session.Id)
assertHTTPError(t, err, http.StatusNotFound, "")
}
// TestGetTurns_OtherUserSameClient_404.
func TestGetTurns_OtherUserSameClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_GET_OTHER", testActorSubject)
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, botOtherActorSubject)
err := btc.cons.ListBotTurns(ctx, btc.clientID, btc.session.Id)
assertHTTPError(t, err, http.StatusNotFound, "")
}
// TestGetTurns_DeletedSession_404.
func TestGetTurns_DeletedSession_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_GET_DEL", testActorSubject)
_, err := btc.cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET is_deleted = true WHERE id = $1`,
uuid.UUID(btc.session.Id))
require.NoError(t, err)
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err = btc.cons.ListBotTurns(ctx, btc.clientID, btc.session.Id)
assertHTTPError(t, err, http.StatusNotFound, "")
}
// TestGetTurns_IncludesErrorJSON: an errored row's error column is
// surfaced verbatim in the response.
func TestGetTurns_IncludesErrorJSON(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_GET_ERR", testActorSubject)
seedTurnRow(t, btc, 1, "errored", "p", time.Now().UTC().Add(-1*time.Hour))
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, btc.cons.ListBotTurns(ctx, btc.clientID, btc.session.Id))
var resp queryapi.BotTurnListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Turns, 1)
require.NotNil(t, resp.Turns[0].Error,
"errored row must surface error column in the wire response")
}
// TestGetTurns_NoSubject_401.
func TestGetTurns_NoSubject_401(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
btc := newBotTurnsContext(t, "BOT_M4_GET_401", testActorSubject)
path := fmt.Sprintf("/client/%s/bot/sessions/%s/turns", btc.clientID, btc.session.Id)
ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, "")
err := btc.cons.ListBotTurns(ctx, btc.clientID, btc.session.Id)
assertHTTPError(t, err, http.StatusUnauthorized, "")
}