17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
445 lines
16 KiB
Go
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))
|
|
})
|
|
}
|