"""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