feat(sp41): wire claim-id dedup into submit_file pre-flight
Inserts a per-claim-id check_duplicate() walk into submit_file between
the payer-mismatch guard and the BatchRecord837 DB write (Task 9).
If any CLM01 in the parsed 837P file was submitted within the 30-day
window recorded in the submission_dedup table, submit_file raises
DuplicateClaimError before any DB write or SFTP upload. The DB-first,
upload-second invariant is preserved: a duplicate blocks both the
BatchRecord837 write AND the paramiko upload.
The api_routers/submission.submit_batch per-file loop special-cases
DuplicateClaimError BEFORE the generic Exception handler so the
200-with-results status-code contract documented at the top of the
module is preserved (per-file failures stay in the body). The
exception remains 409-class by design — a future caller that
propagates it past the per-file loop will surface 409 at the FastAPI
layer.
Approach A per the Task 9 spec (raise, don't return a SubmitResult).
Tests:
- test_submission_dedup.test_submit_file_raises_on_duplicate_claim_id
pre-populates the dedup table via record_submission('CLM001', ...),
calls submit_file against minimal_837p.txt, expects DuplicateClaimError
with structured claim_id / original_submission_at attributes, and
pins the DB-write invariant (no Batch row landed) plus an
AssertionError sentinel on the SFTP factory.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
"""Claim-id dedup at the SFTP pre-flight stage."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.submission.core import submit_file
|
||||
from cyclone.store.exceptions import DuplicateClaimError
|
||||
from cyclone.store.submission_dedup import (
|
||||
record_submission,
|
||||
@@ -7,6 +13,12 @@ from cyclone.store.submission_dedup import (
|
||||
DEFAULT_WINDOW_DAYS,
|
||||
)
|
||||
|
||||
# Fixture file with a single CLM01 = "CLM001" claim (the minimal_837p
|
||||
# fixture is the source of truth; duplicated into submit-batch/single-claim.x12
|
||||
# but both byte-equal so we pick the canonical minimal path here).
|
||||
_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
|
||||
_FIXTURE_CLAIM_ID = "CLM001"
|
||||
|
||||
|
||||
def test_check_duplicate_returns_none_for_first_submission(tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
@@ -36,3 +48,73 @@ def test_check_duplicate_passes_after_window(tmp_path):
|
||||
|
||||
def test_default_window_is_30_days():
|
||||
assert DEFAULT_WINDOW_DAYS == 30
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SP41 Task 9 integration: dedup guard wired into submit_file pre-flight.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_submit_file_raises_on_duplicate_claim_id(tmp_path):
|
||||
"""``submit_file`` raises ``DuplicateClaimError`` if any CLM01 in the
|
||||
file was submitted within the 30-day dedup window, and the DB write
|
||||
+ SFTP factory are both skipped.
|
||||
|
||||
Mirrors the DB-first, upload-second invariant from ``submit_file``:
|
||||
the dedup check sits between the payer-mismatch guard and the
|
||||
BatchRecord837 DB write, so a duplicate blocks both the DB write
|
||||
AND the SFTP upload. This test pins both invariants.
|
||||
|
||||
The conftest autouse fixture wires a fresh per-test SQLite DB at
|
||||
``tmp_path/test.db`` via the ``CYCLONE_DB_URL`` env var (so
|
||||
``cycl_db.SessionLocal()`` points there) — same engine the Batch
|
||||
write would use, same engine the dedup helper reads.
|
||||
"""
|
||||
# 1. Pre-populate the dedup table with the claim_id from the
|
||||
# fixture at a timestamp within the 30-day window. ``db_url``
|
||||
# is ignored inside the helper (signature-preserving shim) but
|
||||
# the argument is required by the current Task 8 signature, so
|
||||
# we pass the per-test DB URL for clarity.
|
||||
record_submission(
|
||||
_FIXTURE_CLAIM_ID,
|
||||
submitted_at=datetime.now(timezone.utc),
|
||||
db_url=f"sqlite:///{tmp_path}/test.db",
|
||||
)
|
||||
|
||||
# 2. Sentinel: if submit_file reaches the SFTP factory call,
|
||||
# fail loudly. ``submit_file`` raises DuplicateClaimError
|
||||
# BEFORE the factory is invoked, so the AssertionError here
|
||||
# only fires if the dedup guard is bypassed (regression).
|
||||
def _sftp_factory(_block): # pragma: no cover - regression only
|
||||
raise AssertionError(
|
||||
"SFTP factory must not be called when dedup blocks submit_file"
|
||||
)
|
||||
|
||||
sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
|
||||
|
||||
# 3. Expect the 409-class domain exception with structured
|
||||
# ``claim_id`` / ``original_submission_at`` attributes so the
|
||||
# operator can see which CLM01 tripped the guard.
|
||||
with pytest.raises(DuplicateClaimError) as exc_info:
|
||||
submit_file(
|
||||
_FIXTURE,
|
||||
sftp_block=sftp_block,
|
||||
actor="test",
|
||||
validate=True,
|
||||
sftp_client_factory=_sftp_factory,
|
||||
)
|
||||
assert exc_info.value.claim_id == _FIXTURE_CLAIM_ID
|
||||
assert exc_info.value.original_submission_at is not None
|
||||
|
||||
# 4. DB invariant: no Batch row was written (dedup blocks the
|
||||
# DB write, not just the SFTP upload). The autouse fixture
|
||||
# points cycl_db at tmp_path/test.db, so a fresh
|
||||
# ``db_mod.SessionLocal()()`` re-opens it.
|
||||
from cyclone import db as db_mod
|
||||
|
||||
with db_mod.SessionLocal()() as session:
|
||||
batch_count = session.query(db_mod.Batch).count()
|
||||
assert batch_count == 0, (
|
||||
"dedup guard must block the BatchRecord837 DB write; "
|
||||
f"found {batch_count} Batch row(s)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user