From 89522ca572a3db93ec7aae868aec9f981f6e1ee2 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 15 Jun 2026 14:06:54 +0200 Subject: [PATCH] test(contract): add gateway batch OCR consumer pact (Deck #332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the consumer-driven Pact for the gateway's async batch OCR routes, consumed by GatewayBatchOcrClient. The embedding gateway is a separate provider (astrolabe-cloud-gateway) from the existing `astrolabe` credentials pact, so it gets its own fixture + pact file. Interactions (only the fields the single-document client reads are pinned, so the contract is robust to the gateway's additive OcrBatchJobOut fields): - POST /v1/ocr/batch -> 202 { job_id } (namespaced /) - GET /v1/ocr/batch/{job_id} -> pending / succeeded (per-page markdown) / failed The gateway is unauthenticated today, so no bearer is sent (matching the M2M-optional client). Provider-side: this publishes a pact the gateway's verification job must now satisfy — it needs provider-state handlers (pending/succeeded/failed jobs) + a Mistral stub on the astrolabe-cloud-website side (its verification was a deliberate no-op until a consumer pact existed). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/contract/conftest.py | 22 ++- .../test_gateway_batch_ocr_consumer.py | 145 ++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/contract/test_gateway_batch_ocr_consumer.py 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"