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

1001 lines
36 KiB
Go

// Package queryapi_test — Milestone 2 user-route tests for the chatbot
// session and scope CRUD endpoints (plan §3, §10 M2, §11).
//
// Routes covered here:
// - POST /client/{clientId}/bot/sessions
// - GET /client/{clientId}/bot/sessions
// - GET /client/{clientId}/bot/sessions/{sessionId}
// - PATCH /client/{clientId}/bot/sessions/{sessionId}/scope
//
// Super-admin routes live in bot_super_admin_test.go. Turn endpoints
// belong to M4. Document schema/all-metadata belong to M5.
//
// Compile contract: this file references the generated types/methods
// (CreateBotSession, ListBotSessions, GetBotSession, PatchBotSessionScope
// and their request/response/params types) that backend-eng emits when
// the bot routes land in serviceAPIs/queryAPI.yaml and `task generate`
// runs. Until then the file fails to compile — the failing state the
// team-lead M2 dispatch endorsed.
//
// All tests use:
// - real Postgres via internal/test.CreateDB
// - the controller helpers established in customschemas_test.go
// (newCustomSchemaContext, initializeTestConfig, ControllerConfig)
// - testActorSubject as the default Cognito sub
//
// No mocks. No t.Skip beyond testing.Short. No commented-out tests.
package queryapi_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
queryapi "queryorchestration/api/queryAPI"
"queryorchestration/internal/cognitoauth"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// botOtherActorSubject is a second fake JWT subject used to prove
// owner-scoped visibility (plan §9). The two subjects must differ; the
// service treats them as opaque.
const botOtherActorSubject = "f1e2d3c4-b5a6-4789-9012-3456789abcde"
// newBotContext returns an echo.Context with the test actor's subject
// injected via user_claims. Mirrors newCustomSchemaContext but with the
// path-template and param wiring controlled here so different tests can
// vary the path. The body argument is JSON-marshaled if non-nil.
func newBotContext(t testing.TB, method, path string, body interface{}) (echo.Context, *httptest.ResponseRecorder) {
t.Helper()
return newBotContextAs(t, method, path, body, testActorSubject)
}
// newBotContextAs is newBotContext with an explicit subject so tests can
// drive the U2/cross-user matrix without copy-paste.
func newBotContextAs(t testing.TB, method, path string, body interface{}, subject string) (echo.Context, *httptest.ResponseRecorder) {
t.Helper()
e := echo.New()
var reqBody []byte
if body != nil {
var err error
reqBody, err = json.Marshal(body)
require.NoError(t, err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(reqBody))
if reqBody != nil {
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
}
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
if subject != "" {
ctx.Set("user_claims", map[string]interface{}{"sub": subject})
ctx.Set("user_info", cognitoauth.UserInfo{Username: "client-user"})
}
return ctx, rec
}
// resetClientForBotTests cascades a client and every chatbot-relevant
// child row out of the DB so reruns of the same test start from a clean
// slate. The shared testcontainer Postgres reuses volumes across
// invocations, so leftover bot_sessions / bot_session_documents /
// bot_session_folders / bot_turns rows must be wiped before CreateClient
// or the FK-cascade chain on `clients.clientId` would otherwise re-create
// duplicates.
func resetClientForBotTests(t testing.TB, cfg *ControllerConfig, clientID string) {
t.Helper()
ctx := t.Context()
pool := cfg.GetDBPool()
stmts := []string{
// bot_turns rows cascade away with their parent session, but the
// session row depends on a clean clients chain. Drop anything that
// could pin the clients row first.
`DELETE FROM document_custom_metadata WHERE document_id IN (SELECT id FROM documents WHERE clientId = $1)`,
`UPDATE documents SET custom_schema_id = NULL WHERE clientId = $1`,
`DELETE FROM client_metadata_schemas WHERE client_id = $1`,
`DELETE FROM documentEntries WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
`DELETE FROM documentFieldExtractionVersions WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
`DELETE FROM documentFieldExtractions WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
`DELETE FROM documentLabels WHERE documentId IN (SELECT id FROM documents WHERE clientId = $1)`,
`DELETE FROM bot_session_documents WHERE document_id IN (SELECT id FROM documents WHERE clientId = $1)`,
`DELETE FROM bot_session_folders WHERE folder_id IN (SELECT id FROM folders WHERE clientId = $1)`,
`DELETE FROM bot_sessions WHERE client_id = $1`,
`DELETE FROM documents WHERE clientId = $1`,
`DELETE FROM folders WHERE clientId = $1`,
`DELETE FROM clientCanSync WHERE clientId = $1`,
`DELETE FROM clients WHERE clientId = $1`,
}
for _, stmt := range stmts {
_, err := pool.Exec(ctx, stmt, clientID)
require.NoErrorf(t, err, "reset stmt failed: %s", stmt)
}
}
// seedBotSessionViaService creates a bot session through the real
// CreateBotSession handler so each test exercises the full handler->
// service->repository chain. Returns the parsed response.
func seedBotSessionViaService(t *testing.T, cons *queryapi.Controllers, clientID, subject string) queryapi.BotSessionResponse {
t.Helper()
body := queryapi.CreateBotSessionRequest{}
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
ctx, rec := newBotContextAs(t, http.MethodPost, path, body, subject)
require.NoError(t, cons.CreateBotSession(ctx, clientID))
require.Equal(t, http.StatusCreated, rec.Code)
var resp queryapi.BotSessionResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return resp
}
// seedDocumentForBot inserts a real documents row owned by clientID and
// returns its id. Bot scope tests need at least one same-client doc to
// add and one cross-client doc to reject.
func seedDocumentForBot(t *testing.T, cfg *ControllerConfig, clientID, hash string) uuid.UUID {
t.Helper()
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
Clientid: clientID,
Hash: hash,
})
require.NoError(t, err, "CreateDocument(clientID=%s) must succeed", clientID)
return docID
}
// seedFolderForBot inserts a real folders row owned by clientID and
// returns its id.
func seedFolderForBot(t *testing.T, cfg *ControllerConfig, clientID, path string) uuid.UUID {
t.Helper()
folder, err := cfg.GetDBQueries().CreateFolder(t.Context(), &repository.CreateFolderParams{
Path: path,
Parentid: nil,
Clientid: clientID,
Createdby: "tester",
})
require.NoError(t, err, "CreateFolder(clientID=%s) must succeed", clientID)
return folder.ID
}
// asUUID converts a session id string from the response into the
// openapi_types.UUID the handlers accept on path parameters.
func asUUID(t testing.TB, id openapi_types.UUID) openapi_types.UUID {
t.Helper()
return id
}
// ---------- POST /client/{clientId}/bot/sessions ----------
// TestPostBotSession_OwnerCreates: client_user posts a session and the
// 201 response carries the documented BotSession entity shape from
// plan §3 / §4: id, clientId, createdBy, title (empty until first turn),
// state ({}), lastTurn (0), createdAt, updatedAt.
func TestPostBotSession_OwnerCreates(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_POST_OWN"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Post Owner")
body := queryapi.CreateBotSessionRequest{}
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
ctx, rec := newBotContext(t, http.MethodPost, path, body)
require.NoError(t, cons.CreateBotSession(ctx, clientID))
require.Equal(t, http.StatusCreated, rec.Code)
var resp queryapi.BotSessionResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotEqual(t, uuid.Nil, uuid.UUID(resp.Id))
assert.Equal(t, clientID, resp.ClientId)
assert.Equal(t, testActorSubject, resp.CreatedBy)
assert.Equal(t, "", resp.Title,
"plan §6: title is empty until first turn arrives")
assert.Equal(t, int32(0), resp.LastTurn,
"new session has no terminal turns; lastTurn=0")
assert.False(t, resp.CreatedAt.IsZero(), "createdAt must be set by DEFAULT NOW()")
assert.False(t, resp.UpdatedAt.IsZero(), "updatedAt must be set by DEFAULT NOW()")
// state defaults to '{}'::jsonb on the row; the response must reflect
// that as an empty JSON object.
assert.NotNil(t, resp.State)
}
// TestPostBotSession_NoSubject_401: with no Cognito subject in context,
// the handler returns 401.
func TestPostBotSession_NoSubject_401(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_POST_401"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Post 401")
body := queryapi.CreateBotSessionRequest{}
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
// Empty subject means no user_claims is set on the context.
ctx, _ := newBotContextAs(t, http.MethodPost, path, body, "")
err := cons.CreateBotSession(ctx, clientID)
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
}
// ---------- GET /client/{clientId}/bot/sessions/{sessionId} ----------
// TestGetBotSession_Owner_200: the session creator can read their session
// back. lastTurn echoes last_terminal_ordinal (plan §4 derived note); a
// freshly created session has zero turns, so lastTurn must be 0.
func TestGetBotSession_Owner_200(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_GET_OWN"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Get Owner")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
path := fmt.Sprintf("/client/%s/bot/sessions/%s", clientID, created.Id)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, cons.GetBotSession(ctx, clientID, asUUID(t, created.Id)))
require.Equal(t, http.StatusOK, rec.Code)
var fetched queryapi.BotSessionResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &fetched))
assert.Equal(t, created.Id, fetched.Id)
assert.Equal(t, clientID, fetched.ClientId)
assert.Equal(t, testActorSubject, fetched.CreatedBy)
assert.Equal(t, int32(0), fetched.LastTurn,
"plan §4: lastTurn = last_terminal_ordinal; no turns yet -> 0")
}
// TestGetBotSession_CrossClient_404: a session belonging to clientA must
// not be readable when the URL clientId is clientB.
func TestGetBotSession_CrossClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const (
clientA = "BOT_M2_GET_X_A"
clientB = "BOT_M2_GET_X_B"
)
resetClientForBotTests(t, cfg, clientA)
resetClientForBotTests(t, cfg, clientB)
test.CreateTestClient(t, cfg, clientA, "Bot M2 Get X A")
test.CreateTestClient(t, cfg, clientB, "Bot M2 Get X B")
created := seedBotSessionViaService(t, cons, clientA, testActorSubject)
path := fmt.Sprintf("/client/%s/bot/sessions/%s", clientB, created.Id)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err := cons.GetBotSession(ctx, clientB, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusNotFound, httpErr.Code,
"plan §9: cross-client read must return 404")
}
// TestGetBotSession_OtherUserSameClient_404: U1 owns the session; U2 in
// the same client must see 404 because plan §9 mandates owner-scoped
// reads.
func TestGetBotSession_OtherUserSameClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_GET_OTHER_USER"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Get Other User")
// U1 creates the session.
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
// U2 attempts to read.
path := fmt.Sprintf("/client/%s/bot/sessions/%s", clientID, created.Id)
ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, botOtherActorSubject)
err := cons.GetBotSession(ctx, clientID, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusNotFound, httpErr.Code,
"plan §9: same-client / other-user read must return 404")
}
// TestGetBotSession_DeletedSession_404: a session marked is_deleted=true
// is invisible even to its owner. Soft-delete is set by the super-admin
// DELETE handler; here we set the flag directly to isolate the read path.
func TestGetBotSession_DeletedSession_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_GET_DELETED"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Get Deleted")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
// Mark the session deleted at the row level.
_, err := cfg.GetDBPool().Exec(t.Context(),
`UPDATE bot_sessions SET is_deleted = true WHERE id = $1`,
uuid.UUID(created.Id))
require.NoError(t, err)
path := fmt.Sprintf("/client/%s/bot/sessions/%s", clientID, created.Id)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err = cons.GetBotSession(ctx, clientID, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusNotFound, httpErr.Code,
"is_deleted=true session must not be returned to its owner")
}
// ---------- GET /client/{clientId}/bot/sessions ----------
// TestListBotSessions_OwnerVisibility: U1 has 3 sessions, U2 has 2 in
// the same client. U1's GET returns only U1's three sessions. The
// owner-scoped index from plan §4 backs this query.
func TestListBotSessions_OwnerVisibility(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_LIST_VIS"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 List Vis")
// Seed 3 U1 sessions and 2 U2 sessions.
u1Sessions := map[openapi_types.UUID]bool{}
for i := 0; i < 3; i++ {
s := seedBotSessionViaService(t, cons, clientID, testActorSubject)
u1Sessions[s.Id] = true
}
for i := 0; i < 2; i++ {
seedBotSessionViaService(t, cons, clientID, botOtherActorSubject)
}
// U1 lists.
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{}))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Len(t, resp.Sessions, 3, "U1 must see exactly its three sessions")
for _, s := range resp.Sessions {
assert.True(t, u1Sessions[s.Id],
"session %s should belong to U1's seed set", s.Id)
assert.Equal(t, testActorSubject, s.CreatedBy)
}
}
// TestListBotSessions_LimitBounds: limit=0 -> 400; limit=201 -> 400;
// limit=200 accepted; limit=1 accepted. Plan §3 mandates [1, 200].
func TestListBotSessions_LimitBounds(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_LIST_LIM"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 List Lim")
type tc struct {
name string
limit int32
want int
reason string
}
cases := []tc{
{name: "limit_zero_rejected", limit: 0, want: http.StatusBadRequest, reason: "below [1, 200]"},
{name: "limit_negative_rejected", limit: -1, want: http.StatusBadRequest, reason: "negative"},
{name: "limit_201_rejected", limit: 201, want: http.StatusBadRequest, reason: "above [1, 200]"},
{name: "limit_one_accepted", limit: 1, want: http.StatusOK, reason: "lower bound inclusive"},
{name: "limit_two_hundred_accepted", limit: 200, want: http.StatusOK, reason: "upper bound inclusive"},
}
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
limit := c.limit
err := cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{
Limit: &limit,
})
if c.want == http.StatusBadRequest {
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError for %s, got %T: %v", c.reason, err, err)
assert.Equal(t, c.want, httpErr.Code, "limit=%d (%s)", c.limit, c.reason)
return
}
require.NoError(t, err, "limit=%d (%s) must be accepted", c.limit, c.reason)
assert.Equal(t, c.want, rec.Code)
})
}
}
// TestListBotSessions_OffsetBounds: offset=-1 -> 400; offset=0 accepted.
// Plan §3 mandates offset >= 0.
func TestListBotSessions_OffsetBounds(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_LIST_OFF"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 List Off")
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
t.Run("offset_negative_rejected", func(t *testing.T) {
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
neg := int32(-1)
err := cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{
Offset: &neg,
})
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
})
t.Run("offset_zero_accepted", func(t *testing.T) {
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
zero := int32(0)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{
Offset: &zero,
}))
assert.Equal(t, http.StatusOK, rec.Code)
})
}
// TestListBotSessions_DefaultsAndPaging: a default request returns up
// to repo defaults; offset paging works (5 sessions, request limit=2
// offset=2 returns the middle two ordered by updated_at DESC per plan §4).
func TestListBotSessions_DefaultsAndPaging(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_LIST_PAGE"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 List Page")
// Seed 5 sessions in order; we will assert the offset=2 limit=2 page
// contains two sessions, regardless of which two — the seed order
// flexes by updatedAt DESC, which is implementation-defined for ties
// in this test (all five seeded back-to-back).
for i := 0; i < 5; i++ {
seedBotSessionViaService(t, cons, clientID, testActorSubject)
}
t.Run("defaults_return_all_five", func(t *testing.T) {
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{}))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Len(t, resp.Sessions, 5)
})
t.Run("offset_two_limit_two_returns_two", func(t *testing.T) {
path := fmt.Sprintf("/client/%s/bot/sessions?limit=2&offset=2", clientID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
limit := int32(2)
offset := int32(2)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{
Limit: &limit,
Offset: &offset,
}))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Len(t, resp.Sessions, 2,
"offset=2 limit=2 over 5 rows must yield 2 sessions")
})
}
// TestListBotSessions_TurnPreviewLimit: each session has 7 completed
// turns; per-session preview turn count is min(limit, cfg.RecentTurns).
// With cfg.RecentTurns=5 (default), limit=3 -> preview 3; limit=10 -> 5.
//
// Direct turn seeding via raw SQL: M2 does not yet expose a turns
// endpoint to seed via the API (that's M4). The SQL is identical to the
// M1 repository tests so any drift is caught by both layers.
func TestListBotSessions_TurnPreviewLimit(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_LIST_PREV"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 List Prev")
// Seed two sessions, each with 7 completed turns.
pool := cfg.GetDBPool()
for i := 0; i < 2; i++ {
s := seedBotSessionViaService(t, cons, clientID, testActorSubject)
for ord := 1; ord <= 7; ord++ {
_, err := pool.Exec(t.Context(), `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, $2, $3, 'completed', $4, 'answer')
`, uuid.UUID(s.Id), ord, fmt.Sprintf("p%d", ord), uuid.New())
require.NoError(t, err)
}
}
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
t.Run("limit_three_caps_preview_at_three", func(t *testing.T) {
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
limit := int32(3)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{
Limit: &limit,
}))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Sessions, 2)
for _, s := range resp.Sessions {
assert.Len(t, s.RecentTurns, 3,
"limit=3 cfg.RecentTurns=5 -> preview = min(3, 5) = 3")
}
})
t.Run("limit_ten_caps_preview_at_recent_turns", func(t *testing.T) {
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
limit := int32(10)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{
Limit: &limit,
}))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
// Service caps the inner preview at cfg.RecentTurns = 5 even when
// the request asks for more sessions. The resp.Sessions length
// is bounded by total seeded sessions (2), but each session's
// recentTurns must be capped at 5.
require.LessOrEqual(t, len(resp.Sessions), 10,
"limit=10 over 2 sessions must yield at most 2 entries")
for _, s := range resp.Sessions {
assert.Len(t, s.RecentTurns, 5,
"limit=10 cfg.RecentTurns=5 -> preview = min(10, 5) = 5")
}
})
}
// TestListBotSessions_LastTurnIsTerminal: a session with 3 completed
// turns plus 1 in-flight turn returns lastTurn=3 (the last terminal
// ordinal), not 4 (the last seen ordinal). Plan §4 derived note.
func TestListBotSessions_LastTurnIsTerminal(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_LIST_TERM"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 List Term")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
pool := cfg.GetDBPool()
// 3 completed turns at ordinals 1..3.
for ord := 1; ord <= 3; ord++ {
_, err := pool.Exec(t.Context(), `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
VALUES ($1, $2, $3, 'completed', $4, 'answer')
`, uuid.UUID(created.Id), ord, fmt.Sprintf("p%d", ord), uuid.New())
require.NoError(t, err)
}
// 1 in-flight turn at ordinal 4.
_, err := pool.Exec(t.Context(), `
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
VALUES ($1, 4, 'p4', 'in_flight', $2)
`, uuid.UUID(created.Id), uuid.New())
require.NoError(t, err)
path := fmt.Sprintf("/client/%s/bot/sessions", clientID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, cons.ListBotSessions(ctx, clientID, queryapi.ListBotSessionsParams{}))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Sessions, 1)
assert.Equal(t, int32(3), resp.Sessions[0].LastTurn,
"plan §4: lastTurn = last_terminal_ordinal; 3 completed + 1 in_flight -> 3")
}
// ---------- PATCH /client/{clientId}/bot/sessions/{sessionId}/scope ----------
// TestPatchScope_AddDocuments: PATCH adds three documents to a session;
// response shows three documents in the scope.
func TestPatchScope_AddDocuments(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_PATCH_ADD"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Patch Add")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
docA := seedDocumentForBot(t, cfg, clientID, "patch_add_doc_a")
docB := seedDocumentForBot(t, cfg, clientID, "patch_add_doc_b")
docC := seedDocumentForBot(t, cfg, clientID, "patch_add_doc_c")
body := queryapi.BotSessionScopePatchRequest{
AddDocuments: &[]openapi_types.UUID{docA, docB, docC},
}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientID, created.Id)
ctx, rec := newBotContext(t, http.MethodPatch, path, body)
require.NoError(t, cons.PatchBotSessionScope(ctx, clientID, asUUID(t, created.Id)))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotNil(t, resp.Scope)
assert.ElementsMatch(t,
[]openapi_types.UUID{docA, docB, docC},
resp.Scope.DocumentIds,
"scope must reflect the three added documents")
}
// TestPatchScope_DedupesDuplicateIds: client sends duplicate docA in
// addDocuments; the persisted set is {docA, docB} once.
func TestPatchScope_DedupesDuplicateIds(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_PATCH_DUP"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Patch Dup")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
docA := seedDocumentForBot(t, cfg, clientID, "patch_dup_doc_a")
docB := seedDocumentForBot(t, cfg, clientID, "patch_dup_doc_b")
body := queryapi.BotSessionScopePatchRequest{
AddDocuments: &[]openapi_types.UUID{docA, docA, docB},
}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientID, created.Id)
ctx, rec := newBotContext(t, http.MethodPatch, path, body)
require.NoError(t, cons.PatchBotSessionScope(ctx, clientID, asUUID(t, created.Id)))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.BotSessionResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotNil(t, resp.Scope)
assert.ElementsMatch(t,
[]openapi_types.UUID{docA, docB},
resp.Scope.DocumentIds,
"duplicate docA must be deduplicated to a single membership")
}
// TestPatchScope_AddRemoveSameId_400: a request that both adds and
// removes the same id is rejected with 400.
func TestPatchScope_AddRemoveSameId_400(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_PATCH_AR"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Patch AR")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
docA := seedDocumentForBot(t, cfg, clientID, "patch_ar_doc_a")
body := queryapi.BotSessionScopePatchRequest{
AddDocuments: &[]openapi_types.UUID{docA},
RemoveDocuments: &[]openapi_types.UUID{docA},
}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientID, created.Id)
ctx, _ := newBotContext(t, http.MethodPatch, path, body)
err := cons.PatchBotSessionScope(ctx, clientID, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusBadRequest, httpErr.Code,
"add+remove of the same id must be 400")
}
// TestPatchScope_DocumentCrossClient_400: a document belonging to
// clientB cannot be added to a session in clientA. Dispatch:
// 400 with reason `document_cross_client`.
func TestPatchScope_DocumentCrossClient_400(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const (
clientA = "BOT_M2_PATCH_X_A"
clientB = "BOT_M2_PATCH_X_B"
)
resetClientForBotTests(t, cfg, clientA)
resetClientForBotTests(t, cfg, clientB)
test.CreateTestClient(t, cfg, clientA, "Bot M2 Patch X A")
test.CreateTestClient(t, cfg, clientB, "Bot M2 Patch X B")
created := seedBotSessionViaService(t, cons, clientA, testActorSubject)
// Document belongs to clientB, not clientA.
crossClientDoc := seedDocumentForBot(t, cfg, clientB, "patch_x_doc")
body := queryapi.BotSessionScopePatchRequest{
AddDocuments: &[]openapi_types.UUID{crossClientDoc},
}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientA, created.Id)
ctx, _ := newBotContext(t, http.MethodPatch, path, body)
err := cons.PatchBotSessionScope(ctx, clientA, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusBadRequest, httpErr.Code,
"cross-client document add must be 400 (document_cross_client)")
}
// TestPatchScope_FolderCrossClient_400: a folder belonging to clientB
// cannot be added to a session in clientA. Same shape as the document
// test.
func TestPatchScope_FolderCrossClient_400(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const (
clientA = "BOT_M2_PATCH_FX_A"
clientB = "BOT_M2_PATCH_FX_B"
)
resetClientForBotTests(t, cfg, clientA)
resetClientForBotTests(t, cfg, clientB)
test.CreateTestClient(t, cfg, clientA, "Bot M2 Patch FX A")
test.CreateTestClient(t, cfg, clientB, "Bot M2 Patch FX X B")
created := seedBotSessionViaService(t, cons, clientA, testActorSubject)
crossClientFolder := seedFolderForBot(t, cfg, clientB, "/patch-x-folder")
body := queryapi.BotSessionScopePatchRequest{
AddFolders: &[]openapi_types.UUID{crossClientFolder},
}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientA, created.Id)
ctx, _ := newBotContext(t, http.MethodPatch, path, body)
err := cons.PatchBotSessionScope(ctx, clientA, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusBadRequest, httpErr.Code,
"cross-client folder add must be 400 (folder_cross_client)")
}
// TestPatchScope_NoSession_404: PATCH to a non-existent session id
// returns 404.
func TestPatchScope_NoSession_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_PATCH_NF"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Patch NF")
missingID := openapi_types.UUID(uuid.New())
body := queryapi.BotSessionScopePatchRequest{}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientID, missingID)
ctx, _ := newBotContext(t, http.MethodPatch, path, body)
err := cons.PatchBotSessionScope(ctx, clientID, missingID)
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusNotFound, httpErr.Code)
}
// TestPatchScope_OtherUserSameClient_404: U1 owns the session; U2 in the
// same client cannot PATCH it (owner-scoped writes per plan §9).
func TestPatchScope_OtherUserSameClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
const clientID = "BOT_M2_PATCH_OU"
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "Bot M2 Patch OU")
created := seedBotSessionViaService(t, cons, clientID, testActorSubject)
body := queryapi.BotSessionScopePatchRequest{}
path := fmt.Sprintf("/client/%s/bot/sessions/%s/scope", clientID, created.Id)
ctx, _ := newBotContextAs(t, http.MethodPatch, path, body, botOtherActorSubject)
err := cons.PatchBotSessionScope(ctx, clientID, asUUID(t, created.Id))
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, http.StatusNotFound, httpErr.Code,
"plan §9: other-user PATCH must return 404")
}
// ---------- Authorization smoke ----------
// TestBotRoutes_PluralRouteAbsent: a request to the plural-form route
// /clients/{clientId}/bot/sessions must miss. Plan §3 spells out the
// Permit.io reasoning: existing policy gives client_user write
// permissions on resource `client`, not `clients`. Ensures no plural
// route slipped into the OpenAPI spec.
//
// We assert the OpenAPI spec contains zero `/clients/{...}/bot/`
// references; this is the most reliable check given the M2 spec is
// regenerated and tests run before the router is wired up by main.go.
func TestBotRoutes_PluralRouteAbsent(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
swagger, err := queryapi.GetSwagger()
require.NoError(t, err)
require.NotNil(t, swagger.Paths)
// Iterate over every declared path and prove none uses the plural
// `/clients/{...}/bot/...` form.
for _, p := range []string{
"/clients/{clientId}/bot/sessions",
"/clients/{clientId}/bot/sessions/{sessionId}",
"/clients/{clientId}/bot/sessions/{sessionId}/scope",
} {
assert.Nil(t, swagger.Paths.Find(p),
"plural route %s must not be registered (plan §3 mandates singular `client`)", p)
}
// Positive control: at least one of the singular bot routes must be
// declared. If both this and the plural-absent assertion pass, the
// spec correctly uses the singular form.
for _, p := range []string{
"/client/{clientId}/bot/sessions",
"/client/{clientId}/bot/sessions/{sessionId}",
"/client/{clientId}/bot/sessions/{sessionId}/scope",
} {
assert.NotNilf(t, swagger.Paths.Find(p),
"singular bot route %s must be registered", p)
}
}