// 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) } }