Files
mcp-nextcloud/tests/unit/test_decomposition_config.py
T
Chris CoutinhoandClaude Opus 4.8 ad211ee2da fix: make procrastinate ingest queue opt-in (default to in-process anyio)
An unset INGEST_QUEUE auto-derived "postgres" whenever DATABASE_URL was
PostgreSQL, silently starting the procrastinate ingest worker (schema
migration, reclaim cron, deferred jobs) on every Postgres-backed tenant —
even though none had opted into the api/worker split. Observed on
tenant-blackbox-demo (:0.98.0): ~600 "Deferred 1 job" log lines / 24h.

Resolve an unset INGEST_QUEUE to "memory" (the in-process anyio queue)
regardless of the database backend. procrastinate is now strictly opt-in
via an explicit INGEST_QUEUE=postgres; the existing guard still rejects
postgres against a SQLite DATABASE_URL. Docs + unit test updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:59:24 +02:00

194 lines
7.3 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 ",
)
assert s.collection_metadata_source == "qdrant"
assert s.mcp_role == "api"
class TestEnumValidation:
@pytest.mark.parametrize(
"field,value",
[
("embedding_provider", "openai"),
("mcp_role", "leader"),
("collection_metadata_source", "redis"),
],
)
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()))))