diff --git a/tests/contract/conftest.py b/tests/contract/conftest.py index 2ab72ab3..5b3682fa 100644 --- a/tests/contract/conftest.py +++ b/tests/contract/conftest.py @@ -13,10 +13,15 @@ from pathlib import Path import pytest from pact import Pact -# Pact participant names. These MUST match the names used on the astrolabe side -# and in the broker, so keep them in sync with the astrolabe repo's pact tests. +# Pact participant names. These MUST match the names used on the provider side +# and in the broker, so keep them in sync with the provider repos' pact tests. CONSUMER = "nextcloud-mcp-server" PROVIDER = "astrolabe" +# The embedding gateway is a *separate* provider (astrolabe-cloud-website, +# services/embedding-gateway). Its provider-verification job +# (test_gateway_provider_verification.py, PROVIDER_NAME="astrolabe-cloud-gateway") +# picks up this consumer's pact from the broker. +GATEWAY_PROVIDER = "astrolabe-cloud-gateway" PACT_DIR = Path(__file__).parent / "pacts" @@ -40,3 +45,16 @@ def consumer_pact(): pact = Pact(CONSUMER, PROVIDER).with_specification("V4") yield pact pact.write_file(PACT_DIR, overwrite=False) + + +@pytest.fixture +def gateway_consumer_pact(): + """A fresh Pact (consumer=nextcloud-mcp-server, provider=astrolabe-cloud-gateway). + + Separate from ``consumer_pact`` because the embedding gateway is a distinct + provider — its interactions merge into their own pact file, verified by the + gateway's provider job (Deck #332). + """ + pact = Pact(CONSUMER, GATEWAY_PROVIDER).with_specification("V4") + yield pact + pact.write_file(PACT_DIR, overwrite=False) diff --git a/tests/contract/test_gateway_batch_ocr_consumer.py b/tests/contract/test_gateway_batch_ocr_consumer.py new file mode 100644 index 00000000..e9610318 --- /dev/null +++ b/tests/contract/test_gateway_batch_ocr_consumer.py @@ -0,0 +1,145 @@ +"""Consumer contract: nextcloud-mcp-server -> embedding-gateway batch OCR (Deck #332). + +When ``DOCUMENT_OCR_MODE=batch`` the ingest worker drives the gateway's async +Batch OCR routes via :class:`GatewayBatchOcrClient` +(``embedding/gateway_batch_client.py``): + +- ``POST /v1/ocr/batch`` — submit one document, returns a namespaced ``job_id``. +- ``GET /v1/ocr/batch/{job_id}`` — poll; pending until terminal, then per-page + markdown (succeeded) or an error (failed). + +This pact pins the request/response shapes the consumer depends on, for the +``astrolabe-cloud-gateway`` provider (whose verification job lives in +astrolabe-cloud-website, services/embedding-gateway). Only the fields the client +actually reads are asserted, so the contract stays robust to additive response +changes (the gateway's ``OcrBatchJobOut`` carries more fields — total/completed/ +counts — that the single-document client ignores). + +The gateway is unauthenticated today, so no bearer is sent (matching the +M2M-optional ``GatewayBatchOcrClient``). See ADR-029 for the contract-testing +architecture. +""" + +import base64 + +import pytest +from pact import match + +from nextcloud_mcp_server.embedding.gateway_batch_client import GatewayBatchOcrClient + +pytestmark = pytest.mark.contract + +_MODEL = "mistral/mistral-ocr-latest" +# A small, valid base64 PDF payload — the gateway base64-decodes + size-checks +# the document, so the replayed request must carry decodable bytes. +_PDF_B64 = base64.b64encode(b"%PDF-1.4 contract test").decode("ascii") + + +async def test_submit_returns_namespaced_job_id(gateway_consumer_pact): + ( + gateway_consumer_pact.upon_receiving("a batch OCR submission for one document") + .given("the gateway accepts a batch OCR submission") + .with_request("POST", "/v1/ocr/batch") + .with_body( + { + "model": _MODEL, + "documents": [ + { + "custom_id": "0", + "mime_type": "application/pdf", + "document_b64": _PDF_B64, + } + ], + }, + content_type="application/json", + ) + .will_respond_with(202) + .with_body( + { + # Namespaced "/" — the only field submit() reads. + "job_id": match.regex("mistral/job-abc", regex=r"[^/]+/.+"), + "status": "pending", + }, + content_type="application/json", + ) + ) + + with gateway_consumer_pact.serve() as srv: + client = GatewayBatchOcrClient(str(srv.url), _MODEL) + job_id = await client.submit( + b"%PDF-1.4 contract test", "application/pdf", custom_id="0" + ) + + assert job_id == "mistral/job-abc" + + +async def test_poll_pending(gateway_consumer_pact): + ( + gateway_consumer_pact.upon_receiving("a poll for a still-running batch OCR job") + .given("a pending batch OCR job mistral/job-pending exists") + .with_request("GET", "/v1/ocr/batch/mistral/job-pending") + .will_respond_with(200) + .with_body({"status": "pending"}, content_type="application/json") + ) + + with gateway_consumer_pact.serve() as srv: + result = await GatewayBatchOcrClient(str(srv.url), _MODEL).poll( + "mistral/job-pending" + ) + + assert result.is_pending + + +async def test_poll_succeeded_returns_pages(gateway_consumer_pact): + ( + gateway_consumer_pact.upon_receiving("a poll for a succeeded batch OCR job") + .given("a succeeded batch OCR job mistral/job-done exists") + .with_request("GET", "/v1/ocr/batch/mistral/job-done") + .will_respond_with(200) + .with_body( + { + "status": "succeeded", + "results": [ + { + "custom_id": "0", + "pages": [ + { + "index": match.integer(0), + "markdown": match.string("# Page one"), + } + ], + } + ], + }, + content_type="application/json", + ) + ) + + with gateway_consumer_pact.serve() as srv: + result = await GatewayBatchOcrClient(str(srv.url), _MODEL).poll( + "mistral/job-done" + ) + + assert result.is_succeeded + assert result.pages == [(0, "# Page one")] + + +async def test_poll_failed_surfaces_error(gateway_consumer_pact): + ( + gateway_consumer_pact.upon_receiving("a poll for a failed batch OCR job") + .given("a failed batch OCR job mistral/job-failed exists") + .with_request("GET", "/v1/ocr/batch/mistral/job-failed") + .will_respond_with(200) + .with_body( + {"status": "failed", "error": match.string("batch job failed")}, + content_type="application/json", + ) + ) + + with gateway_consumer_pact.serve() as srv: + result = await GatewayBatchOcrClient(str(srv.url), _MODEL).poll( + "mistral/job-failed" + ) + + assert result.is_failed + assert result.error == "batch job failed"