fix(sp37): submit_file default factory uses paramiko directly
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.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""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.
|
||||
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
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
|
||||
@@ -48,14 +47,6 @@ class _FakeSftp:
|
||||
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()
|
||||
@@ -79,8 +70,14 @@ def test_submit_file_happy_path():
|
||||
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."""
|
||||
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
|
||||
@@ -137,4 +134,62 @@ def test_submit_file_sftp_fail():
|
||||
# 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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user