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).
152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
"""SP16 — Admin scheduler API endpoint tests.
|
|
|
|
The endpoints under /api/admin/scheduler/* are thin wrappers around
|
|
:class:`cyclone.scheduler.Scheduler`. These tests exercise them via
|
|
the FastAPI TestClient to confirm wiring (auth-free admin endpoints
|
|
work, response shapes match, idempotency holds).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def _stub_scheduler_env(tmp_path, monkeypatch):
|
|
"""Set up: a stub-mode SFTP block, scheduler configured.
|
|
|
|
Yields (staging_dir, scheduler_singleton). We deliberately do
|
|
NOT enable SQLCipher encryption in this fixture — the scheduler
|
|
doesn't care about encryption, and patching ``db_crypto.get_secret``
|
|
here would cause the lifespan handler to rebuild the engine with
|
|
SQLCipher on a plain-SQLite test file (which raises "file is not
|
|
a database"). The encryption-at-rest tests live in
|
|
``test_db_crypto.py``.
|
|
"""
|
|
from cyclone import db
|
|
from cyclone import scheduler as sched_mod
|
|
from cyclone.providers import SftpBlock
|
|
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
db._reset_for_tests()
|
|
|
|
staging = tmp_path / "staging"
|
|
inbound = staging / "FromHPE"
|
|
inbound.mkdir(parents=True)
|
|
sftp_block = 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"},
|
|
)
|
|
sched_mod.reset_scheduler_for_tests()
|
|
sched = sched_mod.configure_scheduler(sftp_block, sftp_block_name="t")
|
|
yield staging, sched
|
|
sched_mod.reset_scheduler_for_tests()
|
|
db._reset_for_tests()
|
|
|
|
|
|
def _drop_file(staging: Path, name: str, body: bytes) -> Path:
|
|
p = staging / "FromHPE" / name
|
|
p.write_bytes(body)
|
|
return p
|
|
|
|
|
|
def test_scheduler_status_starts_not_running(_stub_scheduler_env):
|
|
from fastapi.testclient import TestClient
|
|
from cyclone.api import app
|
|
_, sched = _stub_scheduler_env
|
|
with TestClient(app) as client:
|
|
r = client.get("/api/admin/scheduler/status")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["running"] is False
|
|
assert body["poll_interval_seconds"] == 60
|
|
assert body["sftp_block_name"] == "t"
|
|
|
|
|
|
def test_scheduler_start_then_status_then_stop(_stub_scheduler_env):
|
|
from fastapi.testclient import TestClient
|
|
from cyclone.api import app
|
|
with TestClient(app) as client:
|
|
r1 = client.post("/api/admin/scheduler/start")
|
|
assert r1.status_code == 200
|
|
assert r1.json()["status"]["running"] is True
|
|
|
|
r2 = client.get("/api/admin/scheduler/status")
|
|
assert r2.json()["running"] is True
|
|
|
|
r3 = client.post("/api/admin/scheduler/stop")
|
|
assert r3.status_code == 200
|
|
assert r3.json()["status"]["running"] is False
|
|
|
|
|
|
def test_scheduler_tick_processes_one_file(_stub_scheduler_env):
|
|
from fastapi.testclient import TestClient
|
|
from cyclone.api import app
|
|
staging, _ = _stub_scheduler_env
|
|
_drop_file(
|
|
staging,
|
|
"TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
(Path(__file__).parent / "fixtures" / "minimal_ta1.txt").read_bytes(),
|
|
)
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/admin/scheduler/tick")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["tick"]["files_seen"] == 1
|
|
assert body["tick"]["files_processed"] == 1
|
|
|
|
|
|
def test_scheduler_processed_files_lists_history(_stub_scheduler_env):
|
|
from fastapi.testclient import TestClient
|
|
from cyclone.api import app
|
|
staging, _ = _stub_scheduler_env
|
|
_drop_file(
|
|
staging,
|
|
"TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
(Path(__file__).parent / "fixtures" / "minimal_ta1.txt").read_bytes(),
|
|
)
|
|
with TestClient(app) as client:
|
|
client.post("/api/admin/scheduler/tick")
|
|
r = client.get("/api/admin/scheduler/processed-files")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["count"] == 1
|
|
f = body["files"][0]
|
|
assert f["status"] == "ok"
|
|
assert f["parser_used"] == "parse_ta1"
|
|
assert "TP11525703" in f["name"]
|
|
|
|
|
|
def test_scheduler_processed_files_filters_by_status(_stub_scheduler_env):
|
|
from fastapi.testclient import TestClient
|
|
from cyclone.api import app
|
|
staging, _ = _stub_scheduler_env
|
|
# Drop a file with a type Cyclone doesn't parse — gets recorded as
|
|
# "skipped".
|
|
_drop_file(
|
|
staging,
|
|
"TP11525703-837P_M019048402-20260618130000000-1of1_270.x12",
|
|
b"some bytes",
|
|
)
|
|
with TestClient(app) as client:
|
|
client.post("/api/admin/scheduler/tick")
|
|
r_all = client.get("/api/admin/scheduler/processed-files")
|
|
r_skipped = client.get(
|
|
"/api/admin/scheduler/processed-files?status=skipped",
|
|
)
|
|
r_ok = client.get(
|
|
"/api/admin/scheduler/processed-files?status=ok",
|
|
)
|
|
assert r_all.json()["count"] == 1
|
|
assert r_skipped.json()["count"] == 1
|
|
assert r_ok.json()["count"] == 0 |