test(contract): add gateway batch OCR consumer pact (Deck #332)
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 <provider>/<id>)
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
210a234c11
commit
89522ca572
@@ -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)
|
||||
|
||||
@@ -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 "<provider>/<batch_job_id>" — 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"
|
||||
Reference in New Issue
Block a user