Files
cyclone/backend/tests/test_store_batches_synthetic.py
T

168 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SP25: defensive read-side stub for synthetic ``<synthetic:orphan-reconcile>``
``Batch`` rows.
Background: ``CycloneStore.reconcile_orphan_st02s()`` (sp38) seeds ``Batch``
rows for orphan 999 ACK ST02s so the envelope index can resolve
``source_batch_id`` lookups. Those rows are inserted with
``raw_result_json=NULL`` because no source 837 file was ever parsed
for them. The read-side helper ``_row_to_record()`` in
``store/batches.py`` was written assuming the column is always
populated, so it raises a Pydantic ``ValidationError`` on ``summary:
Field required`` the moment it sees a synthetic row — which makes
``GET /api/batches?limit=5`` return HTTP 500.
This file pins the contract: a synthetic (or otherwise unparseable)
``Batch`` row MUST still hydrate to a typed ``BatchRecord`` with an
empty ``claims`` list and the ST02 echoed on ``result.summary.control_number``,
so the dashboard "Recent batches" widget can render them as
"synthetic: 0 claims" without the whole list blowing up.
Live state witnessed during the 2026-07-07 dashboard postmortem:
the sp38 reconcile pass on the production DB seeded 4 such rows
(SCN 991102986989). Once the stub lands, ``/api/batches`` returns 200
instead of 500 and the dashboard "Recent batches" panel surfaces the
real underlying batches (the test runs).
Note: the rows inserted here are *ORM-level* fixtures (Batch rows with
NULL raw_result_json, used to exercise the read-path's defensive
deserialize). They are NOT synthetic EDI — the actual claim data for
the recover-ingest path lives under ``ingest/`` and is replayed by the
operator after this defensive stub ships.
"""
from __future__ import annotations
from datetime import datetime, timezone
from cyclone import db, store
from cyclone.db import Batch
from cyclone.store.records import BatchRecord837, BatchRecord835
def _now():
return datetime.now(timezone.utc)
def _insert_synthetic(s, *, kind: str, st02: str, filename: str = "<synthetic:orphan-reconcile>"):
bid = f"syn-{st02}"
s.add(Batch(
id=bid,
kind=kind,
input_filename=filename,
parsed_at=_now(),
transaction_set_control_number=st02,
raw_result_json=None,
))
s.commit()
return bid
def test_row_to_record_handles_null_raw_json_837p():
"""837p synthetic row → stub BatchRecord837 with empty claims[] + SCN preserved."""
s = db.SessionLocal()()
try:
_insert_synthetic(s, kind="837p", st02="991102986")
finally:
s.close()
# No raw_result_json on the row → _row_to_record must NOT raise.
rows = store.list_batches(limit=50)
syn = [r for r in rows if r.id == "syn-991102986"]
assert len(syn) == 1
rec = syn[0]
assert isinstance(rec, BatchRecord837)
assert rec.result.claims == []
assert rec.result.summary.total_claims == 0
assert rec.result.summary.control_number == "991102986"
assert rec.result.summary.passed == 0
assert rec.result.summary.failed == 0
def test_row_to_record_handles_null_raw_json_835():
"""835 synthetic row → stub BatchRecord835 with empty claims[].claims."""
s = db.SessionLocal()()
try:
_insert_synthetic(s, kind="835", st02="991102987")
finally:
s.close()
rows = store.list_batches(limit=50)
syn = [r for r in rows if r.id == "syn-991102987"]
assert len(syn) == 1
rec = syn[0]
assert isinstance(rec, BatchRecord835)
assert rec.result.claims == []
# summary control_number echoes ST02.
assert rec.result.summary.control_number == "991102987"
def test_row_to_record_handles_empty_string_raw_json():
"""raw_result_json='' (empty string) gets the same defensive treatment."""
s = db.SessionLocal()()
try:
bid = "syn-empty"
s.add(Batch(
id=bid, kind="837p",
input_filename="<synthetic:orphan-reconcile>",
parsed_at=_now(),
transaction_set_control_number="991102988",
raw_result_json="", # empty string instead of None
))
s.commit()
finally:
s.close()
rows = store.list_batches(limit=50)
syn = [r for r in rows if r.id == "syn-empty"]
assert len(syn) == 1
rec = syn[0]
assert rec.result.claims == []
assert rec.result.summary.total_claims == 0
def test_row_to_record_normal_row_still_parses():
"""Regression guard: a well-formed raw_result_json still deserializes fully.
Batch.raw_result_json is JSONText-backed, so we store a dict; the type
decorator takes care of the JSON round-trip.
"""
payload = {
"envelope": {
"sender_id": "11525703",
"receiver_id": "RECEIVERID",
"control_number": "000000001",
"transaction_date": "2026-07-07",
"transaction_time": "120000",
"implementation_guide": "005010X222A1",
},
"claims": [],
"summary": {
"input_file": "single-claim.x12",
"control_number": "000000001",
"transaction_date": "2026-07-07",
"total_claims": 1,
"passed": 1,
"failed": 0,
},
}
s = db.SessionLocal()()
try:
s.add(Batch(
id="syn-normal",
kind="837p",
input_filename="single-claim.x12",
parsed_at=_now(),
transaction_set_control_number="991102977",
raw_result_json=payload,
))
s.commit()
finally:
s.close()
rows = store.list_batches(limit=50)
norm = [r for r in rows if r.id == "syn-normal"]
assert len(norm) == 1
rec = norm[0]
assert rec.result.summary.total_claims == 1
assert rec.result.summary.passed == 1