// Package queryapi_test — Milestone 2 super-admin route tests. // // Routes covered: // - GET /super-admin/bot/sessions?clientId=... // - GET /super-admin/bot/sessions/{sessionId}?clientId=... // - DELETE /super-admin/bot/sessions/{sessionId}?clientId=... // // Compile contract: this file references the generated method names // (ListBotSessionsAsSuperAdmin, GetBotSessionAsSuperAdmin, // DeleteBotSessionAsSuperAdmin) and Params types backend-eng emits when // the M2 spec lands. Until then the file fails to compile — the failing // state the team-lead M2 dispatch endorsed. // // Note: operationIds are fixed by the team-lead M2 dispatch // (ListBotSessionsAsSuperAdmin / GetBotSessionAsSuperAdmin / // DeleteBotSessionAsSuperAdmin); no rename is pending. package queryapi_test import ( "encoding/json" "fmt" "net/http" "testing" queryapi "queryorchestration/api/queryAPI" "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" ) // TestSuperAdminListSessions_RequiresClientId: missing the clientId // query param returns 400. Plan §3: super-admin list takes a required // `clientId=...`. func TestSuperAdminListSessions_RequiresClientId(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) ctx, _ := newBotContext(t, http.MethodGet, "/super-admin/bot/sessions", nil) // ClientId left empty. err := cons.ListBotSessionsAsSuperAdmin(ctx, queryapi.ListBotSessionsAsSuperAdminParams{}) httpErr, ok := err.(*echo.HTTPError) require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err) assert.Equal(t, http.StatusBadRequest, httpErr.Code, "super-admin list without clientId must return 400") } // TestSuperAdminListSessions_VisibleAcrossUsers: super-admin sees U1's // AND U2's sessions in clientA when listing with clientId=clientA. // Plan §9: super-admin reads filter on client_id and is_deleted=false // only — no created_by predicate. func TestSuperAdminListSessions_VisibleAcrossUsers(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_SA_LIST" resetClientForBotTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "Bot M2 SA List") // Two sessions per user. u1Created := seedBotSessionViaService(t, cons, clientID, testActorSubject) u2Created := seedBotSessionViaService(t, cons, clientID, botOtherActorSubject) path := fmt.Sprintf("/super-admin/bot/sessions?clientId=%s", clientID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, cons.ListBotSessionsAsSuperAdmin(ctx, queryapi.ListBotSessionsAsSuperAdminParams{ClientId: clientID})) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.BotSessionListResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) ids := map[openapi_types.UUID]bool{} for _, s := range resp.Sessions { ids[s.Id] = true } assert.Truef(t, ids[u1Created.Id], "super-admin list must include U1's session %s", u1Created.Id) assert.Truef(t, ids[u2Created.Id], "super-admin list must include U2's session %s", u2Created.Id) } // TestSuperAdminListSessions_DeletedHidden: by default, // is_deleted=true sessions are excluded. Plan §10 M2 does not expose an // includeDeleted query parameter for M2. func TestSuperAdminListSessions_DeletedHidden(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_SA_DEL" resetClientForBotTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "Bot M2 SA Del") // Seed two sessions, soft-delete one. keep := seedBotSessionViaService(t, cons, clientID, testActorSubject) deleted := seedBotSessionViaService(t, cons, clientID, testActorSubject) _, err := cfg.GetDBPool().Exec(t.Context(), `UPDATE bot_sessions SET is_deleted = true WHERE id = $1`, uuid.UUID(deleted.Id)) require.NoError(t, err) path := fmt.Sprintf("/super-admin/bot/sessions?clientId=%s", clientID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, cons.ListBotSessionsAsSuperAdmin(ctx, queryapi.ListBotSessionsAsSuperAdminParams{ClientId: clientID})) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.BotSessionListResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) gotIDs := map[openapi_types.UUID]bool{} for _, s := range resp.Sessions { gotIDs[s.Id] = true } assert.Truef(t, gotIDs[keep.Id], "non-deleted session %s must appear", keep.Id) assert.Falsef(t, gotIDs[deleted.Id], "is_deleted=true session %s must be hidden", deleted.Id) } // TestSuperAdminGetSession_RequiresClientIdAndId: super-admin can GET // any session in clientA when calling with clientId=clientA. Missing // clientId returns 400. func TestSuperAdminGetSession_RequiresClientIdAndId(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_SA_GET" resetClientForBotTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "Bot M2 SA Get") created := seedBotSessionViaService(t, cons, clientID, testActorSubject) t.Run("missing_client_id_400", func(t *testing.T) { path := fmt.Sprintf("/super-admin/bot/sessions/%s", created.Id) ctx, _ := newBotContext(t, http.MethodGet, path, nil) err := cons.GetBotSessionAsSuperAdmin(ctx, asUUID(t, created.Id), queryapi.GetBotSessionAsSuperAdminParams{}) 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("with_client_id_returns_session", func(t *testing.T) { path := fmt.Sprintf("/super-admin/bot/sessions/%s?clientId=%s", created.Id, clientID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, cons.GetBotSessionAsSuperAdmin(ctx, asUUID(t, created.Id), queryapi.GetBotSessionAsSuperAdminParams{ClientId: clientID})) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.BotSessionResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Equal(t, created.Id, resp.Id) assert.Equal(t, clientID, resp.ClientId) }) } // TestSuperAdminGetSession_CrossClient_404: passing clientId=clientB // for a session belonging to clientA returns 404. func TestSuperAdminGetSession_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_SA_X_A" clientB = "BOT_M2_SA_X_B" ) resetClientForBotTests(t, cfg, clientA) resetClientForBotTests(t, cfg, clientB) test.CreateTestClient(t, cfg, clientA, "Bot M2 SA X A") test.CreateTestClient(t, cfg, clientB, "Bot M2 SA X B") created := seedBotSessionViaService(t, cons, clientA, testActorSubject) path := fmt.Sprintf("/super-admin/bot/sessions/%s?clientId=%s", created.Id, clientB) ctx, _ := newBotContext(t, http.MethodGet, path, nil) err := cons.GetBotSessionAsSuperAdmin(ctx, asUUID(t, created.Id), queryapi.GetBotSessionAsSuperAdminParams{ClientId: clientB}) httpErr, ok := err.(*echo.HTTPError) require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err) assert.Equal(t, http.StatusNotFound, httpErr.Code, "super-admin GET with mismatched clientId must be 404") } // TestSuperAdminDeleteSession_SoftDeletes: DELETE marks is_deleted=true; // the row remains in the table; subsequent owner GET returns 404; // subsequent super-admin GET also returns 404 (default filter). func TestSuperAdminDeleteSession_SoftDeletes(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_SA_DEL_SOFT" resetClientForBotTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "Bot M2 SA Del Soft") created := seedBotSessionViaService(t, cons, clientID, testActorSubject) // DELETE. delPath := fmt.Sprintf("/super-admin/bot/sessions/%s?clientId=%s", created.Id, clientID) delCtx, delRec := newBotContext(t, http.MethodDelete, delPath, nil) require.NoError(t, cons.DeleteBotSessionAsSuperAdmin(delCtx, asUUID(t, created.Id), queryapi.DeleteBotSessionAsSuperAdminParams{ClientId: clientID})) // Per existing super-admin DELETE patterns in this repo, 204 No Content // is the canonical success code for soft-delete-only endpoints. assert.Equal(t, http.StatusNoContent, delRec.Code, "DELETE must return 204 No Content for soft-delete success") // Row must still exist with is_deleted=true. var isDeleted bool err := cfg.GetDBPool().QueryRow(t.Context(), `SELECT is_deleted FROM bot_sessions WHERE id = $1`, uuid.UUID(created.Id)).Scan(&isDeleted) require.NoError(t, err, "row must remain in bot_sessions after soft-delete") assert.True(t, isDeleted, "is_deleted must be flipped to true") // Owner GET -> 404. getPath := fmt.Sprintf("/client/%s/bot/sessions/%s", clientID, created.Id) ownerCtx, _ := newBotContext(t, http.MethodGet, getPath, nil) ownerErr := cons.GetBotSession(ownerCtx, clientID, asUUID(t, created.Id)) httpErr, ok := ownerErr.(*echo.HTTPError) require.Truef(t, ok, "owner GET expected *echo.HTTPError, got %T: %v", ownerErr, ownerErr) assert.Equal(t, http.StatusNotFound, httpErr.Code, "owner GET must miss after super-admin soft-delete") // Super-admin GET (default filter) -> 404. saPath := fmt.Sprintf("/super-admin/bot/sessions/%s?clientId=%s", created.Id, clientID) saCtx, _ := newBotContext(t, http.MethodGet, saPath, nil) saErr := cons.GetBotSessionAsSuperAdmin(saCtx, asUUID(t, created.Id), queryapi.GetBotSessionAsSuperAdminParams{ClientId: clientID}) httpErrSA, ok := saErr.(*echo.HTTPError) require.Truef(t, ok, "super-admin GET expected *echo.HTTPError, got %T: %v", saErr, saErr) assert.Equal(t, http.StatusNotFound, httpErrSA.Code, "super-admin GET default filter must hide soft-deleted rows") } // TestSuperAdminDeleteSession_TurnsBecomeSessionDeleted: deleting a // session causes any in-flight turns to transition to session_deleted // status with appropriate error JSON. Plan §6 cross-references this // behavior for the AddTurn flow; here we assert it triggers from the // delete handler. // // Note: the M2 plan does not explicitly mandate this side-effect at // the delete handler level (it is described in plan §6 as the AddTurn // tx2 path). If backend-eng's design defers this to M4, the assertion // will fail and the team-lead can adjudicate whether to defer. func TestSuperAdminDeleteSession_TurnsBecomeSessionDeleted(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_SA_DEL_TURNS" resetClientForBotTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "Bot M2 SA Del Turns") created := seedBotSessionViaService(t, cons, clientID, testActorSubject) // Seed one in-flight turn directly via SQL (M2 has no turn endpoint). pool := cfg.GetDBPool() _, err := pool.Exec(t.Context(), ` INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id) VALUES ($1, 1, 'p1', 'in_flight', $2) `, uuid.UUID(created.Id), uuid.New()) require.NoError(t, err) // DELETE the session. delPath := fmt.Sprintf("/super-admin/bot/sessions/%s?clientId=%s", created.Id, clientID) delCtx, _ := newBotContext(t, http.MethodDelete, delPath, nil) require.NoError(t, cons.DeleteBotSessionAsSuperAdmin(delCtx, asUUID(t, created.Id), queryapi.DeleteBotSessionAsSuperAdminParams{ClientId: clientID})) // The in-flight turn must transition to session_deleted with an // error JSON payload containing the documented code. var status string var errJSON []byte err = pool.QueryRow(t.Context(), ` SELECT status, error::text FROM bot_turns WHERE session_id = $1 AND ordinal = 1 `, uuid.UUID(created.Id)).Scan(&status, &errJSON) require.NoError(t, err) assert.Equal(t, "session_deleted", status, "in-flight turn must transition to session_deleted on session delete") assert.NotEmpty(t, errJSON, "session_deleted row must carry error JSON per plan §4 CHECK constraint") }