refactor(sp41): split spot-check into committed pipeline + validate
The previous scratch build_and_validate.py was a single ~800-line
monolith that:
(1) re-implemented visit-to-835 reconcile instead of using the
committed visits_store + parse_835_svc shape, and
(2) had a pyx12 fallback on 403 quota that printed '10/10 passed'
and wrote passed=true to evidence without ever talking to
Edifabric — directly contradicting AC5/VP4.
This commit splits it into two committed modules under
cyclone.rebill/:
spot_check_pipeline.py — generation
- select_spot_check_visits(session, *, window_start, window_end, n)
reads visits + service_line_payments + cas_adjustments; drops
PAID + DENIED_DUPLICATE_NOISE (OA-18 only); returns top-n by
billed_amount desc.
- write_spot_check_files(visits, out_dir) — uses committed
spot_check.build_claim_output + serialize_837 +
build_outbound_filename; writes segment-per-line with
uppercase-R CLM01 (so plan's literal grep ^CLM*[A-Z] matches).
spot_check_validate.py — validation (NO FALLBACK)
- is_edifabric_pass(result) — True for Status in {success, warning}.
- validate_spot_check_files(paths) — calls ONLY
cyclone.edifabric.validate_edi; on EdifabricError re-raises
(no pyx12 substitute). passed=true requires a real
OperationResult with Status in {success, warning}.
test_spot_check_pipeline.py — 13 unit tests
- 4 pipeline tests: in-window filter, NOT_IN_835 default,
n-truncation, OA-18 dedup.
- 2 file-write tests: plan-grep matches all 6 segment classes
(NM1*41|NM1*40|NM1*QC|^CLM*[A-Z]|SV1*HC:|DTP*472),
segment-per-line shape.
- 4 is_edifabric_pass classifier tests (success/warning/error/unknown).
- 3 validate_spot_check_files tests: success status → passed;
error status → passed=false; 403 quota → EdifabricError raised
(no silent substitution).
The scratch driver (build_and_validate.py) is now ~40 lines: imports
the two modules, prints '10/10 passed' only when all 10 entries are
live Edifabric passes.
Also removes tests/test_spot_check_driver.py (subsumed by the
pipeline tests above).
This commit is contained in:
@@ -0,0 +1,274 @@
|
|||||||
|
"""SP41-goal: spot-check 837P generation pipeline (committed under
|
||||||
|
``cyclone.rebill``).
|
||||||
|
|
||||||
|
Splits the SP41 spot-check flow into a pure-Python generation step
|
||||||
|
that consumes the canonical ``visits`` + ``service_line_payments``
|
||||||
|
+ ``cas_adjustments`` tables and writes well-formed 837P files to
|
||||||
|
disk. The companion validation step lives in
|
||||||
|
:mod:`cyclone.rebill.spot_check_validate` (no fallback path —
|
||||||
|
quota / transport errors fail loud).
|
||||||
|
|
||||||
|
Public surface
|
||||||
|
--------------
|
||||||
|
|
||||||
|
- :func:`select_spot_check_visits` — DB-only: read the in-window
|
||||||
|
visits, reconcile against the ingested 835 SVC rows, drop
|
||||||
|
PAID / OA-18-only adjudications, return the top-N candidates by
|
||||||
|
``billed_amount`` desc.
|
||||||
|
- :func:`write_spot_check_files` — produce one well-formed 837P per
|
||||||
|
visit via the canonical ``cyclone.parsers.serialize_837.serialize_837``
|
||||||
|
single-claim path. Files are written segment-per-line to ``out_dir``
|
||||||
|
with the HCPF-spec filename via
|
||||||
|
:func:`cyclone.edi.filenames.build_outbound_filename`.
|
||||||
|
|
||||||
|
The dedup of OA-18 (Exact Duplicate) adjudications reflects the
|
||||||
|
operator's note that the 835s show multiple rejections because
|
||||||
|
files were submitted multiple times; visits whose only adjudication
|
||||||
|
is OA-18 on a duplicate SVC are NOT in-window rebill candidates.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone.db import Visit
|
||||||
|
from cyclone.edi.filenames import build_outbound_filename
|
||||||
|
from cyclone.parsers.serialize_837 import serialize_837
|
||||||
|
from cyclone.rebill.spot_check import (
|
||||||
|
VisitRow,
|
||||||
|
build_claim_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Spot-check envelope constants (must match the HCPF production
|
||||||
|
# trading-partner profile seeded in config/payers.yaml).
|
||||||
|
SENDER_ID = "11525703"
|
||||||
|
RECEIVER_ID = "CO_TXIX"
|
||||||
|
SUBMITTER_NAME = "Dzinesco"
|
||||||
|
SUBMITTER_CONTACT_NAME = "Tyler Martinez"
|
||||||
|
SUBMITTER_CONTACT_EMAIL = "tyler@dzinesco.com"
|
||||||
|
SUBMITTER_CONTACT_PHONE = "8005550100"
|
||||||
|
RECEIVER_NAME = "COLORADO MEDICAL ASSISTANCE PROGRAM"
|
||||||
|
SBR09 = "MC" # Medicaid
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SpotCheckFile:
|
||||||
|
"""One well-formed spot-check 837P file on disk + its source visit."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
visit: VisitRow
|
||||||
|
disposition: str # "DENIED" / "PARTIAL" / "NOT_IN_835"
|
||||||
|
claim_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def _visit_row_from_db(row: Visit) -> VisitRow:
|
||||||
|
"""Translate a ``Visit`` ORM row to a flat ``VisitRow``.
|
||||||
|
|
||||||
|
The visits table stores modifiers colon-joined (post-normalize);
|
||||||
|
split them back into a tuple so :func:`build_claim_output` sees
|
||||||
|
a list-of-modifiers shape.
|
||||||
|
"""
|
||||||
|
mods = tuple(
|
||||||
|
m.strip() for m in (row.modifiers or "").split(":") if m.strip()
|
||||||
|
)
|
||||||
|
return VisitRow(
|
||||||
|
dos=row.dos,
|
||||||
|
member_id=row.member_id,
|
||||||
|
client_name=row.client_name or "",
|
||||||
|
procedure_code=row.procedure_code,
|
||||||
|
modifiers=mods,
|
||||||
|
billed_amount=Decimal(str(row.billed_amount or 0)),
|
||||||
|
icd10=row.icd10,
|
||||||
|
prior_auth=row.prior_auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_claim_id(visit: VisitRow, idx: int) -> str:
|
||||||
|
"""Spot-check CLM01 — uppercase ``R`` prefix satisfies the plan's
|
||||||
|
literal grep ``^CLM\\*[A-Z]`` (the lowercase ``r`` would not match).
|
||||||
|
The shape ``R<dos>-<member>-<idx>`` round-trips back to the visit.
|
||||||
|
"""
|
||||||
|
return f"R{visit.dos.isoformat().replace('-', '')}-{visit.member_id}-{idx:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def select_spot_check_visits(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
window_start: date,
|
||||||
|
window_end: date,
|
||||||
|
n: int = 10,
|
||||||
|
) -> list[tuple[VisitRow, str, list[str]]]:
|
||||||
|
"""Pick ``n`` spot-check candidates from the canonical visits table.
|
||||||
|
|
||||||
|
Reads ``visits`` for the DOS window and ``service_line_payments`` /
|
||||||
|
``cas_adjustments`` for the same window. Reconciles each visit on
|
||||||
|
``(member_id, procedure_code, DOS)`` against the SVC rows.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of ``(VisitRow, disposition, cas_reasons)`` tuples, top
|
||||||
|
``n`` by ``billed_amount`` desc, from the
|
||||||
|
``{"DENIED", "PARTIAL", "NOT_IN_835"}`` cohort (PAID and
|
||||||
|
``DENIED_DUPLICATE_NOISE`` are excluded).
|
||||||
|
|
||||||
|
Disposition classification:
|
||||||
|
* ``PAID`` — at least one matching SVC paid ≥ 95% of billed.
|
||||||
|
* ``PARTIAL`` — at least one SVC paid but < 95%.
|
||||||
|
* ``DENIED`` — matching SVCs but no payment.
|
||||||
|
* ``DENIED_DUPLICATE_NOISE`` — pure OA-18 (Exact Duplicate) CAS.
|
||||||
|
Excluded per the operator's note about prior duplicate
|
||||||
|
submissions — these are NOT in-window rebill candidates.
|
||||||
|
* ``NOT_IN_835`` — no matching SVC at all.
|
||||||
|
"""
|
||||||
|
visits = session.execute(
|
||||||
|
select(Visit).where(
|
||||||
|
Visit.dos >= window_start, Visit.dos <= window_end,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
svc_rows = session.execute(
|
||||||
|
db_mod.text(
|
||||||
|
"""
|
||||||
|
SELECT slp.procedure_code AS procedure,
|
||||||
|
slp.service_date AS svc_date,
|
||||||
|
slp.charge AS charge,
|
||||||
|
slp.payment AS paid,
|
||||||
|
slp.remittance_id AS remittance_id
|
||||||
|
FROM service_line_payments slp
|
||||||
|
JOIN remittances r ON r.id = slp.remittance_id
|
||||||
|
WHERE slp.service_date BETWEEN :start AND :end
|
||||||
|
AND r.is_reversal = 0
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"start": window_start.isoformat(), "end": window_end.isoformat()},
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
cas_by_remit: dict[str, list[str]] = {}
|
||||||
|
cas_rows = session.execute(
|
||||||
|
db_mod.text(
|
||||||
|
"""
|
||||||
|
SELECT remittance_id, group_code, reason_code
|
||||||
|
FROM cas_adjustments
|
||||||
|
WHERE remittance_id IN (
|
||||||
|
SELECT id FROM remittances WHERE is_reversal = 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for cr in cas_rows:
|
||||||
|
cas_by_remit.setdefault(str(cr.remittance_id), []).append(
|
||||||
|
f"{cr.group_code}-{cr.reason_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# SQLite returns DATE columns as ISO strings; normalize to date.
|
||||||
|
def _to_date(v) -> date:
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v
|
||||||
|
return date.fromisoformat(str(v)[:10])
|
||||||
|
|
||||||
|
# Best-of-N SVC match by (procedure, DOS). The DB-side 835 parser
|
||||||
|
# (SP20) does not capture member_id at SVC scope, so we use the
|
||||||
|
# visits table's member_id directly — the visits table is the
|
||||||
|
# canonical member-of-record source, and an SVC at (procedure,
|
||||||
|
# DOS) for the same billed amount within 5% tolerance is
|
||||||
|
# unambiguous in practice.
|
||||||
|
by_pdos: dict[tuple[str, date], list] = {}
|
||||||
|
for s in svc_rows:
|
||||||
|
d = _to_date(s.svc_date)
|
||||||
|
by_pdos.setdefault((s.procedure, d), []).append(s)
|
||||||
|
|
||||||
|
out: list[tuple[VisitRow, str, list[str]]] = []
|
||||||
|
for v in visits:
|
||||||
|
vr = _visit_row_from_db(v)
|
||||||
|
cands = by_pdos.get((vr.procedure_code, vr.dos), [])
|
||||||
|
if not cands:
|
||||||
|
out.append((vr, "NOT_IN_835", []))
|
||||||
|
continue
|
||||||
|
total_paid = sum(
|
||||||
|
(Decimal(str(c.paid or 0)) for c in cands), Decimal("0")
|
||||||
|
)
|
||||||
|
any_paid = any(Decimal(str(c.paid or 0)) > 0 for c in cands)
|
||||||
|
cas_reasons: list[str] = []
|
||||||
|
for c in cands:
|
||||||
|
cas_reasons.extend(cas_by_remit.get(str(c.remittance_id), []))
|
||||||
|
if any_paid and total_paid >= vr.billed_amount * Decimal("0.95"):
|
||||||
|
out.append((vr, "PAID", cas_reasons))
|
||||||
|
elif any_paid:
|
||||||
|
out.append((vr, "PARTIAL", cas_reasons))
|
||||||
|
else:
|
||||||
|
if cas_reasons and set(cas_reasons) == {"OA-18"}:
|
||||||
|
out.append((vr, "DENIED_DUPLICATE_NOISE", cas_reasons))
|
||||||
|
else:
|
||||||
|
out.append((vr, "DENIED", cas_reasons))
|
||||||
|
|
||||||
|
# Keep DENIED / PARTIAL / NOT_IN_835; drop PAID + duplicate-noise.
|
||||||
|
candidates = [(v, d, c) for (v, d, c) in out
|
||||||
|
if d in ("DENIED", "PARTIAL", "NOT_IN_835")]
|
||||||
|
candidates.sort(key=lambda t: t[0].billed_amount, reverse=True)
|
||||||
|
return candidates[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def write_spot_check_files(
|
||||||
|
visits: list[tuple[VisitRow, str, list[str]]],
|
||||||
|
out_dir: Path,
|
||||||
|
) -> list[SpotCheckFile]:
|
||||||
|
"""Write one well-formed 837P file per visit into ``out_dir``.
|
||||||
|
|
||||||
|
Each file is produced via the canonical
|
||||||
|
:func:`cyclone.parsers.serialize_837.serialize_837` single-claim
|
||||||
|
path. Output is segment-per-line (X12 spec accepts both forms;
|
||||||
|
segment-per-line matches the prodfiles canonical shape so the
|
||||||
|
plan's literal grep works on the file directly).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of :class:`SpotCheckFile` records (path + source visit
|
||||||
|
+ disposition + claim_id) — caller passes the ``.path`` to the
|
||||||
|
validator.
|
||||||
|
"""
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out: list[SpotCheckFile] = []
|
||||||
|
for idx, (visit, disp, _cas) in enumerate(visits, start=1):
|
||||||
|
claim_id = _build_claim_id(visit, idx)
|
||||||
|
claim = build_claim_output(visit, claim_id=claim_id)
|
||||||
|
x12_text = serialize_837(
|
||||||
|
claim,
|
||||||
|
sender_id=SENDER_ID,
|
||||||
|
receiver_id=RECEIVER_ID,
|
||||||
|
submitter_name=SUBMITTER_NAME,
|
||||||
|
submitter_contact_name=SUBMITTER_CONTACT_NAME,
|
||||||
|
submitter_contact_phone=SUBMITTER_CONTACT_PHONE,
|
||||||
|
submitter_contact_email=SUBMITTER_CONTACT_EMAIL,
|
||||||
|
receiver_name=RECEIVER_NAME,
|
||||||
|
claim_filing_indicator_code=SBR09,
|
||||||
|
)
|
||||||
|
fname = build_outbound_filename(SENDER_ID, "837P")
|
||||||
|
# Disambiguate same-millisecond filenames with the claim_id.
|
||||||
|
stem, dot_ext = fname.rsplit(".", 1)
|
||||||
|
path = out_dir / f"{stem}-{claim_id}.{dot_ext}"
|
||||||
|
# Write segment-per-line so the plan's literal grep works
|
||||||
|
# directly on the file.
|
||||||
|
path.write_text(
|
||||||
|
x12_text.replace("~", "\n").rstrip("\n") + "\n",
|
||||||
|
encoding="ascii",
|
||||||
|
)
|
||||||
|
out.append(SpotCheckFile(
|
||||||
|
path=path,
|
||||||
|
visit=visit,
|
||||||
|
disposition=disp,
|
||||||
|
claim_id=claim_id,
|
||||||
|
))
|
||||||
|
_log.info(
|
||||||
|
"wrote spot-check file %s (disposition=%s, dos=%s, member=%s, "
|
||||||
|
"procedure=%s, amount=%s)",
|
||||||
|
path.name, disp, visit.dos, visit.member_id,
|
||||||
|
visit.procedure_code, visit.billed_amount,
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""SP41-goal: spot-check 837P validation against the Edifabric
|
||||||
|
``/v2/x12/validate`` endpoint (committed under ``cyclone.rebill``).
|
||||||
|
|
||||||
|
Public surface
|
||||||
|
--------------
|
||||||
|
|
||||||
|
- :func:`is_edifabric_pass` — inspect an ``OperationResult`` dict and
|
||||||
|
return ``True`` only for ``Status in {"success", "warning"}``.
|
||||||
|
- :func:`validate_spot_check_files` — submit every file in ``paths``
|
||||||
|
to ``cyclone.edifabric.validate_edi`` (the shipped /v2/x12/validate
|
||||||
|
client) and return a list of per-file result dicts.
|
||||||
|
|
||||||
|
No fallback path
|
||||||
|
----------------
|
||||||
|
|
||||||
|
The original scratch driver fell back to local pyX12 on a 403
|
||||||
|
quota response from Edifabric. That fallback was masking the
|
||||||
|
acceptance-criterion breach: AC5/VP4 require live Edifabric
|
||||||
|
validation, and a pyx12 substitute does not satisfy the criterion.
|
||||||
|
This module deliberately has NO fallback. On any non-2xx HTTP
|
||||||
|
response (``EdifabricError``) or any ``Status == "error"``
|
||||||
|
OperationResult, the per-file record carries ``passed: false`` and
|
||||||
|
the OperationResult body is preserved verbatim so the operator can
|
||||||
|
diagnose.
|
||||||
|
|
||||||
|
Transport / network failures raise so the caller can decide
|
||||||
|
whether to exit-loud or retry — the orchestrator (the thin driver)
|
||||||
|
prints ``10/10 passed`` only when all 10 entries are live Edifabric
|
||||||
|
passes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cyclone.edifabric import EdifabricError, validate_edi
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_edifabric_pass(result: dict) -> bool:
|
||||||
|
"""Return True iff ``result`` is a passing ``OperationResult``.
|
||||||
|
|
||||||
|
Per the EdiNation docs, ``Status`` is one of:
|
||||||
|
* ``"success"`` — passes.
|
||||||
|
* ``"warning"`` — passes (advisory notices don't block a
|
||||||
|
structurally valid envelope; the SP41 contract treats
|
||||||
|
warning as a pass per the operator's sign-off).
|
||||||
|
* ``"error"`` — fails; the Details array carries the failing
|
||||||
|
segments / messages.
|
||||||
|
* anything else — fail loud so we don't silently approve.
|
||||||
|
"""
|
||||||
|
status = (result.get("Status") or "").lower()
|
||||||
|
return status in ("success", "warning")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_spot_check_files(paths: list[Path]) -> list[dict]:
|
||||||
|
"""Submit each file in ``paths`` to Edifabric ``/v2/x12/validate``.
|
||||||
|
|
||||||
|
Returns a list of per-file result dicts, one per input path, in
|
||||||
|
the same order. Each dict has the shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"file": str, # path to the submitted file
|
||||||
|
"passed": bool, # True only if Status is success/warning
|
||||||
|
"issue": str, # human-readable verdict
|
||||||
|
"result": dict | None, # OperationResult verbatim (None on transport error)
|
||||||
|
"transport_error": dict | None, # {status_code, body} on EdifabricError
|
||||||
|
}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EdifabricError: on the first transport failure. The caller
|
||||||
|
decides whether to retry, exit non-zero, or fall back.
|
||||||
|
This module never silently substitutes a different
|
||||||
|
validator.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in paths:
|
||||||
|
body = p.read_bytes()
|
||||||
|
try:
|
||||||
|
result = validate_edi(body)
|
||||||
|
except EdifabricError as exc:
|
||||||
|
out.append({
|
||||||
|
"file": str(p),
|
||||||
|
"passed": False,
|
||||||
|
"issue": (
|
||||||
|
f"EdifabricError {exc.status_code}: {exc.body!r}"
|
||||||
|
),
|
||||||
|
"result": None,
|
||||||
|
"transport_error": {
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"body": exc.body,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# Re-raise so the caller can't silently absorb the failure
|
||||||
|
# (the original scratch driver caught + fallback'd which
|
||||||
|
# is the bug this module fixes).
|
||||||
|
raise
|
||||||
|
|
||||||
|
if is_edifabric_pass(result):
|
||||||
|
out.append({
|
||||||
|
"file": str(p),
|
||||||
|
"passed": True,
|
||||||
|
"issue": f"edifabric {result.get('Status')!r}",
|
||||||
|
"result": result,
|
||||||
|
"transport_error": None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
details = result.get("Details") or []
|
||||||
|
first_msgs = []
|
||||||
|
for d in details[:5]:
|
||||||
|
if isinstance(d, dict):
|
||||||
|
first_msgs.append(
|
||||||
|
f"{d.get('SegmentId', '?')}: {d.get('Message', '?')}"
|
||||||
|
)
|
||||||
|
out.append({
|
||||||
|
"file": str(p),
|
||||||
|
"passed": False,
|
||||||
|
"issue": (
|
||||||
|
f"edifabric {result.get('Status')!r}: "
|
||||||
|
+ ("; ".join(first_msgs) or "no details")
|
||||||
|
),
|
||||||
|
"result": result,
|
||||||
|
"transport_error": None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
"""SP41-goal: prove the spot-check driver's validate_edi code path
|
|
||||||
works against the Edifabric /v2/x12/validate OperationResult shape.
|
|
||||||
|
|
||||||
The live Edifabric API is quota-blocked (resets next UTC midnight);
|
|
||||||
until then we can't make real network calls. This test wires the
|
|
||||||
shipped ``cyclone.edifabric`` MockTransport seam to return a clean
|
|
||||||
``Status: success`` OperationResult for each of the 10 spot-check
|
|
||||||
files and asserts the driver's ``passed()`` classifier returns True.
|
|
||||||
|
|
||||||
This pins the end-to-end driver path against the real
|
|
||||||
``cyclone.edifabric.validate_edi`` client — without using a network
|
|
||||||
call. The test exercises:
|
|
||||||
1. validate_edi → OperationResult with Status='success' → passed=True
|
|
||||||
2. validate_edi → OperationResult with Status='warning' → passed=True
|
|
||||||
3. validate_edi → OperationResult with Status='error' → passed=False
|
|
||||||
4. validate_edi → 403 quota → EdifabricError raised (caught by driver)
|
|
||||||
|
|
||||||
Each scenario runs against the actual 837P files produced by the
|
|
||||||
driver in this session (loaded from the scratch dir).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Make the spot-check driver importable.
|
|
||||||
SCRATCH = Path("/tmp/grok-goal-4336f8e1af6d/implementer")
|
|
||||||
sys.path.insert(0, str(SCRATCH))
|
|
||||||
|
|
||||||
import cyclone.edifabric as edifabric # noqa: E402
|
|
||||||
import cyclone.secrets as _secrets # noqa: E402
|
|
||||||
|
|
||||||
_REAL_GET_SECRET = _secrets.get_secret
|
|
||||||
|
|
||||||
_TEST_KEY = "3ecf6b1c5cf34bd797a5f4c57951a1cf"
|
|
||||||
_X12_INTERCHANGE = {
|
|
||||||
"SegmentDelimiter": "~",
|
|
||||||
"DataElementDelimiter": "*",
|
|
||||||
"ISA": {"InterchangeControlNumber_13": "000000001"},
|
|
||||||
"Groups": [],
|
|
||||||
"IEATrailers": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_secrets(patched_key: str):
|
|
||||||
def _fake(name: str):
|
|
||||||
if name == "edifabric.api_key":
|
|
||||||
return patched_key
|
|
||||||
return _REAL_GET_SECRET(name)
|
|
||||||
_secrets.get_secret = _fake # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
def _install(handler):
|
|
||||||
transport = httpx.MockTransport(handler)
|
|
||||||
edifabric.set_transport_factory(lambda: httpx.Client(transport=transport, timeout=10.0))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _restore():
|
|
||||||
yield
|
|
||||||
edifabric._reset_transport_factory()
|
|
||||||
_secrets.get_secret = _REAL_GET_SECRET # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
def _first_spot_file() -> Path:
|
|
||||||
"""Return the first spot-check 837P file from the scratch dir.
|
|
||||||
|
|
||||||
Skip if no driver run has produced files yet (the live driver
|
|
||||||
writes them; this test reads them).
|
|
||||||
"""
|
|
||||||
files = sorted((SCRATCH / "spot-check-837Ps").glob("*.x12"))
|
|
||||||
if not files:
|
|
||||||
pytest.skip("no spot-check 837P files in scratch dir — run build_and_validate.py first")
|
|
||||||
return files[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def spot_file() -> Path:
|
|
||||||
return _first_spot_file()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_handler(operation_result: dict):
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
if request.url.path.endswith("/read"):
|
|
||||||
return httpx.Response(200, json=[_X12_INTERCHANGE])
|
|
||||||
if request.url.path.endswith("/validate"):
|
|
||||||
return httpx.Response(200, json=operation_result)
|
|
||||||
raise AssertionError(f"unexpected path: {request.url.path}")
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
# --- The 4 scenarios --------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_driver_passed_classifier_on_success_status(spot_file: Path):
|
|
||||||
"""Status='success' → driver.passed() returns (True, 'success')."""
|
|
||||||
from build_and_validate import passed # type: ignore
|
|
||||||
|
|
||||||
_mock_secrets(_TEST_KEY)
|
|
||||||
_install(_make_handler({"Status": "success", "Details": [], "LastIndex": 46}))
|
|
||||||
body = spot_file.read_bytes()
|
|
||||||
result = edifabric.validate_edi(body)
|
|
||||||
ok, msg = passed(result)
|
|
||||||
assert ok is True
|
|
||||||
assert msg == "success"
|
|
||||||
|
|
||||||
|
|
||||||
def test_driver_passed_classifier_on_warning_status(spot_file: Path):
|
|
||||||
"""Status='warning' → driver.passed() returns (True, 'warning').
|
|
||||||
|
|
||||||
Edifabric 'warning' is advisory (deprecation / best-practice
|
|
||||||
notices) and does NOT block a structurally valid envelope per the
|
|
||||||
SP41 contract.
|
|
||||||
"""
|
|
||||||
from build_and_validate import passed # type: ignore
|
|
||||||
|
|
||||||
_mock_secrets(_TEST_KEY)
|
|
||||||
_install(_make_handler({
|
|
||||||
"Status": "warning",
|
|
||||||
"Details": [{
|
|
||||||
"Index": 12,
|
|
||||||
"SegmentId": "CLM",
|
|
||||||
"Value": "CLM*R20260205-Q944140-01*333.62***12:B:1*Y*A*Y*Y",
|
|
||||||
"Message": "Best practice advisory: use place-of-service code 11",
|
|
||||||
"Status": "warning",
|
|
||||||
}],
|
|
||||||
"LastIndex": 46,
|
|
||||||
}))
|
|
||||||
body = spot_file.read_bytes()
|
|
||||||
result = edifabric.validate_edi(body)
|
|
||||||
ok, msg = passed(result)
|
|
||||||
assert ok is True
|
|
||||||
assert msg == "warning"
|
|
||||||
|
|
||||||
|
|
||||||
def test_driver_passed_classifier_on_error_status(spot_file: Path):
|
|
||||||
"""Status='error' → driver.passed() returns (False, error details)."""
|
|
||||||
from build_and_validate import passed # type: ignore
|
|
||||||
|
|
||||||
_mock_secrets(_TEST_KEY)
|
|
||||||
_install(_make_handler({
|
|
||||||
"Status": "error",
|
|
||||||
"Details": [{
|
|
||||||
"Index": 5,
|
|
||||||
"SegmentId": "CLM",
|
|
||||||
"Value": "CLM*X",
|
|
||||||
"Message": "Segment CLM is missing required data elements",
|
|
||||||
"Status": "error",
|
|
||||||
}],
|
|
||||||
"LastIndex": 5,
|
|
||||||
}))
|
|
||||||
body = spot_file.read_bytes()
|
|
||||||
result = edifabric.validate_edi(body)
|
|
||||||
ok, msg = passed(result)
|
|
||||||
assert ok is False
|
|
||||||
assert "missing required data elements" in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_driver_passed_classifier_on_403_quota(spot_file: Path):
|
|
||||||
"""HTTP 403 quota → EdifabricError raised (driver falls back to pyX12)."""
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
if request.url.path.endswith("/read"):
|
|
||||||
return httpx.Response(
|
|
||||||
403,
|
|
||||||
json={"statusCode": 403,
|
|
||||||
"message": "Out of call volume quota."},
|
|
||||||
)
|
|
||||||
raise AssertionError(f"unexpected path: {request.url.path}")
|
|
||||||
|
|
||||||
_mock_secrets(_TEST_KEY)
|
|
||||||
_install(handler)
|
|
||||||
body = spot_file.read_bytes()
|
|
||||||
with pytest.raises(edifabric.EdifabricError) as exc_info:
|
|
||||||
edifabric.validate_edi(body)
|
|
||||||
assert exc_info.value.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_driver_emits_correct_validate_edi_call(spot_file: Path):
|
|
||||||
"""The driver calls validate_edi (read+validate) — pin the wire shape.
|
|
||||||
|
|
||||||
Validates the read path returns the X12Interchange shape and the
|
|
||||||
validate path receives that same shape (not the raw bytes).
|
|
||||||
"""
|
|
||||||
captured_validates: list[dict] = []
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
if request.url.path.endswith("/read"):
|
|
||||||
return httpx.Response(200, json=[_X12_INTERCHANGE])
|
|
||||||
if request.url.path.endswith("/validate"):
|
|
||||||
captured_validates.append(json.loads(request.content))
|
|
||||||
return httpx.Response(200, json={"Status": "success", "Details": [], "LastIndex": 46})
|
|
||||||
raise AssertionError(f"unexpected path: {request.url.path}")
|
|
||||||
|
|
||||||
_mock_secrets(_TEST_KEY)
|
|
||||||
_install(handler)
|
|
||||||
body = spot_file.read_bytes()
|
|
||||||
result = edifabric.validate_edi(body)
|
|
||||||
assert result["Status"] == "success"
|
|
||||||
assert len(captured_validates) == 1
|
|
||||||
# The validate body is the X12Interchange, not the raw bytes.
|
|
||||||
assert captured_validates[0]["ISA"]["InterchangeControlNumber_13"] == "000000001"
|
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""SP41-goal: unit tests for the committed spot-check pipeline + validate
|
||||||
|
modules.
|
||||||
|
|
||||||
|
The two modules are pure (no I/O outside the DB session + Edifabric
|
||||||
|
HTTP seam) and unit-testable end-to-end:
|
||||||
|
|
||||||
|
- :mod:`cyclone.rebill.spot_check_pipeline` — generation (DB → files).
|
||||||
|
- :mod:`cyclone.rebill.spot_check_validate` — validation (files →
|
||||||
|
OperationResult, no fallback).
|
||||||
|
|
||||||
|
These tests pin the live-shape contract that the production driver
|
||||||
|
exercises, so a regression in either module shows up here without
|
||||||
|
having to run the live driver.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
import cyclone.edifabric as edifabric
|
||||||
|
import cyclone.secrets as _secrets
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone.rebill.spot_check_pipeline import (
|
||||||
|
select_spot_check_visits,
|
||||||
|
write_spot_check_files,
|
||||||
|
)
|
||||||
|
from cyclone.rebill.spot_check_validate import (
|
||||||
|
is_edifabric_pass,
|
||||||
|
validate_spot_check_files,
|
||||||
|
)
|
||||||
|
from cyclone.rebill.visits_store import load_visits_csv
|
||||||
|
|
||||||
|
_REAL_GET_SECRET = _secrets.get_secret
|
||||||
|
_TEST_KEY = "3ecf6b1c5cf34bd797a5f4c57951a1cf"
|
||||||
|
|
||||||
|
_X12_INTERCHANGE = {
|
||||||
|
"SegmentDelimiter": "~",
|
||||||
|
"DataElementDelimiter": "*",
|
||||||
|
"ISA": {"InterchangeControlNumber_13": "000000001"},
|
||||||
|
"Groups": [],
|
||||||
|
"IEATrailers": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fixtures --------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def visits_csv(tmp_path: Path) -> Path:
|
||||||
|
csv_path = tmp_path / "visits.csv"
|
||||||
|
with csv_path.open("w", newline="", encoding="utf-8") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
"Client", "Visit Date", "Payer", "Authorized", "Member ID",
|
||||||
|
"Auth Start Date", "Auth End Date", "Authorization #", "ICD-10",
|
||||||
|
"Service", "Procedure Code", "Modifiers", "Client Classes",
|
||||||
|
"Billable Hours", "Billable Amount", "Invoice #", "Claimed",
|
||||||
|
])
|
||||||
|
writer.writerow([
|
||||||
|
"Doe, Jane", "01/15/2026", "COHCPF", "Yes", "R111111",
|
||||||
|
"01/01/2026", "12/31/2026", "1", "R69",
|
||||||
|
"Homemaker S5150", "S5150", "U8", "DD Waiver", "1", "$300.00",
|
||||||
|
"INV-2026-01-15-R111111", "Yes",
|
||||||
|
])
|
||||||
|
writer.writerow([
|
||||||
|
"Roe, Richard", "02/20/2026", "COHCPF", "Yes", "R222222",
|
||||||
|
"01/01/2026", "12/31/2026", "2", "R69",
|
||||||
|
"IHSS T1019", "T1019", "KX, SC, U2", "IHSS MR", "1.5", "$250.00",
|
||||||
|
"INV-2026-02-20-R222222", "Yes",
|
||||||
|
])
|
||||||
|
writer.writerow([
|
||||||
|
"Old, Stale", "12/31/2025", "COHCPF", "Yes", "R999999",
|
||||||
|
"01/01/2025", "12/31/2025", "0", "R69",
|
||||||
|
"Old S5150", "S5150", "U8", "DD Waiver", "1", "$100.00",
|
||||||
|
"INV-2025-12-31-R999999", "Yes", # OUT-OF-WINDOW
|
||||||
|
])
|
||||||
|
return csv_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loaded_db(tmp_path: Path, visits_csv: Path, monkeypatch):
|
||||||
|
"""Load visits_csv into a fresh DB and return the session."""
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path / 'test.db'}")
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
db_mod.init_db()
|
||||||
|
inserted = load_visits_csv(
|
||||||
|
visits_csv,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
)
|
||||||
|
assert inserted == 2 # the stale row is out-of-window
|
||||||
|
SessionLocal = db_mod.SessionLocal()
|
||||||
|
sess = SessionLocal()
|
||||||
|
yield sess
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore():
|
||||||
|
yield
|
||||||
|
edifabric._reset_transport_factory()
|
||||||
|
_secrets.get_secret = _REAL_GET_SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_secrets(patched_key: str):
|
||||||
|
def _fake(name: str):
|
||||||
|
if name == "edifabric.api_key":
|
||||||
|
return patched_key
|
||||||
|
return _REAL_GET_SECRET(name)
|
||||||
|
_secrets.get_secret = _fake # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def _install(handler):
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
edifabric.set_transport_factory(
|
||||||
|
lambda: httpx.Client(transport=transport, timeout=10.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pipeline tests --------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_spot_check_visits_returns_in_window_only(loaded_db):
|
||||||
|
"""select_spot_check_visits honors the DOS window."""
|
||||||
|
out = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
assert len(out) == 2
|
||||||
|
# The stale 2025 row was filtered out at the load step.
|
||||||
|
dos_list = [v.dos for (v, _d, _c) in out]
|
||||||
|
assert all(date(2026, 1, 1) <= d <= date(2026, 6, 27) for d in dos_list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_spot_check_visits_marks_not_in_835(loaded_db):
|
||||||
|
"""With no SVC rows in DB, visits are NOT_IN_835 (rebill candidates)."""
|
||||||
|
out = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
dispositions = [d for (_v, d, _c) in out]
|
||||||
|
assert dispositions == ["NOT_IN_835", "NOT_IN_835"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_spot_check_visits_n_truncates(loaded_db):
|
||||||
|
"""n=1 returns the top 1 by billed_amount desc."""
|
||||||
|
out = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=1,
|
||||||
|
)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0][0].member_id == "R111111" # $300 > $250
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_spot_check_visits_oa18_excluded(loaded_db):
|
||||||
|
"""OA-18-only SVC matches are excluded as DENIED_DUPLICATE_NOISE."""
|
||||||
|
# Insert a batch + remittance + SVC so the Jan-15 visit has a hit.
|
||||||
|
loaded_db.execute(text("""
|
||||||
|
INSERT INTO batches (
|
||||||
|
id, kind, input_filename, parsed_at
|
||||||
|
) VALUES (
|
||||||
|
'B-TEST-OA18', '835', 'tp-test-20260115.835',
|
||||||
|
'2026-01-20 00:00:00'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
loaded_db.execute(text("""
|
||||||
|
INSERT INTO remittances (
|
||||||
|
id, batch_id, payer_claim_control_number,
|
||||||
|
total_charge, total_paid, adjustment_amount,
|
||||||
|
status_code, received_at, is_reversal
|
||||||
|
) VALUES (
|
||||||
|
'R-TEST-OA18', 'B-TEST-OA18', 'CLM-TEST-1',
|
||||||
|
300.00, 0.00, 0.00,
|
||||||
|
'DENIED', '2026-01-20 00:00:00', 0
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
loaded_db.execute(text("""
|
||||||
|
INSERT INTO service_line_payments (
|
||||||
|
remittance_id, line_number, procedure_qualifier,
|
||||||
|
procedure_code, service_date, charge, payment
|
||||||
|
) VALUES (
|
||||||
|
'R-TEST-OA18', 1, 'HC',
|
||||||
|
'S5150', '2026-01-15', 300.00, 0.00
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
loaded_db.commit()
|
||||||
|
|
||||||
|
out = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
# R222222 (no SVC) → NOT_IN_835
|
||||||
|
# R111111 (SVC hit with no CAS yet → DENIED)
|
||||||
|
dispositions = sorted(d for (_v, d, _c) in out)
|
||||||
|
assert dispositions == ["DENIED", "NOT_IN_835"]
|
||||||
|
|
||||||
|
# Now add OA-18 CAS — R111111 flips to DENIED_DUPLICATE_NOISE.
|
||||||
|
loaded_db.execute(text("""
|
||||||
|
INSERT INTO cas_adjustments (
|
||||||
|
remittance_id, group_code, reason_code, amount
|
||||||
|
) VALUES ('R-TEST-OA18', 'OA', '18', 300.00)
|
||||||
|
"""))
|
||||||
|
loaded_db.commit()
|
||||||
|
|
||||||
|
out = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
# R111111 now excluded (DENIED_DUPLICATE_NOISE); only R222222 remains.
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0][0].member_id == "R222222"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_spot_check_files_emits_required_segments(tmp_path, loaded_db):
|
||||||
|
"""Every emitted file passes the plan's literal grep."""
|
||||||
|
out_dir = tmp_path / "spot-check-837Ps"
|
||||||
|
visits = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
written = write_spot_check_files(visits, out_dir)
|
||||||
|
assert len(written) == 2
|
||||||
|
plan_grep = re.compile(
|
||||||
|
r"NM1\*41|NM1\*40|NM1\*QC|^CLM\*[A-Z]|SV1\*HC:|DTP\*472",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
for sc in written:
|
||||||
|
content = sc.path.read_text()
|
||||||
|
hits = plan_grep.findall(content)
|
||||||
|
# 6 distinct segment classes.
|
||||||
|
assert len(set(hits)) == 6, (
|
||||||
|
f"{sc.path.name}: missing one of the 6 required segment "
|
||||||
|
f"classes; got {set(hits)}"
|
||||||
|
)
|
||||||
|
# CLM01 must start with uppercase R.
|
||||||
|
clm_line = next(
|
||||||
|
(l for l in content.splitlines() if l.startswith("CLM*")), None
|
||||||
|
)
|
||||||
|
assert clm_line and clm_line.startswith("CLM*R"), clm_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_spot_check_files_segment_per_line(tmp_path, loaded_db):
|
||||||
|
"""Files are written segment-per-line (matches prodfiles canonical shape)."""
|
||||||
|
out_dir = tmp_path / "spot-check-837Ps"
|
||||||
|
visits = select_spot_check_visits(
|
||||||
|
loaded_db,
|
||||||
|
window_start=date(2026, 1, 1),
|
||||||
|
window_end=date(2026, 6, 27),
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
written = write_spot_check_files(visits, out_dir)
|
||||||
|
# Known segment names that may appear in a well-formed 837P.
|
||||||
|
valid_segment_prefixes = {
|
||||||
|
"ISA", "GS", "ST", "BHT", "NM1", "PER", "HL", "PRV", "N3", "N4",
|
||||||
|
"REF", "SBR", "DMG", "PAT", "CLM", "HI", "LX", "SV1", "SV2",
|
||||||
|
"SV3", "SV4", "SV5", "SV6", "SV7", "DTP", "SE", "GE", "IEA",
|
||||||
|
}
|
||||||
|
for sc in written:
|
||||||
|
content = sc.path.read_text()
|
||||||
|
# No tildes in the file body (only segment terminator is newline).
|
||||||
|
assert "~" not in content, f"{sc.path.name} still contains ~"
|
||||||
|
# Every line begins with a known segment name.
|
||||||
|
for line in content.splitlines():
|
||||||
|
prefix = line.split("*", 1)[0]
|
||||||
|
assert prefix in valid_segment_prefixes, (
|
||||||
|
f"unexpected segment prefix in {sc.path.name}: {line!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Validate tests --------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_edifabric_pass_true_on_success():
|
||||||
|
assert is_edifabric_pass({"Status": "success", "Details": []}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_edifabric_pass_true_on_warning():
|
||||||
|
assert is_edifabric_pass({"Status": "warning", "Details": []}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_edifabric_pass_false_on_error():
|
||||||
|
assert is_edifabric_pass({"Status": "error", "Details": []}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_edifabric_pass_false_on_unknown():
|
||||||
|
assert is_edifabric_pass({"Status": "fubar"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_spot_check_files_passes_on_success_status(tmp_path):
|
||||||
|
"""validate_spot_check_files returns passed=True on Status='success'."""
|
||||||
|
f = tmp_path / "test.x12"
|
||||||
|
f.write_bytes(b"ISA*00*~ST*837*~SE*1*~IEA*1*~")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/read"):
|
||||||
|
return httpx.Response(200, json=[_X12_INTERCHANGE])
|
||||||
|
if request.url.path.endswith("/validate"):
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"Status": "success", "Details": [], "LastIndex": 4},
|
||||||
|
)
|
||||||
|
raise AssertionError(f"unexpected path: {request.url.path}")
|
||||||
|
|
||||||
|
_mock_secrets(_TEST_KEY)
|
||||||
|
_install(handler)
|
||||||
|
out = validate_spot_check_files([f])
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["passed"] is True
|
||||||
|
assert out[0]["result"]["Status"] == "success"
|
||||||
|
assert out[0]["transport_error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_spot_check_files_fails_on_error_status(tmp_path):
|
||||||
|
"""validate_spot_check_files returns passed=False on Status='error'."""
|
||||||
|
f = tmp_path / "test.x12"
|
||||||
|
f.write_bytes(b"ISA*00*~ST*837*~SE*1*~IEA*1*~")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/read"):
|
||||||
|
return httpx.Response(200, json=[_X12_INTERCHANGE])
|
||||||
|
if request.url.path.endswith("/validate"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"Status": "error",
|
||||||
|
"Details": [{
|
||||||
|
"SegmentId": "CLM", "Message": "missing elements",
|
||||||
|
"Status": "error",
|
||||||
|
}],
|
||||||
|
"LastIndex": 2,
|
||||||
|
})
|
||||||
|
raise AssertionError(f"unexpected path: {request.url.path}")
|
||||||
|
|
||||||
|
_mock_secrets(_TEST_KEY)
|
||||||
|
_install(handler)
|
||||||
|
out = validate_spot_check_files([f])
|
||||||
|
assert out[0]["passed"] is False
|
||||||
|
assert "missing elements" in out[0]["issue"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_spot_check_files_raises_on_403_quota(tmp_path):
|
||||||
|
"""On 403 quota, raises EdifabricError — NO fallback to pyx12.
|
||||||
|
|
||||||
|
This is the structural fix: the previous scratch driver caught
|
||||||
|
this error and fell back to a local validator, which silently
|
||||||
|
marked passed=True without ever talking to Edifabric. The new
|
||||||
|
module deliberately has no fallback.
|
||||||
|
"""
|
||||||
|
f = tmp_path / "test.x12"
|
||||||
|
f.write_bytes(b"ISA*00*~ST*837*~SE*1*~IEA*1*~")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/read"):
|
||||||
|
return httpx.Response(
|
||||||
|
403,
|
||||||
|
json={"statusCode": 403,
|
||||||
|
"message": "Out of call volume quota."},
|
||||||
|
)
|
||||||
|
raise AssertionError(f"unexpected path: {request.url.path}")
|
||||||
|
|
||||||
|
_mock_secrets(_TEST_KEY)
|
||||||
|
_install(handler)
|
||||||
|
with pytest.raises(edifabric.EdifabricError) as exc_info:
|
||||||
|
validate_spot_check_files([f])
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
Reference in New Issue
Block a user