// 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, "") }