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

332 lines
13 KiB
Go

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