Files
mcp-nextcloud/tests/unit/test_decomposition_config.py
T
Chris CoutinhoandClaude Opus 4.8 b5ed1e3b4d fix: address PR #814 review + SonarCloud gate
SonarCloud:
- Resolve 6 S5332 hotspots (http→https in test fixture URLs).
- S6418: hoist the unauthenticated AsyncOpenAI placeholder to a named constant
  + NOSONAR (genuine non-secret; gateway ignores it when unauthenticated).
- Fix two reliability bugs: None-index guard in the gateway token-cache test
  (S2259) and float `> 0.0` instead of `!= 0.0` in the sentinel test (S1244).
- status.py idle path sleeps 0.1s instead of sleep(0) (S7491); NOSONAR on the
  protocol-required async no-await aclose() stubs (S7503).

Claude review:
- Remove three leftover debug print() calls in app.py (logger.info already
  covers them).
- payload_backfill: drop parsed_at from the backfilled-keys docstring (it is
  per-document state, not a deployment scalar); add a clean 404 precondition
  for BasicAuth deployments without an OAuth token verifier.
- status.py: task_status typed TaskStatus | None (drop type: ignore).
- nats.py: TODO to thread etags for file/deck/news; note etag default → None.
- factory: warn on unknown INGEST_BUS_URL scheme; raise ValueError instead of
  assert for the external-mode preconditions.
- docs/configuration.md: document the decomposition hook-point env vars + that
  nats-py ships core (lazy-imported) and external+bus uses two NATS connections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:36:42 +02:00

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="https://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()))))