Files
query-orchestration/internal/database/repository/bot_repository_test.go
T
Jay Brown 72de690894 Merged in feature/chatbot1 (pull request #223)
Chatbot functionality

* baseline working

* missing test file

* more tests
2026-05-07 20:56:18 +00:00

922 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
})
}