"""SP25: defensive read-side stub for synthetic ```` ``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 991102986–989). 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 = ""): 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="", 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