Address PR #858 review round 3: - document_tier1_engine / document_ocr_provider now validate + normalize via Settings.__post_init__ _enum_fields (the repo's canonical opt-in-enum pattern; case-insensitive) instead of dynaconf Validators. A typo now raises ValueError at load and "Gateway" normalizes to "gateway". - classify_from_text gates no_text_layer/bad_text_layer on ocr_frac >= OCR_PAGE_FRACTION, matching classify_pdf -- a "fast"-routed doc with a few junk pages no longer emits a misleading flag (keeps the shadow vs hot-path classification metrics consistent). - build_ocr_backend warns when an EXPLICIT provider is misconfigured (gateway without EMBEDDING_GATEWAY_URL, mistral without MISTRAL_API_KEY) instead of silently returning None. - Pypdfium2FastProcessor.health_check probes the import; documented why OcrProcessor.health_check is unconditionally True (lazy per-tenant backends). - Removed the leftover per-boundary / per-chunk debug logging loops. Tests: enum normalization + rejection for the two new settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7.6 KiB
Python
200 lines
7.6 KiB
Python
"""Tests for the MCP decomposition hook-point settings (design §10, Deck #183).
|
|
|
|
Every default must reproduce the monolith; the opt-in settings are validated
|
|
in ``Settings.__post_init__``.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
import nextcloud_mcp_server.config as config_module
|
|
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"
|
|
# SQLite/dev default → the in-process memory queue.
|
|
assert s.ingest_queue == "memory"
|
|
assert s.mcp_role == "all"
|
|
assert s.collection_metadata_source == "qdrant"
|
|
assert s.embedding_gateway_url is None
|
|
assert s.tenant_id is None
|
|
|
|
def test_enum_values_normalized(self):
|
|
# Mixed case / surrounding whitespace is normalized before validation.
|
|
s = Settings(
|
|
collection_metadata_source=" QDRANT ",
|
|
mcp_role=" API ",
|
|
document_tier1_engine=" PyPDFium2 ",
|
|
document_ocr_provider=" Gateway ",
|
|
)
|
|
assert s.collection_metadata_source == "qdrant"
|
|
assert s.mcp_role == "api"
|
|
assert s.document_tier1_engine == "pypdfium2"
|
|
assert s.document_ocr_provider == "gateway"
|
|
|
|
|
|
class TestEnumValidation:
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("embedding_provider", "openai"),
|
|
("mcp_role", "leader"),
|
|
("collection_metadata_source", "redis"),
|
|
("document_tier1_engine", "mupdf"),
|
|
("document_ocr_provider", "gatway"),
|
|
],
|
|
)
|
|
def test_invalid_enum_rejected(self, field, value):
|
|
with pytest.raises(ValueError, match=field.upper()):
|
|
Settings(**{field: value})
|
|
|
|
def test_invalid_ingest_queue_rejected(self):
|
|
with pytest.raises(ValueError, match="INGEST_QUEUE"):
|
|
Settings(ingest_queue="kafka")
|
|
|
|
|
|
class TestIngestQueueResolution:
|
|
def test_postgres_requires_postgres_url(self):
|
|
# Explicit postgres against the default SQLite DATABASE_URL is a
|
|
# misconfiguration (procrastinate is Postgres-only).
|
|
with pytest.raises(ValueError, match="INGEST_QUEUE=postgres requires"):
|
|
Settings(ingest_queue="postgres")
|
|
|
|
def test_memory_default_even_on_postgres_url(self, monkeypatch):
|
|
# Procrastinate is opt-in: a Postgres DATABASE_URL with INGEST_QUEUE
|
|
# unset must NOT silently enable procrastinate. Default → memory.
|
|
monkeypatch.setattr(
|
|
config_module,
|
|
"get_database_url",
|
|
lambda: "postgresql+asyncpg://mcp:mcp@db/mcp",
|
|
)
|
|
assert Settings().ingest_queue == "memory"
|
|
|
|
def test_explicit_postgres_on_postgres_url(self, monkeypatch):
|
|
# Opting in explicitly against a Postgres URL is the supported path.
|
|
monkeypatch.setattr(
|
|
config_module,
|
|
"get_database_url",
|
|
lambda: "postgresql+asyncpg://mcp:mcp@db/mcp",
|
|
)
|
|
assert Settings(ingest_queue="postgres").ingest_queue == "postgres"
|
|
|
|
def test_explicit_memory_on_postgres_url(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
config_module,
|
|
"get_database_url",
|
|
lambda: "postgresql+asyncpg://mcp:mcp@db/mcp",
|
|
)
|
|
assert Settings(ingest_queue="memory").ingest_queue == "memory"
|
|
|
|
|
|
class TestConditionalRequired:
|
|
def test_gateway_requires_gateway_url(self):
|
|
with pytest.raises(ValueError, match="EMBEDDING_GATEWAY_URL is required"):
|
|
Settings(embedding_provider="gateway")
|
|
|
|
def test_gateway_happy_path(self):
|
|
s = Settings(
|
|
embedding_provider="gateway",
|
|
embedding_gateway_url="https://gateway:8083",
|
|
)
|
|
assert s.embedding_provider == "gateway"
|
|
|
|
|
|
class TestTenantId:
|
|
def test_arbitrary_tenant_id_accepted(self):
|
|
# The old NATS-subject charset restriction was dropped with NATS
|
|
# (Deck #183); tenant_id is now just an opaque per-tenant identity.
|
|
s = Settings(tenant_id="0a1b2c3d-0000-0000-0000-000000000000")
|
|
assert s.tenant_id == "0a1b2c3d-0000-0000-0000-000000000000"
|
|
|
|
|
|
class TestProcrastinateConninfo:
|
|
@pytest.mark.parametrize(
|
|
"url,expected_sslmode",
|
|
[
|
|
("postgresql+asyncpg://mcp:p%40ss@db:5432/mcp", None),
|
|
],
|
|
)
|
|
def test_conninfo_round_trips_password(self, monkeypatch, url, expected_sslmode):
|
|
from psycopg.conninfo import conninfo_to_dict
|
|
|
|
monkeypatch.setattr(config_module, "get_database_url", lambda: url)
|
|
# No SSL settings → sslmode omitted (libpq default ``prefer``).
|
|
monkeypatch.setattr(config_module, "get_database_ssl", lambda: None)
|
|
parsed = conninfo_to_dict(config_module.get_procrastinate_conninfo())
|
|
assert parsed["password"] == "p@ss"
|
|
assert parsed["host"] == "db"
|
|
assert parsed["dbname"] == "mcp"
|
|
assert parsed.get("sslmode") == expected_sslmode
|
|
|
|
def test_conninfo_connect_timeout_defaults_to_10(self, monkeypatch):
|
|
from psycopg.conninfo import conninfo_to_dict
|
|
|
|
monkeypatch.setattr(
|
|
config_module,
|
|
"get_database_url",
|
|
lambda: "postgresql+asyncpg://mcp:s@db/mcp",
|
|
)
|
|
monkeypatch.setattr(config_module, "get_database_ssl", lambda: None)
|
|
parsed = conninfo_to_dict(config_module.get_procrastinate_conninfo())
|
|
assert parsed["connect_timeout"] == "10"
|
|
|
|
def test_conninfo_honors_url_connect_timeout(self, monkeypatch):
|
|
from psycopg.conninfo import conninfo_to_dict
|
|
|
|
monkeypatch.setattr(
|
|
config_module,
|
|
"get_database_url",
|
|
lambda: "postgresql+asyncpg://mcp:s@db/mcp?connect_timeout=3",
|
|
)
|
|
monkeypatch.setattr(config_module, "get_database_ssl", lambda: None)
|
|
parsed = conninfo_to_dict(config_module.get_procrastinate_conninfo())
|
|
assert parsed["connect_timeout"] == "3"
|
|
|
|
def test_conninfo_ssl_mapping(self, monkeypatch):
|
|
from psycopg.conninfo import conninfo_to_dict
|
|
|
|
monkeypatch.setattr(
|
|
config_module,
|
|
"get_database_url",
|
|
lambda: "postgresql+asyncpg://mcp:s@db/mcp",
|
|
)
|
|
# verify off → encrypt without verifying.
|
|
monkeypatch.setattr(config_module, "get_database_ssl", lambda: False)
|
|
assert (
|
|
conninfo_to_dict(config_module.get_procrastinate_conninfo())["sslmode"]
|
|
== "require"
|
|
)
|
|
# verify on → verify-full.
|
|
monkeypatch.setattr(config_module, "get_database_ssl", lambda: True)
|
|
assert (
|
|
conninfo_to_dict(config_module.get_procrastinate_conninfo())["sslmode"]
|
|
== "verify-full"
|
|
)
|
|
|
|
def test_conninfo_rejects_non_postgres(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
config_module, "get_database_url", lambda: "sqlite+aiosqlite:///x.db"
|
|
)
|
|
with pytest.raises(ValueError, match="requires a PostgreSQL DATABASE_URL"):
|
|
config_module.get_procrastinate_conninfo()
|
|
|
|
|
|
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()))))
|