dd7da18279
The dzinesco SP9 seed had `paths.inbound` and `paths.outbound` mapped to
the wrong Gainwell MFT directories:
- paths.outbound was FromHPE/ (HPE sends files FROM here TO us — inbound)
- paths.inbound was ToHPE/ (we send files TO here — outbound)
So `/api/clearhouse/submit` was writing 837P claims to FromHPE (where
HPE puts acks/835s) and the SP16 scheduler was polling ToHPE (where our
claims go). 999 / TA1 / 835 files in the real FromHPE inbox were
unreachable.
Semantics per operator (2026-06-24):
- FromHPE = HPE/Gainwell → us = 999, TA1, 835 (inbound)
- ToHPE = us → HPE/Gainwell = 837P claims (outbound)
**Runtime code (the actual fix):**
- backend/src/cyclone/store.py — SP9 seed paths flipped
- backend/src/cyclone/edi/filenames.py — docstring corrected
**Test fixtures + assertions (would otherwise fail on the new seed):**
- backend/tests/test_clearhouse_api.py
- backend/tests/test_providers_seed.py
- backend/tests/test_sftp_stub.py — incl. inbound dir paths
- backend/tests/test_sftp_paramiko.py
- backend/tests/test_store_update_clearhouse.py
- backend/tests/test_api_clearhouse_patch.py
- backend/tests/test_scheduler.py
- backend/tests/test_api_scheduler.py
**Docs (text-only — keeps the codebase self-consistent):**
- README.md
- docs/reference/co-medicaid.md
- docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
**Operator action required after merge:** the existing clearhouse row in
`~/.local/share/cyclone/cyclone.db` was seeded with the old wrong
paths. Easiest recovery:
1. `rm ~/.local/share/cyclone/cyclone.db` and let `ensure_clearhouse_seeded` re-run on next boot, OR
2. PATCH /api/clearhouse with the new `paths` block (the SP25
reconfigure hook picks it up live, including by the running scheduler).
**Verification:**
- 82 tests in the affected files pass (test_clearhouse_api, test_providers_seed,
test_sftp_stub, test_sftp_paramiko, test_store_update_clearhouse,
test_api_clearhouse_patch, test_scheduler, test_api_scheduler, test_filenames).
- Full backend suite: 1029 pass + 36 pre-existing order-dependent flakes
unrelated to this change (verified by running the same tests in isolation).
287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""SP16 — Inbound MFT polling scheduler tests.
|
|
|
|
We test the Scheduler class with a fake ``SftpClient`` factory that
|
|
returns files we drop on disk (the SFTP stub already does this; we
|
|
just need to control which files appear between ticks). The handlers
|
|
themselves (999/835/277CA/TA1) are exercised through real parsers
|
|
using the fixtures in ``tests/fixtures/``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
import pytest
|
|
|
|
from cyclone import db, scheduler as sched_mod
|
|
from cyclone.db import ProcessedInboundFile
|
|
from cyclone.providers import SftpBlock
|
|
from cyclone.scheduler import (
|
|
HANDLERS,
|
|
ROUTED_FILE_TYPES,
|
|
Scheduler,
|
|
STATUS_ERROR,
|
|
STATUS_OK,
|
|
STATUS_SKIPPED,
|
|
TickResult,
|
|
)
|
|
|
|
|
|
# ----- fixtures -----------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def sftp_block(tmp_path):
|
|
staging = tmp_path / "staging"
|
|
inbound_dir = staging / "FromHPE"
|
|
inbound_dir.mkdir(parents=True)
|
|
return SftpBlock(
|
|
host="mft.example.com",
|
|
port=22,
|
|
username="test",
|
|
paths={
|
|
"outbound": "/ToHPE",
|
|
"inbound": "/FromHPE",
|
|
},
|
|
stub=True,
|
|
staging_dir=str(staging),
|
|
poll_seconds=60,
|
|
auth={"method": "keychain", "secret_ref": "test.password"},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def _drop_file(sftp_block):
|
|
"""Helper: drop a named file in the inbound dir. Returns the path."""
|
|
inbound_dir = Path(sftp_block.staging_dir) / "FromHPE"
|
|
|
|
def _drop(name: str, body: bytes) -> Path:
|
|
inbound_dir.mkdir(parents=True, exist_ok=True)
|
|
p = inbound_dir / name
|
|
p.write_bytes(body)
|
|
return p
|
|
|
|
return _drop
|
|
|
|
|
|
def _make_scheduler(sftp_block, tmp_path) -> Scheduler:
|
|
"""Build a Scheduler wired to the real (stub) SftpClient."""
|
|
sched = Scheduler(
|
|
sftp_block,
|
|
poll_interval_seconds=60,
|
|
sftp_block_name="test-block",
|
|
# Use the real SftpClient — it reads from the stub staging dir.
|
|
sftp_client_factory=None,
|
|
)
|
|
return sched
|
|
|
|
|
|
def _load_999_text() -> str:
|
|
return (Path(__file__).parent / "fixtures" / "minimal_999.txt").read_text()
|
|
|
|
|
|
def _load_835_text() -> str:
|
|
return (Path(__file__).parent / "fixtures" / "minimal_835.txt").read_text()
|
|
|
|
|
|
def _load_277ca_text() -> str:
|
|
return (Path(__file__).parent / "fixtures" / "minimal_277ca.txt").read_text()
|
|
|
|
|
|
def _load_ta1_text() -> str:
|
|
return (Path(__file__).parent / "fixtures" / "minimal_ta1.txt").read_text()
|
|
|
|
|
|
# ----- tests --------------------------------------------------------------
|
|
|
|
|
|
class TestSchedulerStatus:
|
|
def test_not_running_by_default(self, sftp_block):
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
st = sched.status()
|
|
assert st.running is False
|
|
assert st.poll_count == 0
|
|
assert st.last_poll_at is None
|
|
|
|
def test_running_after_start(self, sftp_block):
|
|
async def _go():
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
await sched.start()
|
|
try:
|
|
assert sched.is_running() is True
|
|
finally:
|
|
await sched.stop()
|
|
asyncio.run(_go())
|
|
|
|
|
|
class TestTickOnEmptyInbox:
|
|
def test_tick_with_no_files_records_zero(self, sftp_block):
|
|
async def _go():
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
result = await sched.tick()
|
|
assert isinstance(result, TickResult)
|
|
assert result.files_seen == 0
|
|
assert result.files_processed == 0
|
|
assert result.files_skipped == 0
|
|
assert result.files_errored == 0
|
|
assert result.finished_at is not None
|
|
assert result.errors == []
|
|
asyncio.run(_go())
|
|
|
|
|
|
class TestTickRoutesFiles:
|
|
def test_999_file_processed(self, sftp_block, _drop_file):
|
|
async def _go():
|
|
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_999.x12",
|
|
_load_999_text().encode("utf-8"))
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
result = await sched.tick()
|
|
assert result.files_seen == 1
|
|
assert result.files_processed == 1
|
|
assert result.files_errored == 0
|
|
|
|
with db.SessionLocal()() as session:
|
|
rows = (
|
|
session.query(ProcessedInboundFile)
|
|
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_999.x12")
|
|
.all()
|
|
)
|
|
assert len(rows) == 1
|
|
assert rows[0].status == STATUS_OK
|
|
assert rows[0].parser_used == "parse_999"
|
|
asyncio.run(_go())
|
|
|
|
def test_ta1_file_processed(self, sftp_block, _drop_file):
|
|
async def _go():
|
|
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
_load_ta1_text().encode("utf-8"))
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
result = await sched.tick()
|
|
assert result.files_processed == 1, result.errors
|
|
|
|
with db.SessionLocal()() as session:
|
|
row = (
|
|
session.query(ProcessedInboundFile)
|
|
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12")
|
|
.first()
|
|
)
|
|
assert row is not None
|
|
assert row.status == STATUS_OK
|
|
assert row.parser_used == "parse_ta1"
|
|
asyncio.run(_go())
|
|
|
|
def test_unknown_file_type_marked_skipped(self, sftp_block, _drop_file):
|
|
async def _go():
|
|
# 270 (eligibility request) is in the HCPF allowed set but
|
|
# NOT in ROUTED_FILE_TYPES — Cyclone doesn't have a 270 parser.
|
|
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_270.x12",
|
|
b"some bytes")
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
result = await sched.tick()
|
|
assert result.files_skipped == 1
|
|
assert result.files_processed == 0
|
|
|
|
with db.SessionLocal()() as session:
|
|
row = (
|
|
session.query(ProcessedInboundFile)
|
|
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_270.x12")
|
|
.first()
|
|
)
|
|
assert row is not None
|
|
assert row.status == STATUS_SKIPPED
|
|
assert row.error_message and "270" in row.error_message
|
|
asyncio.run(_go())
|
|
|
|
def test_filename_not_matching_hcpf_marked_skipped(self, sftp_block, _drop_file):
|
|
async def _go():
|
|
_drop_file("random.txt", b"garbage")
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
result = await sched.tick()
|
|
assert result.files_skipped == 1
|
|
asyncio.run(_go())
|
|
|
|
def test_parse_error_marked_error(self, sftp_block, _drop_file):
|
|
async def _go():
|
|
# Valid filename but malformed body — parser raises.
|
|
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_999.x12",
|
|
b"this is not a 999 file at all")
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
result = await sched.tick()
|
|
assert result.files_errored == 1
|
|
assert result.files_processed == 0
|
|
|
|
with db.SessionLocal()() as session:
|
|
row = (
|
|
session.query(ProcessedInboundFile)
|
|
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_999.x12")
|
|
.first()
|
|
)
|
|
assert row.status == STATUS_ERROR
|
|
assert row.error_message
|
|
asyncio.run(_go())
|
|
|
|
|
|
class TestTickIdempotent:
|
|
def test_second_tick_does_not_reprocess(self, sftp_block, _drop_file):
|
|
async def _go():
|
|
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
_load_ta1_text().encode("utf-8"))
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
r1 = await sched.tick()
|
|
r2 = await sched.tick()
|
|
assert r1.files_processed == 1
|
|
assert r2.files_seen == 1 # still lists it
|
|
assert r2.files_processed == 0 # but skips — already done
|
|
asyncio.run(_go())
|
|
|
|
|
|
class TestSchedulerStartStop:
|
|
def test_start_then_stop_returns_to_not_running(self, sftp_block):
|
|
async def _go():
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
await sched.start()
|
|
assert sched.is_running()
|
|
await sched.stop()
|
|
assert not sched.is_running()
|
|
asyncio.run(_go())
|
|
|
|
def test_double_start_is_idempotent(self, sftp_block):
|
|
async def _go():
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
await sched.start()
|
|
await sched.start() # no-op
|
|
assert sched.is_running()
|
|
await sched.stop()
|
|
asyncio.run(_go())
|
|
|
|
def test_stop_when_not_running_is_safe(self, sftp_block):
|
|
async def _go():
|
|
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
await sched.stop() # no-op
|
|
asyncio.run(_go())
|
|
|
|
|
|
class TestModuleSingleton:
|
|
def test_get_scheduler_raises_if_not_configured(self):
|
|
sched_mod.reset_scheduler_for_tests()
|
|
with pytest.raises(RuntimeError, match="not configured"):
|
|
sched_mod.get_scheduler()
|
|
|
|
def test_configure_then_get_returns_same_instance(self, sftp_block):
|
|
sched_mod.reset_scheduler_for_tests()
|
|
s = sched_mod.configure_scheduler(sftp_block, sftp_block_name="t")
|
|
try:
|
|
assert sched_mod.get_scheduler() is s
|
|
finally:
|
|
sched_mod.reset_scheduler_for_tests()
|
|
|
|
|
|
class TestRoutedFileTypes:
|
|
"""Frozen-set guards — adding a new routed type without updating
|
|
the dispatch table would silently skip every inbound file of that
|
|
type. These tests are the regression net."""
|
|
|
|
def test_handlers_cover_all_routed_types(self):
|
|
assert set(HANDLERS.keys()) == ROUTED_FILE_TYPES |