diff --git a/internal/bot/service_turn_test.go b/internal/bot/service_turn_test.go index 1d5a7450..f3502058 100644 --- a/internal/bot/service_turn_test.go +++ b/internal/bot/service_turn_test.go @@ -117,6 +117,21 @@ func seedSessionDirect(t *testing.T, ctx context.Context, dbCfg *serviceconfig.B return id } +// seedTerminalTurn inserts a bot_turns row in a terminal status +// (errored, abandoned, session_deleted). The CHECK constraint +// bot_turns_status_fields_consistent requires `error` to be non-null +// for these statuses; this helper supplies a placeholder JSON value so +// callers do not have to repeat the boilerplate. +func seedTerminalTurn(t *testing.T, ctx context.Context, dbCfg *serviceconfig.BaseConfig, + sessionID uuid.UUID, ordinal int, status, prompt string) { + t.Helper() + _, err := dbCfg.GetDBPool().Exec(ctx, ` + 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, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour') + `, sessionID, ordinal, prompt, status, uuid.New(), `{"code":"x","message":"y"}`) + require.NoError(t, err) +} + // TestAddTurn_TenConcurrentCreates_OneWinner: ten goroutines POST // ordinal 1 simultaneously to a fresh session. The partial unique // index `bot_turns_one_inflight_per_session` and the FOR UPDATE lock @@ -360,3 +375,157 @@ func TestAddTurn_StaleTx2CallbackZeroRows(t *testing.T) { assert.Equal(t, currentAttemptID, attemptID, "row's attempt_id must remain the current value (B), not the stale (A)") } + +// ---------- classifyExistingTurn branch coverage ---------- +// +// The next three tests exercise the errored/abandoned arms of +// service_turn.go classifyExistingTurn that the public HTTP handler +// cannot reach because FindTargetOrdinalForPrompt pre-resolves the +// target ordinal. They call svc.AddTurn directly with crafted DB state +// so coverage on these defensive arms does not depend on integration +// flakes elsewhere in the suite. + +// TestAddTurn_ExistingErroredOrdinalMismatch_OutOfRange covers the +// errored/abandoned -> input.Ordinal != session.LastTerminalOrdinal arm. +// +// Setup: ord 1 errored ("X"), ord 2 completed -> LastTerminalOrdinal = 2. +// AddTurn(Ordinal=1) lands on the errored row at ord 1, but 1 != 2 so +// classifyExistingTurn returns ErrOrdinalOutOfRange before touching +// the prompt-match check. +func TestAddTurn_ExistingErroredOrdinalMismatch_OutOfRange(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + dbCfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, dbCfg) + + const ( + clientID = "BOT_M4_CET_ORD" + actor = "ord-mismatch@example.com" + ) + resetForTurnTests(t, ctx, dbCfg, clientID, "bot-m4-cet-ord") + sessionID := seedSessionDirect(t, ctx, dbCfg, clientID, actor) + + seedTerminalTurn(t, ctx, dbCfg, sessionID, 1, "errored", "X") + _, err := dbCfg.GetDBPool().Exec(ctx, ` + INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, attempt_started_at, created_at) + VALUES ($1, 2, 'second', 'completed', $2, 'a', NOW(), NOW()) + `, sessionID, uuid.New()) + require.NoError(t, err) + + server, _ := fastAPIServerForTurnTests(t, "unused") + cfg := turnTestCfg(server.URL) + svc, err := bot.New(cfg, dbCfg) + require.NoError(t, err) + + _, err = svc.AddTurn(ctx, bot.AddTurnInput{ + SessionID: sessionID, + ClientID: clientID, + Actor: actor, + Ordinal: 1, + Prompt: "X", + }) + require.Error(t, err) + assert.Truef(t, errors.Is(err, bot.ErrOrdinalOutOfRange), + "expected ErrOrdinalOutOfRange when ord != LastTerminalOrdinal in errored arm; got %v", err) +} + +// TestAddTurn_ExistingErroredPromptMismatch covers the +// errored/abandoned -> existing.Prompt != input.Prompt arm. +// +// Setup: ord 1 errored ("X"), no other turns -> LastTerminalOrdinal = 1. +// AddTurn(Ordinal=1, Prompt="Y") clears the ord check (1 == 1) then +// trips the prompt-match check ("X" != "Y"). +func TestAddTurn_ExistingErroredPromptMismatch(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + dbCfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, dbCfg) + + const ( + clientID = "BOT_M4_CET_PRM" + actor = "prompt-mismatch@example.com" + ) + resetForTurnTests(t, ctx, dbCfg, clientID, "bot-m4-cet-prm") + sessionID := seedSessionDirect(t, ctx, dbCfg, clientID, actor) + + seedTerminalTurn(t, ctx, dbCfg, sessionID, 1, "errored", "X") + + server, _ := fastAPIServerForTurnTests(t, "unused") + cfg := turnTestCfg(server.URL) + svc, err := bot.New(cfg, dbCfg) + require.NoError(t, err) + + _, err = svc.AddTurn(ctx, bot.AddTurnInput{ + SessionID: sessionID, + ClientID: clientID, + Actor: actor, + Ordinal: 1, + Prompt: "Y", + }) + require.Error(t, err) + assert.Truef(t, errors.Is(err, bot.ErrPromptMismatch), + "expected ErrPromptMismatch when existing.Prompt != input.Prompt; got %v", err) +} + +// TestAddTurn_RetryRejectedByInflightIndex +// covers the errored/abandoned -> ResetBotTurnForRetry unique-violation +// arm (isUniqueViolation -> ErrPriorTurnInFlight). +// +// Setup: ord 1 errored ("X"), ord 2 in_flight (fresh, so the grace- +// abandon pass leaves it alone). LastTerminalOrdinal = 1 because in_flight +// rows are excluded from the MAX. AddTurn(Ordinal=1, Prompt="X") clears +// the ord and prompt checks, then ResetBotTurnForRetry tries to flip ord +// 1 to in_flight — the partial unique index +// bot_turns_one_inflight_per_session rejects the second in_flight row +// for this session, surfacing SQLSTATE 23505. +func TestAddTurn_RetryRejectedByInflightIndex(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + ctx := t.Context() + dbCfg := &serviceconfig.BaseConfig{} + test.CreateDB(t, dbCfg) + + const ( + clientID = "BOT_M4_CET_PIF" + actor = "prior-inflight@example.com" + ) + resetForTurnTests(t, ctx, dbCfg, clientID, "bot-m4-cet-pif") + sessionID := seedSessionDirect(t, ctx, dbCfg, clientID, actor) + + seedTerminalTurn(t, ctx, dbCfg, sessionID, 1, "errored", "X") + _, err := dbCfg.GetDBPool().Exec(ctx, ` + INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at) + VALUES ($1, 2, 'second', 'in_flight', $2, NOW(), NOW()) + `, sessionID, uuid.New()) + require.NoError(t, err) + + server, _ := fastAPIServerForTurnTests(t, "unused") + cfg := turnTestCfg(server.URL) + svc, err := bot.New(cfg, dbCfg) + require.NoError(t, err) + + _, err = svc.AddTurn(ctx, bot.AddTurnInput{ + SessionID: sessionID, + ClientID: clientID, + Actor: actor, + Ordinal: 1, + Prompt: "X", + }) + require.Error(t, err) + assert.Truef(t, errors.Is(err, bot.ErrPriorTurnInFlight), + "expected ErrPriorTurnInFlight when reset trips partial unique index; got %v", err) + + // Sanity: the errored row at ord 1 must not have been flipped. + var ord1Status string + err = dbCfg.GetDBPool().QueryRow(ctx, ` + SELECT status FROM bot_turns WHERE session_id = $1 AND ordinal = 1 + `, sessionID).Scan(&ord1Status) + require.NoError(t, err) + assert.Equal(t, "errored", ord1Status, + "reset must roll back when the unique index rejects it; ord 1 stays errored") +}