feat(backend): add cyclone.store with BatchRecord and InMemoryStore

This commit is contained in:
Tyler
2026-06-19 19:04:53 -06:00
parent da4d02e6e8
commit 112c08fe14
2 changed files with 192 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"""In-memory batch store for parsed X12 files.
Lives at module scope so route handlers and tests can import a single
singleton. Single-process only — no cross-process coordination. Threading:
a re-entrant lock guards every public method so FastAPI's threadpool
request handlers can call them concurrently.
"""
from __future__ import annotations
import threading
from datetime import datetime, timezone
from typing import Iterable, Literal
from pydantic import BaseModel, ConfigDict, Field
from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835
BatchKind = Literal["837p", "835"]
class BatchRecord(BaseModel):
"""One parsed file, with a stable uuid4 id and the full ParseResult."""
model_config = ConfigDict(extra="ignore")
id: str
kind: BatchKind
input_filename: str
parsed_at: str # ISO 8601 UTC, e.g. "2026-06-19T12:00:00Z"
result: ParseResult | ParseResult835 = Field(discriminator=None)
class InMemoryStore:
"""Thread-safe list of BatchRecord, newest-first on `list`."""
def __init__(self) -> None:
self._batches: list[BatchRecord] = []
self._lock = threading.RLock()
def add(self, record: BatchRecord) -> None:
with self._lock:
self._batches.append(record)
def list(self, *, limit: int = 100) -> list[BatchRecord]:
with self._lock:
return list(reversed(self._batches[-limit:]))
def get(self, batch_id: str) -> BatchRecord | None:
with self._lock:
for b in self._batches:
if b.id == batch_id:
return b
return None
def all(self) -> list[BatchRecord]:
with self._lock:
return list(self._batches)
store = InMemoryStore()
def utcnow_iso() -> str:
"""ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+124
View File
@@ -0,0 +1,124 @@
"""Tests for the in-memory batch store."""
from __future__ import annotations
from decimal import Decimal
from cyclone.parsers.models import (
BatchSummary,
BillingProvider,
ClaimHeader,
ClaimOutput,
Envelope,
Payer,
ParseResult,
Subscriber,
ValidationIssue,
ValidationReport,
)
from cyclone.parsers.models_835 import ClaimPayment
from cyclone.store import BatchRecord, InMemoryStore, store
# ---------------------------------------------------------------------------
# Stubs (match real model field names)
# ---------------------------------------------------------------------------
def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput:
"""Minimal ClaimOutput stub. Real parser not needed here."""
return ClaimOutput(
claim_id=claim_id,
control_number="0001",
transaction_date="2026-06-19",
billing_provider=BillingProvider(
name="Test",
npi="1234567890",
tax_id=None,
address=None,
),
subscriber=Subscriber(
first_name="Jane",
last_name="Doe",
member_id="M1",
dob=None,
gender=None,
address=None,
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id=claim_id,
total_charge=Decimal("0.00"),
place_of_service="11",
facility_code_qualifier=None,
frequency_code="1",
provider_signature=None,
assignment=None,
release_of_info=None,
prior_auth=None,
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
def _make_result() -> ParseResult:
return ParseResult(
envelope=Envelope(
sender_id="SENDER",
receiver_id="RECEIVER",
control_number="0001",
transaction_date="2026-06-19",
transaction_time=None,
implementation_guide=None,
),
claims=[_make_claim("CLM-1")],
summary=BatchSummary(
input_file="test.txt",
control_number="0001",
transaction_date="2026-06-19",
total_claims=1,
passed=1,
failed=0,
failed_claim_ids=[],
issues_by_rule={},
output_dir=None,
),
)
# ---------------------------------------------------------------------------
# Task 1: BatchRecord + InMemoryStore
# ---------------------------------------------------------------------------
def test_module_singleton_store_exists():
"""The module-level `store` should be importable and be an InMemoryStore."""
assert isinstance(store, InMemoryStore)
def test_in_memory_store_starts_empty():
s = InMemoryStore()
assert s.list() == []
def test_add_and_get_round_trip():
s = InMemoryStore()
result = _make_result()
rec = BatchRecord(
id="abc123",
kind="837p",
input_filename="test.txt",
parsed_at="2026-06-19T12:00:00Z",
result=result,
)
s.add(rec)
assert s.get("abc123") is rec
assert s.list() == [rec]
def test_get_missing_returns_none():
s = InMemoryStore()
assert s.get("does-not-exist") is None