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

499 lines
19 KiB
Go

// Package queryapi_test — Milestone 5 client-scoped read-only document
// endpoints (plan §10 M5).
//
// Routes covered here:
// - GET /client/{clientId}/documents/{documentId}/schema
// - GET /client/{clientId}/documents/{documentId}/all-metadata
//
// These endpoints expose the M1-shipped client-scoped repo queries
// (GetDocumentEnrichedForClient, GetDocumentLabelsForClient,
// GetDocumentCustomSchemaIdForClient, GetCurrentDocumentCustomMetadataForClient)
// behind two HTTP routes plan §3 calls "user routes". Plan §3 mandates
// the singular `/client/...` form; the OpenAPI spec must register only
// the singular path so Permit.io's existing `client_user` policy applies.
//
// Compile contract: this file references queryapi.GetDocumentSchema,
// queryapi.GetDocumentAllMetadata controller methods plus
// queryapi.DocumentSchemaResponse and queryapi.DocumentAllMetadataResponse
// types. Backend-eng adds the OpenAPI spec entries, regenerates, and
// the file compiles. Until then it fails to compile — the failing
// state plan §10 M5 endorses.
//
// All tests use:
// - real Postgres via internal/test.CreateDB
// - the M2 helpers (newBotContext, newBotContextAs, resetClientForBotTests)
// for actor-injection and per-test cleanup
// - testActorSubject as the default Cognito sub
//
// No mocks. No t.Skip beyond testing.Short. No commented-out tests.
package queryapi_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
queryapi "queryorchestration/api/queryAPI"
"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"
)
// trivialDocSchemaJSON is a minimal JSON Schema that passes the
// schema-validator but carries enough fields for the M5 schema endpoint
// to demonstrate it serializes the schema body verbatim. Not the literal
// from sample.response.json — that is the real Aaria payload; here we
// just need a recognizable test fixture.
const trivialDocSchemaJSON = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"contract_title": {"type": "string"},
"effective_date": {"type": "string"}
},
"additionalProperties": false
}`
// botDocumentsContext bundles the per-test fixture: ControllerConfig,
// Controllers, and a clientID. Each test owns its own client so the
// shared testcontainer Postgres reuse is safe.
type botDocumentsContext struct {
cfg *ControllerConfig
cons *queryapi.Controllers
clientID string
}
// newBotDocumentsContext is the standard fixture for M5 tests. It
// stands up a real Postgres, runs initializeTestConfig (which sets the
// CHATBOT_* env placeholders), builds the full controller stack, and
// resets the test client. Mirrors newBotTurnsContext from M4 but
// without the FastAPI httptest server because M5 endpoints do not
// touch FastAPI.
func newBotDocumentsContext(t *testing.T, clientID string) *botDocumentsContext {
t.Helper()
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
resetClientForBotTests(t, cfg, clientID)
test.CreateTestClient(t, cfg, clientID, "bot-m5-"+clientID)
return &botDocumentsContext{
cfg: cfg,
cons: cons,
clientID: clientID,
}
}
// seedDocumentForClient creates a documents row owned by clientID and
// returns its uuid. Tests that need a cross-client document call this
// helper with the other clientID.
func seedDocumentForClient(t *testing.T, ctx *botDocumentsContext, clientID, hash string) uuid.UUID {
t.Helper()
docID, err := ctx.cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
Clientid: clientID,
Hash: hash,
})
require.NoError(t, err)
return docID
}
// seedSchemaForClient inserts an active client_metadata_schemas row
// and returns its id. The schema body is trivialDocSchemaJSON; tests
// only need the row to exist for the binding/rejection cases.
func seedSchemaForClient(t *testing.T, ctx *botDocumentsContext, clientID, name string) uuid.UUID {
t.Helper()
desc := "m5 test schema"
row, err := ctx.cfg.GetDBQueries().CreateClientMetadataSchema(t.Context(), &repository.CreateClientMetadataSchemaParams{
ClientID: clientID,
Name: name,
Description: &desc,
SchemaDef: []byte(trivialDocSchemaJSON),
Version: 1,
Status: repository.SchemaStatusTypeActive,
CreatedBy: "tester",
})
require.NoError(t, err)
return row.ID
}
// bindSchemaToDocument writes documents.custom_schema_id via the sqlc
// generated query. Same-client constraint comes from the composite FK
// (migration 128); cross-client binding is rejected at DB level.
func bindSchemaToDocument(t *testing.T, ctx *botDocumentsContext, docID, schemaID uuid.UUID) {
t.Helper()
require.NoError(t, ctx.cfg.GetDBQueries().SetDocumentCustomSchemaId(t.Context(), &repository.SetDocumentCustomSchemaIdParams{
CustomSchemaID: &schemaID,
ID: docID,
}))
}
// applyLabel writes a documentLabels row. The label string must exist
// in the labels seed (migration 110 seeds: Ingested, OCR_Processed,
// GenAI_Processed, Doczy_AI_Completed, Dashboard_Ready).
func applyLabel(t *testing.T, ctx *botDocumentsContext, docID uuid.UUID, label string) {
t.Helper()
_, err := ctx.cfg.GetDBQueries().ApplyLabel(t.Context(), &repository.ApplyLabelParams{
Documentid: docID,
Label: label,
Appliedby: "tester",
})
require.NoError(t, err)
}
// applyCustomMetadata writes one document_custom_metadata row at the
// supplied version. Caller is responsible for monotonic version values
// because the table enforces (document_id, version) uniqueness.
func applyCustomMetadata(t *testing.T, ctx *botDocumentsContext, docID uuid.UUID, version int32, metadata string) {
t.Helper()
_, err := ctx.cfg.GetDBQueries().CreateDocumentCustomMetadata(t.Context(), &repository.CreateDocumentCustomMetadataParams{
DocumentID: docID,
Metadata: []byte(metadata),
Version: version,
CreatedBy: "tester",
})
require.NoError(t, err)
}
// assertHTTPErrorM5 asserts the returned err is *echo.HTTPError with
// the expected code. Distinct from the M4 assertHTTPError so the M5
// file does not depend on M4 helper symbols.
func assertHTTPErrorM5(t *testing.T, err error, wantCode int) {
t.Helper()
require.Error(t, err)
httpErr, ok := err.(*echo.HTTPError)
require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err)
assert.Equal(t, wantCode, httpErr.Code)
}
// ---------- GET /client/{clientId}/documents/{documentId}/schema ----------
// TestGetDocumentSchema_OwnerSuccess: client_user authenticated for
// clientA fetches a document owned by clientA whose schema is bound to
// schemaS; response is 200 with the schema body serialized verbatim.
func TestGetDocumentSchema_OwnerSuccess(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_SCHEMA_OK"
bdc := newBotDocumentsContext(t, clientID)
docID := seedDocumentForClient(t, bdc, clientID, "m5-schema-ok-doc")
schemaID := seedSchemaForClient(t, bdc, clientID, "m5-schema-ok")
bindSchemaToDocument(t, bdc, docID, schemaID)
path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, docID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(docID)))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.DocumentSchemaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotNil(t, resp.Schema, "bound document must return a non-nil schema body")
// The schema body round-trips through the response. Asserting on a
// known property name keeps the test resilient to whichever exact
// field name backend-eng picks (Schema, schemaDef, etc.); the
// JSON-tag-driven serialization will surface "properties" verbatim.
props, ok := (*resp.Schema)["properties"].(map[string]interface{})
require.Truef(t, ok, "schema body must include 'properties' map; got %v", *resp.Schema)
require.Contains(t, props, "contract_title")
}
// TestGetDocumentSchema_CrossClient_404: client_user authenticated for
// clientA requests a document owned by clientB. Plan §9: cross-client
// reads must miss with 404.
func TestGetDocumentSchema_CrossClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const (
clientA = "BOT_M5_SCHEMA_X_A"
clientB = "BOT_M5_SCHEMA_X_B"
)
bdc := newBotDocumentsContext(t, clientA)
resetClientForBotTests(t, bdc.cfg, clientB)
test.CreateTestClient(t, bdc.cfg, clientB, "bot-m5-x-b")
// docB belongs to clientB.
docB := seedDocumentForClient(t, bdc, clientB, "m5-schema-x-doc-b")
schemaB := seedSchemaForClient(t, bdc, clientB, "m5-schema-x-b")
bindSchemaToDocument(t, bdc, docB, schemaB)
// Caller authenticates for clientA.
path := fmt.Sprintf("/client/%s/documents/%s/schema", clientA, docB)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err := bdc.cons.GetDocumentSchema(ctx, clientA, openapi_types.UUID(docB))
assertHTTPErrorM5(t, err, http.StatusNotFound)
}
// TestGetDocumentSchema_UnboundDocument_NullSchema: document exists but
// has NULL custom_schema_id. Per the dispatch authoritative answer, the
// endpoint returns 200 with schema=null in the body (the document
// exists; only the optional binding is absent).
func TestGetDocumentSchema_UnboundDocument_NullSchema(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_SCHEMA_UB"
bdc := newBotDocumentsContext(t, clientID)
// Document with no schema bound.
docID := seedDocumentForClient(t, bdc, clientID, "m5-schema-unbound-doc")
path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, docID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(docID)))
require.Equal(t, http.StatusOK, rec.Code,
"plan §10 M5: unbound document returns 200 with schema=null, not 404")
var resp queryapi.DocumentSchemaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Nil(t, resp.Schema,
"unbound document must return schema=null (the document exists; only the binding is absent)")
}
// TestGetDocumentSchema_NoSubject_401: handler returns 401 when no
// Cognito subject is present.
func TestGetDocumentSchema_NoSubject_401(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_SCHEMA_401"
bdc := newBotDocumentsContext(t, clientID)
docID := seedDocumentForClient(t, bdc, clientID, "m5-schema-401-doc")
path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, docID)
ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, "")
err := bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(docID))
assertHTTPErrorM5(t, err, http.StatusUnauthorized)
}
// TestGetDocumentSchema_DocumentNotFound_404: the supplied document id
// does not exist in the documents table.
func TestGetDocumentSchema_DocumentNotFound_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_SCHEMA_NF"
bdc := newBotDocumentsContext(t, clientID)
missingID := uuid.New()
path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, missingID)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err := bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(missingID))
assertHTTPErrorM5(t, err, http.StatusNotFound)
}
// ---------- GET /client/{clientId}/documents/{documentId}/all-metadata ----------
// TestGetDocumentAllMetadata_OwnerSuccess: docA belongs to clientA,
// has 2 labels and 3 custom-metadata key/value pairs. GET returns 200
// with document, labels, and customMetadata populated.
func TestGetDocumentAllMetadata_OwnerSuccess(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_ALL_OK"
bdc := newBotDocumentsContext(t, clientID)
docID := seedDocumentForClient(t, bdc, clientID, "m5-all-ok-doc")
schemaID := seedSchemaForClient(t, bdc, clientID, "m5-all-ok-schema")
bindSchemaToDocument(t, bdc, docID, schemaID)
// Two labels.
applyLabel(t, bdc, docID, "Ingested")
applyLabel(t, bdc, docID, "OCR_Processed")
// One metadata version with three keys.
applyCustomMetadata(t, bdc, docID, 1,
`{"contract_title":"AAA","effective_date":"2026-01-01","payer_name":"X Health"}`)
path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID)))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.DocumentAllMetadataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
// Document fields.
assert.Equal(t, openapi_types.UUID(docID), resp.Document.Id)
assert.Equal(t, clientID, string(resp.Document.ClientId))
// Labels: must contain both seeded labels (order is implementation-
// defined; the labels SQL ORDERs by appliedAt DESC).
require.Len(t, resp.Labels, 2,
"both seeded labels must appear in the response")
labelStrings := []string{string(resp.Labels[0].Label), string(resp.Labels[1].Label)}
assert.Contains(t, labelStrings, "Ingested")
assert.Contains(t, labelStrings, "OCR_Processed")
// Custom metadata: three keys round-trip with their values.
require.NotNil(t, resp.CustomMetadata)
assert.Equal(t, "AAA", (*resp.CustomMetadata)["contract_title"])
assert.Equal(t, "2026-01-01", (*resp.CustomMetadata)["effective_date"])
assert.Equal(t, "X Health", (*resp.CustomMetadata)["payer_name"])
}
// TestGetDocumentAllMetadata_CrossClient_404: clientA's document,
// requested as clientB. Plan §9: cross-client reads miss with 404.
func TestGetDocumentAllMetadata_CrossClient_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const (
clientA = "BOT_M5_ALL_X_A"
clientB = "BOT_M5_ALL_X_B"
)
bdc := newBotDocumentsContext(t, clientA)
resetClientForBotTests(t, bdc.cfg, clientB)
test.CreateTestClient(t, bdc.cfg, clientB, "bot-m5-all-x-b")
docB := seedDocumentForClient(t, bdc, clientB, "m5-all-x-doc-b")
path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientA, docB)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err := bdc.cons.GetDocumentAllMetadata(ctx, clientA, openapi_types.UUID(docB))
assertHTTPErrorM5(t, err, http.StatusNotFound)
}
// TestAllMetadata_UnboundSchemaEmptyMetadata: document has labels but no
// custom-metadata schema bound (no metadata rows possible without a
// schema). Response shows labels populated and customMetadata as an
// empty map / null.
//
// Renamed from TestGetDocumentAllMetadata_UnboundCustomSchema_EmptyMetadata
// because the original 60-char Go name produced a 66-char Postgres
// alias when prefixed with `postgrestest`, exceeding NAMEDATALEN=63.
// Same trap M1 hit on TestGetCurrentDocumentCustomMetadataForClient.
func TestAllMetadata_UnboundSchemaEmptyMetadata(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_ALL_UB"
bdc := newBotDocumentsContext(t, clientID)
docID := seedDocumentForClient(t, bdc, clientID, "m5-all-unbound-doc")
applyLabel(t, bdc, docID, "Ingested")
path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID)))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.DocumentAllMetadataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Labels, 1, "the one applied label must appear")
assert.Equal(t, "Ingested", string(resp.Labels[0].Label))
// CustomMetadata is either nil or an empty map. Both shapes match the
// "no schema bound -> no metadata" semantics; assert that nothing
// surfaces a non-trivial entry.
if resp.CustomMetadata != nil {
assert.Empty(t, *resp.CustomMetadata,
"unbound document must have empty/null customMetadata")
}
}
// TestGetDocumentAllMetadata_NoLabels_EmptyArray: document has no
// labels and no custom metadata. Response is well-formed with empty
// arrays/maps.
func TestGetDocumentAllMetadata_NoLabels_EmptyArray(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_ALL_EMPTY"
bdc := newBotDocumentsContext(t, clientID)
docID := seedDocumentForClient(t, bdc, clientID, "m5-all-empty-doc")
path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID)
ctx, rec := newBotContext(t, http.MethodGet, path, nil)
require.NoError(t, bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID)))
require.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.DocumentAllMetadataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotNil(t, resp.Labels,
"labels must be a non-nil slice so json.Marshal emits []")
assert.Empty(t, resp.Labels)
if resp.CustomMetadata != nil {
assert.Empty(t, *resp.CustomMetadata)
}
}
// TestGetDocumentAllMetadata_NoSubject_401: handler returns 401 when no
// Cognito subject is present.
func TestGetDocumentAllMetadata_NoSubject_401(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_ALL_401"
bdc := newBotDocumentsContext(t, clientID)
docID := seedDocumentForClient(t, bdc, clientID, "m5-all-401-doc")
path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID)
ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, "")
err := bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID))
assertHTTPErrorM5(t, err, http.StatusUnauthorized)
}
// TestGetDocumentAllMetadata_DocumentNotFound_404: the supplied
// document id does not exist.
func TestGetDocumentAllMetadata_DocumentNotFound_404(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
const clientID = "BOT_M5_ALL_NF"
bdc := newBotDocumentsContext(t, clientID)
missingID := uuid.New()
path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, missingID)
ctx, _ := newBotContext(t, http.MethodGet, path, nil)
err := bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(missingID))
assertHTTPErrorM5(t, err, http.StatusNotFound)
}
// ---------- Authorization smoke ----------
// TestGetDocumentEndpoints_PluralRouteAbsent: assert via OpenAPI grep
// that /clients/{clientId}/documents/.../{schema,all-metadata} (plural)
// is NOT registered. Plan §3 mandates the singular form so Permit.io's
// existing client_user policy applies.
func TestGetDocumentEndpoints_PluralRouteAbsent(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
swagger, err := queryapi.GetSwagger()
require.NoError(t, err)
require.NotNil(t, swagger.Paths)
for _, p := range []string{
"/clients/{clientId}/documents/{documentId}/schema",
"/clients/{clientId}/documents/{documentId}/all-metadata",
} {
assert.Nil(t, swagger.Paths.Find(p),
"plural route %s must not be registered (plan §3 mandates singular `client`)", p)
}
// Positive control: the singular routes must be declared.
for _, p := range []string{
"/client/{clientId}/documents/{documentId}/schema",
"/client/{clientId}/documents/{documentId}/all-metadata",
} {
assert.NotNilf(t, swagger.Paths.Find(p),
"singular route %s must be registered", p)
}
}