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:
@@ -51,17 +51,17 @@ 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 5 after the
|
||||
0004 rejection columns + state-history index (SP6) and the 0005
|
||||
ta1_acks table (this PR)."""
|
||||
user_version already at the latest version — currently 7 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, and SP9's 0007
|
||||
providers/payers/clearhouse)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 6
|
||||
assert v1 == 7
|
||||
# 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 == 6
|
||||
assert v2 == 7
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""SP9 — clearhouse API endpoint tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db as db_mod
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
db_mod._reset_for_tests()
|
||||
db_mod.init_db()
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_get_clearhouse_seeded(client):
|
||||
# Lifespan runs ensure_clearhouse_seeded()
|
||||
r = client.get("/api/clearhouse")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "dzinesco"
|
||||
assert body["tpid"] == "11525703"
|
||||
assert body["sftp_block"]["stub"] is True
|
||||
assert "FromHPE" in body["sftp_block"]["paths"]["outbound"]
|
||||
assert "ToHPE" in body["sftp_block"]["paths"]["inbound"]
|
||||
|
||||
|
||||
def test_list_providers(client):
|
||||
r = client.get("/api/config/providers")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert {p["label"] for p in body} == {"Montrose", "Delta", "Salida"}
|
||||
assert {p["npi"] for p in body} == {"1881068062", "1851446637", "1467507269"}
|
||||
|
||||
|
||||
def test_get_provider_by_npi(client):
|
||||
r = client.get("/api/config/providers/1881068062")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["label"] == "Montrose"
|
||||
|
||||
|
||||
def test_get_provider_404(client):
|
||||
r = client.get("/api/config/providers/9999999999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_list_payers(client):
|
||||
r = client.get("/api/config/payers")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["payer_id"] == "CO_TXIX"
|
||||
|
||||
|
||||
def test_list_payer_configs_for_co_txix(client):
|
||||
r = client.get("/api/config/payers/CO_TXIX/configs")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# The lifespan loads config/payers.yaml; configs come from the registry
|
||||
tx_types = {c["transaction_type"] for c in body}
|
||||
assert "837P" in tx_types or "835" in tx_types
|
||||
|
||||
|
||||
def test_reload_config(client):
|
||||
r = client.post("/api/admin/reload-config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert isinstance(body["loaded"], int)
|
||||
|
||||
|
||||
def test_submit_clearhouse_rejects_empty_claim_ids(client):
|
||||
r = client.post("/api/clearhouse/submit", json={"claim_ids": [], "payer_id": "CO_TXIX"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_submit_clearhouse_rejects_missing_payer_id(client):
|
||||
r = client.post("/api/clearhouse/submit", json={"claim_ids": ["X"]})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_submit_clearhouse_handles_unknown_claim_id(client):
|
||||
r = client.post("/api/clearhouse/submit", json={
|
||||
"claim_ids": ["DOES_NOT_EXIST"],
|
||||
"payer_id": "CO_TXIX",
|
||||
})
|
||||
# 200 with per-claim ok:false entries (graceful degradation)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["stub"] is True
|
||||
assert len(body["submitted"]) == 1
|
||||
assert body["submitted"][0]["ok"] is False
|
||||
assert "not found" in body["submitted"][0]["error"] or "cannot be re-serialized" in body["submitted"][0]["error"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,113 @@
|
||||
"""SP9 — payer config YAML loader tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from cyclone import payers
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
payers.reset()
|
||||
yield
|
||||
payers.reset()
|
||||
|
||||
|
||||
def test_load_default_config_has_co_txix_837p():
|
||||
configs = payers.load_payer_configs()
|
||||
assert ("CO_TXIX", "837P") in configs
|
||||
block = configs[("CO_TXIX", "837P")]
|
||||
assert block["payer_id"] == "CO_TXIX"
|
||||
assert "CH" in block["bht06_allowed"]
|
||||
assert "RP" in block["bht06_allowed"]
|
||||
assert block["bht06_default"] == "CH"
|
||||
assert block["sbr09_default"] == "MC"
|
||||
|
||||
|
||||
def test_load_default_config_has_co_txix_835():
|
||||
configs = payers.load_payer_configs()
|
||||
assert ("CO_TXIX", "835") in configs
|
||||
block = configs[("CO_TXIX", "835")]
|
||||
assert "1811725341" in block["expected_payer_tax_ids"]
|
||||
assert block["expected_payer_health_plan_id"] == "7912900843"
|
||||
|
||||
|
||||
def test_get_config_returns_block():
|
||||
payers.load_payer_configs()
|
||||
block = payers.get_config("CO_TXIX", "837P")
|
||||
assert block is not None
|
||||
assert block["submitter_name"] == "Dzinesco"
|
||||
|
||||
|
||||
def test_get_config_returns_none_for_missing():
|
||||
payers.load_payer_configs()
|
||||
assert payers.get_config("UNKNOWN_PAYER", "837P") is None
|
||||
|
||||
|
||||
def test_load_invalid_yaml_raises(tmp_path):
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text(yaml.safe_dump({"payers": [
|
||||
{"payer_id": "X", "configs": {"837P": {"submitter_name": "ok"}}} # missing required keys
|
||||
]}))
|
||||
with pytest.raises(ValueError, match="invalid config"):
|
||||
payers.load_payer_configs(bad)
|
||||
|
||||
|
||||
def test_load_missing_payers_key_raises(tmp_path):
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text("not_a_payers_key: 1")
|
||||
with pytest.raises(ValueError, match="missing top-level 'payers:'"):
|
||||
payers.load_payer_configs(bad)
|
||||
|
||||
|
||||
def test_load_missing_file_warns_and_clears(tmp_path, caplog):
|
||||
fake = tmp_path / "does-not-exist.yaml"
|
||||
with caplog.at_level("WARNING"):
|
||||
result = payers.load_payer_configs(fake)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_all_configs_returns_snapshot():
|
||||
payers.load_payer_configs()
|
||||
snap = payers.all_configs()
|
||||
assert isinstance(snap, dict)
|
||||
assert len(snap) >= 2
|
||||
# Mutating snapshot must not affect registry
|
||||
snap.clear()
|
||||
assert len(payers.all_configs()) >= 2
|
||||
|
||||
|
||||
def test_reload_picks_up_changes(tmp_path):
|
||||
a = tmp_path / "a.yaml"
|
||||
a.write_text(yaml.safe_dump({"payers": [
|
||||
{"payer_id": "AAA", "configs": {"837P": {
|
||||
"submitter_name": "A", "submitter_contact_name": "A",
|
||||
"submitter_contact_email": "a@a",
|
||||
"receiver_name": "R", "receiver_id": "R",
|
||||
"bht06_allowed": ["CH"], "bht06_default": "CH",
|
||||
"sbr09_default": "MC", "sbr09_allowed": ["MC"],
|
||||
"payer_id_qualifier": "PI", "payer_id": "AAA",
|
||||
"pwk_supported": False, "cas_2320_group_allowed": False,
|
||||
}}}
|
||||
]}))
|
||||
b = tmp_path / "b.yaml"
|
||||
b.write_text(yaml.safe_dump({"payers": [
|
||||
{"payer_id": "BBB", "configs": {"837P": {
|
||||
"submitter_name": "B", "submitter_contact_name": "B",
|
||||
"submitter_contact_email": "b@b",
|
||||
"receiver_name": "R", "receiver_id": "R",
|
||||
"bht06_allowed": ["CH"], "bht06_default": "CH",
|
||||
"sbr09_default": "MC", "sbr09_allowed": ["MC"],
|
||||
"payer_id_qualifier": "PI", "payer_id": "BBB",
|
||||
"pwk_supported": False, "cas_2320_group_allowed": False,
|
||||
}}}
|
||||
]}))
|
||||
payers.load_payer_configs(a)
|
||||
assert payers.get_config("AAA", "837P") is not None
|
||||
payers.load_payer_configs(b)
|
||||
assert payers.get_config("BBB", "837P") is not None
|
||||
assert payers.get_config("AAA", "837P") is None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""SP9 — providers/payers/clearhouse seed + CRUD tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db as db_mod
|
||||
from cyclone.providers import Provider
|
||||
from cyclone.store import store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db(tmp_path, monkeypatch):
|
||||
"""Use a fresh in-memory SQLite for each test."""
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
db_mod._reset_for_tests()
|
||||
db_mod.init_db()
|
||||
yield
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_seed_creates_3_providers():
|
||||
store.ensure_clearhouse_seeded()
|
||||
providers = store.list_providers()
|
||||
labels = {p.label for p in providers}
|
||||
assert labels == {"Montrose", "Delta", "Salida"}
|
||||
npis = {p.npi for p in providers}
|
||||
assert npis == {"1881068062", "1851446637", "1467507269"}
|
||||
|
||||
|
||||
def test_seed_uses_consistent_tax_id_and_taxonomy():
|
||||
store.ensure_clearhouse_seeded()
|
||||
providers = store.list_providers()
|
||||
for p in providers:
|
||||
assert p.tax_id == "721587149"
|
||||
assert p.taxonomy_code == "251E00000X"
|
||||
assert p.legal_name == "TOC, Inc."
|
||||
assert p.address_line1 == "1100 East Main St"
|
||||
assert p.address_line2 == "Suite A"
|
||||
assert p.city == "Montrose"
|
||||
assert p.state == "CO"
|
||||
assert p.zip == "814014063"
|
||||
|
||||
|
||||
def test_seed_creates_clearhouse_singleton():
|
||||
store.ensure_clearhouse_seeded()
|
||||
ch = store.get_clearhouse()
|
||||
assert ch is not None
|
||||
assert ch.id == 1
|
||||
assert ch.name == "dzinesco"
|
||||
assert ch.tpid == "11525703"
|
||||
assert ch.submitter_name == "Dzinesco"
|
||||
assert ch.sftp_block.host == "mft.gainwelltechnologies.com"
|
||||
assert ch.sftp_block.stub is True
|
||||
assert "FromHPE" in ch.sftp_block.paths["outbound"]
|
||||
assert "ToHPE" in ch.sftp_block.paths["inbound"]
|
||||
|
||||
|
||||
def test_seed_creates_co_txix_payer_with_both_configs():
|
||||
store.ensure_clearhouse_seeded()
|
||||
payers = store.list_payers()
|
||||
assert {p.payer_id for p in payers} == {"CO_TXIX"}
|
||||
p837 = store.get_payer_config("CO_TXIX", "837P")
|
||||
p835 = store.get_payer_config("CO_TXIX", "835")
|
||||
assert p837 is not None and p837["payer_id"] == "CO_TXIX"
|
||||
assert p835 is not None and "1811725341" in p835["expected_payer_tax_ids"]
|
||||
|
||||
|
||||
def test_seed_is_idempotent():
|
||||
store.ensure_clearhouse_seeded()
|
||||
store.ensure_clearhouse_seeded()
|
||||
store.ensure_clearhouse_seeded()
|
||||
assert len(store.list_providers()) == 3
|
||||
assert len(store.list_payers()) == 1
|
||||
assert store.get_clearhouse() is not None
|
||||
|
||||
|
||||
def test_get_provider_returns_none_for_unknown_npi():
|
||||
store.ensure_clearhouse_seeded()
|
||||
assert store.get_provider("9999999999") is None
|
||||
|
||||
|
||||
def test_upsert_provider_creates_then_updates():
|
||||
store.ensure_clearhouse_seeded()
|
||||
p = Provider(
|
||||
npi="1111111111",
|
||||
label="Test",
|
||||
legal_name="Test, Inc.",
|
||||
tax_id="123456789",
|
||||
taxonomy_code="207R00000X",
|
||||
address_line1="1 Test Way",
|
||||
city="Testville",
|
||||
state="CO",
|
||||
zip="80000",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
stored = store.upsert_provider(p)
|
||||
assert stored.label == "Test"
|
||||
p2 = p.model_copy(update={"label": "Updated"})
|
||||
stored2 = store.upsert_provider(p2)
|
||||
assert stored2.label == "Updated"
|
||||
assert len(store.list_providers(is_active=None)) == 4
|
||||
|
||||
|
||||
def test_list_providers_filter_is_active():
|
||||
store.ensure_clearhouse_seeded()
|
||||
p = Provider(
|
||||
npi="2222222222",
|
||||
label="Inactive",
|
||||
legal_name="X",
|
||||
tax_id="1",
|
||||
taxonomy_code="X",
|
||||
address_line1="X",
|
||||
city="X",
|
||||
state="CO",
|
||||
zip="0",
|
||||
is_active=False,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
store.upsert_provider(p)
|
||||
active = store.list_providers(is_active=True)
|
||||
assert {pp.label for pp in active} == {"Montrose", "Delta", "Salida"}
|
||||
all_p = store.list_providers(is_active=None)
|
||||
assert {pp.label for pp in all_p} == {"Montrose", "Delta", "Salida", "Inactive"}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""SP9 — macOS Keychain secret accessor tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import secrets
|
||||
from cyclone.secrets import STUB_SECRET, get_secret, has_keyring, set_secret
|
||||
|
||||
|
||||
def test_has_keyring_true_when_lib_present():
|
||||
# The test env has keyring installed
|
||||
assert has_keyring() is True
|
||||
|
||||
|
||||
def test_get_secret_returns_none_when_keyring_missing(monkeypatch):
|
||||
monkeypatch.setattr(secrets, "_HAS_KEYRING", False)
|
||||
monkeypatch.setattr(secrets, "keyring", None)
|
||||
assert get_secret("anything") is None
|
||||
|
||||
|
||||
def test_get_secret_returns_keychain_value():
|
||||
with patch("cyclone.secrets.keyring") as mock_kr:
|
||||
mock_kr.get_password.return_value = "p@ssw0rd"
|
||||
v = get_secret("sftp.gainwell.password")
|
||||
assert v == "p@ssw0rd"
|
||||
mock_kr.get_password.assert_called_once_with("cyclone", "sftp.gainwell.password")
|
||||
|
||||
|
||||
def test_get_secret_returns_none_on_keychain_exception():
|
||||
with patch("cyclone.secrets.keyring") as mock_kr:
|
||||
mock_kr.get_password.side_effect = RuntimeError("keychain locked")
|
||||
v = get_secret("x")
|
||||
assert v is None
|
||||
|
||||
|
||||
def test_set_secret_returns_true_on_success():
|
||||
with patch("cyclone.secrets.keyring") as mock_kr:
|
||||
assert set_secret("a", "b") is True
|
||||
mock_kr.set_password.assert_called_once_with("cyclone", "a", "b")
|
||||
|
||||
|
||||
def test_set_secret_returns_false_when_keyring_missing(monkeypatch):
|
||||
monkeypatch.setattr(secrets, "_HAS_KEYRING", False)
|
||||
monkeypatch.setattr(secrets, "keyring", None)
|
||||
assert set_secret("a", "b") is False
|
||||
|
||||
|
||||
def test_set_secret_returns_false_on_keychain_exception():
|
||||
with patch("cyclone.secrets.keyring") as mock_kr:
|
||||
mock_kr.set_password.side_effect = RuntimeError("denied")
|
||||
assert set_secret("a", "b") is False
|
||||
|
||||
|
||||
def test_stub_secret_is_distinct_string():
|
||||
assert STUB_SECRET == "<stub-secret>"
|
||||
assert STUB_SECRET != ""
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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_real_mode_write_raises_not_implemented(sftp_block):
|
||||
block = sftp_block.model_copy(update={"stub": False})
|
||||
client = SftpClient(block)
|
||||
with pytest.raises(NotImplementedError, match="SP13"):
|
||||
client.write_file("/x.x12", b"y")
|
||||
|
||||
|
||||
def test_real_mode_list_inbound_raises_not_implemented(sftp_block):
|
||||
block = sftp_block.model_copy(update={"stub": False})
|
||||
client = SftpClient(block)
|
||||
with pytest.raises(NotImplementedError, match="SP13"):
|
||||
client.list_inbound()
|
||||
|
||||
|
||||
def test_stub_read_file_raises(sftp_block):
|
||||
client = SftpClient(sftp_block)
|
||||
with pytest.raises(RuntimeError, match="Stub SFTP cannot read"):
|
||||
client.read_file("/x.x12")
|
||||
@@ -0,0 +1,263 @@
|
||||
"""SP9 — R200-R210 validation rule tests.
|
||||
|
||||
These tests use the in-code PAYER_FACTORIES (which include the live
|
||||
payer_config blocks loaded from YAML) so the rules have a non-empty
|
||||
cfg_block to read. The live ``cyclone.payers`` registry is the source
|
||||
of truth for CO_MAP; the rules fall back to the in-code PayerConfig
|
||||
when the registry is empty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db as db_mod
|
||||
from cyclone import payers as payer_loader
|
||||
from cyclone.parsers.models import (
|
||||
Address,
|
||||
BillingProvider,
|
||||
ClaimHeader,
|
||||
ClaimOutput,
|
||||
Diagnosis,
|
||||
Payer,
|
||||
Procedure,
|
||||
ServiceLine,
|
||||
Subscriber,
|
||||
ValidationReport,
|
||||
)
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
from cyclone.parsers.validator import validate
|
||||
from cyclone.store import store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _seed_db(tmp_path, monkeypatch):
|
||||
"""Seed an in-memory DB with 3 providers + CO_TXIX payer, load YAML."""
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
db_mod._reset_for_tests()
|
||||
db_mod.init_db()
|
||||
payer_loader.load_payer_configs()
|
||||
store.ensure_clearhouse_seeded()
|
||||
yield
|
||||
db_mod._reset_for_tests()
|
||||
payer_loader.reset()
|
||||
|
||||
|
||||
def _co_txix_cfg() -> PayerConfig:
|
||||
"""Build a PayerConfig matching the live CO_TXIX YAML block."""
|
||||
return PayerConfig(
|
||||
name="Colorado Medical Assistance Program",
|
||||
sbr09_claim_filing="MC",
|
||||
allowed_claim_frequencies={1, 7, 8},
|
||||
require_ref_g1_for_adjustments=False,
|
||||
allowed_bht06={"CH", "RP"},
|
||||
payer_id="CO_TXIX",
|
||||
payer_name="COHCPF",
|
||||
no_patient_loop=True,
|
||||
encounter_claim_in_same_batch=False,
|
||||
)
|
||||
|
||||
|
||||
def _claim(*, npi="1881068062", sbr09=None, prv_code=None, payer_id="CO_TXIX",
|
||||
payer_name="COHCPF", ref_ei="721587149", transaction_type_code="CH",
|
||||
has_pwk=False) -> ClaimOutput:
|
||||
raw = []
|
||||
if sbr09 is not None:
|
||||
raw.append(["SBR", "P", "18", "", "", "", "", "", "", sbr09])
|
||||
if prv_code is not None:
|
||||
raw.append(["PRV", "BI", "PXC", f"PXC{prv_code}"])
|
||||
raw.append(["NM1", "85", "2", "TOC, Inc.", "", "", "", "", "XX", npi])
|
||||
if ref_ei is not None:
|
||||
raw.append(["REF", "EI", ref_ei])
|
||||
if has_pwk:
|
||||
raw.append(["PWK"])
|
||||
return ClaimOutput(
|
||||
claim_id="CLM1",
|
||||
control_number="0001",
|
||||
transaction_date=date(2026, 6, 20),
|
||||
billing_provider=BillingProvider(
|
||||
name="TOC, Inc.",
|
||||
npi=npi,
|
||||
tax_id=ref_ei,
|
||||
address=Address(line1="1100 East Main St", city="Montrose", state="CO", zip="81401"),
|
||||
),
|
||||
subscriber=Subscriber(first_name="John", last_name="Doe", member_id="M1"),
|
||||
payer=Payer(name=payer_name, id=payer_id),
|
||||
claim=ClaimHeader(
|
||||
claim_id="CLM1",
|
||||
total_charge=Decimal("100.00"),
|
||||
frequency_code="1",
|
||||
place_of_service="11",
|
||||
facility_code_qualifier="B",
|
||||
),
|
||||
transaction_type_code=transaction_type_code,
|
||||
diagnoses=[Diagnosis(code="R69")],
|
||||
service_lines=[
|
||||
ServiceLine(
|
||||
line_number=1,
|
||||
procedure=Procedure(qualifier="HC", code="99213"),
|
||||
charge=Decimal("100.00"),
|
||||
units=Decimal("1"),
|
||||
),
|
||||
],
|
||||
raw_segments=raw,
|
||||
validation=ValidationReport(passed=True, errors=[], warnings=[]),
|
||||
)
|
||||
|
||||
|
||||
# ----- R200: BHT06 allowed ------------------------------------------------
|
||||
|
||||
|
||||
def test_r200_passes_for_allowed_bht06():
|
||||
c = _claim(transaction_type_code="CH")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule.startswith("R200")]
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_r200_fails_for_disallowed_bht06():
|
||||
c = _claim(transaction_type_code="XX")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R200_bht06_allowed"]
|
||||
assert len(bad) == 1
|
||||
assert "not in" in bad[0].message
|
||||
|
||||
|
||||
def test_r200_skips_when_no_bht06():
|
||||
c = _claim(transaction_type_code="")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R200_bht06_allowed"]
|
||||
|
||||
|
||||
# ----- R201: BHT06 no mixed batch ------------------------------------------
|
||||
|
||||
|
||||
def test_r201_is_a_per_claim_noop():
|
||||
# Batch-level rule; per-claim the rule yields nothing
|
||||
c = _claim(transaction_type_code="CH")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R201_bht06_no_mixed_batch"]
|
||||
|
||||
|
||||
# ----- R202: SBR09 allowed ------------------------------------------------
|
||||
|
||||
|
||||
def test_r202_passes_for_mc():
|
||||
c = _claim(sbr09="MC")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
|
||||
|
||||
|
||||
def test_r202_fails_for_unknown():
|
||||
c = _claim(sbr09="AB")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
|
||||
assert len(bad) == 1
|
||||
|
||||
|
||||
def test_r202_passes_for_zz_mco_encounter():
|
||||
c = _claim(sbr09="ZZ")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
|
||||
|
||||
|
||||
# ----- R203: PRV matches provider ----------------------------------------
|
||||
|
||||
|
||||
def test_r203_passes_when_prv_matches_provider_taxonomy():
|
||||
c = _claim(prv_code="251E00000X")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R203_prv_matches_provider"]
|
||||
|
||||
|
||||
def test_r203_fails_when_prv_mismatch():
|
||||
c = _claim(prv_code="999Z00000X")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R203_prv_matches_provider"]
|
||||
assert len(bad) == 1
|
||||
|
||||
|
||||
# ----- R204: NPI in providers table --------------------------------------
|
||||
|
||||
|
||||
def test_r204_passes_for_known_npi():
|
||||
c = _claim(npi="1881068062")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R204_npi_in_providers_table"]
|
||||
|
||||
|
||||
def test_r204_fails_for_unknown_npi():
|
||||
c = _claim(npi="9999999999")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R204_npi_in_providers_table"]
|
||||
assert len(bad) == 1
|
||||
|
||||
|
||||
# ----- R205: REF*EI matches provider --------------------------------------
|
||||
|
||||
|
||||
def test_r205_passes_when_ref_ei_matches():
|
||||
c = _claim(ref_ei="721587149")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R205_ref_ei_matches_provider"]
|
||||
|
||||
|
||||
def test_r205_fails_when_ref_ei_mismatch():
|
||||
c = _claim(ref_ei="000000000")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R205_ref_ei_matches_provider"]
|
||||
assert len(bad) == 1
|
||||
|
||||
|
||||
# ----- R206: Payer ID matches ---------------------------------------------
|
||||
|
||||
|
||||
def test_r206_passes_for_co_txix():
|
||||
c = _claim(payer_id="CO_TXIX")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R206_payer_id_matches"]
|
||||
|
||||
|
||||
def test_r206_fails_for_wrong_payer_id():
|
||||
c = _claim(payer_id="ZZZZZZ")
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R206_payer_id_matches"]
|
||||
assert len(bad) == 1
|
||||
|
||||
|
||||
# ----- R207: no PWK segment ----------------------------------------------
|
||||
|
||||
|
||||
def test_r207_passes_when_no_pwk():
|
||||
c = _claim(has_pwk=False)
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R207_no_pwk_segment"]
|
||||
|
||||
|
||||
def test_r207_fails_when_pwk_present():
|
||||
c = _claim(has_pwk=True)
|
||||
report = validate(c, _co_txix_cfg())
|
||||
bad = [i for i in report.errors if i.rule == "R207_no_pwk_segment"]
|
||||
assert len(bad) == 1
|
||||
|
||||
|
||||
# ----- R208: 2320 CAS*PI* group ------------------------------------------
|
||||
|
||||
|
||||
def test_r208_is_a_per_claim_noop():
|
||||
c = _claim()
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule == "R208_cas_2320_group"]
|
||||
|
||||
|
||||
# ----- R209, R210: filename rules (validate at submit / parse time) ----
|
||||
|
||||
|
||||
def test_r209_r210_per_claim_noop():
|
||||
# These run at file-routing time, not per-claim validate
|
||||
c = _claim()
|
||||
report = validate(c, _co_txix_cfg())
|
||||
assert not [i for i in report.errors if i.rule in ("R209_outbound_filename", "R210_inbound_filename")]
|
||||
Reference in New Issue
Block a user