40f184c858
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).
95 lines
3.2 KiB
Python
95 lines
3.2 KiB
Python
"""SP9 — SFTP stub tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cyclone.clearhouse import SftpClient
|
|
from cyclone.providers import SftpBlock
|
|
|
|
|
|
@pytest.fixture
|
|
def sftp_block(tmp_path):
|
|
staging = tmp_path / "staging"
|
|
return SftpBlock(
|
|
host="mft.gainwelltechnologies.com",
|
|
port=22,
|
|
username="colorado-fts\\coxix_prod_11525703",
|
|
paths={
|
|
"outbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
|
|
"inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
|
|
},
|
|
stub=True,
|
|
staging_dir=str(staging),
|
|
poll_seconds=300,
|
|
auth={"method": "keychain", "secret_ref": "sftp.gainwell.password"},
|
|
)
|
|
|
|
|
|
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
|
client = SftpClient(sftp_block)
|
|
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
|
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
|
|
assert target.exists()
|
|
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
|
# Confirm the full nested MFT path is preserved under staging
|
|
rel = target.relative_to(sftp_block.staging_dir)
|
|
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
|
|
|
|
|
def test_stub_creates_parent_dirs(sftp_block):
|
|
client = SftpClient(sftp_block)
|
|
remote = "/deep/nested/path/file.x12"
|
|
target = client.write_file(remote, b"data")
|
|
assert target.exists()
|
|
assert target.parent.is_dir()
|
|
|
|
|
|
def test_stub_list_inbound_empty_when_no_local_files(sftp_block):
|
|
client = SftpClient(sftp_block)
|
|
assert client.list_inbound() == []
|
|
|
|
|
|
def test_stub_list_inbound_returns_local_files(sftp_block):
|
|
# Simulate operator dropping a file in the inbound staging dir
|
|
inbound_dir = Path(sftp_block.staging_dir) / "CO XIX/PROD/coxix_prod_11525703/ToHPE"
|
|
inbound_dir.mkdir(parents=True, exist_ok=True)
|
|
(inbound_dir / "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12").write_bytes(b"X")
|
|
client = SftpClient(sftp_block)
|
|
files = client.list_inbound()
|
|
assert len(files) == 1
|
|
assert files[0].name.startswith("TP11525703-837P_M019048402")
|
|
assert files[0].size == 1
|
|
|
|
|
|
def test_stub_get_secret_returns_stub_when_keychain_empty(sftp_block):
|
|
client = SftpClient(sftp_block)
|
|
# Keychain is empty on the test box, so we get the stub sentinel
|
|
secret = client.get_secret("sftp.gainwell.password")
|
|
assert secret == "<stub-secret>"
|
|
|
|
|
|
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)
|
|
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")
|