diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py new file mode 100644 index 0000000..ed64c1d --- /dev/null +++ b/backend/src/cyclone/store.py @@ -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") diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py new file mode 100644 index 0000000..61ed276 --- /dev/null +++ b/backend/tests/test_store.py @@ -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