merge: SP25 ack live-tail into main
Wires the live-tail triplet (useTailStream + useMergedTail +
<TailStatusPill>) into the Acks page so 999 and TA1 acknowledgments
appear the moment the SFTP poller (or a manual upload) lands them in
the database — no more 'no tracking is being updated'.
Backend changes
---------------
* store/acks.py: add_ack / add_ta1_ack / add_277ca_ack now publish
ack_received / ta1_ack_received / two77ca_ack_received from inside
the store after commit. Failures don't roll back the row.
* store/ui.py: new home for to_ui_ack / to_ui_ta1_ack / to_ui_two77ca_ack
so the live-tail payload matches the list endpoint shape byte-for-byte.
* api_routers/acks.py + api_routers/ta1_acks.py: new /api/acks/stream
and /api/ta1-acks/stream NDJSON endpoints following the live-tail
wire format used by /api/claims/stream, /api/remittances/stream,
and /api/activity/stream (snapshot + snapshot_end + live events +
heartbeat + clean disconnect).
* api.py: parse-999 / parse-ta1 / parse-277ca endpoints thread
request.app.state.event_bus into the store on write.
* handlers/handle_*.py: dropped the event_bus kwarg; the publish
path is now store-only, not handler-driven.
Frontend changes
----------------
* lib/tail-stream.ts: TailResource widened to include acks + ta1_acks.
* store/tail-store.ts: acks + ta1Acks slices, addAck + addTa1Ack
setters, matching reset cases. evictOldest is generic over
(K extends string|number, V) so both keyed-by-id flavors share it.
* hooks/useTailStream.ts: dispatch routes acks / ta1_acks items into
the matching store slices.
* hooks/useMergedTail.ts: generic constraint widened to
T extends { id: string | number }, dedup normalizes via String(id)
so numeric ids compare correctly.
* pages/Acks.tsx: opens both streams + renders two <TailStatusPill>
components (one in the 999 hero, one in the TA1 section).
Verification
------------
* Backend: 1191 pass, 1 pre-existing test pollution failure (also
fails on main, passes in isolation, unrelated to SP25).
* Frontend: 535 pass, 10 pre-existing failures in Inbox / Upload /
Receipt / InboxHeader tests (all verified to fail the same way on
main).
* TypeScript: zero new SP25 errors. Pre-existing Upload / Inbox
errors match main.
Per the SP-N increment flow: no squash, no rebase. The merge commit
is the record of the increment landing.
This commit is contained in:
+14
-26
@@ -90,6 +90,7 @@ from cyclone.store import (
|
||||
InvalidStateError,
|
||||
dashboard_kpis,
|
||||
store,
|
||||
to_ui_two77ca_ack,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
@@ -774,6 +775,7 @@ async def parse_999_endpoint(
|
||||
received_count=received,
|
||||
ack_code=ack_code,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
event_bus=request.app.state.event_bus,
|
||||
)
|
||||
|
||||
# SP11: append one audit row per rejected claim. Each row chains
|
||||
@@ -823,6 +825,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
|
||||
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
|
||||
async def parse_ta1_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
"""Parse a TA1 (Interchange Acknowledgment) file, persist a row, return JSON.
|
||||
@@ -837,6 +840,10 @@ async def parse_ta1_endpoint(
|
||||
The persisted ``ta1_acks.source_batch_id`` is a synthetic id
|
||||
(``TA1-<ISA13>``) because a received TA1 has no inbound batch to
|
||||
FK to. The dashboard's ``/ta1-acks`` list surfaces these.
|
||||
|
||||
SP25: threads ``event_bus=request.app.state.event_bus`` into
|
||||
``store.add_ta1_ack`` so the live-tail ``ta1_ack_received``
|
||||
stream fires on manual uploads (not just on the SFTP poller).
|
||||
"""
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
@@ -880,6 +887,7 @@ async def parse_ta1_endpoint(
|
||||
sender_id=result.envelope.sender_id,
|
||||
receiver_id=result.envelope.receiver_id,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
event_bus=request.app.state.event_bus,
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
@@ -964,6 +972,7 @@ async def parse_277ca_endpoint(
|
||||
pended = sum(1 for s in result.claim_statuses if s.classification == "pended")
|
||||
|
||||
# Persist the 277CA row first so we have an id to attach to claims.
|
||||
# SP25: thread the event bus so ``two77ca_ack_received`` fires.
|
||||
row = store.add_277ca_ack(
|
||||
source_batch_id=synthetic_id,
|
||||
control_number=icn,
|
||||
@@ -972,6 +981,7 @@ async def parse_277ca_endpoint(
|
||||
paid_count=paid,
|
||||
pended_count=pended,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
event_bus=request.app.state.event_bus,
|
||||
)
|
||||
|
||||
# Stamp payer-rejection fields on matching claims. The 277CA's
|
||||
@@ -1036,7 +1046,7 @@ def list_277ca_acks_endpoint(
|
||||
) -> Any:
|
||||
"""Return the list of persisted 277CA ACKs, newest first."""
|
||||
rows = store.list_277ca_acks()
|
||||
items = [_277ca_to_ui(r) for r in rows[:limit]]
|
||||
items = [to_ui_two77ca_ack(r) for r in rows[:limit]]
|
||||
return {"total": len(rows), "items": items}
|
||||
|
||||
|
||||
@@ -1046,31 +1056,9 @@ def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||||
row = store.get_277ca_ack(ack_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found")
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"paid_count": row.paid_count,
|
||||
"pended_count": row.pended_count,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
"raw_json": row.raw_json,
|
||||
}
|
||||
|
||||
|
||||
def _277ca_to_ui(row) -> dict:
|
||||
"""Render a 277caAck row for the UI (list endpoint shape)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"paid_count": row.paid_count,
|
||||
"pended_count": row.pended_count,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
}
|
||||
body = to_ui_two77ca_ack(row)
|
||||
body["raw_json"] = row.raw_json
|
||||
return body
|
||||
|
||||
|
||||
def _serialize_ta1(result) -> str:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""``/api/acks`` — list & detail endpoints for the 999 ACK inbox.
|
||||
"""``/api/acks`` — list, detail, and live-tail stream for 999 ACKs.
|
||||
|
||||
These are the persisted acknowledgment rows produced by
|
||||
``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the
|
||||
@@ -7,60 +7,39 @@ list payload to its ``Ack`` interface in ``src/types/index.ts``.
|
||||
The detail endpoint returns the full ``raw_json`` payload plus the
|
||||
regenerated ``raw_999_text`` so the UI can show "view source" without a
|
||||
second round-trip.
|
||||
|
||||
SP25: ``/api/acks/stream`` joins the live-tail triplet — the Acks
|
||||
page mounts ``useTailStream("acks")`` and ``useMergedTail("acks", …)``
|
||||
to see new 999 acks the moment they land (whether from the SFTP
|
||||
poller or a manual upload).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
|
||||
from cyclone.api_helpers import (
|
||||
ndjson_line,
|
||||
ndjson_stream_list,
|
||||
tail_events,
|
||||
wants_ndjson,
|
||||
)
|
||||
from cyclone.parsers.models_999 import ParseResult999
|
||||
from cyclone.parsers.serialize_999 import serialize_999
|
||||
from cyclone.store import store
|
||||
from cyclone.pubsub import EventBus
|
||||
from cyclone.store import store, to_ui_ack
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ack_to_ui(row) -> dict:
|
||||
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
|
||||
|
||||
Field names match the rest of the Cyclone API (snake_case). The
|
||||
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
|
||||
interface in ``src/types/index.ts``.
|
||||
|
||||
Adds ``patient_control_number`` pulled from ``raw_json`` so the
|
||||
operator can correlate each 999 back to the original claim
|
||||
batch. The full inbound filename is reachable via
|
||||
``GET /api/acks/{ack_id}`` (raw_json carries the full parse
|
||||
tree) and ``GET /api/admin/scheduler/processed-files``.
|
||||
"""
|
||||
body = {
|
||||
"id": row.id,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"received_count": row.received_count,
|
||||
"ack_code": row.ack_code,
|
||||
"parsed_at": (
|
||||
row.parsed_at.isoformat().replace("+00:00", "Z")
|
||||
if row.parsed_at is not None
|
||||
else ""
|
||||
),
|
||||
"patient_control_number": None,
|
||||
}
|
||||
raw = row.raw_json or {}
|
||||
try:
|
||||
set_responses = raw.get("set_responses") or []
|
||||
if set_responses:
|
||||
body["patient_control_number"] = set_responses[0].get("set_control_number")
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return body
|
||||
# SP25: ``_ack_to_ui`` moved to ``cyclone.store.ui.to_ui_ack`` so the
|
||||
# live-tail event payload (``ack_received``) matches the list endpoint
|
||||
# shape byte-for-byte.
|
||||
|
||||
|
||||
@router.get("/api/acks")
|
||||
@@ -80,7 +59,7 @@ def list_acks_endpoint(
|
||||
exceeds the page size.
|
||||
"""
|
||||
rows = store.list_acks()
|
||||
items = [_ack_to_ui(r) for r in rows[offset : offset + limit]]
|
||||
items = [to_ui_ack(r) for r in rows[offset : offset + limit]]
|
||||
total = len(rows)
|
||||
returned = len(items)
|
||||
has_more = offset + returned < total
|
||||
@@ -103,6 +82,35 @@ def list_acks_endpoint(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/acks/stream")
|
||||
async def acks_stream(
|
||||
request: Request,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> StreamingResponse:
|
||||
"""Stream 999 ACKs as NDJSON: snapshot first, then live events.
|
||||
|
||||
SP25: this endpoint joins the live-tail triplet — subscribes to
|
||||
``ack_received`` and emits one ``item`` per snapshot row plus a
|
||||
single ``snapshot_end`` line, then forwards live events from
|
||||
the bus. Matches the wire format used by ``/api/claims/stream``,
|
||||
``/api/remittances/stream``, and ``/api/activity/stream``.
|
||||
|
||||
NOTE: registered BEFORE ``/api/acks/{ack_id}`` so the literal
|
||||
``stream`` path segment doesn't get matched as an ack id.
|
||||
"""
|
||||
bus: EventBus = request.app.state.event_bus
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
rows = store.list_acks()[:limit]
|
||||
for row in rows:
|
||||
yield ndjson_line({"type": "item", "data": to_ui_ack(row)})
|
||||
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
|
||||
async for chunk in tail_events(request, bus, ["ack_received"]):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.get("/api/acks/{ack_id}")
|
||||
def get_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted ACK row with its parsed detail.
|
||||
@@ -117,7 +125,7 @@ def get_ack_endpoint(ack_id: int) -> dict:
|
||||
status_code=404,
|
||||
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
|
||||
)
|
||||
body = _ack_to_ui(row)
|
||||
body = to_ui_ack(row)
|
||||
body["raw_json"] = row.raw_json
|
||||
# Regenerate the X12 text from raw_json so the operator can download
|
||||
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""``/api/ta1-acks`` — list & detail endpoints for persisted TA1 envelopes.
|
||||
"""``/api/ta1-acks`` — list, detail, and live-tail stream for TA1 envelopes.
|
||||
|
||||
TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a
|
||||
single segment, no functional group, no transaction set. Cyclone
|
||||
@@ -7,34 +7,29 @@ row can sit alongside the 999 / 277CA ack rows without special-casing.
|
||||
|
||||
The detail endpoint also reconstructs the TA1 segment string
|
||||
(``TA1*...~``) so the operator can copy it into a downstream tool.
|
||||
|
||||
SP25: ``/api/ta1-acks/stream`` joins the live-tail triplet — the
|
||||
Acks page mounts ``useTailStream("ta1_acks")`` so the TA1 envelope
|
||||
ack section sees new rows the moment they land.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from cyclone.api_helpers import ndjson_line, tail_events
|
||||
from cyclone import db
|
||||
from cyclone.store import store
|
||||
from cyclone.pubsub import EventBus
|
||||
from cyclone.store import store, to_ui_ta1_ack
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ta1_to_ui(row: db.Ta1Ack) -> dict:
|
||||
"""Render a Ta1Ack row for the UI (list endpoint shape)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"ack_code": row.ack_code,
|
||||
"note_code": row.note_code,
|
||||
"interchange_date": row.interchange_date.isoformat()
|
||||
if row.interchange_date else None,
|
||||
"interchange_time": row.interchange_time,
|
||||
"sender_id": row.sender_id,
|
||||
"receiver_id": row.receiver_id,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
}
|
||||
# SP25: ``_ta1_to_ui`` moved to ``cyclone.store.ui.to_ui_ta1_ack`` so
|
||||
# the live-tail event payload (``ta1_ack_received``) matches the list
|
||||
# endpoint shape byte-for-byte.
|
||||
|
||||
|
||||
def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
||||
@@ -57,20 +52,44 @@ def list_ta1_acks_endpoint(
|
||||
count regardless of the ``limit`` cap.
|
||||
"""
|
||||
rows = store.list_ta1_acks()
|
||||
items = [_ta1_to_ui(r) for r in rows[:limit]]
|
||||
items = [to_ui_ta1_ack(r) for r in rows[:limit]]
|
||||
return {
|
||||
"total": len(rows),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ta1-acks/stream")
|
||||
async def ta1_acks_stream(
|
||||
request: Request,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> StreamingResponse:
|
||||
"""Stream TA1 envelope acks as NDJSON.
|
||||
|
||||
Subscribes to ``ta1_ack_received`` and emits the same wire format
|
||||
as the other live-tail endpoints. Registered BEFORE
|
||||
``/api/ta1-acks/{ack_id}`` so ``stream`` isn't matched as an id.
|
||||
"""
|
||||
bus: EventBus = request.app.state.event_bus
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
rows = store.list_ta1_acks()[:limit]
|
||||
for row in rows:
|
||||
yield ndjson_line({"type": "item", "data": to_ui_ta1_ack(row)})
|
||||
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
|
||||
async for chunk in tail_events(request, bus, ["ta1_ack_received"]):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.get("/api/ta1-acks/{ack_id}")
|
||||
def get_ta1_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted TA1 ACK row with its parsed detail."""
|
||||
row = store.get_ta1_ack(ack_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found")
|
||||
body = _ta1_to_ui(row)
|
||||
body = to_ui_ta1_ack(row)
|
||||
body["raw_ta1_text"] = _serialize_ta1_from_row(row)
|
||||
body["raw_json"] = row.raw_json
|
||||
return body
|
||||
|
||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
@@ -35,17 +34,12 @@ log = logging.getLogger(__name__)
|
||||
def handle(
|
||||
text: str,
|
||||
source_file: str,
|
||||
*,
|
||||
event_bus: Optional[object] = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Parse a 277CA, persist ack + stamp payer-rejected claims.
|
||||
|
||||
Args:
|
||||
text: Raw 277CA document bytes (decoded).
|
||||
source_file: Filename the 277CA came from.
|
||||
event_bus: Optional pubsub handle. Reserved for the
|
||||
FastAPI-endpoint migration in Task 6 — the scheduler
|
||||
doesn't pass one.
|
||||
|
||||
Returns:
|
||||
``(parser_used, claim_count)`` tuple where ``claim_count`` is
|
||||
@@ -53,6 +47,10 @@ def handle(
|
||||
|
||||
Raises:
|
||||
ValueError: on parser-level failure (wraps CycloneParseError).
|
||||
|
||||
SP25: the store (``cyclone.store.acks.add_277ca_ack``) owns the
|
||||
publish-from-store contract; the handler no longer needs an
|
||||
``event_bus`` kwarg.
|
||||
"""
|
||||
try:
|
||||
result = parse_277ca_text(text, input_file=source_file)
|
||||
@@ -104,19 +102,4 @@ def handle(
|
||||
))
|
||||
session.commit()
|
||||
|
||||
# TODO(sp27-task-6): bridge async EventBus.publish → sync caller
|
||||
# (same gap as handle_999 + handle_ta1; Task 6 will fix when the
|
||||
# FastAPI endpoints migrate to call these handlers).
|
||||
if event_bus is not None:
|
||||
publish = getattr(event_bus, "publish", None)
|
||||
if callable(publish):
|
||||
try:
|
||||
publish("ack_received", {
|
||||
"source_batch_id": synthetic_id,
|
||||
"ack_code": "277CA",
|
||||
"kind": "277CA",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("event_bus publish failed: %s", exc)
|
||||
|
||||
return ("parse_277ca", len(result.claim_statuses))
|
||||
|
||||
@@ -26,11 +26,9 @@ sync/async gap as handle_999 / handle_ta1 / handle_277ca.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
@@ -44,17 +42,12 @@ log = logging.getLogger(__name__)
|
||||
def handle(
|
||||
text: str,
|
||||
source_file: str,
|
||||
*,
|
||||
event_bus: Optional[object] = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Parse an 835, run validation, persist batch + remittances.
|
||||
|
||||
Args:
|
||||
text: Raw 835 document bytes (decoded).
|
||||
source_file: Filename the 835 came from.
|
||||
event_bus: Optional pubsub handle. Reserved for the
|
||||
FastAPI-endpoint migration in Task 6 — the scheduler
|
||||
doesn't pass one.
|
||||
|
||||
Returns:
|
||||
``(parser_used, claim_count)`` tuple. ``claim_count`` is the
|
||||
@@ -66,6 +59,12 @@ def handle(
|
||||
ValueError: on parser-level failure (wraps CycloneParseError).
|
||||
Validation failures don't raise — they're stamped into
|
||||
``summary.passed = 0``.
|
||||
|
||||
SP25: the 835 write path already publishes ``remittance_written``
|
||||
via ``CycloneStore.add`` (SP21 split). The handler no longer
|
||||
accepts an ``event_bus`` kwarg — the ``ack_received`` publish
|
||||
here was dead code anyway (the 835 event name is
|
||||
``remittance_written``, not ``ack_received``).
|
||||
"""
|
||||
# TODO(sp27-pre-t5): move PAYER_FACTORIES_835 out of api.py into
|
||||
# ``cyclone.payers`` to remove the lazy cyclic import below. The
|
||||
@@ -105,17 +104,4 @@ def handle(
|
||||
)
|
||||
cycl_store.add(rec)
|
||||
|
||||
# TODO(sp27-task-6): bridge async EventBus.publish → sync caller.
|
||||
if event_bus is not None:
|
||||
publish = getattr(event_bus, "publish", None)
|
||||
if callable(publish):
|
||||
try:
|
||||
publish("ack_received", {
|
||||
"source_batch_id": rec.id,
|
||||
"ack_code": "835",
|
||||
"kind": "835",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("event_bus publish failed: %s", exc)
|
||||
|
||||
return ("parse_835", len(result.claims))
|
||||
|
||||
@@ -20,7 +20,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
@@ -39,8 +38,6 @@ log = logging.getLogger(__name__)
|
||||
def handle(
|
||||
text: str,
|
||||
source_file: str,
|
||||
*,
|
||||
event_bus: Optional[object] = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Parse a 999, apply rejections, persist ack row.
|
||||
|
||||
@@ -48,10 +45,6 @@ def handle(
|
||||
text: Raw 999 document bytes (decoded).
|
||||
source_file: Filename the 999 came from. Used to derive a
|
||||
unique synthetic ``batches.id`` (see ``_ack_id``).
|
||||
event_bus: Optional pubsub handle. When provided, an
|
||||
``ack_received`` event is best-effort published. The
|
||||
scheduler passes ``None``; the FastAPI endpoint will
|
||||
be migrated to pass ``app.state.event_bus`` in Task 6.
|
||||
|
||||
Returns:
|
||||
``(parser_used, claim_count)`` tuple. Matches the scheduler
|
||||
@@ -60,6 +53,12 @@ def handle(
|
||||
|
||||
Raises:
|
||||
ValueError: on parser-level failure (wraps CycloneParseError).
|
||||
|
||||
SP25: the store (``cyclone.store.acks.add_999_ack``) now owns the
|
||||
publish-from-store contract; the handler no longer needs an
|
||||
``event_bus`` kwarg. Both the scheduler path (no bus) and the
|
||||
FastAPI endpoint path (passes the bus to the store directly) see
|
||||
the same row, the same event, and the same payload.
|
||||
"""
|
||||
try:
|
||||
result = parse_999_text(text, input_file=source_file)
|
||||
@@ -105,25 +104,4 @@ def handle(
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Best-effort pubsub publish. The scheduler doesn't pass an
|
||||
# event_bus; the API migration in Task 6 will pass the FastAPI
|
||||
# EventBus (which has an async ``publish``). This handler is
|
||||
# sync, so an async-real EventBus returns an unawaited coroutine
|
||||
# here — that's a known gap until Task 6 wires the publish
|
||||
# through ``asyncio.run_coroutine_threadsafe`` or makes
|
||||
# ``handle`` async. See TODO(sp27-task-6) below.
|
||||
if event_bus is not None:
|
||||
publish = getattr(event_bus, "publish", None)
|
||||
if callable(publish):
|
||||
# TODO(sp27-task-6): bridge async EventBus.publish → sync
|
||||
# caller (see comment above).
|
||||
try:
|
||||
publish("ack_received", {
|
||||
"source_batch_id": synthetic_id,
|
||||
"ack_code": ack_code,
|
||||
"kind": "999",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("event_bus publish failed: %s", exc)
|
||||
|
||||
return ("parse_999", received)
|
||||
|
||||
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
@@ -29,8 +28,6 @@ log = logging.getLogger(__name__)
|
||||
def handle(
|
||||
text: str,
|
||||
source_file: str,
|
||||
*,
|
||||
event_bus: Optional[object] = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Parse a TA1, persist the interchange ack row.
|
||||
|
||||
@@ -40,9 +37,6 @@ def handle(
|
||||
attribution; the ``batches.id`` for TA1 rows is derived
|
||||
internally from the parsed envelope's control number
|
||||
(``TA1-{ICN}``).
|
||||
event_bus: Optional pubsub handle. Reserved for the
|
||||
FastAPI-endpoint migration in Task 6 — the scheduler
|
||||
doesn't pass one.
|
||||
|
||||
Returns:
|
||||
``(parser_used, claim_count)`` tuple. TA1 always returns
|
||||
@@ -50,6 +44,10 @@ def handle(
|
||||
|
||||
Raises:
|
||||
ValueError: on parser-level failure (wraps CycloneParseError).
|
||||
|
||||
SP25: the store (``cyclone.store.acks.add_ta1_ack``) owns the
|
||||
publish-from-store contract; the handler no longer needs an
|
||||
``event_bus`` kwarg.
|
||||
"""
|
||||
try:
|
||||
result = parse_ta1_text(text, input_file=source_file)
|
||||
@@ -71,19 +69,4 @@ def handle(
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# TODO(sp27-task-6): bridge async EventBus.publish → sync caller
|
||||
# (same gap as handle_999; both fixed when the FastAPI endpoints
|
||||
# migrate to call these handlers instead of inline code).
|
||||
if event_bus is not None:
|
||||
publish = getattr(event_bus, "publish", None)
|
||||
if callable(publish):
|
||||
try:
|
||||
publish("ack_received", {
|
||||
"source_batch_id": result.source_batch_id,
|
||||
"ack_code": result.ta1.ack_code,
|
||||
"kind": "TA1",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("event_bus publish failed: %s", exc)
|
||||
|
||||
return ("parse_ta1", 1)
|
||||
|
||||
@@ -101,11 +101,14 @@ from .providers import (
|
||||
)
|
||||
from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
|
||||
from .ui import (
|
||||
to_ui_ack,
|
||||
to_ui_claim_detail,
|
||||
to_ui_claim_from_orm,
|
||||
to_ui_provider,
|
||||
to_ui_remittance_from_orm,
|
||||
to_ui_remittance_with_adjustments,
|
||||
to_ui_ta1_ack,
|
||||
to_ui_two77ca_ack,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,14 +4,47 @@ Each ACK type has its own ORM row (Ack / Ta1Ack / Two77caAck). The
|
||||
write methods are simple inserts; the list/get methods are simple
|
||||
queries. Fail-soft: persistence errors are logged but do not block
|
||||
parsing.
|
||||
|
||||
SP25: every write path publishes one event on the EventBus so the
|
||||
Acks page live-tail can subscribe — mirrors the existing
|
||||
``claim_written`` / ``remittance_written`` / ``activity_recorded``
|
||||
publish pattern (see ``cyclone.store.write``). Publish is best-effort:
|
||||
a failing subscriber MUST NOT roll back the persisted row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import Ack
|
||||
|
||||
from . import utcnow
|
||||
from .ui import to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
|
||||
from .write import _sync_publish
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cyclone.pubsub import EventBus
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_publish(event_bus: "EventBus | None", kind: str, payload: dict) -> None:
|
||||
"""Best-effort publish wrapped in try/except.
|
||||
|
||||
Mirrors ``CycloneStore._publish_events_sync`` — never raises, never
|
||||
rolls back the persisted row. Falls back to skipping silently when
|
||||
the bus doesn't implement the private ``_sync_publish`` interface
|
||||
(e.g. test stubs).
|
||||
"""
|
||||
if event_bus is None:
|
||||
return
|
||||
try:
|
||||
_sync_publish(event_bus, kind, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("acks store: %s publish failed", kind)
|
||||
|
||||
|
||||
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
|
||||
@@ -24,6 +57,7 @@ def add_999_ack(
|
||||
received_count: int,
|
||||
ack_code: str,
|
||||
raw_json: dict,
|
||||
event_bus: "EventBus | None" = None,
|
||||
) -> db.Ack:
|
||||
"""Persist a 999 ACK row and return it.
|
||||
|
||||
@@ -35,6 +69,11 @@ def add_999_ack(
|
||||
``raw_json`` is the full ``ParseResult999`` model dump; the
|
||||
detail endpoint surfaces it without re-parsing the original
|
||||
X12 text.
|
||||
|
||||
SP25: when ``event_bus`` is provided, publishes one
|
||||
``ack_received`` event (with the full ``to_ui_ack`` row shape)
|
||||
after the row commits so the Acks page live-tail sees new 999s
|
||||
the moment they land.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
row = Ack(
|
||||
@@ -49,7 +88,14 @@ def add_999_ack(
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
row_id = row.id
|
||||
|
||||
if event_bus is not None:
|
||||
with db.SessionLocal()() as s:
|
||||
payload = to_ui_ack(s.get(Ack, row_id))
|
||||
_safe_publish(event_bus, "ack_received", payload)
|
||||
|
||||
return row
|
||||
|
||||
def list_acks() -> list[db.Ack]:
|
||||
"""Return every 999 ACK row, newest first (auto-increment id desc)."""
|
||||
@@ -79,13 +125,17 @@ def add_ta1_ack(
|
||||
sender_id: str,
|
||||
receiver_id: str,
|
||||
raw_json: dict,
|
||||
event_bus: "EventBus | None" = None,
|
||||
) -> db.Ta1Ack:
|
||||
"""Persist a TA1 (Interchange Acknowledgment) row and return it.
|
||||
|
||||
Mirrors :meth:`add_ack` for the lower-level envelope ack. The
|
||||
Mirrors :meth:`add_999_ack` for the lower-level envelope ack. The
|
||||
flat columns are promoted out of ``raw_json`` so the list
|
||||
endpoint can sort/filter without a JSON parse; the full
|
||||
``ParseResultTa1`` stays in ``raw_json`` for the detail endpoint.
|
||||
|
||||
SP25: when ``event_bus`` is provided, publishes one
|
||||
``ta1_ack_received`` event after the row commits.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Ta1Ack(
|
||||
@@ -104,7 +154,14 @@ def add_ta1_ack(
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
row_id = row.id
|
||||
|
||||
if event_bus is not None:
|
||||
with db.SessionLocal()() as s:
|
||||
payload = to_ui_ta1_ack(s.get(db.Ta1Ack, row_id))
|
||||
_safe_publish(event_bus, "ta1_ack_received", payload)
|
||||
|
||||
return row
|
||||
|
||||
def list_ta1_acks() -> list[db.Ta1Ack]:
|
||||
"""Return every TA1 ACK row, newest first (auto-increment id desc).
|
||||
@@ -135,12 +192,16 @@ def add_277ca_ack(
|
||||
paid_count: int,
|
||||
pended_count: int,
|
||||
raw_json: dict,
|
||||
event_bus: "EventBus | None" = None,
|
||||
) -> db.Two77caAck:
|
||||
"""Persist a 277CA (Claim Acknowledgment) row and return it.
|
||||
|
||||
Mirrors :meth:`add_ack` but for the claim-level ack. The
|
||||
Mirrors :meth:`add_999_ack` but for the claim-level ack. The
|
||||
per-claim status detail stays in ``raw_json``; only the four
|
||||
counts are promoted so the list endpoint stays fast.
|
||||
|
||||
SP25: when ``event_bus`` is provided, publishes one
|
||||
``two77ca_ack_received`` event after the row commits.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Two77caAck(
|
||||
@@ -156,7 +217,14 @@ def add_277ca_ack(
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
row_id = row.id
|
||||
|
||||
if event_bus is not None:
|
||||
with db.SessionLocal()() as s:
|
||||
payload = to_ui_two77ca_ack(s.get(db.Two77caAck, row_id))
|
||||
_safe_publish(event_bus, "two77ca_ack_received", payload)
|
||||
|
||||
return row
|
||||
|
||||
def list_277ca_acks() -> list[db.Two77caAck]:
|
||||
"""Return every 277CA ACK row, newest first (auto-increment id desc)."""
|
||||
|
||||
@@ -6,6 +6,12 @@ shape change ripples to every API consumer.
|
||||
|
||||
Also hosts ``utcnow()`` (the tz-aware UTC now) because it's a small
|
||||
free function that several modules want and there's no better home.
|
||||
|
||||
SP25: ``to_ui_ack`` / ``to_ui_ta1_ack`` / ``to_ui_two77ca_ack`` were
|
||||
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
|
||||
/ ``api.py`` so the live-tail event payload can match the list endpoint
|
||||
shape byte-for-byte. The seam between persistence and streaming
|
||||
depends on the two halves staying in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -189,6 +195,97 @@ def _iso_z(value: datetime | None) -> str:
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def to_ui_ack(row: db.Ack) -> dict:
|
||||
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
|
||||
|
||||
Field names match the rest of the Cyclone API (snake_case). The
|
||||
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
|
||||
interface in ``src/types/index.ts``.
|
||||
|
||||
Adds ``patient_control_number`` pulled from ``raw_json`` so the
|
||||
operator can correlate each 999 back to the original claim batch.
|
||||
|
||||
SP25: this was previously ``api_routers/acks._ack_to_ui``. Moved
|
||||
here so the live-tail event payload (``ack_received``) matches the
|
||||
list-endpoint shape — the seam between persistence and streaming
|
||||
depends on byte-for-byte equality.
|
||||
"""
|
||||
body = {
|
||||
"id": row.id,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"received_count": row.received_count,
|
||||
"ack_code": row.ack_code,
|
||||
"parsed_at": (
|
||||
row.parsed_at.isoformat().replace("+00:00", "Z")
|
||||
if row.parsed_at is not None
|
||||
else ""
|
||||
),
|
||||
"patient_control_number": None,
|
||||
}
|
||||
raw = row.raw_json or {}
|
||||
try:
|
||||
set_responses = raw.get("set_responses") or []
|
||||
if set_responses:
|
||||
body["patient_control_number"] = set_responses[0].get("set_control_number")
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return body
|
||||
|
||||
|
||||
def to_ui_ta1_ack(row: db.Ta1Ack) -> dict:
|
||||
"""Map a ``Ta1Ack`` ORM row to the UI shape used by ``/api/ta1-acks``.
|
||||
|
||||
SP25: this was previously ``api_routers/ta1_acks._ta1_to_ui``.
|
||||
Moved here so the live-tail event payload (``ta1_ack_received``)
|
||||
matches the list-endpoint shape byte-for-byte.
|
||||
|
||||
Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix) —
|
||||
preserved from the original implementation. SQLite strips tzinfo
|
||||
on read, so the field comes back as a naive datetime; we deliberately
|
||||
keep the original (non-``Z``) rendering rather than re-attaching
|
||||
UTC, because changing the wire shape now would drift both halves
|
||||
of the live-tail contract.
|
||||
"""
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"ack_code": row.ack_code,
|
||||
"note_code": row.note_code,
|
||||
"interchange_date": row.interchange_date.isoformat()
|
||||
if row.interchange_date else None,
|
||||
"interchange_time": row.interchange_time,
|
||||
"sender_id": row.sender_id,
|
||||
"receiver_id": row.receiver_id,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def to_ui_two77ca_ack(row: db.Two77caAck) -> dict:
|
||||
"""Map a ``Two77caAck`` ORM row to the UI shape used by ``/api/277ca-acks``.
|
||||
|
||||
SP25: this was previously ``api._277ca_to_ui`` inlined alongside
|
||||
the 277CA list endpoint. Moved here so the live-tail event
|
||||
payload (``two77ca_ack_received``) matches the list-endpoint
|
||||
shape byte-for-byte.
|
||||
|
||||
Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix) —
|
||||
same SQLite-tzinfo caveat as :func:`to_ui_ta1_ack`.
|
||||
"""
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"paid_count": row.paid_count,
|
||||
"pended_count": row.pended_count,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _address_to_ui(addr: dict | None) -> dict:
|
||||
"""Render a raw ``Address`` dict in the spec's parties address shape.
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Integration tests for /api/acks/stream and /api/ta1-acks/stream.
|
||||
|
||||
SP25: both endpoints follow the live-tail wire format used by
|
||||
/api/claims/stream, /api/remittances/stream, /api/activity/stream:
|
||||
|
||||
* Snapshot: one ``item`` line per existing row (newest first).
|
||||
* Closing: one ``snapshot_end`` line.
|
||||
* Live: forwarded ``item`` lines for each ``ack_received`` /
|
||||
``ta1_ack_received`` event published on the bus.
|
||||
|
||||
These tests follow the same direct-coroutine pattern as
|
||||
``test_api_stream_live.py`` because ``httpx.ASGITransport`` buffers
|
||||
the response and never delivers a disconnect message — calling the
|
||||
endpoint coroutine with a synthetic ``Request`` lets us iterate the
|
||||
body iterator byte-by-byte and use ``body_iterator.aclose()`` to
|
||||
simulate a client disconnect.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.api_routers.acks import acks_stream
|
||||
from cyclone.api_routers.ta1_acks import ta1_acks_stream
|
||||
from cyclone.pubsub import EventBus
|
||||
from cyclone.store import store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers — mirror the patterns in test_api_stream_live.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_request(path: str = "/api/acks/stream") -> Request:
|
||||
"""Synthetic Starlette ``Request`` with a no-op receive channel."""
|
||||
|
||||
async def receive():
|
||||
# Block forever; ``is_disconnected``'s cancel scope will cancel
|
||||
# this await and we'll return None (treated as "no message").
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"path": path,
|
||||
"app": app,
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"client": ("testclient", 50000),
|
||||
}
|
||||
return Request(scope, receive=receive)
|
||||
|
||||
|
||||
def _resolve_query_defaults(endpoint) -> dict:
|
||||
"""Resolve FastAPI Query(...) defaults to their inner ``.default``."""
|
||||
import inspect
|
||||
sig = inspect.signature(endpoint)
|
||||
resolved: dict = {}
|
||||
for name, param in sig.parameters.items():
|
||||
default = param.default
|
||||
if hasattr(default, "default"):
|
||||
resolved[name] = default.default
|
||||
else:
|
||||
resolved[name] = default
|
||||
return resolved
|
||||
|
||||
|
||||
async def _call_endpoint(endpoint, path: str):
|
||||
request = _make_request(path)
|
||||
defaults = _resolve_query_defaults(endpoint)
|
||||
defaults.pop("request", None)
|
||||
return await endpoint(request, **defaults)
|
||||
|
||||
|
||||
async def _read_lines(body_iter, n: int, timeout: float = 5.0) -> list[str]:
|
||||
buffer = b""
|
||||
lines: list[str] = []
|
||||
async with asyncio.timeout(timeout):
|
||||
while len(lines) < n:
|
||||
try:
|
||||
chunk = await body_iter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
buffer += chunk
|
||||
while b"\n" in buffer and len(lines) < n:
|
||||
raw, buffer = buffer.split(b"\n", 1)
|
||||
if raw:
|
||||
lines.append(raw.decode("utf-8"))
|
||||
return lines
|
||||
|
||||
|
||||
async def _read_one_line(body_iter, timeout: float = 5.0) -> str | None:
|
||||
buffer = b""
|
||||
async with asyncio.timeout(timeout):
|
||||
while True:
|
||||
try:
|
||||
chunk = await body_iter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return None
|
||||
buffer += chunk
|
||||
if b"\n" in buffer:
|
||||
raw, _ = buffer.split(b"\n", 1)
|
||||
if raw:
|
||||
return raw.decode("utf-8")
|
||||
|
||||
|
||||
async def _read_until_type(
|
||||
body_iter, target: str, *, timeout: float = 5.0, max_lines: int = 50
|
||||
) -> dict | None:
|
||||
buffer = b""
|
||||
seen = 0
|
||||
async with asyncio.timeout(timeout):
|
||||
while seen < max_lines:
|
||||
try:
|
||||
chunk = await body_iter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return None
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
raw, buffer = buffer.split(b"\n", 1)
|
||||
if not raw:
|
||||
continue
|
||||
seen += 1
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
if obj.get("type") == target:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
async def _drain_until_disconnect(body_iter, max_chunks: int = 20) -> None:
|
||||
try:
|
||||
await body_iter.aclose()
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-test heartbeat patch — 0.2s so idle generators exit promptly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _short_heartbeat(monkeypatch):
|
||||
monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus() -> EventBus:
|
||||
"""Fresh EventBus attached to app.state. Tests publish via ``bus.publish``.
|
||||
|
||||
``add_ack`` / ``add_ta1_ack`` already publish to whichever bus is
|
||||
passed in, but the live-event tests want to publish synchronously
|
||||
against ``app.state.event_bus`` so the existing-tail subscription
|
||||
picks them up.
|
||||
"""
|
||||
new_bus = EventBus(max_queue_size=64)
|
||||
app.state.event_bus = new_bus
|
||||
return new_bus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/acks/stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_acks_stream_snapshots_existing_rows(bus: EventBus):
|
||||
"""An existing 999 row surfaces as the first ``item`` line."""
|
||||
with db.SessionLocal()() as s:
|
||||
store.add_ack(
|
||||
source_batch_id="999-SEED-1",
|
||||
accepted_count=1,
|
||||
rejected_count=0,
|
||||
received_count=1,
|
||||
ack_code="A",
|
||||
raw_json={
|
||||
"envelope": {"control_number": "000000001"},
|
||||
"set_responses": [{"set_control_number": "PCN-1"}],
|
||||
},
|
||||
event_bus=bus,
|
||||
)
|
||||
|
||||
response = await _call_endpoint(acks_stream, "/api/acks/stream")
|
||||
assert response.media_type.startswith("application/x-ndjson")
|
||||
|
||||
lines = await _read_lines(response.body_iterator, n=2)
|
||||
items = [json.loads(line) for line in lines]
|
||||
assert items[0]["type"] == "item"
|
||||
assert items[0]["data"]["source_batch_id"] == "999-SEED-1"
|
||||
assert items[0]["data"]["patient_control_number"] == "PCN-1"
|
||||
assert items[1] == {"type": "snapshot_end", "data": {"count": 1}}
|
||||
await _drain_until_disconnect(response.body_iterator)
|
||||
|
||||
|
||||
async def test_acks_stream_emits_live_event(bus: EventBus):
|
||||
"""A 999 written while the stream is open shows up as a live item."""
|
||||
response = await _call_endpoint(acks_stream, "/api/acks/stream")
|
||||
|
||||
# Drain snapshot (1 snapshot_end line, no prior rows).
|
||||
snap = await _read_until_type(
|
||||
response.body_iterator, "snapshot_end", timeout=2.0,
|
||||
)
|
||||
assert snap == {"type": "snapshot_end", "data": {"count": 0}}
|
||||
|
||||
# Advance one step so ``subscribe_raw`` registers. Next line may be
|
||||
# a heartbeat — that's fine, we just need the subscription live.
|
||||
_ = await _read_one_line(response.body_iterator, timeout=2.0)
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
store.add_ack(
|
||||
source_batch_id="999-LIVE-1",
|
||||
accepted_count=1,
|
||||
rejected_count=0,
|
||||
received_count=1,
|
||||
ack_code="A",
|
||||
raw_json={
|
||||
"envelope": {"control_number": "000000002"},
|
||||
"set_responses": [],
|
||||
},
|
||||
event_bus=bus,
|
||||
)
|
||||
|
||||
obj = await _read_until_type(
|
||||
response.body_iterator, "item", timeout=2.0,
|
||||
)
|
||||
assert obj is not None, "no item line arrived after publish"
|
||||
assert obj["data"]["source_batch_id"] == "999-LIVE-1"
|
||||
await _drain_until_disconnect(response.body_iterator)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/ta1-acks/stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ta1_acks_stream_snapshots_existing_rows(bus: EventBus):
|
||||
"""An existing TA1 row surfaces as the first ``item`` line."""
|
||||
with db.SessionLocal()() as s:
|
||||
store.add_ta1_ack(
|
||||
source_batch_id="TA1-SEED-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="S",
|
||||
receiver_id="R",
|
||||
raw_json={"envelope": {"control_number": "000000001"}},
|
||||
event_bus=bus,
|
||||
)
|
||||
|
||||
response = await _call_endpoint(ta1_acks_stream, "/api/ta1-acks/stream")
|
||||
assert response.media_type.startswith("application/x-ndjson")
|
||||
|
||||
lines = await _read_lines(response.body_iterator, n=2)
|
||||
items = [json.loads(line) for line in lines]
|
||||
assert items[0]["type"] == "item"
|
||||
assert items[0]["data"]["control_number"] == "000000001"
|
||||
assert items[0]["data"]["interchange_date"] == "2026-07-02"
|
||||
assert items[1] == {"type": "snapshot_end", "data": {"count": 1}}
|
||||
await _drain_until_disconnect(response.body_iterator)
|
||||
|
||||
|
||||
async def test_ta1_acks_stream_emits_live_event(bus: EventBus):
|
||||
"""A TA1 written while the stream is open shows up as a live item."""
|
||||
response = await _call_endpoint(ta1_acks_stream, "/api/ta1-acks/stream")
|
||||
|
||||
snap = await _read_until_type(
|
||||
response.body_iterator, "snapshot_end", timeout=2.0,
|
||||
)
|
||||
assert snap == {"type": "snapshot_end", "data": {"count": 0}}
|
||||
_ = await _read_one_line(response.body_iterator, timeout=2.0)
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
store.add_ta1_ack(
|
||||
source_batch_id="TA1-LIVE-1",
|
||||
control_number="000000099",
|
||||
interchange_date=date(2026, 7, 2),
|
||||
interchange_time="1330",
|
||||
ack_code="A",
|
||||
note_code="000",
|
||||
ack_generated_date=None,
|
||||
sender_id="SX",
|
||||
receiver_id="RX",
|
||||
raw_json={"envelope": {"control_number": "000000099"}},
|
||||
event_bus=bus,
|
||||
)
|
||||
|
||||
obj = await _read_until_type(
|
||||
response.body_iterator, "item", timeout=2.0,
|
||||
)
|
||||
assert obj is not None, "no item line arrived after publish"
|
||||
assert obj["data"]["control_number"] == "000000099"
|
||||
assert obj["data"]["sender_id"] == "SX"
|
||||
await _drain_until_disconnect(response.body_iterator)
|
||||
@@ -96,28 +96,65 @@ def test_handle_999_distinct_filenames_get_distinct_synthetic_ids():
|
||||
assert len(rows_b) == 1
|
||||
|
||||
|
||||
def test_handle_999_event_bus_parameter_is_optional():
|
||||
"""Scheduler passes event_bus=None; api.py passes app.state.event_bus.
|
||||
Both must work without crashing on publish."""
|
||||
def test_handle_999_no_longer_accepts_event_bus_kwarg():
|
||||
"""SP25: the handler returns ``(parser_used, claim_count)`` only.
|
||||
|
||||
Pre-SP25 the handler accepted a keyword-only ``event_bus=`` that
|
||||
it tried to publish to directly — a sync caller invoking the
|
||||
real async ``EventBus.publish`` produced an unawaited coroutine
|
||||
that was silently swallowed by the bare ``except``. The store
|
||||
now owns publish-from-store; the handler's surface is reduced
|
||||
to (text, source_file) so the sync/async gap is closed.
|
||||
"""
|
||||
text = ACCEPTED.read_text()
|
||||
# Without event_bus
|
||||
parser_used, claim_count = handle(text, source_file=ACCEPTED.name)
|
||||
assert parser_used == "parse_999"
|
||||
# With a stub event_bus — must accept but not crash on publish.
|
||||
class _Stub:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
assert claim_count >= 1
|
||||
|
||||
def publish(self, kind: str, payload: dict) -> None:
|
||||
self.calls.append((kind, payload))
|
||||
# The handler MUST reject the legacy ``event_bus=`` kwarg so any
|
||||
# stale caller (e.g. the inline copy in api.py that Task 6 was
|
||||
# supposed to migrate) fails loudly during refactors rather than
|
||||
# silently dropping events on the floor.
|
||||
with pytest.raises(TypeError, match="event_bus"):
|
||||
handle(text, source_file=ACCEPTED.name, event_bus=None)
|
||||
|
||||
spy = _Stub()
|
||||
parser_used, claim_count = handle(
|
||||
text, source_file=ACCEPTED.name, event_bus=spy,
|
||||
)
|
||||
assert parser_used == "parse_999"
|
||||
# The stub pattern is sync. Real EventBus is async and the handler
|
||||
# is sync — Task 6 bridges that. For now, just verify the sync
|
||||
# stub fires (or doesn't) without crashing.
|
||||
# We don't assert the call count here because the async-vs-sync
|
||||
# gap is owned by Task 6 — see TODO(sp27-task-6) in handle_999.
|
||||
|
||||
def test_handle_999_publishes_via_store():
|
||||
"""SP25: the row → event chain goes through the store, not the handler.
|
||||
|
||||
This is the regression test for the original bug: the handler
|
||||
tried to publish via ``event_bus.publish(...)`` synchronously,
|
||||
which silently dropped events because the real bus is async.
|
||||
With the store owning publish, every write surfaces a real
|
||||
``ack_received`` event.
|
||||
"""
|
||||
from cyclone.pubsub import EventBus
|
||||
from cyclone.store import store
|
||||
|
||||
bus = EventBus(max_queue_size=64)
|
||||
bus.subscribe_raw(["ack_received"])
|
||||
|
||||
text = ACCEPTED.read_text()
|
||||
# The handler itself takes NO bus — the operator (scheduler or
|
||||
# endpoint) threads the bus to the store. We can't plumb a bus
|
||||
# through handle(), but we can verify that *any* path that uses
|
||||
# the store's add_ack fires the event. This is enough to lock
|
||||
# the regression: pre-SP25 the handler would have published via
|
||||
# its own path and the store's publish would be a no-op. Post-SP25
|
||||
# only the store path exists.
|
||||
with db.SessionLocal()() as _s:
|
||||
row = store.add_ack(
|
||||
source_batch_id="999-HANDLE-PATH",
|
||||
accepted_count=1,
|
||||
rejected_count=0,
|
||||
received_count=1,
|
||||
ack_code="A",
|
||||
raw_json={"envelope": {"control_number": "000000001"}},
|
||||
event_bus=bus,
|
||||
)
|
||||
|
||||
queue = bus._subscribers["ack_received"][0]
|
||||
assert not queue.empty()
|
||||
event = queue.get_nowait()
|
||||
assert event["_kind"] == "ack_received"
|
||||
assert event["id"] == row.id
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for the moved-to-store UI serializers for 999 / TA1 / 277CA acks.
|
||||
|
||||
The serializers were previously inlined in ``api_routers/acks.py``,
|
||||
``api_routers/ta1_acks.py``, and ``api.py`` (`_277ca_to_ui`). SP25
|
||||
moves them to ``cyclone.store.ui`` so the live-tail event payload can
|
||||
match the list endpoint shape byte-for-byte — the seam between
|
||||
persistence and streaming depends on the two halves staying in sync.
|
||||
|
||||
Shape contract (locked to existing wire formats — DO NOT DRIFT):
|
||||
- ``to_ui_ack`` mirrors ``api_routers/acks._ack_to_ui`` exactly
|
||||
- ``to_ui_ta1_ack`` mirrors ``api_routers/ta1_acks._ta1_to_ui`` exactly
|
||||
- ``to_ui_two77ca_ack`` mirrors ``api._277ca_to_ui`` exactly
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.store.ui import to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ack_row() -> db.Ack:
|
||||
"""Insert a 999 ack row and return it."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Ack(
|
||||
source_batch_id="999-TEST-1",
|
||||
accepted_count=3,
|
||||
rejected_count=1,
|
||||
received_count=4,
|
||||
ack_code="P",
|
||||
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||
raw_json={
|
||||
"envelope": {"control_number": "000000001"},
|
||||
"set_responses": [
|
||||
{"set_control_number": "PCN-42"},
|
||||
],
|
||||
},
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ta1_row() -> db.Ta1Ack:
|
||||
"""Insert a TA1 ack row and return it."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Ta1Ack(
|
||||
source_batch_id="TA1-TEST-1",
|
||||
control_number="000000007",
|
||||
interchange_date=date(2026, 7, 2),
|
||||
interchange_time="1200",
|
||||
ack_code="A",
|
||||
note_code="000",
|
||||
ack_generated_date=None,
|
||||
sender_id="SENDER-1",
|
||||
receiver_id="RECEIVER-1",
|
||||
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||
raw_json={"envelope": {"control_number": "000000007"}},
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two77ca_row() -> db.Two77caAck:
|
||||
"""Insert a 277CA ack row and return it."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Two77caAck(
|
||||
source_batch_id="277CA-TEST-1",
|
||||
control_number="000000011",
|
||||
accepted_count=2,
|
||||
rejected_count=1,
|
||||
paid_count=0,
|
||||
pended_count=4,
|
||||
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||
raw_json={"envelope": {"control_number": "000000011"}, "claim_statuses": []},
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# to_ui_ack (999)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_ui_ack_round_trips_999_fields(ack_row):
|
||||
"""to_ui_ack must produce the exact ``_ack_to_ui`` wire shape."""
|
||||
body = to_ui_ack(ack_row)
|
||||
assert body["id"] == ack_row.id
|
||||
assert body["source_batch_id"] == "999-TEST-1"
|
||||
assert body["accepted_count"] == 3
|
||||
assert body["rejected_count"] == 1
|
||||
assert body["received_count"] == 4
|
||||
assert body["ack_code"] == "P"
|
||||
# SQLite strips tzinfo from DateTime(timezone=True) values — the
|
||||
# original `_ack_to_ui` serializer therefore produces a tz-naive
|
||||
# ISO string in tests. Preserve the byte-for-byte format.
|
||||
assert body["parsed_at"] == "2026-07-02T12:00:00"
|
||||
# patient_control_number comes from raw_json.set_responses[0]
|
||||
assert body["patient_control_number"] == "PCN-42"
|
||||
|
||||
|
||||
def test_to_ui_ack_handles_missing_set_responses(ack_row):
|
||||
"""No set_responses in raw_json → patient_control_number is None."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Ack(
|
||||
source_batch_id="999-TEST-NULL",
|
||||
accepted_count=0,
|
||||
rejected_count=0,
|
||||
received_count=0,
|
||||
ack_code="A",
|
||||
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||
raw_json={"envelope": {}, "set_responses": []},
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
fresh = row
|
||||
body = to_ui_ack(fresh)
|
||||
assert body["patient_control_number"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# to_ui_ta1_ack (TA1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_ui_ta1_ack_round_trips_fields(ta1_row):
|
||||
"""to_ui_ta1_ack must produce the exact ``_ta1_to_ui`` wire shape."""
|
||||
body = to_ui_ta1_ack(ta1_row)
|
||||
assert body["id"] == ta1_row.id
|
||||
assert body["control_number"] == "000000007"
|
||||
assert body["ack_code"] == "A"
|
||||
assert body["note_code"] == "000"
|
||||
# interchange_date is a Date (not datetime) → ISO-8601 YYYY-MM-DD
|
||||
assert body["interchange_date"] == "2026-07-02"
|
||||
assert body["interchange_time"] == "1200"
|
||||
assert body["sender_id"] == "SENDER-1"
|
||||
assert body["receiver_id"] == "RECEIVER-1"
|
||||
assert body["source_batch_id"] == "TA1-TEST-1"
|
||||
# Same SQLite-tzinfo caveat as to_ui_ack: naive isoformat in tests.
|
||||
assert body["parsed_at"] == "2026-07-02T12:00:00"
|
||||
|
||||
|
||||
def test_to_ui_ta1_ack_handles_null_interchange_date():
|
||||
"""Null interchange_date → None (matches original behavior)."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Ta1Ack(
|
||||
source_batch_id="TA1-TEST-NULL",
|
||||
control_number="000000099",
|
||||
interchange_date=None,
|
||||
interchange_time=None,
|
||||
ack_code="R",
|
||||
note_code="001",
|
||||
ack_generated_date=None,
|
||||
sender_id="S",
|
||||
receiver_id="R",
|
||||
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||
raw_json={"envelope": {}},
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
fresh = row
|
||||
body = to_ui_ta1_ack(fresh)
|
||||
assert body["interchange_date"] is None
|
||||
assert body["interchange_time"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# to_ui_two77ca_ack (277CA)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_ui_two77ca_ack_round_trips_fields(two77ca_row):
|
||||
"""to_ui_two77ca_ack must produce the exact ``_277ca_to_ui`` wire shape."""
|
||||
body = to_ui_two77ca_ack(two77ca_row)
|
||||
assert body["id"] == two77ca_row.id
|
||||
assert body["source_batch_id"] == "277CA-TEST-1"
|
||||
assert body["control_number"] == "000000011"
|
||||
assert body["accepted_count"] == 2
|
||||
assert body["rejected_count"] == 1
|
||||
assert body["paid_count"] == 0
|
||||
assert body["pended_count"] == 4
|
||||
# Same SQLite-tzinfo caveat: naive isoformat in tests.
|
||||
assert body["parsed_at"] == "2026-07-02T12:00:00"
|
||||
@@ -7,7 +7,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useMergedTail } from "./useMergedTail";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim, Remittance, Activity } from "@/types";
|
||||
import type { Ack, Claim, Remittance, Activity, Ta1Ack } from "@/types";
|
||||
|
||||
/**
|
||||
* Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`,
|
||||
@@ -88,6 +88,34 @@ function activity(id: string, message: string): Activity {
|
||||
};
|
||||
}
|
||||
|
||||
function ack(id: number, sourceBatchId: string): Ack {
|
||||
return {
|
||||
id,
|
||||
sourceBatchId,
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-07-02T12:00:00Z",
|
||||
patientControlNumber: `PCN-${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function ta1Ack(id: number, controlNumber: string): Ta1Ack {
|
||||
return {
|
||||
id,
|
||||
controlNumber,
|
||||
ackCode: "A",
|
||||
noteCode: "000",
|
||||
interchangeDate: "2026-07-02",
|
||||
interchangeTime: "1200",
|
||||
senderId: "S",
|
||||
receiverId: "R",
|
||||
sourceBatchId: `TA1-${id}`,
|
||||
parsedAt: "2026-07-02T12:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useMergedTail", () => {
|
||||
beforeEach(() => {
|
||||
// Singleton store — clear each slice between tests so cases are
|
||||
@@ -95,6 +123,8 @@ describe("useMergedTail", () => {
|
||||
useTailStore.getState().reset("claims");
|
||||
useTailStore.getState().reset("remittances");
|
||||
useTailStore.getState().reset("activity");
|
||||
useTailStore.getState().reset("acks");
|
||||
useTailStore.getState().reset("ta1_acks");
|
||||
});
|
||||
|
||||
it("test_merges_base_and_tail_by_id_base_first", () => {
|
||||
@@ -228,4 +258,50 @@ describe("useMergedTail", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SP25: the Acks page mounts two merge hooks — one per ack flavor.
|
||||
// Both have numeric ids (database row ids) instead of string ids, so
|
||||
// the dedup must compare via String(id) to keep the keyed-by-id
|
||||
// contract intact.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("test_acks_resource_orders_by_ack_order_and_dedupes", () => {
|
||||
const base = [ack(1, "BASE-A"), ack(2, "BASE-B")];
|
||||
const { addAck } = useTailStore.getState();
|
||||
addAck(ack(2, "REPLAY-B")); // duplicate id — must be dropped
|
||||
addAck(ack(3, "TAIL-C"));
|
||||
addAck(ack(4, "TAIL-D"));
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("acks", base),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
// Base first, then tail in arrival order. Tail's "2" is dropped
|
||||
// because the base already has id 2.
|
||||
expect(merged.map((a) => a.id)).toEqual([1, 2, 3, 4]);
|
||||
// Base version wins for id 2.
|
||||
expect(merged[1]?.sourceBatchId).toBe("BASE-B");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_ta1_acks_resource_orders_by_ta1_ack_order_and_dedupes", () => {
|
||||
const base = [ta1Ack(1, "000000001")];
|
||||
const { addTa1Ack } = useTailStore.getState();
|
||||
addTa1Ack(ta1Ack(2, "000000002"));
|
||||
addTa1Ack(ta1Ack(3, "000000003"));
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("ta1_acks", base),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
expect(merged.map((a) => a.id)).toEqual([1, 2, 3]);
|
||||
expect(merged[0]?.controlNumber).toBe("000000001");
|
||||
expect(merged[1]?.controlNumber).toBe("000000002");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
// every render of the caller, so memo deps would invalidate anyway.
|
||||
// The merge is cheap (one Set build + two filters) and zustand's
|
||||
// selector ensures we only re-render when the slice actually changes.
|
||||
// - SP25: the `id` type is widened to `string | number` because `Ack`
|
||||
// and `Ta1Ack` use numeric database ids while `Claim` / `Remittance`
|
||||
// use string ids. Dedup normalizes via `String(...)`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { TailResource } from "@/lib/tail-stream";
|
||||
|
||||
export function useMergedTail<T extends { id: string }>(
|
||||
export function useMergedTail<T extends { id: string | number }>(
|
||||
resource: TailResource,
|
||||
baseItems: T[],
|
||||
filterFn?: (item: T) => boolean,
|
||||
@@ -56,15 +59,39 @@ export function useMergedTail<T extends { id: string }>(
|
||||
// Activity is already an array — the store appends in arrival
|
||||
// order, so iteration order is exactly what we want.
|
||||
return s.activity as unknown[];
|
||||
// SP25: same keyed-by-id shape as claims/remittances, just with
|
||||
// numeric ids. The store keys the dict by String(id); the order
|
||||
// array carries the numeric id for arrival-order iteration.
|
||||
case "acks": {
|
||||
const out: unknown[] = [];
|
||||
for (const id of s.ackOrder) {
|
||||
const v = s.acks[String(id)];
|
||||
if (v !== undefined) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "ta1_acks": {
|
||||
const out: unknown[] = [];
|
||||
for (const id of s.ta1AckOrder) {
|
||||
const v = s.ta1Acks[String(id)];
|
||||
if (v !== undefined) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Normalize ids to strings so numeric (Ack/Ta1Ack) and string
|
||||
// (Claim/Remittance/Activity) ids dedup correctly against each other.
|
||||
// (The hook is generic over `T extends { id: string | number }`, but
|
||||
// the store's dedup set is a Set<string> internally — using the
|
||||
// string form here keeps the comparison symmetric.)
|
||||
const baseIds = new Set<string>();
|
||||
for (const b of baseItems) baseIds.add(b.id);
|
||||
for (const b of baseItems) baseIds.add(String(b.id));
|
||||
|
||||
const tailAfterDedup: unknown[] = [];
|
||||
for (const t of tailSlice) {
|
||||
const id = (t as { id: string }).id;
|
||||
const id = String((t as { id: string | number }).id);
|
||||
if (baseIds.has(id)) continue;
|
||||
tailAfterDedup.push(t);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "vitest";
|
||||
import { useTailStream } from "./useTailStream";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim } from "@/types";
|
||||
import type { Ack, Claim, Ta1Ack } from "@/types";
|
||||
import type { TailEvent } from "@/lib/tail-stream";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,6 +204,34 @@ function makeClaim(id: string, patientName: string): Claim {
|
||||
};
|
||||
}
|
||||
|
||||
function makeAck(id: number, sourceBatchId: string): Ack {
|
||||
return {
|
||||
id,
|
||||
sourceBatchId,
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-07-02T12:00:00Z",
|
||||
patientControlNumber: `PCN-${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTa1Ack(id: number, controlNumber: string): Ta1Ack {
|
||||
return {
|
||||
id,
|
||||
controlNumber,
|
||||
ackCode: "A",
|
||||
noteCode: "000",
|
||||
interchangeDate: "2026-07-02",
|
||||
interchangeTime: "1200",
|
||||
senderId: "S",
|
||||
receiverId: "R",
|
||||
sourceBatchId: `TA1-${id}`,
|
||||
parsedAt: "2026-07-02T12:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTailStream", () => {
|
||||
beforeEach(() => {
|
||||
mockStreamTail.mockReset();
|
||||
@@ -212,6 +240,8 @@ describe("useTailStream", () => {
|
||||
useTailStore.getState().reset("claims");
|
||||
useTailStore.getState().reset("remittances");
|
||||
useTailStore.getState().reset("activity");
|
||||
useTailStore.getState().reset("acks");
|
||||
useTailStore.getState().reset("ta1_acks");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -400,4 +430,60 @@ describe("useTailStream", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SP25: the Acks page mounts useTailStream("acks") and
|
||||
// useTailStream("ta1_acks"). The dispatcher must route their item events
|
||||
// into the matching store slices.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("test_acks_item_event_lands_in_acks_slice", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { unmount } = renderHook(() => useTailStream("acks"));
|
||||
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||
await waitFor(() => useTailStore.getState().acks !== undefined);
|
||||
|
||||
ctrls[0].push({
|
||||
type: "item",
|
||||
data: makeAck(42, "BATCH-LIVE"),
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => useTailStore.getState().acks["42"] !== undefined,
|
||||
);
|
||||
const stored = useTailStore.getState().acks["42"];
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored?.sourceBatchId).toBe("BATCH-LIVE");
|
||||
// The Acks page must NOT see this row in the claims slice.
|
||||
expect(useTailStore.getState().claims["42"]).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_ta1_acks_item_event_lands_in_ta1_acks_slice", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { unmount } = renderHook(() => useTailStream("ta1_acks"));
|
||||
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||
await waitFor(() => useTailStore.getState().ta1Acks !== undefined);
|
||||
|
||||
ctrls[0].push({
|
||||
type: "item",
|
||||
data: makeTa1Ack(7, "000000007"),
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => useTailStore.getState().ta1Acks["7"] !== undefined,
|
||||
);
|
||||
const stored = useTailStore.getState().ta1Acks["7"];
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored?.controlNumber).toBe("000000007");
|
||||
// Must NOT bleed into the acks slice — keyed by id, separate dicts.
|
||||
expect(useTailStore.getState().acks["7"]).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { streamTail, type TailResource } from "@/lib/tail-stream";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Activity, Claim, Remittance } from "@/types";
|
||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
||||
|
||||
export type TailStatus =
|
||||
| "connecting"
|
||||
@@ -74,6 +74,17 @@ function dispatch(resource: TailResource, data: unknown): void {
|
||||
case "activity":
|
||||
store.addActivity(data as Activity);
|
||||
break;
|
||||
// SP25: the Acks page opens two streams — one per ack flavor —
|
||||
// and the live event payloads are the same shape as the list
|
||||
// endpoint (to_ui_ack / to_ui_ta1_ack in store/ui.py). The first
|
||||
// write wins, so a snapshot replay on reconnect won't trample
|
||||
// the canonical row.
|
||||
case "acks":
|
||||
store.addAck(data as Ack);
|
||||
break;
|
||||
case "ta1_acks":
|
||||
store.addTa1Ack(data as Ta1Ack);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,38 @@ describe("streamTail", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("test_targets_acks_stream_endpoint", async () => {
|
||||
// SP25: the `acks` resource is part of TailResource; the URL must
|
||||
// be /api/acks/stream so useTailStream("acks") can open it.
|
||||
const driver = makeStream();
|
||||
const fetchMock = vi.fn().mockResolvedValue(driver.response);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const gen = streamTail("acks");
|
||||
// Drive the generator one step so the fetch() call is reached
|
||||
// before we close the stream.
|
||||
await gen.next();
|
||||
driver.close();
|
||||
await gen.return?.(undefined);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl] = fetchMock.mock.calls[0] as [string];
|
||||
expect(calledUrl).toBe("/api/acks/stream");
|
||||
});
|
||||
|
||||
it("test_targets_ta1_acks_stream_endpoint", async () => {
|
||||
// SP25: the `ta1_acks` resource is part of TailResource; the URL
|
||||
// must be /api/ta1-acks/stream.
|
||||
const driver = makeStream();
|
||||
const fetchMock = vi.fn().mockResolvedValue(driver.response);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const gen = streamTail("ta1_acks");
|
||||
await gen.next();
|
||||
driver.close();
|
||||
await gen.return?.(undefined);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl] = fetchMock.mock.calls[0] as [string];
|
||||
expect(calledUrl).toBe("/api/ta1-acks/stream");
|
||||
});
|
||||
|
||||
it("test_handles_heartbeat_silently", async () => {
|
||||
// "Silently" here means: still yields the correctly-typed heartbeat
|
||||
// (no special filtering) — the consumer (useTailStream) decides what
|
||||
|
||||
@@ -26,7 +26,7 @@ export type TailEvent =
|
||||
| { type: "item_dropped"; data: { id: string } }
|
||||
| { type: "error"; data: { message: string } };
|
||||
|
||||
export type TailResource = "claims" | "remittances" | "activity";
|
||||
export type TailResource = "claims" | "remittances" | "activity" | "acks" | "ta1_acks";
|
||||
|
||||
export interface StreamTailOptions {
|
||||
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
|
||||
|
||||
+58
-3
@@ -9,18 +9,37 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Acks } from "./Acks";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
vi.mock("@/lib/api", () => {
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listAcks: vi.fn(),
|
||||
getAck: vi.fn(),
|
||||
},
|
||||
ApiError,
|
||||
};
|
||||
});
|
||||
|
||||
// SP25: the Acks page now opens two live-tail streams (one per ack
|
||||
// flavor) via `useTailStream`. The hook has its own dedicated test
|
||||
// suite that covers the full lifecycle; here we just want the page to
|
||||
// render against a stubbed hook so the test never opens a real fetch.
|
||||
// The mock matches the production-default state (`live`) so the pill
|
||||
// renders the "Live" label without surfacing a misleading error.
|
||||
vi.mock("@/hooks/useTailStream", () => ({
|
||||
useTailStream: vi.fn(() => ({
|
||||
status: "live",
|
||||
lastEventAt: null,
|
||||
error: null,
|
||||
forceReconnect: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
@@ -567,4 +586,40 @@ describe("Acks", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SP25: the page now mounts two live-tail streams (one per ack
|
||||
// flavor) via useTailStream. The mocked hook returns status: "live",
|
||||
// so the page should render TWO TailStatusPills — one in the 999
|
||||
// hero and one in the TA1 section — both showing the human label
|
||||
// "Live" (per `STATUS_LABEL` in `TailStatusPill.tsx`).
|
||||
// -------------------------------------------------------------------------
|
||||
it("test_renders_two_tail_status_pills_live", async () => {
|
||||
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
has_more: false,
|
||||
aggregates: { accepted_count: 0, rejected_count: 0, received_count: 0 },
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||
|
||||
// Wait for the page to settle — both pills need the query to
|
||||
// resolve (and even the TA1 section renders a tail pill even
|
||||
// when items is empty).
|
||||
await settle(
|
||||
() => document.body.textContent?.includes("Live") ?? false,
|
||||
);
|
||||
|
||||
// Two "Live" labels: one for the 999 stream, one for the TA1
|
||||
// stream. (Plus the description text on the page may say "live"
|
||||
// in another context — check via the surrounding pill markup.)
|
||||
const pills = Array.from(
|
||||
document.querySelectorAll('[data-testid="tail-status-pill"]'),
|
||||
);
|
||||
expect(pills.length).toBe(2);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
+51
-8
@@ -20,7 +20,10 @@ import { Pagination } from "@/components/ui/pagination";
|
||||
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
||||
import { useAcks } from "@/hooks/useAcks";
|
||||
import { useTa1Acks } from "@/hooks/useTa1Acks";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -47,7 +50,20 @@ export function Acks() {
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
// SP25: subscribe to /api/acks/stream so new 999 acks (whether from
|
||||
// the SFTP poller or a manual upload) land in the table the moment
|
||||
// they hit the database. The status surfaces in the hero pill so the
|
||||
// operator can see whether the connection is healthy.
|
||||
const {
|
||||
status: tailStatus,
|
||||
lastEventAt: tailLastEventAt,
|
||||
forceReconnect,
|
||||
} = useTailStream("acks");
|
||||
// Merge the page's base list with the live-tail delta. The hook
|
||||
// dedupes by id (first write wins), so a snapshot replay on
|
||||
// reconnect can't double-count an existing row.
|
||||
const mergedItems = useMergedTail("acks", data?.items ?? []);
|
||||
const items = mergedItems;
|
||||
const totalCount = data?.total ?? 0;
|
||||
// Server-side aggregates — sum over the *full* ACK set, not just the
|
||||
// visible page. Without this, the KPI strip silently under-reports
|
||||
@@ -213,6 +229,16 @@ export function Acks() {
|
||||
<ShieldCheck className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
{anyRejected ? "Mixed envelope" : "Envelope accepted"}
|
||||
</div>
|
||||
{/* SP25: live-tail connection status. Sits next to the
|
||||
envelope-accepted pill so the operator can see at a
|
||||
glance whether the stream is healthy without having to
|
||||
open the drawer. Reconnect button only renders when
|
||||
the stream is in a visibly-failed state. */}
|
||||
<TailStatusPill
|
||||
status={tailStatus}
|
||||
lastEventAt={tailLastEventAt}
|
||||
onReconnect={forceReconnect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint — same rhythm as Reconciliation, sits under
|
||||
@@ -604,7 +630,14 @@ function downloadBlob(filename: string, content: string) {
|
||||
// ---------------------------------------------------------------------------
|
||||
function Ta1AcksSection() {
|
||||
const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 });
|
||||
const items = data?.items ?? [];
|
||||
// SP25: same live-tail triplet as the 999 register above, just
|
||||
// bound to /api/ta1-acks/stream and the ta1Acks store slice.
|
||||
const {
|
||||
status: tailStatus,
|
||||
lastEventAt: tailLastEventAt,
|
||||
forceReconnect,
|
||||
} = useTailStream("ta1_acks");
|
||||
const items = useMergedTail("ta1_acks", data?.items ?? []);
|
||||
|
||||
const totals = items.reduce(
|
||||
(acc, t) => {
|
||||
@@ -631,12 +664,22 @@ function Ta1AcksSection() {
|
||||
TA1 <span className="italic">envelopes</span>, newest first.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
||||
One row per inbound ISA/IEA interchange — the
|
||||
envelope-level sibling of the 999. Gainwell's Colorado
|
||||
MFT does not ship TA1s today; this section surfaces them
|
||||
when they appear.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
||||
One row per inbound ISA/IEA interchange — the
|
||||
envelope-level sibling of the 999. Gainwell's Colorado
|
||||
MFT does not ship TA1s today; this section surfaces them
|
||||
when they appear.
|
||||
</p>
|
||||
{/* SP25: live-tail status for the TA1 stream. Quietly
|
||||
placed after the description so the eyebrow / display
|
||||
title remains the dominant scan target. */}
|
||||
<TailStatusPill
|
||||
status={tailStatus}
|
||||
lastEventAt={tailLastEventAt}
|
||||
onReconnect={forceReconnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-4 pt-2">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// state shape.
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Activity, Claim, Remittance } from "@/types";
|
||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
||||
import { TAIL_CAP, useTailStore } from "./tail-store";
|
||||
|
||||
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
|
||||
@@ -47,6 +47,36 @@ function makeActivity(id: string, overrides: Partial<Activity> = {}): Activity {
|
||||
};
|
||||
}
|
||||
|
||||
function makeAck(id: number, overrides: Partial<Ack> = {}): Ack {
|
||||
return {
|
||||
id,
|
||||
sourceBatchId: `BATCH-${id}`,
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-07-02T12:00:00Z",
|
||||
patientControlNumber: `PCN-${id}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTa1Ack(id: number, overrides: Partial<Ta1Ack> = {}): Ta1Ack {
|
||||
return {
|
||||
id,
|
||||
controlNumber: `0000000${id}`,
|
||||
ackCode: "A",
|
||||
noteCode: "000",
|
||||
interchangeDate: "2026-07-02",
|
||||
interchangeTime: "1200",
|
||||
senderId: "SENDER",
|
||||
receiverId: "RECEIVER",
|
||||
sourceBatchId: `TA1-BATCH-${id}`,
|
||||
parsedAt: "2026-07-02T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTailStore", () => {
|
||||
// Each test starts from a known-empty state. The store is module-level
|
||||
// (singleton), so we use `reset()` to clear between cases — that's also
|
||||
@@ -55,6 +85,8 @@ describe("useTailStore", () => {
|
||||
useTailStore.getState().reset("claims");
|
||||
useTailStore.getState().reset("remittances");
|
||||
useTailStore.getState().reset("activity");
|
||||
useTailStore.getState().reset("acks");
|
||||
useTailStore.getState().reset("ta1_acks");
|
||||
});
|
||||
|
||||
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
|
||||
@@ -128,4 +160,71 @@ describe("useTailStore", () => {
|
||||
const lastKey = `CLM-${(N - 1).toString().padStart(6, "0")}`;
|
||||
expect(claims[lastKey]).toBeDefined();
|
||||
}, 60_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SP25: acks + ta1_acks slices mirror the claims/remittances key-by-id
|
||||
// pattern (both have a stable `id` from the database row).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("test_add_ack_adds_new_ack_keyed_by_id", () => {
|
||||
const { addAck } = useTailStore.getState();
|
||||
addAck(makeAck(1));
|
||||
addAck(makeAck(2));
|
||||
const { acks } = useTailStore.getState();
|
||||
expect(Object.keys(acks)).toHaveLength(2);
|
||||
expect(acks["1"]?.sourceBatchId).toBe("BATCH-1");
|
||||
expect(acks["2"]?.sourceBatchId).toBe("BATCH-2");
|
||||
});
|
||||
|
||||
it("test_add_ack_with_duplicate_id_is_noop", () => {
|
||||
const { addAck } = useTailStore.getState();
|
||||
addAck(makeAck(1, { sourceBatchId: "BATCH-original" }));
|
||||
addAck(makeAck(1, { sourceBatchId: "BATCH-replay" }));
|
||||
const { acks } = useTailStore.getState();
|
||||
expect(Object.keys(acks)).toHaveLength(1);
|
||||
// First write wins; snapshot replay must not trample the canonical row.
|
||||
expect(acks["1"]?.sourceBatchId).toBe("BATCH-original");
|
||||
});
|
||||
|
||||
it("test_reset_acks_clears_only_acks_slice", () => {
|
||||
const { addAck, addClaim, reset } = useTailStore.getState();
|
||||
addAck(makeAck(1));
|
||||
addClaim(makeClaim("CLM-1"));
|
||||
|
||||
reset("acks");
|
||||
const s = useTailStore.getState();
|
||||
expect(Object.keys(s.acks)).toHaveLength(0);
|
||||
// The other slices are untouched.
|
||||
expect(Object.keys(s.claims)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("test_add_ta1_ack_adds_new_ta1_ack_keyed_by_id", () => {
|
||||
const { addTa1Ack } = useTailStore.getState();
|
||||
addTa1Ack(makeTa1Ack(1));
|
||||
addTa1Ack(makeTa1Ack(2));
|
||||
const { ta1Acks } = useTailStore.getState();
|
||||
expect(Object.keys(ta1Acks)).toHaveLength(2);
|
||||
expect(ta1Acks["1"]?.controlNumber).toBe("00000001");
|
||||
expect(ta1Acks["2"]?.controlNumber).toBe("00000002");
|
||||
});
|
||||
|
||||
it("test_add_ta1_ack_with_duplicate_id_is_noop", () => {
|
||||
const { addTa1Ack } = useTailStore.getState();
|
||||
addTa1Ack(makeTa1Ack(7, { senderId: "ORIGINAL" }));
|
||||
addTa1Ack(makeTa1Ack(7, { senderId: "REPLAY" }));
|
||||
const { ta1Acks } = useTailStore.getState();
|
||||
expect(Object.keys(ta1Acks)).toHaveLength(1);
|
||||
expect(ta1Acks["7"]?.senderId).toBe("ORIGINAL");
|
||||
});
|
||||
|
||||
it("test_reset_ta1_acks_clears_only_ta1_acks_slice", () => {
|
||||
const { addTa1Ack, addAck, reset } = useTailStore.getState();
|
||||
addTa1Ack(makeTa1Ack(1));
|
||||
addAck(makeAck(1));
|
||||
|
||||
reset("ta1_acks");
|
||||
const s = useTailStore.getState();
|
||||
expect(Object.keys(s.ta1Acks)).toHaveLength(0);
|
||||
expect(Object.keys(s.acks)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+57
-7
@@ -21,7 +21,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Activity, Claim, Remittance } from "@/types";
|
||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
||||
import type { TailResource } from "@/lib/tail-stream";
|
||||
|
||||
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
|
||||
@@ -46,6 +46,12 @@ interface TailStore {
|
||||
claims: Record<string, Claim>;
|
||||
remittances: Record<string, Remittance>;
|
||||
activity: Activity[];
|
||||
// SP25: the Acks page needs the same live-tail triplet as
|
||||
// Claims/Remittances/Activity. `acks` and `ta1_acks` both have stable
|
||||
// numeric ids from the database row, so they're keyed-by-id like
|
||||
// `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP).
|
||||
acks: Record<string, Ack>;
|
||||
ta1Acks: Record<string, Ta1Ack>;
|
||||
|
||||
// --- Insertion-order trackers (kept in sync with the dicts above) ----
|
||||
// These are private to the store; consumers only read the dicts. We
|
||||
@@ -54,11 +60,15 @@ interface TailStore {
|
||||
// what triggers a re-render in subscribers that select `claims`).
|
||||
claimOrder: string[];
|
||||
remitOrder: string[];
|
||||
ackOrder: number[];
|
||||
ta1AckOrder: number[];
|
||||
|
||||
// --- Setters ---------------------------------------------------------
|
||||
addClaim: (c: Claim) => void;
|
||||
addRemittance: (r: RemitListItem) => void;
|
||||
addActivity: (a: Activity) => void;
|
||||
addAck: (a: Ack) => void;
|
||||
addTa1Ack: (a: Ta1Ack) => void;
|
||||
reset: (resource: TailResource) => void;
|
||||
}
|
||||
|
||||
@@ -69,11 +79,11 @@ interface TailStore {
|
||||
*/
|
||||
type RemitListItem = Remittance;
|
||||
|
||||
function evictOldest(
|
||||
order: string[],
|
||||
dict: Record<string, Claim | Remittance>,
|
||||
function evictOldest<K extends string | number, V>(
|
||||
order: K[],
|
||||
dict: Record<string, V>,
|
||||
batch: number,
|
||||
): { order: string[]; dict: Record<string, Claim | Remittance> } {
|
||||
): { order: K[]; dict: Record<string, V> } {
|
||||
if (order.length <= TAIL_CAP) return { order, dict };
|
||||
// Drain down to the cap; never evict more than `batch` per call so a
|
||||
// 5-item overflow evicts 5, but a 1 000-item overflow evicts 100 in
|
||||
@@ -83,8 +93,8 @@ function evictOldest(
|
||||
const nextOrder = order.slice(toDrop);
|
||||
// Object.assign is consistently faster than `{ ...dict }` in V8 for
|
||||
// large dicts; we follow up with `delete` for each evicted id.
|
||||
const nextDict: Record<string, Claim | Remittance> = Object.assign({}, dict);
|
||||
for (const id of dropped) delete nextDict[id];
|
||||
const nextDict: Record<string, V> = Object.assign({}, dict);
|
||||
for (const id of dropped) delete nextDict[String(id)];
|
||||
return { order: nextOrder, dict: nextDict };
|
||||
}
|
||||
|
||||
@@ -92,8 +102,12 @@ export const useTailStore = create<TailStore>((set) => ({
|
||||
claims: {},
|
||||
remittances: {},
|
||||
activity: [],
|
||||
acks: {},
|
||||
ta1Acks: {},
|
||||
claimOrder: [],
|
||||
remitOrder: [],
|
||||
ackOrder: [],
|
||||
ta1AckOrder: [],
|
||||
|
||||
addClaim: (c) =>
|
||||
set((s) => {
|
||||
@@ -147,6 +161,38 @@ export const useTailStore = create<TailStore>((set) => ({
|
||||
return { activity: next };
|
||||
}),
|
||||
|
||||
addAck: (a) =>
|
||||
set((s) => {
|
||||
// First write wins; snapshot replay must not trample the
|
||||
// canonical row. `id` is a number from the database row.
|
||||
if (s.acks[String(a.id)]) return s;
|
||||
const nextAcks: Record<string, Ack> = Object.assign({}, s.acks, {
|
||||
[String(a.id)]: a,
|
||||
});
|
||||
const nextOrder = s.ackOrder.concat(a.id);
|
||||
if (nextOrder.length > TAIL_CAP) {
|
||||
const { order, dict } = evictOldest(nextOrder, nextAcks, EVICT_BATCH);
|
||||
return { acks: dict, ackOrder: order };
|
||||
}
|
||||
return { acks: nextAcks, ackOrder: nextOrder };
|
||||
}),
|
||||
|
||||
addTa1Ack: (a) =>
|
||||
set((s) => {
|
||||
if (s.ta1Acks[String(a.id)]) return s;
|
||||
const nextTa1Acks: Record<string, Ta1Ack> = Object.assign(
|
||||
{},
|
||||
s.ta1Acks,
|
||||
{ [String(a.id)]: a },
|
||||
);
|
||||
const nextOrder = s.ta1AckOrder.concat(a.id);
|
||||
if (nextOrder.length > TAIL_CAP) {
|
||||
const { order, dict } = evictOldest(nextOrder, nextTa1Acks, EVICT_BATCH);
|
||||
return { ta1Acks: dict, ta1AckOrder: order };
|
||||
}
|
||||
return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder };
|
||||
}),
|
||||
|
||||
reset: (resource) =>
|
||||
set(() => {
|
||||
switch (resource) {
|
||||
@@ -156,6 +202,10 @@ export const useTailStore = create<TailStore>((set) => ({
|
||||
return { remittances: {}, remitOrder: [] };
|
||||
case "activity":
|
||||
return { activity: [] };
|
||||
case "acks":
|
||||
return { acks: {}, ackOrder: [] };
|
||||
case "ta1_acks":
|
||||
return { ta1Acks: {}, ta1AckOrder: [] };
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user