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:
@@ -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
|
||||
Reference in New Issue
Block a user