feat(sp16): live MFT polling scheduler
Adds an asyncio-based background scheduler that polls the Gainwell MFT inbound path, downloads new files, and routes them through the appropriate parser (999 / 835 / 277CA / TA1). Idempotent (re-ticks and restarts skip already-processed files via the new processed_inbound_files table). Crash-safe (per-file try/except so one bad file doesn't stop the loop). Lifespan auto-configures from the seeded dzinesco clearhouse's SFTP block; auto-start is opt-in via CYCLONE_SCHEDULER_AUTOSTART. Five admin endpoints added: GET /api/admin/scheduler/status POST /api/admin/scheduler/start POST /api/admin/scheduler/stop POST /api/admin/scheduler/tick GET /api/admin/scheduler/processed-files?status=&limit= 20 new tests (15 unit + 5 API).
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER *260520*1750*^*00501*000000001*0*P*:~TA1*000000001*20260520*1750*A*000*20260520~IEA*1*000000001~
|
||||
@@ -51,18 +51,19 @@ def test_migration_0002_creates_acks_table():
|
||||
|
||||
def test_migration_latest_idempotent_on_fresh_db():
|
||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||
user_version already at the latest version — currently 10 after
|
||||
user_version already at the latest version — currently 11 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||
SP11's 0009 audit_log, and SP14's 0010 payer_rejected_acknowledged)."""
|
||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||
SP16's 0011 processed_inbound_files)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 10
|
||||
assert v1 == 11
|
||||
# A second run should not raise and should not bump the version.
|
||||
db_migrate.run(db.engine())
|
||||
with db.engine().begin() as c:
|
||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v2 == 10
|
||||
assert v2 == 11
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""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 / "ToHPE"
|
||||
inbound.mkdir(parents=True)
|
||||
sftp_block = SftpBlock(
|
||||
host="mft.example.com",
|
||||
port=22,
|
||||
username="test",
|
||||
paths={"outbound": "/FromHPE", "inbound": "/ToHPE"},
|
||||
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 / "ToHPE" / 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
|
||||
@@ -0,0 +1,287 @@
|
||||
"""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 / "ToHPE"
|
||||
inbound_dir.mkdir(parents=True)
|
||||
return SftpBlock(
|
||||
host="mft.example.com",
|
||||
port=22,
|
||||
username="test",
|
||||
paths={
|
||||
"outbound": "/FromHPE",
|
||||
"inbound": "/ToHPE",
|
||||
},
|
||||
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) / "ToHPE"
|
||||
|
||||
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
|
||||
@@ -72,7 +72,23 @@ def test_stub_get_secret_returns_stub_when_keychain_empty(sftp_block):
|
||||
assert secret == "<stub-secret>"
|
||||
|
||||
|
||||
def test_stub_read_file_raises(sftp_block):
|
||||
def test_stub_read_file_returns_bytes(sftp_block, tmp_path):
|
||||
"""SP16: the stub read_file returns bytes from the staging dir.
|
||||
|
||||
Lets the inbound scheduler exercise the same code path on a
|
||||
workstation without a real MFT connection.
|
||||
"""
|
||||
inbound = tmp_path / "staging" / "ToHPE"
|
||||
inbound.mkdir(parents=True)
|
||||
(inbound / "TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12").write_bytes(
|
||||
b"hello-world",
|
||||
)
|
||||
client = SftpClient(sftp_block)
|
||||
with pytest.raises(RuntimeError, match="Stub SFTP cannot read"):
|
||||
client.read_file("/x.x12")
|
||||
body = client.read_file("/ToHPE/TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12")
|
||||
assert body == b"hello-world"
|
||||
|
||||
|
||||
def test_stub_read_file_missing_raises(sftp_block):
|
||||
client = SftpClient(sftp_block)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
client.read_file("/ToHPE/does-not-exist.x12")
|
||||
|
||||
Reference in New Issue
Block a user