72de690894
Chatbot functionality * baseline working * missing test file * more tests
922 lines
32 KiB
Go
922 lines
32 KiB
Go
// Milestone 1 §M1 chatbot repository tests.
|
||
//
|
||
// These tests are the authoritative behavioral spec for the sqlc-generated
|
||
// bot.sql query layer (plan §5) and the new client-scoped document/folder
|
||
// queries (plan §5 again, the "client-scoped" bullet list).
|
||
//
|
||
// Compile contract: this file references the generated method names and
|
||
// parameter struct names exactly as plan §5 specifies. Until backend-eng
|
||
// adds internal/database/queries/bot.sql plus the client-scoped variants
|
||
// in document.sql/folders.sql/custommetadata.sql/labels.sql and re-runs
|
||
// `task db:generate`, the file fails to compile. That is the failing
|
||
// state we want — the team-lead M1 dispatch explicitly endorsed it.
|
||
//
|
||
// All tests use:
|
||
// - real Postgres via internal/test.CreateDB
|
||
// - per-test client/session/turn rows seeded with raw SQL where the
|
||
// sqlc layer is not yet available, otherwise via cfg.GetDBQueries()
|
||
// - explicit clock arithmetic for grace-window assertions instead of
|
||
// time.Sleep, so the test does not flake under slow CI
|
||
//
|
||
// No mocks. No t.Skip beyond testing.Short. No commented-out tests.
|
||
package repository_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"queryorchestration/internal/database/repository"
|
||
"queryorchestration/internal/serviceconfig"
|
||
"queryorchestration/internal/test"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// botStatusInFlight etc. mirror the status string set defined by the
|
||
// bot_turns_status_values CHECK constraint in plan §4. Defined here so
|
||
// negative-path tests don't accidentally drift from the migration.
|
||
const (
|
||
botStatusInFlight = "in_flight"
|
||
botStatusCompleted = "completed"
|
||
botStatusErrored = "errored"
|
||
botStatusAbandoned = "abandoned"
|
||
botStatusSessionDeleted = "session_deleted"
|
||
)
|
||
|
||
// resetBotClient mirrors resetClientForBot in bot_migration_test.go but
|
||
// is package-local to bot_repository_test.go so the two test files do
|
||
// not silently share helpers across compilation boundaries. Same idempotency
|
||
// dance: documents first (no cascade from clients), then clients (cascade
|
||
// wipes bot_sessions and the join tables).
|
||
func resetBotClient(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) {
|
||
t.Helper()
|
||
pool := cfg.GetDBPool()
|
||
_, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID)
|
||
require.NoError(t, err, "pre-test DELETE documents must succeed")
|
||
_, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID)
|
||
require.NoError(t, err, "pre-test DELETE clients must succeed")
|
||
err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||
Name: name,
|
||
Clientid: clientID,
|
||
})
|
||
require.NoError(t, err, "CreateClient(%s) must succeed", clientID)
|
||
}
|
||
|
||
// seedSession inserts one bot_sessions row with the requested
|
||
// (client, created_by, is_deleted) and returns its id.
|
||
func seedSession(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, createdBy string, isDeleted bool) uuid.UUID {
|
||
t.Helper()
|
||
var id uuid.UUID
|
||
err := cfg.GetDBPool().QueryRow(ctx, `
|
||
INSERT INTO bot_sessions (client_id, created_by, is_deleted)
|
||
VALUES ($1, $2, $3)
|
||
RETURNING id
|
||
`, clientID, createdBy, isDeleted).Scan(&id)
|
||
require.NoError(t, err, "seed bot_sessions must succeed")
|
||
return id
|
||
}
|
||
|
||
// seedTurn inserts one bot_turns row with the given status and ordinal,
|
||
// minting a fresh attempt_id. attemptStartedAt drives the grace-window
|
||
// abandonment test directly instead of relying on time.Sleep.
|
||
func seedTurn(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, sessionID uuid.UUID, ordinal int, status string, attemptStartedAt time.Time) uuid.UUID {
|
||
t.Helper()
|
||
attemptID := uuid.New()
|
||
pool := cfg.GetDBPool()
|
||
|
||
switch status {
|
||
case botStatusInFlight:
|
||
_, err := pool.Exec(ctx, `
|
||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt)
|
||
require.NoError(t, err, "seed in_flight turn must succeed")
|
||
case botStatusCompleted:
|
||
_, err := pool.Exec(ctx, `
|
||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at, completion)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $6, 'answer')
|
||
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt)
|
||
require.NoError(t, err, "seed completed turn must succeed")
|
||
case botStatusErrored, botStatusAbandoned, botStatusSessionDeleted:
|
||
_, err := pool.Exec(ctx, `
|
||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at, error)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $6, $7::jsonb)
|
||
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt,
|
||
`{"code":"x","message":"y"}`)
|
||
require.NoError(t, err, "seed %s turn must succeed", status)
|
||
default:
|
||
t.Fatalf("seedTurn: unknown status %q", status)
|
||
}
|
||
return attemptID
|
||
}
|
||
|
||
// fetchTurnStatus returns the current status of (sessionID, ordinal).
|
||
// Helper used by retry-reset and abandonment tests.
|
||
func fetchTurnStatus(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, sessionID uuid.UUID, ordinal int) string {
|
||
t.Helper()
|
||
var status string
|
||
err := cfg.GetDBPool().QueryRow(ctx, `
|
||
SELECT status FROM bot_turns WHERE session_id = $1 AND ordinal = $2
|
||
`, sessionID, ordinal).Scan(&status)
|
||
require.NoError(t, err)
|
||
return status
|
||
}
|
||
|
||
// TestLockBotSessionForOwner_DerivedOrdinals proves the derived columns
|
||
// last_seen_ordinal and last_terminal_ordinal in plan §5's owner-lock
|
||
// query are computed correctly across a session whose turns include
|
||
// in_flight + completed + errored + abandoned at known ordinals.
|
||
//
|
||
// Layout: ordinal 1 completed, 2 errored, 3 abandoned, 4 in_flight.
|
||
// last_seen_ordinal must be 4 (the MAX). last_terminal_ordinal must be 3
|
||
// (the MAX where status <> 'in_flight').
|
||
func TestLockBotSessionForOwner_DerivedOrdinals(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientID = "BOT_REPO_LOCK"
|
||
actor = "u1@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-lock")
|
||
sessionID := seedSession(t, ctx, cfg, clientID, actor, false)
|
||
|
||
now := time.Now().UTC()
|
||
seedTurn(t, ctx, cfg, sessionID, 1, botStatusCompleted, now.Add(-30*time.Minute))
|
||
seedTurn(t, ctx, cfg, sessionID, 2, botStatusErrored, now.Add(-20*time.Minute))
|
||
seedTurn(t, ctx, cfg, sessionID, 3, botStatusAbandoned, now.Add(-10*time.Minute))
|
||
seedTurn(t, ctx, cfg, sessionID, 4, botStatusInFlight, now.Add(-1*time.Minute))
|
||
|
||
// LockBotSessionForOwner is FOR UPDATE — must run inside a tx.
|
||
tx, err := pool.Begin(ctx)
|
||
require.NoError(t, err)
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
txQueries := queries.WithTx(tx)
|
||
|
||
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: clientID,
|
||
CreatedBy: actor,
|
||
})
|
||
require.NoError(t, err, "LockBotSessionForOwner must return one row when (client, owner) match")
|
||
require.NotNil(t, row)
|
||
|
||
require.Equal(t, sessionID, row.ID)
|
||
require.Equal(t, clientID, row.ClientID)
|
||
require.Equal(t, actor, row.CreatedBy)
|
||
require.Equal(t, int32(4), row.LastSeenOrdinal,
|
||
"last_seen_ordinal must be MAX(ordinal) = 4")
|
||
require.Equal(t, int32(3), row.LastTerminalOrdinal,
|
||
"last_terminal_ordinal must be MAX(ordinal) where status <> in_flight = 3")
|
||
}
|
||
|
||
// TestLockBotSessionForOwner_CrossClientRejected enumerates the four
|
||
// cells of the (lookup_client, lookup_user) matrix that must miss the
|
||
// session created by (clientA, U1):
|
||
//
|
||
// (clientB, U1) -> 0 rows (cross-client, same user)
|
||
// (clientA, U2) -> 0 rows (same client, different user)
|
||
// (clientB, U2) -> 0 rows (cross-client and cross-user)
|
||
//
|
||
// (clientA, U1) is the positive control covered by DerivedOrdinals; not
|
||
// repeated here to keep the table tight.
|
||
func TestLockBotSessionForOwner_CrossClientRejected(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_X_A"
|
||
clientB = "BOT_REPO_X_B"
|
||
userU1 = "u1@example.com"
|
||
userU2 = "u2@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-x-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-x-b")
|
||
|
||
sessionID := seedSession(t, ctx, cfg, clientA, userU1, false)
|
||
|
||
cases := []struct {
|
||
name string
|
||
clientID string
|
||
createdBy string
|
||
}{
|
||
{name: "cross_client_same_user", clientID: clientB, createdBy: userU1},
|
||
{name: "same_client_other_user", clientID: clientA, createdBy: userU2},
|
||
{name: "cross_client_other_user", clientID: clientB, createdBy: userU2},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
tx, err := pool.Begin(ctx)
|
||
require.NoError(t, err)
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
txQueries := queries.WithTx(tx)
|
||
|
||
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: c.clientID,
|
||
CreatedBy: c.createdBy,
|
||
})
|
||
require.Errorf(t, err,
|
||
"lookup with (clientID=%s, createdBy=%s) must return ErrNoRows", c.clientID, c.createdBy)
|
||
require.ErrorIs(t, err, pgx.ErrNoRows,
|
||
"want pgx.ErrNoRows, got %T: %v", err, err)
|
||
_ = row
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestLockBotSessionForOwner_DeletedExcluded proves that a session with
|
||
// is_deleted = true is invisible to LockBotSessionForOwner even when the
|
||
// caller would otherwise be the rightful owner. Plan §5: WHERE clause
|
||
// must include `is_deleted = false`.
|
||
func TestLockBotSessionForOwner_DeletedExcluded(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientID = "BOT_REPO_DEL"
|
||
actor = "deleted-owner@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-del")
|
||
sessionID := seedSession(t, ctx, cfg, clientID, actor, true)
|
||
|
||
tx, err := pool.Begin(ctx)
|
||
require.NoError(t, err)
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
txQueries := queries.WithTx(tx)
|
||
|
||
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: clientID,
|
||
CreatedBy: actor,
|
||
})
|
||
require.Error(t, err, "is_deleted=true session must not be returned")
|
||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||
_ = row
|
||
}
|
||
|
||
// TestAbandonExpiredInflightForOwner_OwnerOnly proves the EXISTS guard
|
||
// in plan §5: AbandonExpiredInflightForOwner mutates only when the
|
||
// caller's (client_id, created_by) match the session row. Cross-client
|
||
// or cross-user calls are no-ops on the same DB state.
|
||
func TestAbandonExpiredInflightForOwner_OwnerOnly(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_AB_A"
|
||
clientB = "BOT_REPO_AB_B"
|
||
userU1 = "u1@example.com"
|
||
userU2 = "u2@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-ab-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-ab-b")
|
||
|
||
sessionID := seedSession(t, ctx, cfg, clientA, userU1, false)
|
||
// Seed an expired in_flight: attempt_started_at is one hour old.
|
||
seedTurn(t, ctx, cfg, sessionID, 1, botStatusInFlight, time.Now().UTC().Add(-1*time.Hour))
|
||
|
||
// Grace window: 60 seconds. Anything older than 60s must be eligible.
|
||
const graceSeconds = 60
|
||
|
||
t.Run("cross_client_no_op", func(t *testing.T) {
|
||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: clientB, // wrong client
|
||
CreatedBy: userU1,
|
||
GraceSeconds: graceSeconds,
|
||
})
|
||
require.NoError(t, err, "abandon must not error when EXISTS guard fails")
|
||
require.Equal(t, botStatusInFlight,
|
||
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
|
||
"row must remain in_flight when client mismatch")
|
||
})
|
||
|
||
t.Run("cross_user_no_op", func(t *testing.T) {
|
||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: clientA,
|
||
CreatedBy: userU2, // wrong user
|
||
GraceSeconds: graceSeconds,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, botStatusInFlight,
|
||
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
|
||
"row must remain in_flight when user mismatch")
|
||
})
|
||
|
||
t.Run("rightful_owner_abandons", func(t *testing.T) {
|
||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: clientA,
|
||
CreatedBy: userU1,
|
||
GraceSeconds: graceSeconds,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, botStatusAbandoned,
|
||
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
|
||
"rightful owner must abandon expired in_flight row")
|
||
})
|
||
}
|
||
|
||
// TestAbandonExpiredInflightForOwner_GraceWindow proves the grace-window
|
||
// boundary: rows with attempt_started_at newer than NOW() - grace are NOT
|
||
// abandoned; older rows are. Uses two sibling sessions so each row is
|
||
// unambiguously identified by session_id.
|
||
func TestAbandonExpiredInflightForOwner_GraceWindow(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientID = "BOT_REPO_GR"
|
||
actor = "owner@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-gr")
|
||
|
||
now := time.Now().UTC()
|
||
freshSession := seedSession(t, ctx, cfg, clientID, actor, false)
|
||
expiredSession := seedSession(t, ctx, cfg, clientID, actor, false)
|
||
|
||
// Grace = 120 seconds; fresh row is 30s old, expired row is 600s old.
|
||
const graceSeconds = 120
|
||
seedTurn(t, ctx, cfg, freshSession, 1, botStatusInFlight, now.Add(-30*time.Second))
|
||
seedTurn(t, ctx, cfg, expiredSession, 1, botStatusInFlight, now.Add(-600*time.Second))
|
||
|
||
// Two abandon calls — one per session. The fresh-session call must be
|
||
// a no-op; the expired-session call must mutate the row.
|
||
for _, sessionID := range []uuid.UUID{freshSession, expiredSession} {
|
||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||
SessionID: sessionID,
|
||
ClientID: clientID,
|
||
CreatedBy: actor,
|
||
GraceSeconds: graceSeconds,
|
||
})
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
require.Equal(t, botStatusInFlight,
|
||
fetchTurnStatus(t, ctx, cfg, freshSession, 1),
|
||
"fresh row (within grace) must remain in_flight")
|
||
require.Equal(t, botStatusAbandoned,
|
||
fetchTurnStatus(t, ctx, cfg, expiredSession, 1),
|
||
"expired row (outside grace) must be abandoned")
|
||
}
|
||
|
||
// TestResetBotTurnForRetry_OnlyTerminal proves plan §5: the retry reset
|
||
// resets to in_flight only for rows in (errored, abandoned). Rows with
|
||
// status in (in_flight, completed, session_deleted) must be no-ops.
|
||
func TestResetBotTurnForRetry_OnlyTerminal(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientID = "BOT_REPO_RR"
|
||
actor = "retry@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-rr")
|
||
|
||
type tc struct {
|
||
name string
|
||
seededStatus string
|
||
expectReset bool
|
||
expectedStatus string
|
||
}
|
||
|
||
cases := []tc{
|
||
{name: "errored_resets", seededStatus: botStatusErrored, expectReset: true, expectedStatus: botStatusInFlight},
|
||
{name: "abandoned_resets", seededStatus: botStatusAbandoned, expectReset: true, expectedStatus: botStatusInFlight},
|
||
{name: "in_flight_no_op", seededStatus: botStatusInFlight, expectReset: false, expectedStatus: botStatusInFlight},
|
||
{name: "completed_no_op", seededStatus: botStatusCompleted, expectReset: false, expectedStatus: botStatusCompleted},
|
||
{name: "session_deleted_no_op", seededStatus: botStatusSessionDeleted, expectReset: false, expectedStatus: botStatusSessionDeleted},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
// Each subtest gets its own session so the partial unique
|
||
// index on bot_turns(session_id) WHERE status='in_flight'
|
||
// does not interfere across cases.
|
||
sessionID := seedSession(t, ctx, cfg, clientID, actor, false)
|
||
seededAt := time.Now().UTC().Add(-1 * time.Hour) // far in the past
|
||
seedTurn(t, ctx, cfg, sessionID, 1, c.seededStatus, seededAt)
|
||
|
||
// Snapshot original timestamps so we can prove created_at and
|
||
// attempt_started_at advance only when the reset is supposed
|
||
// to fire.
|
||
var origAttemptStartedAt, origCreatedAt time.Time
|
||
var origAttemptID uuid.UUID
|
||
err := pool.QueryRow(ctx, `
|
||
SELECT attempt_started_at, created_at, attempt_id
|
||
FROM bot_turns WHERE session_id = $1 AND ordinal = 1
|
||
`, sessionID).Scan(&origAttemptStartedAt, &origCreatedAt, &origAttemptID)
|
||
require.NoError(t, err)
|
||
|
||
newAttemptID := uuid.New()
|
||
err = queries.ResetBotTurnForRetry(ctx, &repository.ResetBotTurnForRetryParams{
|
||
SessionID: sessionID,
|
||
Ordinal: 1,
|
||
AttemptID: newAttemptID,
|
||
})
|
||
require.NoError(t, err, "ResetBotTurnForRetry must not error in any case")
|
||
|
||
// Read back current state.
|
||
var gotStatus string
|
||
var gotAttemptID uuid.UUID
|
||
var gotAttemptStartedAt, gotCreatedAt time.Time
|
||
err = pool.QueryRow(ctx, `
|
||
SELECT status, attempt_id, attempt_started_at, created_at
|
||
FROM bot_turns WHERE session_id = $1 AND ordinal = 1
|
||
`, sessionID).Scan(&gotStatus, &gotAttemptID, &gotAttemptStartedAt, &gotCreatedAt)
|
||
require.NoError(t, err)
|
||
|
||
require.Equal(t, c.expectedStatus, gotStatus,
|
||
"status must be %q after reset of seeded %q", c.expectedStatus, c.seededStatus)
|
||
|
||
if c.expectReset {
|
||
require.Equal(t, newAttemptID, gotAttemptID,
|
||
"reset must replace attempt_id with the caller-provided value")
|
||
require.Truef(t, gotAttemptStartedAt.After(origAttemptStartedAt),
|
||
"reset must advance attempt_started_at; orig=%v got=%v",
|
||
origAttemptStartedAt, gotAttemptStartedAt)
|
||
require.Truef(t, gotCreatedAt.After(origCreatedAt),
|
||
"reset must advance created_at; orig=%v got=%v",
|
||
origCreatedAt, gotCreatedAt)
|
||
} else {
|
||
require.Equal(t, origAttemptID, gotAttemptID,
|
||
"no-op case must not change attempt_id")
|
||
require.Truef(t, gotAttemptStartedAt.Equal(origAttemptStartedAt),
|
||
"no-op case must not change attempt_started_at; orig=%v got=%v",
|
||
origAttemptStartedAt, gotAttemptStartedAt)
|
||
require.Truef(t, gotCreatedAt.Equal(origCreatedAt),
|
||
"no-op case must not change created_at; orig=%v got=%v",
|
||
origCreatedAt, gotCreatedAt)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestListLastTurnsForSessions_TurnLimit proves plan §5's preview query
|
||
// returns at most turn_limit most-recent turns per session, ordered
|
||
// session_id, ordinal DESC.
|
||
//
|
||
// Layout: 4 sessions × 7 turns each (ordinals 1..7), all completed.
|
||
// turn_limit = 3 must yield exactly 12 rows (4 × 3), and within each
|
||
// session the rows must be ordinals (7, 6, 5).
|
||
func TestListLastTurnsForSessions_TurnLimit(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientID = "BOT_REPO_LIST"
|
||
actor = "list@example.com"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-list")
|
||
|
||
sessions := make([]uuid.UUID, 4)
|
||
now := time.Now().UTC()
|
||
for i := range sessions {
|
||
sessions[i] = seedSession(t, ctx, cfg, clientID, actor, false)
|
||
for ord := 1; ord <= 7; ord++ {
|
||
// Stagger created_at by one second per turn so any future
|
||
// secondary ordering by created_at is also deterministic.
|
||
seedTurn(t, ctx, cfg, sessions[i], ord, botStatusCompleted,
|
||
now.Add(time.Duration(ord)*time.Second))
|
||
}
|
||
}
|
||
|
||
rows, err := queries.ListLastTurnsForSessions(ctx, &repository.ListLastTurnsForSessionsParams{
|
||
SessionIds: sessions,
|
||
TurnLimit: 3,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, rows, 4*3, "must return exactly turn_limit rows per session")
|
||
|
||
// Bucket per session, then assert each bucket holds the top 3
|
||
// ordinals in DESC order.
|
||
bySession := map[uuid.UUID][]int32{}
|
||
for _, r := range rows {
|
||
bySession[r.SessionID] = append(bySession[r.SessionID], r.Ordinal)
|
||
}
|
||
require.Len(t, bySession, 4, "all four sessions must appear in the result")
|
||
for _, sid := range sessions {
|
||
got := bySession[sid]
|
||
require.Equal(t, []int32{7, 6, 5}, got,
|
||
"session %s must yield ordinals (7,6,5) in DESC order; got %v", sid, got)
|
||
}
|
||
}
|
||
|
||
// TestGetDocumentEnrichedForClient_CrossClient proves the client-scoped
|
||
// document enriched read returns no row for a cross-client lookup.
|
||
// Plan §5 mandates a clientId filter on every document/folder read so
|
||
// the chatbot scope cannot leak documents across clients.
|
||
func TestGetDocumentEnrichedForClient_CrossClient(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_DOC_A"
|
||
clientB = "BOT_REPO_DOC_B"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-doc-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-doc-b")
|
||
|
||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||
Clientid: clientA,
|
||
Hash: "bot_repo_doc_hash_enriched",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
t.Run("same_client_returns_row", func(t *testing.T) {
|
||
row, err := queries.GetDocumentEnrichedForClient(ctx, &repository.GetDocumentEnrichedForClientParams{
|
||
ID: docID,
|
||
ClientID: clientA,
|
||
})
|
||
require.NoError(t, err, "same-client lookup must succeed")
|
||
require.NotNil(t, row)
|
||
require.Equal(t, docID, row.ID)
|
||
require.Equal(t, clientA, row.Clientid)
|
||
})
|
||
|
||
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
|
||
_, err := queries.GetDocumentEnrichedForClient(ctx, &repository.GetDocumentEnrichedForClientParams{
|
||
ID: docID,
|
||
ClientID: clientB,
|
||
})
|
||
require.Error(t, err, "cross-client lookup must miss")
|
||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||
})
|
||
}
|
||
|
||
// TestGetDocumentLabelsForClient_CrossClient mirrors the document
|
||
// enriched test for the labels endpoint. Empty result on cross-client
|
||
// lookup, populated result on same-client.
|
||
func TestGetDocumentLabelsForClient_CrossClient(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_LBL_A"
|
||
clientB = "BOT_REPO_LBL_B"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-lbl-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-lbl-b")
|
||
|
||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||
Clientid: clientA,
|
||
Hash: "bot_repo_doc_hash_labels",
|
||
})
|
||
require.NoError(t, err)
|
||
_, err = queries.ApplyLabel(ctx, &repository.ApplyLabelParams{
|
||
Documentid: docID,
|
||
Label: "Ingested", // seeded by migration 110
|
||
Appliedby: "tester",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
t.Run("same_client_returns_labels", func(t *testing.T) {
|
||
labels, err := queries.GetDocumentLabelsForClient(ctx, &repository.GetDocumentLabelsForClientParams{
|
||
DocumentID: docID,
|
||
ClientID: clientA,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, labels, 1, "must return the one applied label")
|
||
})
|
||
|
||
t.Run("cross_client_returns_empty", func(t *testing.T) {
|
||
labels, err := queries.GetDocumentLabelsForClient(ctx, &repository.GetDocumentLabelsForClientParams{
|
||
DocumentID: docID,
|
||
ClientID: clientB,
|
||
})
|
||
require.NoError(t, err, ":many cross-client must return empty slice, not error")
|
||
require.Empty(t, labels, "cross-client must return no labels")
|
||
})
|
||
}
|
||
|
||
// TestGetDocumentCustomSchemaIdForClient_CrossClient: same-client returns
|
||
// the bound schema id (or NULL); cross-client returns no row.
|
||
func TestGetDocumentCustomSchemaIdForClient_CrossClient(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_CSI_A"
|
||
clientB = "BOT_REPO_CSI_B"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-csi-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-csi-b")
|
||
|
||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||
Clientid: clientA,
|
||
Hash: "bot_repo_doc_hash_csi",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
t.Run("same_client_returns_row", func(t *testing.T) {
|
||
schemaID, err := queries.GetDocumentCustomSchemaIdForClient(ctx, &repository.GetDocumentCustomSchemaIdForClientParams{
|
||
ID: docID,
|
||
ClientID: clientA,
|
||
})
|
||
require.NoError(t, err, "same-client lookup must succeed even when schema is NULL")
|
||
// schemaID is *uuid.UUID by sqlc convention for nullable cols;
|
||
// document has no schema bound, so the value must be nil.
|
||
require.Nil(t, schemaID,
|
||
"unbound document must report nil custom_schema_id, got %v", schemaID)
|
||
})
|
||
|
||
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
|
||
_, err := queries.GetDocumentCustomSchemaIdForClient(ctx, &repository.GetDocumentCustomSchemaIdForClientParams{
|
||
ID: docID,
|
||
ClientID: clientB,
|
||
})
|
||
require.Error(t, err)
|
||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||
})
|
||
}
|
||
|
||
// TestCurrentDocMetadataForClient_CrossClient: same-client returns the
|
||
// most recent metadata row; cross-client returns no row.
|
||
//
|
||
// Renamed from TestGetCurrentDocumentCustomMetadataForClient_CrossClient
|
||
// because Postgres NAMEDATALEN caps identifiers at 63 chars; the
|
||
// internal/test.GetAlias helper concatenates `postgrestest` with the
|
||
// test name and the original 52-char name pushed the alias to 64 chars.
|
||
// Postgres truncates silently and rerun resolves to a stale leftover DB.
|
||
// Shorter name keeps the alias at 55 chars, well under the cap.
|
||
func TestCurrentDocMetadataForClient_CrossClient(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_CMD_A"
|
||
clientB = "BOT_REPO_CMD_B"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-cmd-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-cmd-b")
|
||
|
||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||
Clientid: clientA,
|
||
Hash: "bot_repo_doc_hash_meta",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// Seed a metadata row with no schema bound (custom_schema_id NULL is
|
||
// permitted on document_custom_metadata per migration 129; the schema
|
||
// join in the query returns NULL for schemaName/schemaVersion).
|
||
_, err = queries.CreateDocumentCustomMetadata(ctx, &repository.CreateDocumentCustomMetadataParams{
|
||
DocumentID: docID,
|
||
Metadata: []byte(`{"k":"v"}`),
|
||
Version: 1,
|
||
CreatedBy: "tester",
|
||
})
|
||
require.NoError(t, err, "seed CreateDocumentCustomMetadata must succeed")
|
||
|
||
t.Run("same_client_returns_row", func(t *testing.T) {
|
||
row, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{
|
||
DocumentID: docID,
|
||
ClientID: clientA,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, row)
|
||
require.Equal(t, docID, row.DocumentID)
|
||
})
|
||
|
||
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
|
||
_, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{
|
||
DocumentID: docID,
|
||
ClientID: clientB,
|
||
})
|
||
require.Error(t, err)
|
||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||
})
|
||
}
|
||
|
||
// TestGetDocumentIDsInFolderTreeForClient_FolderRecursion proves the
|
||
// recursive folder tree walk filters clientId at every level. Layout:
|
||
//
|
||
// client A folders: client B folders:
|
||
// /A /B
|
||
// /A/sub (parent=/A) /B/sub (parent=/B)
|
||
//
|
||
// Documents:
|
||
//
|
||
// docA1 in /A
|
||
// docA2 in /A/sub
|
||
// docB1 in /B
|
||
// docB2 in /B/sub
|
||
//
|
||
// Calling GetDocumentIDsInFolderTreeForClient(rootFolder=/A, client=A)
|
||
// must return {docA1, docA2}; calling it with (/A, client=B) must
|
||
// return empty even though folder /A exists, because the clientId
|
||
// filter at the recursive step rejects the cross-client root.
|
||
func TestGetDocumentIDsInFolderTreeForClient_FolderRecursion(t *testing.T) {
|
||
t.Parallel()
|
||
if testing.Short() {
|
||
t.SkipNow()
|
||
}
|
||
ctx := t.Context()
|
||
|
||
cfg := &serviceconfig.BaseConfig{}
|
||
test.CreateDB(t, cfg)
|
||
pool := cfg.GetDBPool()
|
||
require.NotNil(t, pool)
|
||
queries := cfg.GetDBQueries()
|
||
require.NotNil(t, queries)
|
||
|
||
const (
|
||
clientA = "BOT_REPO_FT_A"
|
||
clientB = "BOT_REPO_FT_B"
|
||
)
|
||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-ft-a")
|
||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-ft-b")
|
||
|
||
// Folder tree for client A.
|
||
rootA, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||
Path: "/ft-a-root",
|
||
Parentid: nil,
|
||
Clientid: clientA,
|
||
Createdby: "tester",
|
||
})
|
||
require.NoError(t, err)
|
||
subA, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||
Path: "/ft-a-root/sub",
|
||
Parentid: &rootA.ID,
|
||
Clientid: clientA,
|
||
Createdby: "tester",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// Folder tree for client B.
|
||
rootB, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||
Path: "/ft-b-root",
|
||
Parentid: nil,
|
||
Clientid: clientB,
|
||
Createdby: "tester",
|
||
})
|
||
require.NoError(t, err)
|
||
subB, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||
Path: "/ft-b-root/sub",
|
||
Parentid: &rootB.ID,
|
||
Clientid: clientB,
|
||
Createdby: "tester",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// Documents in each folder. The folder column is camelCase per plan §4.
|
||
mkDoc := func(t *testing.T, clientID string, folderID uuid.UUID, hash string) uuid.UUID {
|
||
t.Helper()
|
||
var id uuid.UUID
|
||
err := pool.QueryRow(ctx, `
|
||
INSERT INTO documents (clientId, hash, folderId)
|
||
VALUES ($1, $2, $3) RETURNING id
|
||
`, clientID, hash, folderID).Scan(&id)
|
||
require.NoError(t, err)
|
||
return id
|
||
}
|
||
docA1 := mkDoc(t, clientA, rootA.ID, "ft_doc_a1")
|
||
docA2 := mkDoc(t, clientA, subA.ID, "ft_doc_a2")
|
||
_ = mkDoc(t, clientB, rootB.ID, "ft_doc_b1")
|
||
_ = mkDoc(t, clientB, subB.ID, "ft_doc_b2")
|
||
|
||
t.Run("client_A_root_returns_only_A_docs", func(t *testing.T) {
|
||
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
|
||
FolderID: rootA.ID,
|
||
ClientID: clientA,
|
||
})
|
||
require.NoError(t, err)
|
||
require.ElementsMatch(t, []uuid.UUID{docA1, docA2}, ids,
|
||
"folder tree of client A must include both A documents and exclude B documents")
|
||
})
|
||
|
||
t.Run("cross_client_root_returns_empty", func(t *testing.T) {
|
||
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
|
||
FolderID: rootA.ID, // A's root folder
|
||
ClientID: clientB, // queried as client B
|
||
})
|
||
require.NoError(t, err)
|
||
require.Empty(t, ids,
|
||
"cross-client recursion must reject the root and return zero documents")
|
||
})
|
||
|
||
t.Run("client_B_root_returns_only_B_docs", func(t *testing.T) {
|
||
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
|
||
FolderID: rootB.ID,
|
||
ClientID: clientB,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, ids, 2, "client B tree must yield exactly two documents")
|
||
})
|
||
}
|