Files
mcp-nextcloud/tests/integration/test_ingest_queue_postgres.py
T
Chris CoutinhoandClaude Opus 4.8 3407e3cf64 chore: run ty on tests/ and make the new ingest tests pass it
Stop excluding tests/ from the ty-check pre-commit hook so touched test files
are type-checked. Fix the new ingest tests under the now-active check:
- cast duck-typed JobContext / App test doubles to their declared types;
- narrow the gated Postgres fixture's str | None URL (pytest.skip isn't modelled
  as NoReturn by ty).

Pre-existing type issues in untouched test modules are unaffected (the hook
checks only changed files); they'll be cleaned as those files are next touched.

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

142 lines
4.8 KiB
Python

"""End-to-end Postgres smoke for the procrastinate ingest queue (Deck #183).
Validates the queue mechanics the in-memory connector can't: real
``queueing_lock`` partial-unique dedup, idempotent schema apply, and the
``list_queues`` stats the status surface reads. Opt-in like
``test_storage_postgres.py``::
docker compose --profile postgres up -d postgres-test
export TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp
uv run pytest tests/integration/test_ingest_queue_postgres.py -v -m postgres
Skipped when ``TEST_DATABASE_URL`` is unset or the service is unreachable.
"""
from __future__ import annotations
import os
import socket
from urllib.parse import urlparse
import pytest
import nextcloud_mcp_server.config as config_module
from nextcloud_mcp_server.vector.queue.procrastinate import (
INGEST_QUEUE_NAME,
ProcrastinateTaskProducer,
apply_ingest_queue_schema,
build_app_for_url,
get_ingest_job_counts,
)
from nextcloud_mcp_server.vector.scanner import DocumentTask
pytestmark = [pytest.mark.integration, pytest.mark.postgres]
def _postgres_url() -> str | None:
return os.environ.get("TEST_DATABASE_URL") or None
def _reachable(url: str) -> bool:
parsed = urlparse(url)
try:
with socket.create_connection(
(parsed.hostname or "localhost", parsed.port or 5432), timeout=1.0
):
return True
except OSError:
return False
@pytest.fixture
def postgres_url() -> str:
url = _postgres_url()
if not url:
pytest.skip(
"TEST_DATABASE_URL not set — run "
"`docker compose --profile postgres up -d postgres-test` and export "
"TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp"
)
# pytest.skip raises, but ty doesn't model it as NoReturn — narrow explicitly.
assert url is not None
if not _reachable(url):
pytest.skip(f"Postgres at {url} is not reachable")
return url
@pytest.fixture
async def fresh_app(postgres_url: str, monkeypatch: pytest.MonkeyPatch):
"""Drop+recreate the public schema, then apply procrastinate's schema."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(postgres_url, future=True)
try:
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA public CASCADE"))
await conn.execute(text("CREATE SCHEMA public"))
finally:
await engine.dispose()
# get_procrastinate_conninfo derives ssl from settings; point it at the URL.
monkeypatch.setattr(config_module, "get_database_url", lambda: postgres_url)
monkeypatch.setattr(config_module, "get_database_ssl", lambda: None)
app = build_app_for_url(postgres_url)
await apply_ingest_queue_schema(app)
return app
def _task(doc_id: str, doc_type: str = "note") -> DocumentTask:
return DocumentTask(
user_id="alice",
doc_id=doc_id,
doc_type=doc_type,
operation="index",
modified_at=100,
etag=f"etag-{doc_id}",
)
async def test_ingest_queue_end_to_end(fresh_app):
"""One self-contained smoke against real Postgres.
Kept as a single test so each assertion runs against the same freshly-applied
schema — splitting across functions reintroduces the inter-test ``DROP
SCHEMA`` that confuses pooled psycopg connections' cached prepared statements
(a test-harness artifact, not a production path: prod never drops the schema).
"""
# 1. Schema is present and a second apply is a no-op (idempotent).
await apply_ingest_queue_schema(fresh_app)
async with fresh_app.open_async():
present = await fresh_app.connector.execute_query_one_async(
"SELECT to_regclass('procrastinate_jobs') IS NOT NULL AS present"
)
assert present["present"] is True
# 2. Defer + real queueing_lock dedup (one todo per doc).
producer = ProcrastinateTaskProducer(fresh_app)
await producer.send(_task("1"))
await producer.send(_task("1")) # deduped by queueing_lock
await producer.send(_task("2"))
rows = await fresh_app.connector.execute_query_all_async(
"SELECT count(*) AS n FROM procrastinate_jobs "
"WHERE queue_name = %(q)s AND status = 'todo'",
q=INGEST_QUEUE_NAME,
)
assert rows[0]["n"] == 2
# 3. The status-surface counts read agrees.
counts = await get_ingest_job_counts(fresh_app)
assert counts.get("todo") == 2
# 4. Fresh todo jobs are not "doing", so none are stalled.
stalled = list(
await fresh_app.job_manager.get_stalled_jobs(
queue=INGEST_QUEUE_NAME, seconds_since_heartbeat=0
)
)
assert stalled == []