feat(dispatch): consume QMS asset events from the service inbox (C2)
LegacyHUB is subscribed to AssetRegistered/AssetApproved from QMS but never polled its service inbox. New consumer maps the §7 payload to an AssetManifestEnvelope and runs the local knowledge-ingest, then confirms the delivery. Outcome policy avoids head-of-line blocking: Registered → ingest (accepted/duplicate/rejected all confirm), Approved/unknown → confirm without work, malformed → confirm as poison; only transient infrastructure failures (object_read_failed) leave the message pending and stop the run for the next scheduled retry. Thin CLI wrapper scripts/consume_dispatch_inbox.py (scripts/ ships in the image). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
160
tests/test_dispatch_inbox_consumer.py
Normal file
160
tests/test_dispatch_inbox_consumer.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""C2: consume QMS §7 asset events from the dispatch service inbox.
|
||||
|
||||
AssetRegistered → build AssetManifestEnvelope from the event payload and run
|
||||
knowledge-ingest; accepted/duplicate/rejected → confirm. AssetApproved and
|
||||
unknown types → confirm (they carry no new work). Transient ingest failures →
|
||||
NO confirm and stop: the inbox returns the same head message until confirmed,
|
||||
so continuing would spin; the next cron run retries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.schemas import AssetManifestEnvelope
|
||||
from app.integrations.dispatch_inbox_consumer import (
|
||||
TransientIngestError,
|
||||
consume_inbox,
|
||||
manifest_envelope_from_event,
|
||||
)
|
||||
|
||||
|
||||
def _event(**overrides):
|
||||
event = {
|
||||
"event_type": "AssetRegistered",
|
||||
"event_version": "1.0",
|
||||
"owner_module": "qms",
|
||||
"asset_id": "512d5f53-6edf-58f3-93aa-2a387f38fcc0",
|
||||
"owner_record_type": "release_candidate",
|
||||
"owner_record_id": "rel-2026-001",
|
||||
"asset_kind": "document",
|
||||
"content_type": "application/pdf",
|
||||
"size_bytes": 619,
|
||||
"sha256": "f" * 64,
|
||||
"security": {"gate_status": "approved"},
|
||||
"manifest_ref": {
|
||||
"provider": "s3",
|
||||
"bucket": "teamhub-qmshub-releases",
|
||||
"object_key": "qmshub/2026/07/12/512d5f53-6edf-58f3-93aa-2a387f38fcc0/original/"
|
||||
+ "f" * 64 + ".pdf",
|
||||
"sha256": "f" * 64,
|
||||
},
|
||||
"actor": "qa@x.com",
|
||||
}
|
||||
event.update(overrides)
|
||||
return event
|
||||
|
||||
|
||||
def _message(message_type="AssetRegistered", event=None):
|
||||
return {
|
||||
"message_id": "11111111-2222-3333-4444-555555555555",
|
||||
"message_type": message_type,
|
||||
"envelope": {
|
||||
"message_type": message_type,
|
||||
"participant_code": "qms",
|
||||
"card": {"event": event if event is not None else _event(event_type=message_type)},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- payload -> AssetManifestEnvelope ---
|
||||
|
||||
def test_manifest_envelope_from_event_is_valid_against_schema():
|
||||
envelope = manifest_envelope_from_event(_event())
|
||||
model = AssetManifestEnvelope.model_validate(envelope)
|
||||
assert model.manifest.asset.asset_id == "512d5f53-6edf-58f3-93aa-2a387f38fcc0"
|
||||
assert model.manifest.asset.owner_module == "qms"
|
||||
assert model.manifest.asset.sha256 == "f" * 64
|
||||
assert model.manifest.storage.bucket == "teamhub-qmshub-releases"
|
||||
assert model.manifest.security.gate_status == "approved"
|
||||
assert model.manifest.retention.policy_id
|
||||
|
||||
|
||||
def test_manifest_envelope_rejects_incomplete_event():
|
||||
broken = _event()
|
||||
del broken["manifest_ref"]
|
||||
with pytest.raises(ValueError):
|
||||
manifest_envelope_from_event(broken)
|
||||
|
||||
|
||||
# --- orchestration ---
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, messages, ingest_results=None):
|
||||
self._messages = list(messages)
|
||||
self.ingested = []
|
||||
self.confirmed = []
|
||||
self._ingest_results = list(ingest_results or [])
|
||||
|
||||
def fetch(self):
|
||||
return self._messages.pop(0) if self._messages else None
|
||||
|
||||
def ingest(self, envelope):
|
||||
self.ingested.append(envelope)
|
||||
if self._ingest_results:
|
||||
result = self._ingest_results.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
return "accepted"
|
||||
|
||||
def confirm(self, message_id):
|
||||
self.confirmed.append(message_id)
|
||||
|
||||
|
||||
def test_registered_is_ingested_and_confirmed():
|
||||
rec = Recorder([_message("AssetRegistered")])
|
||||
summary = consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm)
|
||||
assert len(rec.ingested) == 1 and len(rec.confirmed) == 1
|
||||
assert summary == {"ingested": 1, "confirmed": 1, "skipped": 0}
|
||||
|
||||
|
||||
def test_approved_is_confirmed_without_ingest():
|
||||
rec = Recorder([_message("AssetApproved")])
|
||||
summary = consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm)
|
||||
assert rec.ingested == [] and len(rec.confirmed) == 1
|
||||
assert summary["skipped"] == 1
|
||||
|
||||
|
||||
def test_unknown_type_is_confirmed_to_avoid_head_of_line_blocking():
|
||||
rec = Recorder([_message("SomethingElse")])
|
||||
consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm)
|
||||
assert rec.ingested == [] and len(rec.confirmed) == 1
|
||||
|
||||
|
||||
def test_duplicate_and_rejected_still_confirm():
|
||||
rec = Recorder(
|
||||
[_message("AssetRegistered"), _message("AssetRegistered")],
|
||||
ingest_results=["duplicate", "rejected"],
|
||||
)
|
||||
summary = consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm)
|
||||
assert len(rec.confirmed) == 2
|
||||
assert summary["ingested"] == 2
|
||||
|
||||
|
||||
def test_transient_ingest_error_stops_without_confirm():
|
||||
rec = Recorder(
|
||||
[_message("AssetRegistered"), _message("AssetApproved")],
|
||||
ingest_results=[TransientIngestError("minio down")],
|
||||
)
|
||||
summary = consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm)
|
||||
assert rec.confirmed == [] # head message left pending for retry
|
||||
assert summary["ingested"] == 0
|
||||
assert summary.get("stopped") == "transient_error"
|
||||
|
||||
|
||||
def test_malformed_registered_event_is_confirmed_as_poison():
|
||||
"""A permanently unmappable event must not block the queue forever."""
|
||||
bad = _message("AssetRegistered", event={"event_type": "AssetRegistered"})
|
||||
rec = Recorder([bad])
|
||||
summary = consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm)
|
||||
assert rec.ingested == []
|
||||
assert len(rec.confirmed) == 1
|
||||
assert summary["skipped"] == 1
|
||||
|
||||
|
||||
def test_empty_inbox_returns_zero_summary():
|
||||
rec = Recorder([])
|
||||
assert consume_inbox(fetch=rec.fetch, ingest=rec.ingest, confirm=rec.confirm) == {
|
||||
"ingested": 0, "confirmed": 0, "skipped": 0,
|
||||
}
|
||||
Reference in New Issue
Block a user