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>
161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
"""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,
|
|
}
|