dd7da18279
The dzinesco SP9 seed had `paths.inbound` and `paths.outbound` mapped to
the wrong Gainwell MFT directories:
- paths.outbound was FromHPE/ (HPE sends files FROM here TO us — inbound)
- paths.inbound was ToHPE/ (we send files TO here — outbound)
So `/api/clearhouse/submit` was writing 837P claims to FromHPE (where
HPE puts acks/835s) and the SP16 scheduler was polling ToHPE (where our
claims go). 999 / TA1 / 835 files in the real FromHPE inbox were
unreachable.
Semantics per operator (2026-06-24):
- FromHPE = HPE/Gainwell → us = 999, TA1, 835 (inbound)
- ToHPE = us → HPE/Gainwell = 837P claims (outbound)
**Runtime code (the actual fix):**
- backend/src/cyclone/store.py — SP9 seed paths flipped
- backend/src/cyclone/edi/filenames.py — docstring corrected
**Test fixtures + assertions (would otherwise fail on the new seed):**
- backend/tests/test_clearhouse_api.py
- backend/tests/test_providers_seed.py
- backend/tests/test_sftp_stub.py — incl. inbound dir paths
- backend/tests/test_sftp_paramiko.py
- backend/tests/test_store_update_clearhouse.py
- backend/tests/test_api_clearhouse_patch.py
- backend/tests/test_scheduler.py
- backend/tests/test_api_scheduler.py
**Docs (text-only — keeps the codebase self-consistent):**
- README.md
- docs/reference/co-medicaid.md
- docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
**Operator action required after merge:** the existing clearhouse row in
`~/.local/share/cyclone/cyclone.db` was seeded with the old wrong
paths. Easiest recovery:
1. `rm ~/.local/share/cyclone/cyclone.db` and let `ensure_clearhouse_seeded` re-run on next boot, OR
2. PATCH /api/clearhouse with the new `paths` block (the SP25
reconfigure hook picks it up live, including by the running scheduler).
**Verification:**
- 82 tests in the affected files pass (test_clearhouse_api, test_providers_seed,
test_sftp_stub, test_sftp_paramiko, test_store_update_clearhouse,
test_api_clearhouse_patch, test_scheduler, test_api_scheduler, test_filenames).
- Full backend suite: 1029 pass + 36 pre-existing order-dependent flakes
unrelated to this change (verified by running the same tests in isolation).
175 lines
6.2 KiB
Python
175 lines
6.2 KiB
Python
"""SP25 — PATCH /api/clearhouse endpoint.
|
|
|
|
Lets the operator flip sftp_block.stub and adjust host/port/paths
|
|
without raw SQL. The endpoint validates via the Clearhouse Pydantic
|
|
model, writes via store.update_clearhouse(), then hot-reloads the
|
|
running scheduler via scheduler.reconfigure_scheduler().
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from cyclone import scheduler as sched_mod
|
|
from cyclone.api import app
|
|
from cyclone.auth import deps
|
|
from cyclone.store import store as cycl_store
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(app)
|
|
|
|
|
|
def _seed_clearhouse():
|
|
"""Insert the default clearhouse row used by SP9's lifespan seed."""
|
|
cycl_store.ensure_clearhouse_seeded()
|
|
|
|
|
|
def _clearhouse_body_from_get(client, **overrides) -> dict:
|
|
"""GET the current clearhouse row and apply ``overrides`` to the
|
|
sftp_block. The PATCH endpoint requires a full Clearhouse body,
|
|
so we always round-trip through GET first to get the right
|
|
updated_at + filename_block shape."""
|
|
r = client.get("/api/clearhouse")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
sb = dict(body["sftp_block"])
|
|
sb.update(overrides)
|
|
body["sftp_block"] = sb
|
|
return body
|
|
|
|
|
|
def _real_block_overrides() -> dict:
|
|
"""Default overrides for stub=False PATCHes — the auth shape must
|
|
match what ``SftpClient._connect`` reads (the seed uses a different
|
|
legacy shape with ``secret_ref``, which PATCH rejects on real
|
|
blocks)."""
|
|
return {
|
|
"stub": False,
|
|
"host": "mft.example.com",
|
|
"auth": {"password_keychain_account": "sftp.gainwell.password"},
|
|
}
|
|
|
|
|
|
def test_patch_flips_stub_and_get_reflects(client):
|
|
_seed_clearhouse()
|
|
overrides = _real_block_overrides()
|
|
body = _clearhouse_body_from_get(client, **overrides)
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 200, r.text
|
|
payload = r.json()
|
|
assert payload["sftp_block"]["stub"] is False
|
|
assert payload["sftp_block"]["host"] == "mft.example.com"
|
|
|
|
r2 = client.get("/api/clearhouse")
|
|
assert r2.status_code == 200
|
|
assert r2.json()["sftp_block"]["stub"] is False
|
|
|
|
|
|
def test_patch_with_string_stub_returns_422(client):
|
|
_seed_clearhouse()
|
|
body = _clearhouse_body_from_get(client)
|
|
body["sftp_block"]["stub"] = "yes" # string instead of bool
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_patch_real_block_requires_host(client):
|
|
_seed_clearhouse()
|
|
overrides = _real_block_overrides()
|
|
overrides["host"] = "" # override the real-block default
|
|
body = _clearhouse_body_from_get(client, **overrides)
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_patch_real_block_requires_password_keychain_account(client):
|
|
_seed_clearhouse()
|
|
overrides = _real_block_overrides()
|
|
overrides["auth"] = {"password_keychain_account": ""}
|
|
body = _clearhouse_body_from_get(client, **overrides)
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_patch_without_session_returns_401(client):
|
|
"""With AUTH_DISABLED off (the production gate), a request with no
|
|
session cookie must be rejected at the gate before reaching the
|
|
endpoint body. Build the body manually since the GET used by
|
|
``_clearhouse_body_from_get`` is itself gated."""
|
|
_seed_clearhouse()
|
|
# Build a valid body without going through the gated GET.
|
|
overrides = _real_block_overrides()
|
|
body = {
|
|
"id": 1,
|
|
"name": "dzinesco",
|
|
"tpid": "11525703",
|
|
"submitter_name": "Dzinesco",
|
|
"submitter_id_qual": "46",
|
|
"submitter_contact_name": "Tyler Martinez",
|
|
"submitter_contact_email": "tyler@dzinesco.com",
|
|
"filename_block": {
|
|
"tz": "America/Denver",
|
|
"outbound_template": "{tpid}-{tx}-{ts}-1of1.{ext}",
|
|
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
|
},
|
|
"sftp_block": {
|
|
"host": overrides.get("host", "mft.example.com"),
|
|
"port": 22,
|
|
"username": "colorado-fts\\coxix_prod_11525703",
|
|
"paths": {
|
|
"outbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
|
|
"inbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
|
|
},
|
|
"stub": overrides.get("stub", False),
|
|
"staging_dir": "./var/sftp/staging",
|
|
"auth": overrides.get("auth", {"password_keychain_account": "sftp.gainwell.password"}),
|
|
},
|
|
"updated_at": "2026-06-24T00:00:00+00:00",
|
|
}
|
|
|
|
deps.AUTH_DISABLED = False
|
|
try:
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 401
|
|
finally:
|
|
deps.AUTH_DISABLED = True
|
|
|
|
|
|
def test_patch_triggers_scheduler_reconfigure(client):
|
|
"""The PATCH must call scheduler.reconfigure_scheduler() with the new
|
|
SftpBlock so the next tick uses real MFT instead of the stub."""
|
|
_seed_clearhouse()
|
|
|
|
with patch.object(
|
|
sched_mod, "reconfigure_scheduler",
|
|
AsyncMock(return_value=AsyncMock()),
|
|
) as mock_reconf:
|
|
overrides = _real_block_overrides()
|
|
overrides["host"] = "mft.gainwelltechnologies.com"
|
|
body = _clearhouse_body_from_get(client, **overrides)
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 200, r.text
|
|
assert mock_reconf.await_count == 1
|
|
# The new sftp_block is passed positionally; sftp_block_name
|
|
# comes from the clearhouse row's ``name`` field.
|
|
call_args = mock_reconf.await_args
|
|
assert call_args.args[0].stub is False
|
|
assert call_args.args[0].host == "mft.gainwelltechnologies.com"
|
|
assert call_args.kwargs.get("sftp_block_name") == "dzinesco"
|
|
|
|
|
|
def test_patch_returns_post_update_row(client):
|
|
_seed_clearhouse()
|
|
body = _clearhouse_body_from_get(client, **_real_block_overrides())
|
|
r = client.patch("/api/clearhouse", json=body)
|
|
assert r.status_code == 200
|
|
payload = r.json()
|
|
assert payload["name"] == "dzinesco"
|
|
assert payload["tpid"] == "11525703"
|
|
assert payload["sftp_block"]["stub"] is False
|
|
assert payload["sftp_block"]["host"] == "mft.example.com"
|