72de690894
Chatbot functionality * baseline working * missing test file * more tests
1001 lines
36 KiB
Go
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)
|
|
}
|
|
}
|