211 lines
7.2 KiB
Python
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"
|