feat(sp37): cyclone.submission.submit_file helper
Owns the parse → DB write → SFTP upload sequence in one place. CLI and HTTP thin-call it. DB-first invariant: if add_record raises, no SFTP call is made. Idempotency: add_record dedupes via s.get(Claim, claim_id); SFTP layer dedupes via stat().st_size match.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*260611*0814*^*00501*991102977*1*P*:~
|
||||
GS*HC*11525703*COMEDASSISTPROG*20260611*081417*991102977*X*005010X222A1~
|
||||
ST*837*991102977*005010X222A1~
|
||||
BHT*0019*00*ref-001*20260611*081417*CH~
|
||||
NM1*41*2*Test Submitter*****46*11525703~
|
||||
PER*IC*Test Contact*EM*test@example.com~
|
||||
NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~
|
||||
HL*1**20*1~
|
||||
PRV*BI*PXC*251E00000X~
|
||||
NM1*85*2*Test Provider Inc*****XX*1993999998~
|
||||
N3*123 Test St~
|
||||
N4*Denver*CO*80202~
|
||||
REF*EI*123456789~
|
||||
HL*2*1*22*0~
|
||||
SBR*P*18*******MC~
|
||||
NM1*IL*1*Doe*John****MI*ABC123~
|
||||
N3*456 Member St~
|
||||
N4*Denver*CO*80203~
|
||||
DMG*D8*19800101*M~
|
||||
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
|
||||
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
|
||||
REF*G1*PA123~
|
||||
HI*ABK:Z00~
|
||||
LX*1~
|
||||
SV1*HC:99213*100.00*UN*1***1~
|
||||
DTP*472*D8*20260611~
|
||||
REF*6R*REF001~
|
||||
SE*26*991102977~
|
||||
GE*1*991102977~
|
||||
IEA*1*991102977~
|
||||
@@ -0,0 +1,140 @@
|
||||
"""SP37 Task 4: submit_file owns parse → DB write → SFTP upload.
|
||||
|
||||
Tests use a fake SFTP client to avoid hitting the real clearhouse.
|
||||
The DB path is real (autouse fixture) so the join-key wiring is
|
||||
exercised end-to-end.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db as db_mod
|
||||
from cyclone.clearhouse import SftpClient
|
||||
from cyclone.submission.core import submit_file
|
||||
from cyclone.submission.result import SubmitOutcome
|
||||
|
||||
|
||||
_FIXTURE = Path(__file__).parent / "fixtures" / "submit-batch" / "single-claim.x12"
|
||||
|
||||
|
||||
class _FakeSftp:
|
||||
"""Implements just enough of SftpClient for submit_file's idempotency check.
|
||||
|
||||
After every successful ``write_file``, the file is added to
|
||||
``existing_files`` keyed by remote_path with the byte size — so a
|
||||
subsequent ``stat`` returns the right size and the idempotency
|
||||
check in ``submit_file`` short-circuits to ``SKIPPED``. Without
|
||||
this bookkeeping, every re-run would look like the file isn't on
|
||||
the remote and would re-upload.
|
||||
"""
|
||||
|
||||
def __init__(self, existing_files: dict[str, int] | None = None):
|
||||
self.existing_files = existing_files or {}
|
||||
self.put_calls: list[tuple[str, bytes]] = []
|
||||
|
||||
def stat(self, path: str):
|
||||
if path not in self.existing_files:
|
||||
raise IOError(f"no such file: {path}")
|
||||
m = MagicMock()
|
||||
m.st_size = self.existing_files[path]
|
||||
return m
|
||||
|
||||
def write_file(self, remote_path: str, content: bytes):
|
||||
self.put_calls.append((remote_path, content))
|
||||
# Mirror the upload so a subsequent stat() sees it.
|
||||
self.existing_files[remote_path] = len(content)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _db(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
db_mod._reset_for_tests()
|
||||
db_mod.init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_submit_file_happy_path():
|
||||
"""parse → DB write → SFTP upload, all steps succeed."""
|
||||
fake = _FakeSftp()
|
||||
sftp_block = MagicMock()
|
||||
sftp_block.stub = False
|
||||
sftp_block.paths = {"outbound": "/ToHPE"}
|
||||
|
||||
result = submit_file(
|
||||
_FIXTURE,
|
||||
sftp_block=sftp_block,
|
||||
actor="test",
|
||||
validate=True,
|
||||
sftp_client_factory=lambda _block: fake,
|
||||
)
|
||||
|
||||
assert result.outcome == SubmitOutcome.SUBMITTED
|
||||
assert result.batch_id is not None
|
||||
assert len(fake.put_calls) == 1
|
||||
# DB row landed.
|
||||
with db_mod.SessionLocal()() as s:
|
||||
assert s.get(db_mod.Batch, result.batch_id) is not None
|
||||
|
||||
|
||||
def test_submit_file_idempotent_db():
|
||||
"""Re-running submit_file on the same file is a no-op on the DB side."""
|
||||
fake = _FakeSftp()
|
||||
sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
|
||||
factory = lambda _b: fake
|
||||
|
||||
r1 = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
|
||||
r2 = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
|
||||
|
||||
# First call SUBMITTED, second call SKIPPED (size match on stat).
|
||||
assert r1.outcome == SubmitOutcome.SUBMITTED
|
||||
assert r2.outcome == SubmitOutcome.SKIPPED
|
||||
|
||||
|
||||
def test_submit_file_parse_fail(tmp_path):
|
||||
"""Bad 837 → PARSE_FAILED, no DB write, no SFTP call."""
|
||||
bad = tmp_path / "bad.x12"
|
||||
bad.write_bytes(b"not x12")
|
||||
sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
|
||||
fake = _FakeSftp()
|
||||
factory = lambda _b: fake
|
||||
|
||||
result = submit_file(bad, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
|
||||
assert result.outcome == SubmitOutcome.PARSE_FAILED
|
||||
assert len(fake.put_calls) == 0
|
||||
|
||||
|
||||
def test_submit_file_db_fail(monkeypatch):
|
||||
"""If add raises, no SFTP call is made (DB-first invariant)."""
|
||||
from cyclone.store import store as cycl_store
|
||||
monkeypatch.setattr(cycl_store, "add", MagicMock(side_effect=RuntimeError("db down")))
|
||||
|
||||
sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
|
||||
fake = _FakeSftp()
|
||||
factory = lambda _b: fake
|
||||
|
||||
result = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
|
||||
assert result.outcome == SubmitOutcome.DB_FAILED
|
||||
assert len(fake.put_calls) == 0
|
||||
|
||||
|
||||
def test_submit_file_sftp_fail():
|
||||
"""SFTP failure after DB write → SFTP_FAILED, DB row still present."""
|
||||
|
||||
class _Boom:
|
||||
def stat(self, _p):
|
||||
raise IOError("nope")
|
||||
def write_file(self, _p, _c):
|
||||
raise RuntimeError("sftp down")
|
||||
|
||||
sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
|
||||
factory = lambda _b: _Boom()
|
||||
|
||||
result = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
|
||||
assert result.outcome == SubmitOutcome.SFTP_FAILED
|
||||
# DB row was written before the SFTP call.
|
||||
assert result.batch_id is not None
|
||||
with db_mod.SessionLocal()() as s:
|
||||
assert s.get(db_mod.Batch, result.batch_id) is not None
|
||||
Reference in New Issue
Block a user