Files
cyclone/backend/tests/test_store_acks_publish.py
T

211 lines
7.2 KiB
Python

"""Tests that the store's ACK add methods publish on the event bus.
Per SP25, the store is the single write surface; the handlers and
parse endpoints no longer publish. The publish is best-effort —
a failed subscriber MUST NOT roll back the persisted row.
"""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.pubsub import EventBus
from cyclone.store import store
@pytest.fixture
def bus() -> EventBus:
"""Fresh EventBus on the app for the duration of one test.
The autouse conftest already wires an EventBus onto
``app.state.event_bus`` per-test. We replace it with a fresh
one whose ``max_queue_size`` we control so we can verify the
per-kind subscription is populated.
Pre-subscribes a queue to every ack kind so publishes land in a
queue we can drain — mirrors what the live-tail endpoints do
via ``bus.subscribe_raw(...)``.
"""
new_bus = EventBus(max_queue_size=64)
new_bus.subscribe_raw(["ack_received"])
new_bus.subscribe_raw(["ta1_ack_received"])
new_bus.subscribe_raw(["two77ca_ack_received"])
app.state.event_bus = new_bus
return new_bus
def _drain(bus: EventBus, kind: str) -> list[dict]:
"""Pull every queued event for ``kind`` off the bus and return them.
Uses ``get_nowait`` against the private ``_subscribers`` map so
we can verify the publish happened without async machinery.
"""
events: list[dict] = []
for queue in bus._subscribers.get(kind, []):
while not queue.empty():
events.append(queue.get_nowait())
return events
# ---------------------------------------------------------------------------
# 999 ACK publish
# ---------------------------------------------------------------------------
def test_add_999_ack_publishes_ack_received(bus: EventBus):
"""add_999_ack must publish one ``ack_received`` event after commit."""
with db.SessionLocal()() as s:
row = store.add_ack(
source_batch_id="999-SP25-1",
accepted_count=2,
rejected_count=0,
received_count=2,
ack_code="A",
raw_json={
"envelope": {"control_number": "000000001"},
"set_responses": [{"set_control_number": "PCN-1"}],
},
event_bus=bus,
)
events = _drain(bus, "ack_received")
assert len(events) == 1
payload = events[0]
# The event payload MUST match the list-endpoint shape so a
# live-tail consumer sees the same row shape as a JSON fetch.
assert payload["_kind"] == "ack_received"
assert payload["id"] == row.id
assert payload["source_batch_id"] == "999-SP25-1"
assert payload["accepted_count"] == 2
assert payload["rejected_count"] == 0
assert payload["received_count"] == 2
assert payload["ack_code"] == "A"
# Patient control number surfaces through the UI serializer.
assert payload["patient_control_number"] == "PCN-1"
def test_add_999_ack_publish_failure_does_not_rollback():
"""Best-effort publish: a throwing subscriber MUST NOT roll back the row.
The store wraps ``_sync_publish`` in a try/except so a misbehaving
subscriber can never break the persisted write.
"""
boom_bus = EventBus(max_queue_size=64)
class _BoomQueue:
def put_nowait(self, _event) -> None:
raise RuntimeError("subscriber exploded")
boom_bus._subscribers["ack_received"] = [_BoomQueue()] # type: ignore[list-item]
with db.SessionLocal()() as s:
row = store.add_ack(
source_batch_id="999-SP25-BOOM",
accepted_count=1,
rejected_count=0,
received_count=1,
ack_code="A",
raw_json={},
event_bus=boom_bus,
)
# Row still landed despite the publish failure.
assert store.get_ack(row.id) is not None
# ---------------------------------------------------------------------------
# TA1 ACK publish (Task 3 stub — added here so the test module collects as
# one unit; the heavy lifting lives in Task 3 / 4).
# ---------------------------------------------------------------------------
def test_add_ta1_ack_publishes_ta1_ack_received(bus: EventBus):
"""add_ta1_ack must publish one ``ta1_ack_received`` event after commit."""
with db.SessionLocal()() as s:
row = store.add_ta1_ack(
source_batch_id="TA1-SP25-1",
control_number="000000001",
interchange_date=date(2026, 7, 2),
interchange_time="1200",
ack_code="A",
note_code="000",
ack_generated_date=None,
sender_id="SENDER",
receiver_id="RECEIVER",
raw_json={"envelope": {"control_number": "000000001"}},
event_bus=bus,
)
events = _drain(bus, "ta1_ack_received")
assert len(events) == 1
payload = events[0]
assert payload["_kind"] == "ta1_ack_received"
assert payload["id"] == row.id
assert payload["control_number"] == "000000001"
assert payload["ack_code"] == "A"
assert payload["sender_id"] == "SENDER"
assert payload["receiver_id"] == "RECEIVER"
assert payload["interchange_date"] == "2026-07-02"
# ---------------------------------------------------------------------------
# 277CA ACK publish (Task 4 stub)
# ---------------------------------------------------------------------------
def test_add_277ca_ack_publishes_two77ca_ack_received(bus: EventBus):
"""add_277ca_ack must publish one ``two77ca_ack_received`` event."""
with db.SessionLocal()() as s:
row = store.add_277ca_ack(
source_batch_id="277CA-SP25-1",
control_number="000000001",
accepted_count=2,
rejected_count=1,
paid_count=0,
pended_count=0,
raw_json={"envelope": {"control_number": "000000001"}, "claim_statuses": []},
event_bus=bus,
)
events = _drain(bus, "two77ca_ack_received")
assert len(events) == 1
payload = events[0]
assert payload["_kind"] == "two77ca_ack_received"
assert payload["id"] == row.id
assert payload["control_number"] == "000000001"
assert payload["accepted_count"] == 2
assert payload["rejected_count"] == 1
# ---------------------------------------------------------------------------
# /api/parse-999 fires the event (Task 6 stub)
# ---------------------------------------------------------------------------
MINIMAL_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"
def test_parse_999_endpoint_fires_ack_received_event(bus: EventBus):
"""The /api/parse-999 route threads the app.state event bus into add_999_ack.
SP25: the parse endpoint no longer hand-rolls a publish. The store
owns publish-from-store; the endpoint just threads the bus.
"""
client = TestClient(app)
text = MINIMAL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
)
assert resp.status_code == 200, resp.text
events = _drain(bus, "ack_received")
assert len(events) == 1
assert events[0]["_kind"] == "ack_received"