ec065a9082
SftpClient wrapper lacks a stat() method, so the previous default factory produced a client whose stat() raised AttributeError — making the SKIPPED outcome unreachable in production and silently misclassifying all uploads as SFTP_FAILED. The helper now opens a paramiko SFTP session directly (same pattern as resubmit-rejected-claims in cli.py:620-650), so stat() and write_file() both work end-to-end. Also: tighten typing (SftpBlock instead of Any), add PAYER_MISMATCH test, drop duplicate _db fixture, rename misleading test, add class docstrings to SubmitResult/SubmitOutcome.
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
"""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 ``_auto_init_db`` fixture in conftest.py)
|
|
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.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)
|
|
|
|
|
|
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_skipped_when_remote_size_matches():
|
|
"""Re-running submit_file on an already-uploaded file is SKIPPED.
|
|
|
|
The ``SKIPPED`` outcome is reached via the SFTP ``stat()`` check
|
|
(not via the DB) — same-size remote file ⇒ idempotency short-circuit.
|
|
Re-runs therefore don't emit a duplicate ``clearhouse.submitted``
|
|
audit event.
|
|
"""
|
|
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
|
|
|
|
|
|
def test_submit_file_payer_mismatch():
|
|
"""PAYER_MISMATCH → no DB write, no SFTP call.
|
|
|
|
We monkeypatch ``parse_837_text`` to return a single claim whose
|
|
payer.id is ``OTHER_PAYER`` (the helper gates on the first
|
|
claim's payer — same posture as the legacy ``resubmit_rejected_claims``
|
|
CLI at cli.py:657-668).
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
bad_claim = MagicMock()
|
|
bad_claim.payer.id = "OTHER_PAYER"
|
|
parsed_with_bad_payer = MagicMock()
|
|
parsed_with_bad_payer.claims = [bad_claim]
|
|
parsed_with_bad_payer.parsed_at = None
|
|
parsed_with_bad_payer.envelope.transaction_set_control_number = "ST123"
|
|
|
|
fake = _FakeSftp()
|
|
sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
|
|
|
|
with patch("cyclone.submission.core.parse_837_text", return_value=parsed_with_bad_payer):
|
|
result = submit_file(
|
|
_FIXTURE,
|
|
sftp_block=sftp_block,
|
|
actor="t",
|
|
validate=True,
|
|
sftp_client_factory=lambda _b: fake,
|
|
)
|
|
assert result.outcome == SubmitOutcome.PAYER_MISMATCH
|
|
assert "OTHER_PAYER" in (result.error or "")
|
|
assert len(fake.put_calls) == 0
|
|
|
|
|
|
def test_submit_file_uses_default_paramiko_factory(monkeypatch):
|
|
"""When no factory is supplied, helper should use paramiko directly
|
|
(not SftpClient wrapper, which lacks stat()).
|
|
"""
|
|
fake_paramiko_sftp = _FakeSftp()
|
|
monkeypatch.setattr(
|
|
"cyclone.submission.core._default_sftp_factory",
|
|
lambda _block: fake_paramiko_sftp,
|
|
)
|
|
|
|
sftp_block = MagicMock()
|
|
sftp_block.stub = False
|
|
sftp_block.paths = {"outbound": "/ToHPE"}
|
|
|
|
result = submit_file(
|
|
_FIXTURE,
|
|
sftp_block=sftp_block,
|
|
actor="t",
|
|
validate=True,
|
|
# NO sftp_client_factory — should use the paramiko default
|
|
)
|
|
assert result.outcome == SubmitOutcome.SUBMITTED
|
|
assert len(fake_paramiko_sftp.put_calls) == 1
|