feat(sp9): multi-payer, multi-NPI, SFTP stub for dzinesco/TOC

- providers table seeded with 3 NPIs (Montrose, Delta, Salida)
- payers + payer_configs (per-tx config) join table
- clearhouse singleton (dzinesco identity, SFTP block, filename block)
- config/payers.yaml loader with Pydantic schema validation
- cyclone/edi/filenames.py: HCPF X12 File Naming Standards helpers
- cyclone/clearhouse/sftp.py: stub that copies to ./var/sftp/staging/...
- cyclone/secrets.py: macOS Keychain wrapper via keyring
- 11 new validation rules R200-R210 (CO MAP + HCPF naming)
- 6 new API endpoints (/api/clearhouse/*, /api/config/*, /api/admin/reload-config)
- migration 0007
- 72 new tests (646 total passing, was 574)

See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
This commit is contained in:
Tyler
2026-06-20 23:07:31 -06:00
parent fbe9940a3f
commit c62826daea
23 changed files with 2869 additions and 6 deletions
+159
View File
@@ -0,0 +1,159 @@
"""SP9 — HCPF X12 File Naming Standards helper tests."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from cyclone.edi.filenames import (
ALLOWED_FILE_TYPES,
OUTBOUND_RE,
INBOUND_RE,
build_outbound_filename,
is_inbound_filename,
is_outbound_filename,
parse_inbound_filename,
)
from cyclone.providers import InboundFilename
MT = ZoneInfo("America/Denver")
# ----- build_outbound_filename --------------------------------------------
def test_build_outbound_with_explicit_mt():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.x12"
def test_build_outbound_default_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name.endswith(".x12")
def test_build_outbound_custom_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.txt"
def test_build_outbound_uses_mt_when_no_arg():
# Snapshot test — the timestamp will be very recent; check format only
name = build_outbound_filename("11525703", "837P")
assert OUTBOUND_RE.match(name), name
parts = name.split("-")
assert len(parts) == 4
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
def test_build_outbound_rejects_non_numeric_tpid():
now = datetime(2026, 6, 20, tzinfo=MT)
with pytest.raises(ValueError, match="tpid must be digits"):
build_outbound_filename("abc123", "837P", now_mt=now)
def test_build_outbound_rejects_invalid_tx():
now = datetime(2026, 6, 20, tzinfo=MT)
with pytest.raises(ValueError, match="tx must be uppercase alnum"):
build_outbound_filename("11525703", "837-lower", now_mt=now)
def test_build_outbound_rejects_naive_dt():
with pytest.raises(ValueError, match="timezone-aware"):
build_outbound_filename("11525703", "837P", now_mt=datetime(2026, 6, 20))
def test_build_outbound_converts_other_tz_to_mt():
# 2026-06-20 19:22:43 UTC = 2026-06-20 13:22:43 MT (during DST)
from datetime import timezone
now_utc = datetime(2026, 6, 20, 19, 22, 43, 505_000, tzinfo=timezone.utc)
name = build_outbound_filename("11525703", "837P", now_mt=now_utc)
assert "20260620132243505" in name
# ----- parse_inbound_filename ---------------------------------------------
def test_parse_inbound_999_real_prodfile():
# Real production 999 filename from docs/prodfiles/FromHPE/
name = "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
parsed = parse_inbound_filename(name)
assert parsed.tpid == "11525703"
assert parsed.orig_tx == "837P"
assert parsed.tracking == "M019048402"
assert parsed.ts == "20260520231513488"
assert parsed.file_type == "999"
assert parsed.ext == "x12"
def test_parse_inbound_ta1_real_prodfile():
name = "TP11525703-837P_M019044969-20260520180505477-1of1_TA1.x12"
parsed = parse_inbound_filename(name)
assert parsed.file_type == "TA1"
assert parsed.tracking == "M019044969"
def test_parse_inbound_277():
name = "TP11525703-837P_M019110219-20260601003507042-1of1_277.x12"
parsed = parse_inbound_filename(name)
assert parsed.file_type == "277"
def test_parse_inbound_rejects_missing_tp_prefix():
with pytest.raises(ValueError, match="Not a valid HCPF inbound"):
parse_inbound_filename("11525703-837P_M019048402-20260520231513488-1of1_999.x12")
def test_parse_inbound_rejects_wrong_segment_count():
with pytest.raises(ValueError):
parse_inbound_filename("TP11525703_837P_M019048402-1of1_999.x12")
def test_parse_inbound_rejects_unknown_file_type():
with pytest.raises(ValueError, match="not in allowed HCPF set"):
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_XXX.x12")
def test_parse_inbound_rejects_non_x12_ext():
with pytest.raises(ValueError):
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.txt")
# ----- round-trip ---------------------------------------------------------
def test_roundtrip_outbound_to_inbound():
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
# The two regexes use different shapes — round-trip via tpid only.
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
out = build_outbound_filename("11525703", "837P", now_mt=now)
assert OUTBOUND_RE.match(out)
assert "11525703" in out
assert "837P" in out
assert out.endswith("1of1.x12")
# ----- validators ---------------------------------------------------------
def test_is_outbound_filename():
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("not-a-filename")
def test_is_inbound_filename():
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
def test_allowed_file_types_includes_277ca():
assert "277CA" in ALLOWED_FILE_TYPES
assert "TA1" in ALLOWED_FILE_TYPES
assert "999" in ALLOWED_FILE_TYPES
assert "835" in ALLOWED_FILE_TYPES