Adds the seven §10.2 hook-point modules + five env vars so Astrolabe Cloud can offload document processing to the external document-processor / embedding gateway. Purely additive: with every setting unset the server behaves exactly as today, so self-hosters are unaffected (Deck #92). Hook points (all default to current monolith behavior): - config: EMBEDDING_PROVIDER, INGEST_MODE, STATUS_BACKEND, COLLECTION_METADATA_SOURCE, FACT_EVENT_EMITTER (+ supporting settings), validated in Settings.__post_init__ (fail-fast STATUS_BACKEND=local with INGEST_MODE=external); shared canonical.py. - vector/payload_keys.py + acl_hash.py: cross-impl NAMESPACE/point_id (§2.2) and BLAKE2b-128 ACL hash (§11), pinned by fixtures shared with the document-processor repo. - embedding/gateway_client.py: OpenAI-compatible GatewayProvider authenticating via M2M OIDC client-credentials (separate realm); manual-only registry entry. - vector/collection_metadata.py: sentinel-point / API metadata source with env fallback. - vector/queue/: hexagonal ingest producer ports + memory/NATS adapters (Postgres seam); INGEST_MODE=external publishes mcp.ingest.requested.{tenant} instead of the in-memory stream and skips the in-process processor pool. The lifespan becomes a composition root across both deployment branches. - vector/queue/status.py: STATUS_BACKEND=bus subscriber feeding a StatusStore the vector-sync status endpoint reads. - admin/payload_backfill.py: POST /api/v1/admin/payload-backfill (admin scope); processor writes the new payload keys; query-side ACL pre-filter gated behind ACL_PREFILTER_ENABLED (default off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.5 KiB
Python
134 lines
4.5 KiB
Python
"""Tests for the MCP decomposition hook-point settings (design §10).
|
|
|
|
Every default must reproduce the monolith; the opt-in settings are validated
|
|
in ``Settings.__post_init__``.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.canonical import canonical_json
|
|
from nextcloud_mcp_server.config import Settings
|
|
|
|
|
|
class TestDecompositionDefaults:
|
|
"""With nothing set, behavior matches today's monolith."""
|
|
|
|
def test_defaults_are_monolith(self):
|
|
s = Settings()
|
|
assert s.embedding_provider == "autodetect"
|
|
assert s.ingest_mode == "local"
|
|
assert s.status_backend == "local"
|
|
assert s.collection_metadata_source == "qdrant"
|
|
assert s.fact_event_emitter == "none"
|
|
assert s.ingest_bus_url is None
|
|
assert s.embedding_gateway_url is None
|
|
assert s.tenant_id is None
|
|
assert s.ingest_bus_num_replicas == 1
|
|
|
|
def test_enum_values_normalized(self):
|
|
# Mixed case / surrounding whitespace is normalized before validation.
|
|
s = Settings(
|
|
collection_metadata_source=" QDRANT ",
|
|
fact_event_emitter="NONE",
|
|
)
|
|
assert s.collection_metadata_source == "qdrant"
|
|
assert s.fact_event_emitter == "none"
|
|
|
|
|
|
class TestEnumValidation:
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("embedding_provider", "openai"),
|
|
("ingest_mode", "remote"),
|
|
("status_backend", "redis"),
|
|
("collection_metadata_source", "postgres"),
|
|
("fact_event_emitter", "kafka"),
|
|
],
|
|
)
|
|
def test_invalid_enum_rejected(self, field, value):
|
|
with pytest.raises(ValueError, match=field.upper()):
|
|
Settings(**{field: value})
|
|
|
|
|
|
class TestFailFast:
|
|
def test_external_with_local_status_crashes(self):
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="STATUS_BACKEND=local is incompatible with INGEST_MODE=external",
|
|
):
|
|
Settings(
|
|
ingest_mode="external",
|
|
status_backend="local",
|
|
ingest_bus_url="nats://nats:4222",
|
|
tenant_id="tenant-uuid",
|
|
)
|
|
|
|
|
|
class TestConditionalRequired:
|
|
def test_external_requires_bus_url(self):
|
|
with pytest.raises(ValueError, match="INGEST_BUS_URL is required"):
|
|
Settings(ingest_mode="external", status_backend="bus", tenant_id="t1")
|
|
|
|
def test_external_requires_tenant_id(self):
|
|
with pytest.raises(ValueError, match="TENANT_ID is required"):
|
|
Settings(
|
|
ingest_mode="external",
|
|
status_backend="bus",
|
|
ingest_bus_url="nats://nats:4222",
|
|
)
|
|
|
|
def test_gateway_requires_gateway_url(self):
|
|
with pytest.raises(ValueError, match="EMBEDDING_GATEWAY_URL is required"):
|
|
Settings(embedding_provider="gateway")
|
|
|
|
def test_external_happy_path(self):
|
|
s = Settings(
|
|
ingest_mode="external",
|
|
status_backend="bus",
|
|
ingest_bus_url="nats://nats:4222",
|
|
tenant_id="0a1b2c3d-0000-0000-0000-000000000000",
|
|
)
|
|
assert s.ingest_mode == "external"
|
|
assert s.status_backend == "bus"
|
|
|
|
def test_gateway_happy_path(self):
|
|
s = Settings(
|
|
embedding_provider="gateway",
|
|
embedding_gateway_url="http://gateway:8083",
|
|
)
|
|
assert s.embedding_provider == "gateway"
|
|
|
|
|
|
class TestTenantIdSubjectToken:
|
|
@pytest.mark.parametrize(
|
|
"tenant_id",
|
|
["a.b", "a*b", "a>b", "a b", "a\tb"],
|
|
)
|
|
def test_illegal_subject_chars_rejected(self, tenant_id):
|
|
with pytest.raises(ValueError, match="TENANT_ID must not contain"):
|
|
Settings(tenant_id=tenant_id)
|
|
|
|
def test_uuid_form_accepted(self):
|
|
s = Settings(tenant_id="0a1b2c3d-0000-0000-0000-000000000000")
|
|
assert s.tenant_id == "0a1b2c3d-0000-0000-0000-000000000000"
|
|
|
|
|
|
class TestReplicas:
|
|
def test_zero_replicas_rejected(self):
|
|
with pytest.raises(ValueError, match="INGEST_BUS_NUM_REPLICAS must be >= 1"):
|
|
Settings(ingest_bus_num_replicas=0)
|
|
|
|
|
|
class TestCanonicalJson:
|
|
def test_sorted_keys_no_whitespace(self):
|
|
assert canonical_json({"b": 1, "a": 2}) == b'{"a":2,"b":1}'
|
|
|
|
def test_non_ascii_preserved(self):
|
|
# ensure_ascii=False keeps the literal UTF-8 bytes.
|
|
assert canonical_json({"k": "café"}) == '{"k":"café"}'.encode("utf-8")
|
|
|
|
def test_stable_across_calls(self):
|
|
obj = {"tenant_id": "t", "doc_id": "d", "modified_at": "2026-01-01T00:00:00Z"}
|
|
assert canonical_json(obj) == canonical_json(dict(reversed(list(obj.items()))))
|