Files
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

645 lines
24 KiB
Go

// Milestone 1 §M1 chatbot tables — migration shape and constraint tests.
//
// These tests are the authoritative spec for migration 131
// (00000000000131_create_chatbot_tables). They run against the real
// Postgres testcontainer that internal/test.CreateDB stands up. No mocks.
//
// Plan reference: plans/chatbot_plan_codex.v10.md §4 (Data Model).
//
// Coverage:
// - TestMigration131_UpDownRoundtrip: down then up apply cleanly and
// re-create every table, column, index, and CHECK constraint.
// - TestMigration131_BotTurnsCheckConstraints: bot_turns_status_values
// rejects unknown statuses; bot_turns_status_fields_consistent rejects
// every illegal (status, completion, error) tuple per §4.
// - TestMigration131_PartialUniqueIndexInflight: only one in_flight row
// per session is allowed.
// - TestMigration131_BotSessionsPartialIndexes: the two partial indexes
// on bot_sessions exist with the documented WHERE clause.
// - TestMigration131_ScopeTablesCascade: delete-cascade behavior for
// both bot_session_documents and bot_session_folders.
//
// All five tests follow the existing migration-test convention:
// - t.Parallel + testing.Short skip mirroring TestMigration127_*
// - per-test client/session reset because the testcontainer DB is reused
// - introspection via information_schema and pg_catalog
// - SQLSTATE assertions on negative-path expectations
package repository_test
import (
"context"
"errors"
"os"
"testing"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require"
)
// chatbotMigrationFiles is the canonical pair of file paths the round-trip
// test reads. Migration 131 is currently the latest, so single-step
// reversal is correct (team-lead M1 dispatch confirmed this).
const (
chatbotUp131Path = "../migrations/00000000000131_create_chatbot_tables.up.sql"
chatbotDown131Path = "../migrations/00000000000131_create_chatbot_tables.down.sql"
)
// resetClientForBot wipes any leftover client/session rows from prior
// runs. Mirrors resetClient in customschemas_queries_test.go: the
// Postgres container and per-test database are reused across runs, so
// fresh CreateClient calls would collide with leftover rows. Documents
// must be deleted first because clients->documents is NOT cascade.
// bot_sessions cascades via the bot_sessions FK on clients(clientId).
func resetClientForBot(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)
}
// seedBotSession inserts one bot_sessions row and returns its id. Uses
// raw SQL because the sqlc bot.sql generated layer is part of M1 and
// may not be in place when this helper is called.
func seedBotSession(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, createdBy string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := cfg.GetDBPool().QueryRow(ctx, `
INSERT INTO bot_sessions (client_id, created_by)
VALUES ($1, $2)
RETURNING id
`, clientID, createdBy).Scan(&id)
require.NoError(t, err, "seed bot_sessions must succeed")
return id
}
// TestMigration131_UpDownRoundtrip proves the down.sql is complete: it
// drops every object created by up.sql, and a subsequent up.sql re-apply
// re-creates them. Mirrors TestMigration127_UpDownRoundtrip. Best-effort
// cleanup runs the up.sql under context.Background() because t.Context()
// is canceled before t.Cleanup callbacks in Go 1.24+.
func TestMigration131_UpDownRoundtrip(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)
upSQL, err := os.ReadFile(chatbotUp131Path)
require.NoError(t, err, "must read migration 131 up.sql at %s", chatbotUp131Path)
downSQL, err := os.ReadFile(chatbotDown131Path)
require.NoError(t, err, "must read migration 131 down.sql at %s", chatbotDown131Path)
t.Cleanup(func() {
// Restore migrated state on the shared container regardless of
// test outcome. context.Background() is intentional — t.Context()
// is canceled before Cleanup callbacks in Go 1.24+.
_, _ = pool.Exec(context.Background(), string(upSQL)) //nolint:usetesting // see comment
})
// 1. Down — single-step because 131 is currently the latest migration.
_, err = pool.Exec(ctx, string(downSQL))
require.NoError(t, err, "down migration 131 must apply cleanly")
// All four tables must be gone.
for _, table := range []string{"bot_sessions", "bot_session_documents", "bot_session_folders", "bot_turns"} {
var oid *string
err = pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, table).Scan(&oid)
require.NoError(t, err)
require.Nilf(t, oid, "%s must not exist after down migration", table)
}
// 2. Up — re-create everything.
_, err = pool.Exec(ctx, string(upSQL))
require.NoError(t, err, "up migration 131 must re-apply cleanly")
// All four tables back.
for _, table := range []string{"bot_sessions", "bot_session_documents", "bot_session_folders", "bot_turns"} {
var oid *string
err = pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, table).Scan(&oid)
require.NoError(t, err)
require.NotNilf(t, oid, "%s must exist after up migration", table)
require.Equal(t, table, *oid)
}
// bot_turns CHECK constraints reappear.
for _, conname := range []string{"bot_turns_status_values", "bot_turns_status_fields_consistent"} {
var contype string
err = pool.QueryRow(ctx, `
SELECT c.contype::text
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE c.conname = $1 AND t.relname = 'bot_turns'
`, conname).Scan(&contype)
require.NoErrorf(t, err, "%s must exist on bot_turns", conname)
require.Equalf(t, "c", contype, "%s must be a CHECK constraint", conname)
}
// Partial unique index reappears.
var indexExists bool
err = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'bot_turns'
AND indexname = 'bot_turns_one_inflight_per_session'
)
`).Scan(&indexExists)
require.NoError(t, err)
require.True(t, indexExists, "bot_turns_one_inflight_per_session must reappear after up")
// Partial unique index must include the WHERE status='in_flight' clause.
var indexDef string
err = pool.QueryRow(ctx, `
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'bot_turns_one_inflight_per_session'
`).Scan(&indexDef)
require.NoError(t, err)
require.Contains(t, indexDef, "in_flight",
"bot_turns_one_inflight_per_session must filter on status='in_flight'")
// idx_bot_turns_session_completed reappears.
err = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'bot_turns'
AND indexname = 'idx_bot_turns_session_completed'
)
`).Scan(&indexExists)
require.NoError(t, err)
require.True(t, indexExists, "idx_bot_turns_session_completed must reappear after up")
}
// TestMigration131_BotTurnsCheckConstraints exercises both CHECK
// constraints on bot_turns directly. The status-enum CHECK rejects any
// value outside the documented set; the status/payload consistency CHECK
// enforces the (status, completion, error) matrix from plan §4.
func TestMigration131_BotTurnsCheckConstraints(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)
const clientID = "MIG131_CHK"
resetClientForBot(t, ctx, cfg, clientID, "mig131-chk")
sessionID := seedBotSession(t, ctx, cfg, clientID, "tester@example.com")
// Helper: assert the INSERT trips a check_violation (23514) naming
// the expected constraint.
assertCheckViolation := func(t *testing.T, err error, wantConstraint string) {
t.Helper()
require.Error(t, err, "expected CHECK violation, got success")
var pgErr *pgconn.PgError
require.True(t, errors.As(err, &pgErr),
"expected *pgconn.PgError, got %T: %v", err, err)
require.Equal(t, "23514", pgErr.Code,
"expected check_violation SQLSTATE 23514, got %q: %s", pgErr.Code, pgErr.Message)
require.Equal(t, wantConstraint, pgErr.ConstraintName,
"expected %s to be the tripped constraint", wantConstraint)
}
t.Run("status_enum_rejects_unknown", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, error)
VALUES ($1, $2, $3, $4, $5, NULL, NULL)
`, sessionID, 1, "p", "frobnicated", uuid.New())
assertCheckViolation(t, err, "bot_turns_status_values")
})
// Each row of this table sets the (status, completion, error) tuple
// the consistency CHECK must reject. ordinal collisions are avoided
// by giving each case its own ordinal.
type fieldsRow struct {
name string
ordinal int
status string
completionPtr *string
errorJSON *string
}
str := func(s string) *string { return &s }
const errJSON = `{"code":"x","message":"y"}`
t.Run("status_fields_consistent", func(t *testing.T) {
cases := []fieldsRow{
{
name: "in_flight_with_completion_rejected",
ordinal: 2,
status: "in_flight",
completionPtr: str("not allowed"),
errorJSON: nil,
},
{
name: "in_flight_with_error_rejected",
ordinal: 3,
status: "in_flight",
completionPtr: nil,
errorJSON: str(errJSON),
},
{
name: "completed_with_error_rejected",
ordinal: 4,
status: "completed",
completionPtr: str("answer"),
errorJSON: str(errJSON),
},
{
name: "completed_without_completion_rejected",
ordinal: 5,
status: "completed",
completionPtr: nil,
errorJSON: nil,
},
{
name: "errored_with_completion_rejected",
ordinal: 6,
status: "errored",
completionPtr: str("oops"),
errorJSON: str(errJSON),
},
{
name: "errored_without_error_rejected",
ordinal: 7,
status: "errored",
completionPtr: nil,
errorJSON: nil,
},
{
name: "abandoned_without_error_rejected",
ordinal: 8,
status: "abandoned",
completionPtr: nil,
errorJSON: nil,
},
{
name: "abandoned_with_completion_rejected",
ordinal: 9,
status: "abandoned",
completionPtr: str("partial"),
errorJSON: str(errJSON),
},
{
name: "session_deleted_without_error_rejected",
ordinal: 10,
status: "session_deleted",
completionPtr: nil,
errorJSON: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, error)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
`,
sessionID, c.ordinal, "prompt", c.status, uuid.New(),
c.completionPtr, c.errorJSON)
assertCheckViolation(t, err, "bot_turns_status_fields_consistent")
})
}
})
// Positive control: each legal tuple from plan §4 must be accepted.
// One session can hold at most one in_flight (partial unique index),
// so the in_flight case uses a fresh session.
t.Run("legal_status_tuples_accepted", func(t *testing.T) {
legalSessionID := seedBotSession(t, ctx, cfg, clientID, "legal@example.com")
// in_flight with NULL completion and NULL error.
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 1, 'p', 'in_flight', $2)
`, legalSessionID, uuid.New())
require.NoError(t, err, "in_flight with NULLs must be accepted")
// completed with completion non-NULL, error NULL. New session to
// avoid colliding with the legal in_flight row above.
completedSessionID := seedBotSession(t, ctx, cfg, clientID, "completed@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 1, 'p', 'completed', $2, 'ok')
`, completedSessionID, uuid.New())
require.NoError(t, err, "completed with completion must be accepted")
// errored with error non-NULL, completion NULL.
erroredSessionID := seedBotSession(t, ctx, cfg, clientID, "errored@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
VALUES ($1, 1, 'p', 'errored', $2, $3::jsonb)
`, erroredSessionID, uuid.New(), errJSON)
require.NoError(t, err, "errored with error must be accepted")
// abandoned with error non-NULL.
abandonedSessionID := seedBotSession(t, ctx, cfg, clientID, "abandoned@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
VALUES ($1, 1, 'p', 'abandoned', $2, $3::jsonb)
`, abandonedSessionID, uuid.New(), errJSON)
require.NoError(t, err, "abandoned with error must be accepted")
// session_deleted with error non-NULL.
deletedSessionID := seedBotSession(t, ctx, cfg, clientID, "deleted@example.com")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
VALUES ($1, 1, 'p', 'session_deleted', $2, $3::jsonb)
`, deletedSessionID, uuid.New(), errJSON)
require.NoError(t, err, "session_deleted with error must be accepted")
})
}
// TestMigration131_PartialUniqueIndexInflight proves bot_turns_one_inflight_per_session
// rejects a second in_flight row for the same session, but accepts mixed
// states (in_flight + completed) and multiple completed rows.
func TestMigration131_PartialUniqueIndexInflight(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)
const clientID = "MIG131_UNIQ"
resetClientForBot(t, ctx, cfg, clientID, "mig131-uniq")
sessionID := seedBotSession(t, ctx, cfg, clientID, "uniq@example.com")
t.Run("two_inflight_same_session_rejected", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 1, 'p1', 'in_flight', $2)
`, sessionID, uuid.New())
require.NoError(t, err, "first in_flight row must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 2, 'p2', 'in_flight', $2)
`, sessionID, uuid.New())
require.Error(t, err, "second in_flight row for same session must be rejected")
var pgErr *pgconn.PgError
require.True(t, errors.As(err, &pgErr),
"expected *pgconn.PgError, got %T: %v", err, err)
require.Equal(t, "23505", pgErr.Code,
"expected unique_violation SQLSTATE 23505, got %q", pgErr.Code)
require.Equal(t, "bot_turns_one_inflight_per_session", pgErr.ConstraintName,
"expected bot_turns_one_inflight_per_session to be the tripped constraint")
})
t.Run("inflight_plus_completed_allowed", func(t *testing.T) {
// Fresh session so we don't collide with the previous subtest.
mixedSession := seedBotSession(t, ctx, cfg, clientID, "mixed@example.com")
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 1, 'p1', 'completed', $2, 'ok')
`, mixedSession, uuid.New())
require.NoError(t, err, "completed row must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 2, 'p2', 'in_flight', $2)
`, mixedSession, uuid.New())
require.NoError(t, err, "in_flight after completed must be accepted (different status)")
})
t.Run("two_completed_allowed", func(t *testing.T) {
complSession := seedBotSession(t, ctx, cfg, clientID, "completed-twice@example.com")
_, err := pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 1, 'p1', 'completed', $2, 'ok1')
`, complSession, uuid.New())
require.NoError(t, err)
_, err = pool.Exec(ctx, `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, 2, 'p2', 'completed', $2, 'ok2')
`, complSession, uuid.New())
require.NoError(t, err, "two completed rows for the same session must be accepted")
})
}
// TestMigration131_BotSessionsPartialIndexes asserts the two partial
// indexes on bot_sessions exist with the documented WHERE clause
// (is_deleted = false). Plan §4: idx_bot_sessions_client_user_active and
// idx_bot_sessions_client_active are both partial indexes.
func TestMigration131_BotSessionsPartialIndexes(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)
expected := map[string][]string{
// Map index name -> ordered column list per plan §4.
"idx_bot_sessions_client_user_active": {"client_id", "created_by", "updated_at"},
"idx_bot_sessions_client_active": {"client_id", "updated_at"},
}
for indexName, wantCols := range expected {
t.Run(indexName, func(t *testing.T) {
var indexDef string
err := pool.QueryRow(ctx, `
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'bot_sessions'
AND indexname = $1
`, indexName).Scan(&indexDef)
require.NoErrorf(t, err, "index %s must exist on bot_sessions", indexName)
// WHERE clause must filter is_deleted = false. Postgres
// normalises the clause text, so a substring match is enough.
require.Contains(t, indexDef, "is_deleted = false",
"index %s must be partial on is_deleted = false; got %q",
indexName, indexDef)
// Each expected column must appear in the indexdef text.
for _, col := range wantCols {
require.Containsf(t, indexDef, col,
"index %s definition must reference column %s; got %q",
indexName, col, indexDef)
}
})
}
}
// TestMigration131_ScopeTablesCascade exercises the FK-cascade behavior
// declared in plan §4: deleting a bot_sessions row cascades through
// bot_session_documents and bot_session_folders; deleting a referenced
// document or folder cascades the linkage row in those join tables.
func TestMigration131_ScopeTablesCascade(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)
const clientID = "MIG131_CSC"
resetClientForBot(t, ctx, cfg, clientID, "mig131-csc")
// Use the sqlc generated CreateFolder/CreateDocument to seed
// scope-table fixtures. CreateFolder accepts parentId=NULL because
// the migration declares parentId as a nullable FK on folders.
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
t.Run("session_delete_cascades_documents", func(t *testing.T) {
// Seed: one session, one document, one bot_session_documents row.
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-doc@example.com")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "mig131_csc_doc_hash_session",
})
require.NoError(t, err, "CreateDocument must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2)
`, sessionID, docID)
require.NoError(t, err, "INSERT bot_session_documents must succeed")
// Sanity check the link is present.
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_documents
WHERE session_id = $1 AND document_id = $2
`, sessionID, docID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 1, linkCount, "link row must exist before delete")
// Delete the session; the link must vanish via cascade.
_, err = pool.Exec(ctx, `DELETE FROM bot_sessions WHERE id = $1`, sessionID)
require.NoError(t, err, "DELETE bot_sessions must succeed")
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_documents WHERE session_id = $1
`, sessionID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount, "link rows must cascade away with parent session")
})
t.Run("session_delete_cascades_folders", func(t *testing.T) {
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-fld@example.com")
folder, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/csc-folder-session",
Parentid: nil,
Clientid: clientID,
Createdby: "tester",
})
require.NoError(t, err, "CreateFolder must succeed")
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2)
`, sessionID, folder.ID)
require.NoError(t, err, "INSERT bot_session_folders must succeed")
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_folders
WHERE session_id = $1 AND folder_id = $2
`, sessionID, folder.ID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 1, linkCount, "folder link must exist before delete")
_, err = pool.Exec(ctx, `DELETE FROM bot_sessions WHERE id = $1`, sessionID)
require.NoError(t, err)
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_folders WHERE session_id = $1
`, sessionID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount, "folder link must cascade away with parent session")
})
t.Run("document_delete_cascades_link", func(t *testing.T) {
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-doc2@example.com")
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "mig131_csc_doc_hash_doc",
})
require.NoError(t, err)
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2)
`, sessionID, docID)
require.NoError(t, err)
// Delete the document; the link row must cascade.
_, err = pool.Exec(ctx, `DELETE FROM documents WHERE id = $1`, docID)
require.NoError(t, err)
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_documents WHERE document_id = $1
`, docID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount,
"deleting referenced document must cascade away the link row")
})
t.Run("folder_delete_cascades_link", func(t *testing.T) {
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-fld2@example.com")
folder, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/csc-folder-folder",
Parentid: nil,
Clientid: clientID,
Createdby: "tester",
})
require.NoError(t, err)
_, err = pool.Exec(ctx, `
INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2)
`, sessionID, folder.ID)
require.NoError(t, err)
_, err = pool.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folder.ID)
require.NoError(t, err)
var linkCount int
err = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bot_session_folders WHERE folder_id = $1
`, folder.ID).Scan(&linkCount)
require.NoError(t, err)
require.Equal(t, 0, linkCount,
"deleting referenced folder must cascade away the link row")
})
}