Files
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

445 lines
16 KiB
Go

package customschema_test
import (
"context"
"encoding/json"
"errors"
"testing"
"queryorchestration/internal/customschema"
"queryorchestration/internal/database/repository"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mutable metadata feature, milestone 2.4: integration tests for the
// service layer's metadata + document-binding methods. No mocks — every
// test runs against a real Postgres testcontainer stood up by
// internal/test.CreateDB.
// seedDocForMetadataTest creates a fresh document for the given clientID
// and returns its id. Relies on the fixture builder in service_test.go
// which has already cleaned up the client.
func seedDocForMetadataTest(t *testing.T, ctx context.Context, fx *testFixture, clientID, hash string) uuid.UUID {
t.Helper()
docID, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: hash,
})
require.NoError(t, err)
return docID
}
// seedActiveSchemaForMetadataTest creates an active schema for a client.
// Uses the service's CreateSchema so the path exercises Milestone 1's
// happy flow too (defense in depth for test setup correctness).
func seedActiveSchemaForMetadataTest(t *testing.T, ctx context.Context, fx *testFixture, clientID, name string) *customschema.Schema {
t.Helper()
got, err := fx.service.CreateSchema(ctx, customschema.CreateSchemaInput{
ClientID: clientID,
Name: name,
SchemaDef: json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{"foo":{"type":"string"}}}`),
}, testActor)
require.NoError(t, err)
return got
}
// TestService_AssignSchema covers the core AssignSchema paths: happy
// bind, clear (nil schemaID), 404 on missing document, 409 on cross-
// client, 409 on non-active schema, 409 on existing metadata, 409 on
// legacy extractions.
func TestService_AssignSchema(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientA = "TEST_SVC_ASSIGN_A"
const clientB = "TEST_SVC_ASSIGN_B"
fx := newTestService(t, clientA, clientB)
t.Run("happy_bind", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-happy")
docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-happy-doc")
res, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
require.NotNil(t, res)
require.NotNil(t, res.CustomSchemaID)
assert.Equal(t, sch.ID, *res.CustomSchemaID)
require.NotNil(t, res.SchemaName)
assert.Equal(t, "assign-happy", *res.SchemaName)
require.NotNil(t, res.SchemaVersion)
assert.Equal(t, 1, *res.SchemaVersion)
// DB round-trip: documents.custom_schema_id is now the schema id.
got, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, sch.ID, *got)
})
t.Run("clear_binding_when_no_metadata", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-clear")
docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-clear-doc")
// Bind first.
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
// Now clear.
res, err := fx.service.AssignSchema(ctx, docID, nil, testActor)
require.NoError(t, err)
require.NotNil(t, res)
assert.Nil(t, res.CustomSchemaID)
assert.Nil(t, res.SchemaName)
assert.Nil(t, res.SchemaVersion)
got, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID)
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("rejects_missing_document", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-missing-doc")
_, err := fx.service.AssignSchema(ctx, uuid.New(), &sch.ID, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound),
"expected ErrDocumentNotFound, got %v", err)
})
t.Run("rejects_cross_client_schema", func(t *testing.T) {
schB := seedActiveSchemaForMetadataTest(t, ctx, fx, clientB, "assign-cross")
docA := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-cross-docA")
_, err := fx.service.AssignSchema(ctx, docA, &schB.ID, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrSchemaClientMismatch),
"expected ErrSchemaClientMismatch, got %v", err)
})
t.Run("rejects_non_active_schema", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-non-active")
// Retire the schema.
require.NoError(t, fx.service.DeleteSchema(ctx, sch.ID, testActor))
docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-non-active-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrSchemaNotActive),
"expected ErrSchemaNotActive, got %v", err)
})
t.Run("rejects_when_metadata_exists", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-has-meta")
docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-has-meta-doc")
// Bind, then write metadata.
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.NoError(t, err)
// Try to change the binding — must 409.
other := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-has-meta-other")
_, err = fx.service.AssignSchema(ctx, docID, &other.ID, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentHasCustomMetadata),
"expected ErrDocumentHasCustomMetadata, got %v", err)
})
t.Run("idempotent_rebind_same_schema", func(t *testing.T) {
// Contract: re-assigning the same schema to a document that already
// has metadata must succeed (no-op). The mutation guards only fire
// when the incoming value differs from the current binding.
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientA, "assign-idem-rebind")
docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-idem-rebind-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.NoError(t, err)
// Retry with the same schema — must succeed.
res, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err, "re-assigning the same schema must be idempotent")
require.NotNil(t, res)
require.NotNil(t, res.CustomSchemaID)
assert.Equal(t, sch.ID, *res.CustomSchemaID)
})
t.Run("idempotent_clear_already_unbound", func(t *testing.T) {
// Contract: clearing a schema binding on a document that is already
// unbound must succeed (no-op). nil → nil is idempotent.
docID := seedDocForMetadataTest(t, ctx, fx, clientA, "assign-idem-clear-doc")
res, err := fx.service.AssignSchema(ctx, docID, nil, testActor)
require.NoError(t, err, "clearing an already-unbound document must be idempotent")
require.NotNil(t, res)
assert.Nil(t, res.CustomSchemaID)
})
}
// TestService_SetDocumentMetadata covers the happy path and each
// documented rejection rule for SetDocumentMetadata.
func TestService_SetDocumentMetadata(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_SET_META"
fx := newTestService(t, clientID)
t.Run("rejects_when_document_has_no_schema", func(t *testing.T) {
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-meta-no-schema")
_, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentNotBoundToSchema),
"expected ErrDocumentNotBoundToSchema, got %v", err)
})
t.Run("happy_path_then_version_increment", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "set-happy")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-happy-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
// First write → version 1.
m1, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"one"}`),
}, testActor)
require.NoError(t, err)
require.NotNil(t, m1)
assert.Equal(t, 1, m1.Version)
require.NotNil(t, m1.SchemaID)
assert.Equal(t, sch.ID, *m1.SchemaID)
require.NotNil(t, m1.SchemaName)
assert.Equal(t, "set-happy", *m1.SchemaName)
// Second write → version 2.
m2, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"two"}`),
}, testActor)
require.NoError(t, err)
assert.Equal(t, 2, m2.Version)
// Third write → version 3.
m3, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"three"}`),
}, testActor)
require.NoError(t, err)
assert.Equal(t, 3, m3.Version)
})
t.Run("rejects_invalid_payload_against_schema", func(t *testing.T) {
// Schema requires foo to be a string.
sch, err := fx.service.CreateSchema(ctx, customschema.CreateSchemaInput{
ClientID: clientID,
Name: "set-invalid",
SchemaDef: json.RawMessage(
`{"type":"object","additionalProperties":false,"required":["foo"],"properties":{"foo":{"type":"string"}}}`),
}, testActor)
require.NoError(t, err)
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-invalid-doc")
_, err = fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo": 42}`), // number, not string
}, testActor)
require.Error(t, err)
// Neither ErrDocumentNotFound nor ErrDocumentNotBoundToSchema.
assert.False(t, errors.Is(err, customschema.ErrDocumentNotFound))
assert.False(t, errors.Is(err, customschema.ErrDocumentNotBoundToSchema))
})
t.Run("rejects_oversize_payload", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "set-oversize")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "set-oversize-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
// Build a payload strictly larger than MaxMetadataPayloadBytes
// (1 MiB). Shape: `{"foo":"<big string>"}`.
overhead := len(`{"foo":""}`)
padding := customschema.MaxMetadataPayloadBytes - overhead + 1
big := make([]byte, 0, customschema.MaxMetadataPayloadBytes+1)
big = append(big, `{"foo":"`...)
for i := 0; i < padding; i++ {
big = append(big, 'x')
}
big = append(big, `"}`...)
require.Greater(t, len(big), customschema.MaxMetadataPayloadBytes)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(big),
}, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrMetadataPayloadTooLarge),
"expected ErrMetadataPayloadTooLarge, got %v", err)
})
}
// TestService_GetCurrentMetadata covers the read path for the most-recent
// version of a document's metadata, including the schema decoration from
// the joined query.
func TestService_GetCurrentMetadata(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_GET_CURRENT"
fx := newTestService(t, clientID)
t.Run("returns_current_with_schema_decoration", func(t *testing.T) {
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "get-current")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-current-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"v1"}`),
}, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"v2"}`),
}, testActor)
require.NoError(t, err)
got, err := fx.service.GetCurrentMetadata(ctx, docID)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, 2, got.Version, "must return the latest version")
assert.JSONEq(t, `{"foo":"v2"}`, string(got.Metadata))
require.NotNil(t, got.SchemaID)
assert.Equal(t, sch.ID, *got.SchemaID)
require.NotNil(t, got.SchemaName)
assert.Equal(t, "get-current", *got.SchemaName)
require.NotNil(t, got.SchemaVersion)
assert.Equal(t, 1, *got.SchemaVersion)
})
t.Run("returns_not_found_for_missing_document", func(t *testing.T) {
_, err := fx.service.GetCurrentMetadata(ctx, uuid.New())
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound))
})
t.Run("returns_version_not_found_for_empty_history", func(t *testing.T) {
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-current-empty-doc")
_, err := fx.service.GetCurrentMetadata(ctx, docID)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrMetadataVersionNotFound))
})
}
// TestService_GetMetadataByVersion covers the version-scoped read path.
func TestService_GetMetadataByVersion(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_GET_VERSION"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "get-version")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-version-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
for i, payload := range []string{`{"foo":"v1"}`, `{"foo":"v2"}`, `{"foo":"v3"}`} {
_, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(payload),
}, testActor)
require.NoError(t, err, "seed version %d", i+1)
}
t.Run("returns_exact_version_2", func(t *testing.T) {
got, err := fx.service.GetMetadataByVersion(ctx, docID, 2)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, 2, got.Version)
assert.JSONEq(t, `{"foo":"v2"}`, string(got.Metadata))
})
t.Run("returns_version_not_found_for_too_high", func(t *testing.T) {
_, err := fx.service.GetMetadataByVersion(ctx, docID, 99)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrMetadataVersionNotFound))
})
}
// TestService_GetMetadataHistory covers pagination and edge-case handling.
func TestService_GetMetadataHistory(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_GET_HIST"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "get-hist")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "get-hist-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
for i := 0; i < 5; i++ {
_, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"x"}`),
}, testActor)
require.NoError(t, err)
}
t.Run("returns_newest_first_with_limit", func(t *testing.T) {
got, err := fx.service.GetMetadataHistory(ctx, docID, 2, 0)
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, 5, got[0].Version)
assert.Equal(t, 4, got[1].Version)
})
t.Run("paginates_through_offset", func(t *testing.T) {
got, err := fx.service.GetMetadataHistory(ctx, docID, 2, 2)
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, 3, got[0].Version)
assert.Equal(t, 2, got[1].Version)
})
t.Run("rejects_limit_zero", func(t *testing.T) {
_, err := fx.service.GetMetadataHistory(ctx, docID, 0, 0)
require.Error(t, err)
})
t.Run("rejects_negative_offset", func(t *testing.T) {
_, err := fx.service.GetMetadataHistory(ctx, docID, 1, -1)
require.Error(t, err)
})
t.Run("clamps_limit_above_ceiling", func(t *testing.T) {
// 201 is just above the ceiling; result is capped at
// MaxMetadataHistoryLimit rows. We only seeded 5, so we still
// get 5 rows — but the request must not error out.
got, err := fx.service.GetMetadataHistory(ctx, docID, 201, 0)
require.NoError(t, err)
assert.Len(t, got, 5)
})
t.Run("not_found_for_missing_document", func(t *testing.T) {
_, err := fx.service.GetMetadataHistory(ctx, uuid.New(), 10, 0)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound))
})
}