1298 lines
48 KiB
Go
1298 lines
48 KiB
Go
|
|
// 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, "")
|
||
|
|
}
|