finishing QDRO section

This commit is contained in:
HotSwapp
2025-08-15 17:19:51 -05:00
parent 006ef3d7b1
commit abc7f289d1
22 changed files with 2753 additions and 46 deletions

View File

@@ -20,6 +20,7 @@ class _User:
self.is_active = True
self.first_name = "Test"
self.last_name = "User"
self.is_approver = is_admin
@pytest.fixture()
@@ -168,3 +169,79 @@ def test_printer_setup_crud(client_admin: TestClient):
assert "TestPrinter" not in names
def test_qdro_notification_routes_admin_crud(client_admin: TestClient):
# Initially list should succeed
resp = client_admin.get("/api/admin/qdro/notification-routes")
assert resp.status_code == 200
assert "items" in resp.json()
# Create a per-file route
file_no = "ROUTE-123"
payload = {
"scope": "file",
"identifier": file_no,
"email_to": "a@example.com,b@example.com",
"webhook_url": "https://hooks.example.com/qdro",
"webhook_secret": "sekret",
}
resp = client_admin.post("/api/admin/qdro/notification-routes", json=payload)
assert resp.status_code == 200, resp.text
# Verify appears in list
resp = client_admin.get("/api/admin/qdro/notification-routes?scope=file")
assert resp.status_code == 200
items = resp.json().get("items")
assert any(it["identifier"] == file_no and it["email_to"] for it in items)
# Delete route
resp = client_admin.delete(f"/api/admin/qdro/notification-routes/file/{file_no}")
assert resp.status_code == 200
# Verify gone
resp = client_admin.get("/api/admin/qdro/notification-routes?scope=file")
assert resp.status_code == 200
items = resp.json().get("items")
assert not any(it["identifier"] == file_no for it in items)
def test_approver_toggle_admin_only(client_admin: TestClient):
# Create a user
uname = f"u_{uuid.uuid4().hex[:6]}"
resp = client_admin.post(
"/api/admin/users",
json={
"username": uname,
"email": f"{uname}@example.com",
"password": "secret123",
"first_name": "A",
"last_name": "B",
"is_admin": False,
"is_active": True,
"is_approver": False,
},
)
assert resp.status_code == 200, resp.text
user_id = resp.json()["id"]
# Toggle approver on
resp = client_admin.post(f"/api/admin/users/{user_id}/approver", json={"is_approver": True})
assert resp.status_code == 200, resp.text
assert resp.json()["is_approver"] is True
# Toggle approver off
resp = client_admin.post(f"/api/admin/users/{user_id}/approver", json={"is_approver": False})
assert resp.status_code == 200, resp.text
assert resp.json()["is_approver"] is False
# Non-admin should be forbidden
app.dependency_overrides[get_current_user] = lambda: _User(False)
# Ensure admin override is not present so permission is enforced
prev_admin_override = app.dependency_overrides.pop(get_admin_user, None)
try:
c = TestClient(app)
resp = c.post(f"/api/admin/users/{user_id}/approver", json={"is_approver": True})
assert_http_error(resp, 403, "Not enough permissions")
finally:
if prev_admin_override is not None:
app.dependency_overrides[get_admin_user] = prev_admin_override
app.dependency_overrides.pop(get_current_user, None)

View File

@@ -0,0 +1,171 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars before importing app
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
import app.api.qdros as qdros_module # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from app.database.base import get_db # noqa: E402
from app.models.lookups import SystemSetup # noqa: E402
from tests.test_qdros_api import _create_customer_and_file # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "tester"
self.is_admin = True
self.is_active = True
self.is_approver = True
@pytest.fixture()
def client():
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
class DummyNotification:
def __init__(self):
self.events = []
def emit(self, event_type: str, payload: dict):
self.events.append((event_type, payload))
def test_qdro_transition_emits_notification_when_notify_true(client: TestClient, monkeypatch):
dummy = DummyNotification()
# Patch the module-level service reference used by endpoints
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
# Arrange: create file and qdro
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
# Act: submit for approval with notify=True
resp = client.post(
f"/api/qdros/{qid}/submit-for-approval",
json={"reason": "send to approver", "notify": True, "effective_date": date.today().isoformat()},
)
assert resp.status_code == 200, resp.text
# Assert: one event captured with expected shape
assert dummy.events, "Expected a notification event to be emitted"
event_type, payload = dummy.events[-1]
assert event_type == "QDRO_STATUS_CHANGED"
assert payload.get("qdro_id") == qid
assert payload.get("file_no") == file_no
assert payload.get("from") == "DRAFT"
assert payload.get("to") == "APPROVAL_PENDING"
def test_qdro_transition_no_notification_when_notify_false(client: TestClient, monkeypatch):
dummy = DummyNotification()
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
# notify omitted/false
resp = client.post(f"/api/qdros/{qid}/submit-for-approval", json={"reason": "send"})
assert resp.status_code == 200, resp.text
assert dummy.events == []
def _upsert_system_setting(db: Session, key: str, value: str):
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
if row:
row.setting_value = value
else:
row = SystemSetup(setting_key=key, setting_value=value)
db.add(row)
db.commit()
def test_per_file_routing_overrides_email_and_webhook(client: TestClient, monkeypatch):
dummy = DummyNotification()
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
# Create file and qdro
_, file_no = _create_customer_and_file(client)
# Get DB session from dependency directly
db_gen = get_db()
db = next(db_gen)
# Configure per-file routes
_upsert_system_setting(db, f"notifications.qdro.email.to.file.{file_no}", "lawyer@example.com, clerk@example.com")
_upsert_system_setting(db, f"notifications.qdro.webhook.url.file.{file_no}", "https://hooks.example.com/qdro-test")
_upsert_system_setting(db, f"notifications.qdro.webhook.secret.file.{file_no}", "s3cr3t")
resp = client.post("/api/qdros", json={"file_no": file_no, "plan_id": "PL-ABC"})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
resp = client.post(
f"/api/qdros/{qid}/submit-for-approval",
json={"reason": "send", "notify": True, "effective_date": date.today().isoformat()},
)
assert resp.status_code == 200, resp.text
# Last event should include override markers
assert dummy.events, "Expected a notification"
_, payload = dummy.events[-1]
assert payload.get("__notify_override") is True
assert "lawyer@example.com" in str(payload.get("__notify_to"))
assert payload.get("__webhook_override") is True
assert payload.get("__webhook_url") == "https://hooks.example.com/qdro-test"
assert payload.get("__webhook_secret") == "s3cr3t"
# Close session
try:
db_gen.close()
except Exception:
pass
def test_plan_routing_applies_when_no_file_override(client: TestClient, monkeypatch):
dummy = DummyNotification()
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
# Create file + qdro with plan
_, file_no = _create_customer_and_file(client)
db_gen = get_db()
db = next(db_gen)
plan_id = "PL-RTE"
_upsert_system_setting(db, f"notifications.qdro.email.to.plan.{plan_id}", "plan@example.com")
resp = client.post("/api/qdros", json={"file_no": file_no, "plan_id": plan_id})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
resp = client.post(
f"/api/qdros/{qid}/submit-for-approval",
json={"reason": "send", "notify": True, "effective_date": date.today().isoformat()},
)
assert resp.status_code == 200, resp.text
_, payload = dummy.events[-1]
assert payload.get("__notify_override") is True
assert payload.get("__notify_to") == "plan@example.com"
try:
db_gen.close()
except Exception:
pass

257
tests/test_qdros_api.py Normal file
View File

@@ -0,0 +1,257 @@
import os
import io
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars before importing app
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_http_error, assert_validation_error # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "tester"
self.is_admin = True
self.is_active = True
self.is_approver = True
@pytest.fixture()
def client():
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer_and_file(client: TestClient) -> tuple[str, str]:
cust_id = f"Q-{uuid.uuid4().hex[:8]}"
resp = client.post("/api/customers/", json={"id": cust_id, "last": "QDRO", "email": "q@example.com"})
assert resp.status_code == 200, resp.text
file_no = f"QF-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": cust_id,
"regarding": "QDRO matter",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 150.0,
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200, resp.text
return cust_id, file_no
def _dummy_docx_bytes() -> bytes:
try:
from docx import Document
except Exception:
return b"PK\x03\x04"
d = Document()
p = d.add_paragraph()
p.add_run("QDRO for ")
p.add_run("{{CLIENT_FULL}}")
p = d.add_paragraph()
p.add_run("File ")
p.add_run("{{FILE_NO}}")
buf = io.BytesIO()
d.save(buf)
return buf.getvalue()
def test_qdro_crud_and_list_by_file(client: TestClient):
_, file_no = _create_customer_and_file(client)
# 404 when file missing on create
resp = client.post("/api/qdros", json={"file_no": "NOFILE"})
assert_http_error(resp, 404, "File not found")
# Create
create = {
"file_no": file_no,
"version": "01",
"status": "DRAFT",
"case_number": "2025-1234",
"pet": "Alice",
"res": "Bob",
"percent_awarded": "50%",
}
resp = client.post("/api/qdros", json=create)
assert resp.status_code == 200, resp.text
q = resp.json()
qid = q["id"]
assert q["file_no"] == file_no
# List by file
resp = client.get(f"/api/qdros/{file_no}")
assert resp.status_code == 200
assert any(item["id"] == qid for item in resp.json())
# Get by id
resp = client.get(f"/api/qdros/item/{qid}")
assert resp.status_code == 200
assert resp.json()["id"] == qid
# Update
resp = client.put(f"/api/qdros/{qid}", json={"status": "APPROVED"})
assert resp.status_code == 200
assert resp.json()["status"] == "APPROVED"
# Delete
resp = client.delete(f"/api/qdros/{qid}")
assert resp.status_code == 200
# Now 404
resp = client.get(f"/api/qdros/item/{qid}")
assert_http_error(resp, 404, "QDRO not found")
def test_qdro_division_calculation_and_persist_percent(client: TestClient):
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no})
qid = resp.json()["id"]
# percent -> amount
resp = client.post(f"/api/qdros/{qid}/calculate-division", json={"account_balance": 10000.0, "percent": 25.0})
assert resp.status_code == 200, resp.text
assert resp.json()["amount"] == 2500.0
# amount -> percent and save
resp = client.post(
f"/api/qdros/{qid}/calculate-division",
json={"account_balance": 20000.0, "amount": 5000.0, "save_percent_string": True},
)
assert resp.status_code == 200
pct = resp.json()["percent"]
assert round(pct, 2) == 25.0
# Verify saved percent_awarded string
resp = client.get(f"/api/qdros/item/{qid}")
assert resp.status_code == 200
assert "%" in (resp.json().get("percent_awarded") or "")
def test_qdro_versions_and_communications(client: TestClient):
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no, "content": "Initial"})
qid = resp.json()["id"]
# Create version
resp = client.post(f"/api/qdros/{qid}/versions", json={"version_label": "02", "status": "DRAFT"})
assert resp.status_code == 200
ver = resp.json()
assert ver["version_label"] == "02"
# List versions
resp = client.get(f"/api/qdros/{qid}/versions")
assert resp.status_code == 200
assert any(v["id"] == ver["id"] for v in resp.json())
# Communications
comm = {
"channel": "email",
"subject": "Request Info",
"message": "Please provide latest statements",
"contact_name": "Plan Admin",
"status": "sent",
}
resp = client.post(f"/api/qdros/{qid}/communications", json=comm)
assert resp.status_code == 200
comm_id = resp.json()["id"]
resp = client.get(f"/api/qdros/{qid}/communications")
assert resp.status_code == 200
assert any(c["id"] == comm_id for c in resp.json())
def test_plan_info_create_and_list(client: TestClient):
plan_id = f"PL-{uuid.uuid4().hex[:6]}"
payload = {"plan_id": plan_id, "plan_name": "Acme 401k", "plan_type": "401k"}
resp = client.post("/api/plan-info", json=payload)
assert resp.status_code == 200, resp.text
resp = client.get("/api/plan-info")
assert resp.status_code == 200
ids = {row["plan_id"] for row in resp.json()}
assert plan_id in ids
def test_qdro_document_generation_uses_template_system(client: TestClient):
# Create file + qdro
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no, "pet": "Alice", "res": "Bob"})
qid = resp.json()["id"]
# Upload a template through templates API
files = {
"file": (
"qdro.docx",
_dummy_docx_bytes(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
}
resp = client.post(
"/api/templates/upload",
data={"name": f"QDRO Template {uuid.uuid4().hex[:6]}", "semantic_version": "1.0.0"},
files=files,
)
assert resp.status_code == 200, resp.text
tpl_id = resp.json()["id"]
# Generate using our endpoint
resp = client.post(f"/api/qdros/{qid}/generate-document", json={"template_id": tpl_id, "context": {}})
assert resp.status_code == 200, resp.text
body = resp.json()
assert isinstance(body.get("resolved"), dict)
assert isinstance(body.get("unresolved"), list)
assert body.get("output_size", 0) >= 0
def test_qdro_transition_authorization(client: TestClient):
# Override to non-admin, non-approver user
class _LimitedUser:
def __init__(self):
self.id = 2
self.username = "limited"
self.is_admin = False
self.is_active = True
self.is_approver = False
app.dependency_overrides[get_current_user] = lambda: _LimitedUser()
try:
# Set up file and qdro
# Need a client with current override; use existing fixture client indirectly via app
local_client = TestClient(app)
_, file_no = _create_customer_and_file(local_client)
resp = local_client.post("/api/qdros", json={"file_no": file_no})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
# DRAFT -> APPROVAL_PENDING allowed
resp = local_client.post(f"/api/qdros/{qid}/submit-for-approval", json={"reason": "send"})
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "APPROVAL_PENDING"
# APPROVAL_PENDING -> APPROVED forbidden for non-approver
resp = local_client.post(f"/api/qdros/{qid}/approve", json={"reason": "ok"})
assert_http_error(resp, 403, "Not enough permissions")
# APPROVAL_PENDING -> FILED forbidden for non-approver
resp = local_client.post(f"/api/qdros/{qid}/file", json={"reason": "file"})
assert_http_error(resp, 403, "Not enough permissions")
# Generic transition to APPROVED forbidden
resp = local_client.post(f"/api/qdros/{qid}/transition", json={"target_status": "APPROVED"})
assert_http_error(resp, 403, "Not enough permissions")
finally:
app.dependency_overrides.pop(get_current_user, None)

View File

@@ -1,5 +1,6 @@
import os
import io
import uuid
from fastapi.testclient import TestClient
import pytest
@@ -393,3 +394,70 @@ def test_templates_categories_listing(client: TestClient):
assert by_cat_all.get("K1", 0) >= 2
assert by_cat_all.get("K2", 0) >= 1
def test_templates_download_current_version(client: TestClient):
# Upload a DOCX template
payload = {
"name": f"Download Letter {uuid.uuid4().hex[:8]}",
"category": "GENERAL",
"description": "Download test",
"semantic_version": "1.0.0",
}
filename = "letter.docx"
content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
data_bytes = _dummy_docx_bytes()
files = {
"file": (filename, data_bytes, content_type),
}
resp = client.post("/api/templates/upload", data=payload, files=files)
assert resp.status_code == 200, resp.text
tpl_id = resp.json()["id"]
# Download current approved version
resp = client.get(f"/api/templates/{tpl_id}/download")
assert resp.status_code == 200, resp.text
# Verify headers
assert resp.headers.get("content-type") == content_type
cd = resp.headers.get("content-disposition", "")
assert "attachment;" in cd and filename in cd
# Body should be non-empty and equal to uploaded bytes
assert resp.content == data_bytes
def test_templates_download_specific_version_by_id(client: TestClient):
# Upload initial version
files_v1 = {"file": ("v1.docx", _docx_with_tokens("V1"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
resp = client.post(
"/api/templates/upload",
data={"name": f"MultiVersion {uuid.uuid4().hex[:8]}", "semantic_version": "1.0.0"},
files=files_v1,
)
assert resp.status_code == 200, resp.text
tpl_id = resp.json()["id"]
# Add a second version (do not approve so current stays V1)
v2_bytes = _docx_with_tokens("V2 unique")
files_v2 = {"file": ("v2.docx", v2_bytes, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
resp2 = client.post(
f"/api/templates/{tpl_id}/versions",
data={"semantic_version": "1.1.0", "approve": False},
files=files_v2,
)
assert resp2.status_code == 200, resp2.text
# Find versions to get v2 id
resp_list = client.get(f"/api/templates/{tpl_id}/versions")
assert resp_list.status_code == 200
versions = resp_list.json()
# v2 should be in list; grab the one with semantic_version 1.1.0
v2 = next(v for v in versions if v["semantic_version"] == "1.1.0")
v2_id = v2["id"]
# Download specifically v2
resp_dl = client.get(f"/api/templates/{tpl_id}/download", params={"version_id": v2_id})
assert resp_dl.status_code == 200, resp_dl.text
assert resp_dl.headers.get("content-type") == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
cd2 = resp_dl.headers.get("content-disposition", "")
assert "v2.docx" in cd2
assert resp_dl.content == v2_bytes