coming together

This commit is contained in:
HotSwapp
2025-08-13 18:53:35 -05:00
parent acc5155bf7
commit 5111079149
51 changed files with 14457 additions and 588 deletions

11
tests/conftest.py Normal file
View File

@@ -0,0 +1,11 @@
import os
# Ensure required settings exist for app modules imported during tests
os.environ.setdefault("SECRET_KEY", "x" * 32)
# Use a file-based SQLite DB so metadata.create_all and sessions share state
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
os.environ.setdefault("PYTEST_RUNNING", "1")
os.environ.setdefault("DISABLE_LOG_ENQUEUE", "1")

25
tests/helpers.py Normal file
View File

@@ -0,0 +1,25 @@
def assert_validation_error(resp, field_name: str):
assert resp.status_code == 422
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "validation_error"
# Ensure correlation id is present and echoed in header
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid
# Ensure the field appears in details
details = body.get("error", {}).get("details", [])
assert any(field_name in ":".join(map(str, err.get("loc", []))) for err in details)
def assert_http_error(resp, status_code: int, message_substr: str):
assert resp.status_code == status_code
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "http_error"
assert message_substr in body.get("error", {}).get("message", "")
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid

101
tests/test_admin_api.py Normal file
View File

@@ -0,0 +1,101 @@
import os
import uuid
import pytest
from fastapi.testclient import TestClient
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, get_admin_user # noqa: E402
from tests.helpers import assert_http_error # noqa: E402
class _User:
def __init__(self, is_admin: bool):
self.id = 1 if is_admin else 2
self.username = "admin" if is_admin else "user"
self.is_admin = is_admin
self.is_active = True
self.first_name = "Test"
self.last_name = "User"
@pytest.fixture()
def client_admin():
app.dependency_overrides[get_current_user] = lambda: _User(True)
app.dependency_overrides[get_admin_user] = lambda: _User(True)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides.pop(get_admin_user, None)
@pytest.fixture()
def client_user():
app.dependency_overrides[get_current_user] = lambda: _User(False)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def test_admin_only_access(client_user: TestClient):
# Drop auth to simulate unauthenticated
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.get("/api/admin/health")
assert_http_error(resp, 403, "Not authenticated")
# Authenticated non-admin should get 403 from admin endpoints
app.dependency_overrides[get_current_user] = lambda: _User(False)
resp = c.get("/api/admin/users")
assert_http_error(resp, 403, "Not enough permissions")
def test_lookup_crud_file_types_and_statuses_and_audit(client_admin: TestClient):
# List lookup tables
resp = client_admin.get("/api/admin/lookups/tables")
assert resp.status_code == 200
assert "tables" in resp.json()
# Create a system setting (as a simple admin CRUD target)
skey = f"test_setting_{uuid.uuid4().hex[:6]}"
resp = client_admin.post(
"/api/admin/settings",
json={
"setting_key": skey,
"setting_value": "on",
"description": "pytest",
"setting_type": "STRING",
},
)
assert resp.status_code == 200
assert resp.json()["setting"]["setting_key"] == skey
# Update the setting
resp = client_admin.put(
f"/api/admin/settings/{skey}",
json={"setting_value": "off", "description": "changed"},
)
assert resp.status_code == 200
assert resp.json()["setting"]["setting_value"] == "off"
# Read the setting
resp = client_admin.get(f"/api/admin/settings/{skey}")
assert resp.status_code == 200
assert resp.json()["setting_key"] == skey
# Delete the setting
resp = client_admin.delete(f"/api/admin/settings/{skey}")
assert resp.status_code == 200
# Verify audit logs endpoint is accessible and returns structure
resp = client_admin.get("/api/admin/audit/logs")
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"total", "logs"}

View File

@@ -0,0 +1,168 @@
import os
import uuid
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _assert_validation_error(resp, field_name: str):
assert resp.status_code == 422
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "validation_error"
# Ensure correlation id is present and echoed in header
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid
# Ensure the field appears in details
details = body.get("error", {}).get("details", [])
assert any(field_name in ":".join(map(str, err.get("loc", []))) for err in details)
def _assert_http_error(resp, status_code: int, message_substr: str):
assert resp.status_code == status_code
body = resp.json()
assert body.get("success") is False
assert body.get("error", {}).get("code") == "http_error"
assert message_substr in body.get("error", {}).get("message", "")
cid = body.get("correlation_id")
assert isinstance(cid, str) and cid
assert resp.headers.get("X-Correlation-ID") == cid
def test_create_customer_invalid_email_returns_422(client: TestClient):
customer_id = f"SCHEMA-{uuid.uuid4().hex[:8]}"
payload = {
"id": customer_id,
"last": "InvalidEmail",
"email": "not-an-email",
}
resp = client.post("/api/customers/", json=payload)
_assert_validation_error(resp, "email")
def test_update_customer_invalid_email_returns_422(client: TestClient):
customer_id = f"SCHEMA-UPD-{uuid.uuid4().hex[:8]}"
# Create valid customer first
create_payload = {
"id": customer_id,
"last": "Valid",
"email": "ok@example.com",
}
resp = client.post("/api/customers/", json=create_payload)
assert resp.status_code == 200
# Attempt invalid email on update
resp = client.put(f"/api/customers/{customer_id}", json={"email": "bad"})
_assert_validation_error(resp, "email")
# Cleanup
resp = client.delete(f"/api/customers/{customer_id}")
assert resp.status_code == 200
def test_create_customer_duplicate_id_returns_400(client: TestClient):
customer_id = f"DUP-{uuid.uuid4().hex[:8]}"
payload = {
"id": customer_id,
"last": "Doe",
"email": "john.doe@example.com",
}
# First create OK
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
# Duplicate should be 400 with envelope
resp = client.post("/api/customers/", json=payload)
_assert_http_error(resp, 400, "Customer ID already exists")
# Cleanup
resp = client.delete(f"/api/customers/{customer_id}")
assert resp.status_code == 200
def test_get_update_delete_nonexistent_customer_404(client: TestClient):
missing_id = f"NOPE-{uuid.uuid4().hex[:8]}"
resp = client.get(f"/api/customers/{missing_id}")
_assert_http_error(resp, 404, "Customer not found")
resp = client.put(f"/api/customers/{missing_id}", json={"last": "X"})
_assert_http_error(resp, 404, "Customer not found")
resp = client.delete(f"/api/customers/{missing_id}")
_assert_http_error(resp, 404, "Customer not found")
def test_phones_endpoints_404_for_missing_customer_and_phone(client: TestClient):
missing_id = f"NOPE-{uuid.uuid4().hex[:8]}"
# Missing customer: get and add phone
resp = client.get(f"/api/customers/{missing_id}/phones")
_assert_http_error(resp, 404, "Customer not found")
resp = client.post(
f"/api/customers/{missing_id}/phones",
json={"location": "Office", "phone": "(555) 000-0000"},
)
_assert_http_error(resp, 404, "Customer not found")
# Create a real customer to test non-existent phone id
real_id = f"PHONE-{uuid.uuid4().hex[:8]}"
resp = client.post(
"/api/customers/",
json={"id": real_id, "last": "Phones", "email": "phones@example.com"},
)
assert resp.status_code == 200
# Update non-existent phone for this customer
resp = client.put(
f"/api/customers/{real_id}/phones/999999",
json={"location": "Home", "phone": "(555) 111-2222"},
)
_assert_http_error(resp, 404, "Phone number not found")
# Delete non-existent phone for this customer
resp = client.delete(f"/api/customers/{real_id}/phones/999999")
_assert_http_error(resp, 404, "Phone number not found")
# Cleanup
resp = client.delete(f"/api/customers/{real_id}")
assert resp.status_code == 200
def test_list_customers_query_param_validation_422(client: TestClient):
# limit must be >=1 and <=200
resp = client.get("/api/customers/?limit=0")
_assert_validation_error(resp, "limit")
# skip must be >=0
resp = client.get("/api/customers/?skip=-1")
_assert_validation_error(resp, "skip")

View File

@@ -0,0 +1,130 @@
import os
import io
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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 = "uploader"
self.is_admin = True
self.is_active = 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):
customer_id = f"UP-{uuid.uuid4().hex[:8]}"
resp = client.post(
"/api/customers/",
json={"id": customer_id, "last": "Upload", "email": "u@example.com"},
)
assert resp.status_code == 200
file_no = f"U-{uuid.uuid4().hex[:6]}"
file_payload = {
"file_no": file_no,
"id": customer_id,
"regarding": "Upload doc test",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 100.0,
}
resp = client.post("/api/files/", json=file_payload)
assert resp.status_code == 200
return file_no
def test_upload_invalid_file_type_returns_400_envelope_and_correlation_header(client: TestClient):
file_no = _create_customer_and_file(client)
files = {
"file": ("bad.txt", b"hello", "text/plain"),
}
resp = client.post(f"/api/documents/upload/{file_no}", files=files)
assert_http_error(resp, 400, "Invalid file type")
def test_upload_oversize_file_returns_400_envelope_and_correlation_header(client: TestClient):
file_no = _create_customer_and_file(client)
# 10MB + 1 byte
big_bytes = b"x" * (10 * 1024 * 1024 + 1)
files = {
"file": ("large.pdf", big_bytes, "application/pdf"),
}
resp = client.post(f"/api/documents/upload/{file_no}", files=files)
assert_http_error(resp, 400, "File too large")
def test_upload_uses_incoming_correlation_id_when_provided(client: TestClient):
file_no = _create_customer_and_file(client)
cid = f"cid-{uuid.uuid4().hex[:8]}"
files = {
"file": ("bad.txt", b"hello", "text/plain"),
}
resp = client.post(
f"/api/documents/upload/{file_no}",
files=files,
headers={"X-Correlation-ID": cid},
)
# Envelope shape and message
assert_http_error(resp, 400, "Invalid file type")
# Echoes our provided correlation id
body = resp.json()
assert resp.headers.get("X-Correlation-ID") == cid
assert body.get("correlation_id") == cid
def test_upload_without_file_returns_400_no_file_uploaded(client: TestClient):
file_no = _create_customer_and_file(client)
# Provide an empty filename to trigger the explicit 400 in route logic
files = {
# Use a valid filename but zero-byte payload to hit the 400 "No file uploaded" branch
"file": ("empty.pdf", io.BytesIO(b""), "application/pdf"),
}
resp = client.post(f"/api/documents/upload/{file_no}", files=files)
assert_http_error(resp, 400, "No file uploaded")
def test_upload_missing_file_field_returns_422_validation_envelope(client: TestClient):
file_no = _create_customer_and_file(client)
# Submit without the required `file` field
resp = client.post(
f"/api/documents/upload/{file_no}",
data={"description": "missing file"},
headers={"X-Correlation-ID": f"cid-{uuid.uuid4().hex[:8]}"},
)
# Ensure 422 envelope and correlation header; details should mention `file`
assert_validation_error(resp, "file")

155
tests/test_documents_api.py Normal file
View File

@@ -0,0 +1,155 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
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_validation_error, assert_http_error # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "tester"
self.is_admin = True
self.is_active = 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):
cust_id = f"DOC-{uuid.uuid4().hex[:8]}"
resp = client.post("/api/customers/", json={"id": cust_id, "last": "Doc", "email": "d@example.com"})
assert resp.status_code == 200
file_no = f"D-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": cust_id,
"regarding": "Doc matter",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 100.0,
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
return cust_id, file_no
def test_qdro_schema_validation_and_404s(client: TestClient):
# Missing required: file_no
resp = client.post("/api/documents/qdros/", json={"version": "01"})
assert_validation_error(resp, "file_no")
# Bad dates type
resp = client.post(
"/api/documents/qdros/",
json={
"file_no": "NOFILE-1",
"created_date": "not-a-date",
"approved_date": "also-bad",
},
)
assert_validation_error(resp, "created_date")
# 404 get/update/delete for missing
resp = client.get("/api/documents/qdros/NOFILE-1/999999")
assert_http_error(resp, 404, "QDRO not found")
resp = client.put("/api/documents/qdros/NOFILE-1/999999", json={"status": "APPROVED"})
assert_http_error(resp, 404, "QDRO not found")
resp = client.delete("/api/documents/qdros/NOFILE-1/999999")
assert_http_error(resp, 404, "QDRO not found")
def test_qdro_end_to_end_crud(client: TestClient):
_, file_no = _create_customer_and_file(client)
# Create
create_payload = {
"file_no": file_no,
"version": "01",
"status": "DRAFT",
"created_date": date.today().isoformat(),
"plan_name": "Plan X",
"plan_administrator": "Admin",
}
resp = client.post("/api/documents/qdros/", json=create_payload)
assert resp.status_code == 200
qdro = resp.json()
qid = qdro["id"]
assert qdro["file_no"] == file_no
# List by file
resp = client.get(f"/api/documents/qdros/{file_no}")
assert resp.status_code == 200
assert any(item["id"] == qid for item in resp.json())
# Get by composite path
resp = client.get(f"/api/documents/qdros/{file_no}/{qid}")
assert resp.status_code == 200
assert resp.json()["id"] == qid
# Update
resp = client.put(f"/api/documents/qdros/{file_no}/{qid}", json={"status": "APPROVED"})
assert resp.status_code == 200
assert resp.json()["status"] == "APPROVED"
# Delete
resp = client.delete(f"/api/documents/qdros/{file_no}/{qid}")
assert resp.status_code == 200
def test_template_crud_and_generate_requires_file_and_shapes(client: TestClient):
# Create a template
tid = f"TMP-{uuid.uuid4().hex[:6]}"
tpl_payload = {
"form_id": tid,
"form_name": "Letter",
"category": "GENERAL",
"content": "Hello {{CLIENT_FULL}} on ^TODAY for file ^FILE_NO",
}
resp = client.post("/api/documents/templates/", json=tpl_payload)
assert resp.status_code == 200
# Get template
resp = client.get(f"/api/documents/templates/{tid}")
assert resp.status_code == 200
assert resp.json()["form_id"] == tid
# Update template
resp = client.put(f"/api/documents/templates/{tid}", json={"content": "Updated"})
assert resp.status_code == 200
assert "content" in resp.json()
# Generate requires an existing file
_, file_no = _create_customer_and_file(client)
resp = client.post(
f"/api/documents/generate/{tid}",
json={"template_id": tid, "file_no": file_no, "output_format": "HTML"},
)
assert resp.status_code == 200
gen = resp.json()
assert {"document_id", "file_name", "file_path", "size", "created_at"} <= set(gen.keys())
# 404 when file missing
resp = client.post(
f"/api/documents/generate/{tid}",
json={"template_id": tid, "file_no": "X-NOFILE", "output_format": "PDF"},
)
assert_http_error(resp, 404, "File not found")

119
tests/test_files_api.py Normal file
View File

@@ -0,0 +1,119 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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_validation_error, assert_http_error # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer(client: TestClient) -> str:
customer_id = f"FILE-CUST-{uuid.uuid4().hex[:8]}"
payload = {"id": customer_id, "last": "FileOwner", "email": "owner@example.com"}
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
return customer_id
def _valid_file_payload(file_no: str, owner_id: str) -> dict:
return {
"file_no": file_no,
"id": owner_id,
"regarding": "Matter description",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 200.0,
"memo": "Created by pytest",
}
def test_create_file_schema_validation_errors(client: TestClient):
owner_id = _create_customer(client)
file_no = f"F-{uuid.uuid4().hex[:6]}"
# Missing required fields should trigger validation errors
resp = client.post("/api/files/", json={})
assert_validation_error(resp, "file_no")
# Wrong types: rate_per_hour as string, opened wrong format
bad = _valid_file_payload(file_no, owner_id)
bad["rate_per_hour"] = "twenty"
bad["opened"] = "not-a-date"
resp = client.post("/api/files/", json=bad)
assert_validation_error(resp, "rate_per_hour")
assert_validation_error(resp, "opened")
def test_create_file_and_duplicate_file_no_returns_400(client: TestClient):
owner_id = _create_customer(client)
file_no = f"F-{uuid.uuid4().hex[:6]}"
payload = _valid_file_payload(file_no, owner_id)
# First create OK
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
# Duplicate should be 400 with envelope
resp = client.post("/api/files/", json=payload)
assert_http_error(resp, 400, "File number already exists")
# Cleanup
resp = client.delete(f"/api/files/{file_no}")
assert resp.status_code == 200
def test_get_update_delete_missing_file_returns_404(client: TestClient):
missing = f"NOFILE-{uuid.uuid4().hex[:6]}"
resp = client.get(f"/api/files/{missing}")
assert_http_error(resp, 404, "File not found")
resp = client.put(f"/api/files/{missing}", json={"status": "ACTIVE"})
assert_http_error(resp, 404, "File not found")
resp = client.delete(f"/api/files/{missing}")
assert_http_error(resp, 404, "File not found")
def test_financial_and_client_info_404_for_missing_file(client: TestClient):
missing = f"NOFILE-{uuid.uuid4().hex[:6]}"
resp = client.get(f"/api/files/{missing}/financial-summary")
assert_http_error(resp, 404, "File not found")
resp = client.get(f"/api/files/{missing}/client-info")
assert_http_error(resp, 404, "File not found")
def test_list_and_advanced_search_param_validation(client: TestClient):
resp = client.get("/api/files/?limit=0")
assert_validation_error(resp, "limit")
resp = client.get("/api/files/?skip=-1")
assert_validation_error(resp, "skip")

263
tests/test_financial_api.py Normal file
View File

@@ -0,0 +1,263 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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_validation_error, assert_http_error # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer(client: TestClient) -> str:
customer_id = f"LEDGER-CUST-{uuid.uuid4().hex[:8]}"
payload = {"id": customer_id, "last": "LedgerOwner", "email": "owner@example.com"}
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
return customer_id
def _create_file(client: TestClient, owner_id: str) -> str:
file_no = f"L-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": owner_id,
"regarding": "Ledger matter",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 100.0,
"memo": "Created by pytest",
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
return file_no
def test_ledger_create_validation_errors(client: TestClient):
owner_id = _create_customer(client)
file_no = _create_file(client, owner_id)
# Missing required fields
resp = client.post("/api/financial/ledger/", json={})
assert_validation_error(resp, "file_no")
# Wrong types for amount/date
bad = {
"file_no": file_no,
"date": "not-a-date",
"t_code": "TIME",
"t_type": "2",
"empl_num": "E01",
"quantity": 1.5,
"rate": 100.0,
"amount": "one hundred",
"billed": "N",
"note": "Invalid types",
}
resp = client.post("/api/financial/ledger/", json=bad)
assert_validation_error(resp, "date")
assert_validation_error(resp, "amount")
# Query param validation on list endpoint
resp = client.get(f"/api/financial/ledger/{file_no}?limit=0")
assert_validation_error(resp, "limit")
resp = client.get(f"/api/financial/ledger/{file_no}?skip=-1")
assert_validation_error(resp, "skip")
def test_ledger_404s_for_missing_file_and_entries(client: TestClient):
# Create against missing file
payload = {
"file_no": "NOFILE-123",
"date": date.today().isoformat(),
"t_code": "TIME",
"t_type": "2",
"empl_num": "E01",
"quantity": 1.0,
"rate": 100.0,
"amount": 100.0,
"billed": "N",
"note": "Should 404",
}
resp = client.post("/api/financial/ledger/", json=payload)
assert_http_error(resp, 404, "File not found")
# Update/delete missing entry id
resp = client.put("/api/financial/ledger/9999999", json={"amount": 10})
assert_http_error(resp, 404, "Ledger entry not found")
resp = client.delete("/api/financial/ledger/9999999")
assert_http_error(resp, 404, "Ledger entry not found")
# Report and quick endpoints on missing file
resp = client.get("/api/financial/reports/NOFILE-123")
assert_http_error(resp, 404, "File not found")
resp = client.post("/api/financial/time-entry/quick", params={"file_no": "NOFILE-123", "hours": 1.0, "description": "x"})
assert_http_error(resp, 404, "File not found")
resp = client.post("/api/financial/payments/", params={"file_no": "NOFILE-123", "amount": 10.0})
assert_http_error(resp, 404, "File not found")
resp = client.post("/api/financial/expenses/", params={"file_no": "NOFILE-123", "amount": 10.0, "description": "x"})
assert_http_error(resp, 404, "File not found")
# Bill entries with no matching ids (body expects a raw JSON array)
resp = client.post("/api/financial/bill-entries", json=[999999])
assert_http_error(resp, 404, "No entries found")
def test_ledger_totals_update_after_crud(client: TestClient):
owner_id = _create_customer(client)
file_no = _create_file(client, owner_id)
# Baseline
resp = client.get(f"/api/files/{file_no}")
assert resp.status_code == 200
file_data = resp.json()
assert file_data["total_charges"] == 0
assert file_data["amount_owing"] == 0
# 1) Create hourly time entry (t_type "2")
t_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "TIME",
"t_type": "2",
"empl_num": "E01",
"quantity": 2.0,
"rate": 100.0,
"amount": 200.0,
"billed": "N",
"note": "Work",
}
resp = client.post("/api/financial/ledger/", json=t_payload)
assert resp.status_code == 200
time_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["hours"] == 2.0
assert f["hourly_fees"] == 200.0
assert f["total_charges"] == 200.0
assert f["amount_owing"] == 200.0
# 2) Create disbursement (t_type "4") amount 50
d_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "MISC",
"t_type": "4",
"empl_num": "E01",
"quantity": 0.0,
"rate": 0.0,
"amount": 50.0,
"billed": "N",
"note": "Expense",
}
resp = client.post("/api/financial/ledger/", json=d_payload)
assert resp.status_code == 200
disb_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["disbursements"] == 50.0
assert f["total_charges"] == 250.0
assert f["amount_owing"] == 250.0
# 3) Create credit/payment (t_type "5") amount 100
c_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "PMT",
"t_type": "5",
"empl_num": "E01",
"quantity": 0.0,
"rate": 0.0,
"amount": 100.0,
"billed": "Y",
"note": "Payment",
}
resp = client.post("/api/financial/ledger/", json=c_payload)
assert resp.status_code == 200
credit_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["credit_bal"] == 100.0
assert f["amount_owing"] == 150.0
# 4) Trust deposit (t_type "1") amount 80
trust_payload = {
"file_no": file_no,
"date": date.today().isoformat(),
"t_code": "TRUST",
"t_type": "1",
"empl_num": "E01",
"quantity": 0.0,
"rate": 0.0,
"amount": 80.0,
"billed": "Y",
"note": "Trust deposit",
}
resp = client.post("/api/financial/ledger/", json=trust_payload)
assert resp.status_code == 200
trust_entry = resp.json()
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["trust_bal"] == 80.0
assert f["transferable"] == 80.0
# 5) Update credit entry to 200
resp = client.put(f"/api/financial/ledger/{credit_entry['id']}", json={"amount": 200.0})
assert resp.status_code == 200
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["credit_bal"] == 200.0
assert f["amount_owing"] == 50.0
assert f["transferable"] == 50.0
# 6) Delete trust deposit, transferable should drop to 0
resp = client.delete(f"/api/financial/ledger/{trust_entry['id']}")
assert resp.status_code == 200
resp = client.get(f"/api/files/{file_no}")
f = resp.json()
assert f["trust_bal"] == 0.0
assert f["transferable"] == 0.0
# 7) Verify report totals consistency
resp = client.get(f"/api/financial/reports/{file_no}")
assert resp.status_code == 200
report = resp.json()
assert report["total_hours"] == 2.0
assert report["total_hourly_fees"] == 200.0
assert report["total_disbursements"] == 50.0
assert report["total_credits"] == 200.0
assert report["total_charges"] == 250.0
assert report["amount_owing"] == 50.0

View File

@@ -0,0 +1,79 @@
import io
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.import_data import router as import_router
from app.database.base import engine, SessionLocal
from app.models.base import BaseModel
from app.models.user import User
from app.models.flexible import FlexibleImport
from app.auth.security import get_current_user
def test_batch_upload_unknown_csv_saved_as_flexible_rows():
# Fresh DB
BaseModel.metadata.drop_all(bind=engine)
BaseModel.metadata.create_all(bind=engine)
# Seed an admin user
db = SessionLocal()
try:
user = User(
username="tester",
email="tester@example.com",
hashed_password="x",
is_active=True,
is_admin=True,
)
db.add(user)
db.commit()
db.refresh(user)
finally:
db.close()
# Minimal app with import router and auth override
app = FastAPI()
app.include_router(import_router, prefix="/api/import")
def _override_current_user():
return user # type: ignore[return-value]
app.dependency_overrides[get_current_user] = _override_current_user
client = TestClient(app)
# Unknown CSV that should fall back to flexible import
csv_bytes = b"alpha,beta\n1,2\n3,4\n"
files = [("files", ("UNKNOWN.csv", io.BytesIO(csv_bytes), "text/csv"))]
resp = client.post("/api/import/batch-upload", files=files)
assert resp.status_code == 200
body = resp.json()
# Assert API result shows success and correct row count
results = body.get("batch_results", [])
assert any(
r.get("file_type") == "UNKNOWN.csv"
and r.get("status") == "success"
and r.get("imported_count") == 2
for r in results
)
# Assert rows persisted in flexible storage
db = SessionLocal()
try:
rows = (
db.query(FlexibleImport)
.filter(FlexibleImport.file_type == "UNKNOWN.csv")
.order_by(FlexibleImport.id.asc())
.all()
)
assert len(rows) == 2
assert rows[0].target_table is None
assert set(rows[0].extra_data.keys()) == {"alpha", "beta"}
assert set(rows[1].extra_data.keys()) == {"alpha", "beta"}
finally:
db.close()

View File

@@ -0,0 +1,103 @@
import uuid
from fastapi.testclient import TestClient
from app.main import app
from app.auth.security import get_current_user
from app.database.base import SessionLocal
from app.models.flexible import FlexibleImport
def test_batch_import_unknown_csv_saves_flexible_rows():
# Override auth to bypass JWT for this test
app.dependency_overrides[get_current_user] = lambda: {
"id": "test",
"username": "tester",
"is_admin": True,
"is_active": True,
}
client = TestClient(app)
unique_suffix = uuid.uuid4().hex[:8]
filename = f"UNKNOWN_TEST_{unique_suffix}.csv"
csv_content = "col1,col2\nA,B\nC,D\n"
try:
resp = client.post(
"/api/import/batch-upload",
files=[("files", (filename, csv_content, "text/csv"))],
)
assert resp.status_code == 200
payload = resp.json()
assert "batch_results" in payload
result = next((r for r in payload["batch_results"] if r["file_type"] == filename), None)
assert result is not None
assert result["status"] == "success"
assert result["imported_count"] == 2
assert result["auto_mapping"]["flexible_saved_rows"] == 2
# Verify rows persisted
db = SessionLocal()
try:
count = (
db.query(FlexibleImport)
.filter(FlexibleImport.file_type == filename)
.count()
)
assert count == 2
finally:
# Clean up created rows to keep DB tidy
db.query(FlexibleImport).filter(FlexibleImport.file_type == filename).delete()
db.commit()
db.close()
finally:
# Restore dependencies
app.dependency_overrides.pop(get_current_user, None)
def test_single_upload_flexible_creates_rows():
# Override auth to bypass JWT for this test
app.dependency_overrides[get_current_user] = lambda: {
"id": "test",
"username": "tester",
"is_admin": True,
"is_active": True,
}
client = TestClient(app)
filename = "SINGLE_UNKNOWN.csv"
csv_content = "a,b\n1,2\n3,4\n"
try:
# Upload via flexible-only endpoint
resp = client.post(
"/api/import/upload-flexible",
files={"file": (filename, csv_content, "text/csv")},
data={"replace_existing": "false"},
)
assert resp.status_code == 200
payload = resp.json()
assert payload["file_type"] == filename
assert payload["imported_count"] == 2
assert payload["auto_mapping"]["flexible_saved_rows"] == 2
# Verify rows persisted
db = SessionLocal()
try:
count = (
db.query(FlexibleImport)
.filter(FlexibleImport.file_type == filename)
.count()
)
assert count == 2
finally:
# Clean up created rows to keep DB tidy
db.query(FlexibleImport).filter(FlexibleImport.file_type == filename).delete()
db.commit()
db.close()
finally:
# Restore dependencies
app.dependency_overrides.pop(get_current_user, None)

132
tests/test_import_api.py Normal file
View File

@@ -0,0 +1,132 @@
import os
import io
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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, get_admin_user # noqa: E402
from tests.helpers import assert_http_error # noqa: E402
class _User:
def __init__(self, is_admin: bool):
self.id = 1 if is_admin else 2
self.username = "admin" if is_admin else "user"
self.is_admin = is_admin
self.is_active = True
@pytest.fixture()
def client_admin():
app.dependency_overrides[get_current_user] = lambda: _User(True)
app.dependency_overrides[get_admin_user] = lambda: _User(True)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides.pop(get_admin_user, None)
@pytest.fixture()
def client_user():
app.dependency_overrides[get_current_user] = lambda: _User(False)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _make_csv(content: str, filename: str = "ROLODEX.csv"):
return {"file": (filename, io.BytesIO(content.encode("utf-8")), "text/csv")}
def test_import_requires_auth_and_rejects_malformed_csv(client_user: TestClient):
# Unauthenticated should 403 envelope
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.post("/api/import/upload/ROLODEX.csv", files=_make_csv("Id,Last\n"))
assert_http_error(resp, 403, "Not authenticated")
# Auth but malformed content: wrong extension
app.dependency_overrides[get_current_user] = lambda: _User(False)
resp = c.post("/api/import/upload/ROLODEX.csv", files={"file": ("file.txt", io.BytesIO(b"abc"), "text/plain")})
assert_http_error(resp, 400, "File must be a CSV file")
# Unsupported file type
resp = c.post("/api/import/upload/UNKNOWN.csv", files=_make_csv("x,y\n1,2\n", filename="UNKNOWN.csv"))
assert_http_error(resp, 400, "Unsupported file type")
# Severely malformed CSV that can't parse headers
bad = "" # empty
resp = c.post("/api/import/upload/ROLODEX.csv", files=_make_csv(bad))
# The importer treats empty as error in parsing or yields 500; ensure error envelope present
assert resp.status_code in (400, 500)
body = resp.json()
assert body.get("success") is False
assert resp.headers.get("X-Correlation-ID") == body.get("correlation_id")
def test_successful_import_updates_counts(client_admin: TestClient):
# Initial status counts
resp = client_admin.get("/api/import/status")
assert resp.status_code == 200
status_before = resp.json()
rolodex_before = status_before.get("ROLODEX.csv", {}).get("record_count", 0)
# Minimal valid ROLODEX import (id,last)
csv_data = "Id,Last,Email\nIMP-1,Doe,john@example.com\nIMP-2,Smith,smith@example.com\n"
resp = client_admin.post("/api/import/upload/ROLODEX.csv", files=_make_csv(csv_data))
assert resp.status_code == 200
result = resp.json()
assert result["file_type"] == "ROLODEX.csv"
assert result["imported_count"] >= 2
assert isinstance(result["auto_mapping"]["mapped_headers"], dict)
# Status after should increase
resp = client_admin.get("/api/import/status")
assert resp.status_code == 200
status_after = resp.json()
rolodex_after = status_after.get("ROLODEX.csv", {}).get("record_count", 0)
assert rolodex_after >= rolodex_before + 2
def test_batch_validate_and_batch_upload_auth_and_errors(client_admin: TestClient):
# Batch validate with too many files not triggered, but ensure happy path
files = [
("ROLODEX.csv", "Id,Last\nB1,Alpha\n"),
("FILES.csv", "File_No,Id,File_Type,Regarding,Opened,Empl_Num,Status,Rate_Per_Hour\nF-1,B1,CIVIL,Test,2024-01-01,E01,ACTIVE,100\n"),
]
payload = [("files", (name, io.BytesIO(data.encode("utf-8")), "text/csv")) for name, data in files]
resp = client_admin.post("/api/import/batch-validate", files=payload)
assert resp.status_code == 200
body = resp.json()
assert "batch_validation_results" in body
# Batch upload mixed: include unsupported file to trigger a failed result but 200 overall
files2 = [
("UNKNOWN.csv", "a,b\n1,2\n"),
("ROLODEX.csv", "Id,Last\nB2,Beta\n"),
]
payload2 = [("files", (name, io.BytesIO(data.encode("utf-8")), "text/csv")) for name, data in files2]
resp = client_admin.post("/api/import/batch-upload", files=payload2)
assert resp.status_code == 200
summary = resp.json().get("summary", {})
assert "total_files" in summary
def test_clear_requires_admin_and_unknown_type_errors(client_user: TestClient):
# Non-admin authenticated should still be able to call due to current dependency (get_current_user)
# We enforce admin via existing admin endpoint as a proxy
resp = client_user.get("/api/auth/users")
assert_http_error(resp, 403, "Not enough permissions")
# Unknown file type on clear
resp = client_user.delete("/api/import/clear/UNKNOWN.csv")
assert_http_error(resp, 400, "Unknown file type")

286
tests/test_search_api.py Normal file
View File

@@ -0,0 +1,286 @@
import os
import sys
from pathlib import Path
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
# Ensure repository root on sys.path for direct test runs
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_validation_error # noqa: E402
from app.api.financial import LedgerCreate # noqa: E402
from app.database.base import SessionLocal # noqa: E402
from app.models.qdro import QDRO # noqa: E402
@pytest.fixture(scope="module")
def client():
# Override auth to bypass JWT for these tests
class _User:
def __init__(self):
self.id = "test"
self.username = "tester"
self.is_admin = True
self.is_active = True
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer(client: TestClient, last_suffix: str) -> str:
customer_id = f"SRCH-CUST-{uuid.uuid4().hex[:8]}"
payload = {
"id": customer_id,
"last": f"Search-{last_suffix}",
"first": "Unit",
"email": f"{customer_id.lower()}@example.com",
"city": "Austin",
"abrev": "TX",
}
resp = client.post("/api/customers/", json=payload)
assert resp.status_code == 200
return customer_id
def _create_file(client: TestClient, owner_id: str, regarding_token: str) -> str:
file_no = f"SRCH-F-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": owner_id,
"regarding": f"Search Matter {regarding_token}",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 150.0,
"memo": "Created by search tests",
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200
return file_no
def test_search_customers_min_length_and_limit_validation(client: TestClient):
# q must be at least 2 chars
resp = client.get("/api/search/customers", params={"q": "a"})
assert_validation_error(resp, "q")
# limit must be between 1 and 100
resp = client.get("/api/search/customers", params={"q": "ab", "limit": 0})
assert_validation_error(resp, "limit")
resp = client.get("/api/search/customers", params={"q": "ab", "limit": 101})
assert_validation_error(resp, "limit")
def test_search_files_min_length_and_limit_validation(client: TestClient):
resp = client.get("/api/search/files", params={"q": "a"})
assert_validation_error(resp, "q")
resp = client.get("/api/search/files", params={"q": "ab", "limit": 0})
assert_validation_error(resp, "limit")
resp = client.get("/api/search/files", params={"q": "ab", "limit": 101})
assert_validation_error(resp, "limit")
def test_search_customers_results_and_filtering(client: TestClient):
token = f"TOK-{uuid.uuid4().hex[:6]}"
id1 = _create_customer(client, f"{token}-Alpha")
id2 = _create_customer(client, f"{token}-Beta")
# Search by shared token
resp = client.get("/api/search/customers", params={"q": token, "limit": 50})
assert resp.status_code == 200
results = resp.json()
assert isinstance(results, list)
assert all(r.get("type") == "customer" for r in results)
ids = {r.get("id") for r in results}
assert id1 in ids and id2 in ids
# Limit parameter should restrict result count
resp = client.get("/api/search/customers", params={"q": token, "limit": 1})
assert resp.status_code == 200
assert isinstance(resp.json(), list) and len(resp.json()) == 1
def test_search_files_results_and_filtering(client: TestClient):
token = f"FTOK-{uuid.uuid4().hex[:6]}"
owner_id = _create_customer(client, f"Owner-{token}")
f1 = _create_file(client, owner_id, regarding_token=f"{token}-Alpha")
f2 = _create_file(client, owner_id, regarding_token=f"{token}-Beta")
# Search by token in regarding
resp = client.get("/api/search/files", params={"q": token, "limit": 50})
assert resp.status_code == 200
results = resp.json()
assert isinstance(results, list)
assert all(r.get("type") == "file" for r in results)
file_nos = {r.get("id") for r in results}
assert f1 in file_nos and f2 in file_nos
# Limit restricts results
resp = client.get("/api/search/files", params={"q": token, "limit": 1})
assert resp.status_code == 200
assert isinstance(resp.json(), list) and len(resp.json()) == 1
def test_search_case_insensitive_matching_and_highlight_preserves_casing(client: TestClient):
token = f"MC-{uuid.uuid4().hex[:6]}"
# Create customers with specific casing
id_upper = _create_customer(client, f"{token}-SMITH")
id_mixed = _create_customer(client, f"{token}-Smithson")
# Mixed-case query should match both via case-insensitive search
resp = client.get("/api/search/customers", params={"q": token.lower()})
assert resp.status_code == 200
results = resp.json()
ids = {r.get("id") for r in results}
assert id_upper in ids and id_mixed in ids
# Now search files with mixed-case regarding
owner_id = id_upper
file_no = _create_file(client, owner_id, regarding_token=f"{token}-DoE")
# Query should be case-insensitive
resp = client.get("/api/search/files", params={"q": token.lower()})
assert resp.status_code == 200
files = resp.json()
file_ids = {r.get("id") for r in files}
assert file_no in file_ids
# Ensure highlight preserves original casing in snippet when server supplies text
# For customers highlight may include Name/Email/City with original case
cust = next(r for r in results if r.get("id") == id_upper)
# Server should return a snippet with <strong> around matches, preserving original casing
if cust.get("highlight"):
assert "<strong>" in cust["highlight"]
# The word 'Search' prefix should remain with original case if present
assert any(tag in cust["highlight"] for tag in ["Name:", "City:", "Email:"])
# Also create a ledger entry with mixed-case note and ensure highlight
resp = client.post(
"/api/financial/ledger/",
json=LedgerCreate(
file_no=file_no,
date=date.today().isoformat(),
t_code="NOTE",
t_type="2",
empl_num="E01",
quantity=0.0,
rate=0.0,
amount=0.0,
billed="N",
note=f"MixedCase DoE note {token}"
).model_dump(mode="json")
)
assert resp.status_code == 200
# Ledger search via global endpoints isn't exposed directly here, but query through legacy ledger search when available
# We can at least ensure files search returns highlight on regarding; ledger highlight is already unit-tested
def _create_qdro_with_form_name(file_no: str, form_name: str) -> int:
db = SessionLocal()
try:
qdro = QDRO(file_no=file_no, form_name=form_name, status="DRAFT")
db.add(qdro)
db.commit()
db.refresh(qdro)
return qdro.id
finally:
db.close()
def test_advanced_search_highlights_mixed_case_for_customer_file_qdro(client: TestClient):
token_mixed = f"MiXeD{uuid.uuid4().hex[:6]}"
token_lower = token_mixed.lower()
# Customer with mixed-case in name
cust_id = _create_customer(client, last_suffix=token_mixed)
# File with mixed-case in regarding
file_no = _create_file(client, cust_id, regarding_token=token_mixed)
# QDRO seeded directly with mixed-case in form_name
qdro_id = _create_qdro_with_form_name(file_no, form_name=f"Form {token_mixed} Plan")
# Advanced search across types
payload = {
"query": token_lower,
"search_types": ["customer", "file", "qdro"],
"limit": 50,
}
resp = client.post("/api/search/advanced", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data.get("total_results", 0) >= 3
# Index by (type, id)
results = data["results"]
by_key = {(r["type"], r["id"]): r for r in results}
# Customer
cust_res = by_key.get(("customer", cust_id))
assert cust_res is not None and isinstance(cust_res.get("highlight"), str)
assert "<strong>" in cust_res["highlight"]
assert f"<strong>{token_mixed}</strong>" in cust_res["highlight"]
# File
file_res = by_key.get(("file", file_no))
assert file_res is not None and isinstance(file_res.get("highlight"), str)
assert "<strong>" in file_res["highlight"]
assert f"<strong>{token_mixed}</strong>" in file_res["highlight"]
# QDRO
qdro_res = by_key.get(("qdro", qdro_id))
assert qdro_res is not None and isinstance(qdro_res.get("highlight"), str)
assert "<strong>" in qdro_res["highlight"]
assert f"<strong>{token_mixed}</strong>" in qdro_res["highlight"]
def test_global_search_highlights_mixed_case_for_customer_file_qdro(client: TestClient):
token_mixed = f"MiXeD{uuid.uuid4().hex[:6]}"
token_lower = token_mixed.lower()
# Seed data
cust_id = _create_customer(client, last_suffix=token_mixed)
file_no = _create_file(client, cust_id, regarding_token=token_mixed)
qdro_id = _create_qdro_with_form_name(file_no, form_name=f"QDRO {token_mixed} Case")
# Global search
resp = client.get("/api/search/global", params={"q": token_lower, "limit": 50})
assert resp.status_code == 200
data = resp.json()
# Customers
custs = data.get("customers", [])
cust = next((r for r in custs if r.get("id") == cust_id), None)
assert cust is not None and isinstance(cust.get("highlight"), str)
assert "<strong>" in cust["highlight"]
assert f"<strong>{token_mixed}</strong>" in cust["highlight"]
# Files
files = data.get("files", [])
fil = next((r for r in files if r.get("id") == file_no), None)
assert fil is not None and isinstance(fil.get("highlight"), str)
assert "<strong>" in fil["highlight"]
assert f"<strong>{token_mixed}</strong>" in fil["highlight"]
# QDROs
qdros = data.get("qdros", [])
q = next((r for r in qdros if r.get("id") == qdro_id), None)
assert q is not None and isinstance(q.get("highlight"), str)
assert "<strong>" in q["highlight"]
assert f"<strong>{token_mixed}</strong>" in q["highlight"]

View File

@@ -0,0 +1,186 @@
from app.api.search_highlight import (
build_query_tokens,
highlight_text,
create_customer_highlight,
create_file_highlight,
create_ledger_highlight,
create_qdro_highlight,
)
def test_build_query_tokens_dedup_and_trim():
tokens = build_query_tokens(' John, Smith; "Smith" (J.) ')
assert tokens == ['John', 'Smith', 'J']
def test_highlight_text_case_insensitive_preserves_original():
out = highlight_text('John Smith', ['joHN', 'smiTH'])
assert out == '<strong>John</strong> <strong>Smith</strong>'
def test_highlight_text_overlapping_tokens():
out = highlight_text('Anna and Ann went', ['ann', 'anna'])
# Should highlight both; merged ranges will encompass 'Anna' first, then 'Ann'
assert '<strong>Anna</strong>' in out
assert ' and <strong>Ann</strong> went' in out
def test_highlight_text_multiple_occurrences():
out = highlight_text('alpha beta alpha', ['alpha'])
assert out.count('<strong>alpha</strong>') == 2
def test_highlight_text_returns_original_when_token_absent():
out = highlight_text('Hello World', ['zzz'])
assert out == 'Hello World'
def test_highlight_text_merges_overlapping_tokens_single_range():
out = highlight_text('banana', ['ana', 'nan'])
assert out == 'b<strong>anana</strong>'
def test_build_query_tokens_mixed_case_dedup_order_preserving():
tokens = build_query_tokens('ALPHA alpha Beta beta BETA')
assert tokens == ['ALPHA', 'Beta']
def test_build_query_tokens_trims_wrapping_punctuation_and_ignores_empties():
tokens = build_query_tokens('...Alpha!!!, __Alpha__, (Beta); "beta";; gamma---')
assert tokens == ['Alpha', 'Beta', 'gamma']
def test_build_query_tokens_empty_input():
assert build_query_tokens(' ') == []
def _make_customer(**attrs):
obj = type("CustomerStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_customer_highlight_prefers_name_over_other_fields():
customer = _make_customer(first='John', last='Smith', email='john@example.com', city='Johnstown')
out = create_customer_highlight(customer, 'john')
assert out.startswith('Name: ')
assert 'Email:' not in out and 'City:' not in out
def test_create_customer_highlight_uses_email_when_name_not_matching():
customer = _make_customer(first='Alice', last='Wonder', email='johnson@example.com', city='Paris')
out = create_customer_highlight(customer, 'john')
assert out.startswith('Email: ')
def test_create_customer_highlight_uses_city_when_only_city_matches():
customer = _make_customer(first='Alice', last='Wonder', email='awonder@example.com', city='Ann Arbor')
out = create_customer_highlight(customer, 'arbor')
assert out.startswith('City: ')
def test_create_customer_highlight_requires_full_query_in_single_field():
customer = _make_customer(first='John', last='Smith', email='js@example.com', city='Boston')
# 'john boston' does not occur in any single attribute; should return empty string
out = create_customer_highlight(customer, 'john boston')
assert out == ''
def test_create_customer_highlight_highlights_both_tokens_in_full_name():
customer = _make_customer(first='John', last='Smith', email='js@example.com', city='Boston')
out = create_customer_highlight(customer, 'John Smith')
assert out == 'Name: <strong>John</strong> <strong>Smith</strong>'
def _make_file(**attrs):
obj = type("FileStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_file_highlight_prefers_matter_over_type():
file_obj = _make_file(regarding='Divorce Matter - John Doe', file_type='QDRO')
out = create_file_highlight(file_obj, 'divorce')
assert out.startswith('Matter: ')
assert '<strong>Divorce</strong>' in out
def test_create_file_highlight_uses_type_when_matter_not_matching():
file_obj = _make_file(regarding='Miscellaneous', file_type='Income Tax')
out = create_file_highlight(file_obj, 'tax')
assert out.startswith('Type: ')
# Preserve original casing from the source
assert '<strong>Tax</strong>' in out
def test_create_file_highlight_returns_empty_when_no_match():
file_obj = _make_file(regarding='Misc', file_type='General')
out = create_file_highlight(file_obj, 'unrelated')
assert out == ''
def _make_ledger(**attrs):
obj = type("LedgerStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_ledger_highlight_truncates_to_160_with_suffix_and_highlights():
prefix = 'x' * 50
match = 'AlphaBeta'
filler = 'y' * 200
marker_after = 'ZZZ_AFTER'
note_text = prefix + match + filler + marker_after
ledger = _make_ledger(note=note_text)
out = create_ledger_highlight(ledger, 'alpha')
assert out.startswith('Note: ')
# Should include highlight within the preview
assert '<strong>Alpha</strong>Beta' in out
# Should be truncated with suffix because original length > 160
assert out.endswith('...')
# Ensure content after 160 chars (marker_after) is not present
assert 'ZZZ_AFTER' not in out
def _make_qdro(**attrs):
obj = type("QdroStub", (), {})()
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def test_create_qdro_highlight_prefers_form_name_over_pet_and_case():
qdro = _make_qdro(form_name='Domestic Relations Form - QDRO', pet='Jane Doe', case_number='2024-XYZ')
out = create_qdro_highlight(qdro, 'qdro')
assert out.startswith('Form: ')
assert '<strong>QDRO</strong>' in out
def test_create_qdro_highlight_uses_pet_when_form_not_matching():
qdro = _make_qdro(form_name='Child Support', pet='John Johnson', case_number='A-1')
out = create_qdro_highlight(qdro, 'john')
assert out.startswith('Petitioner: ')
def test_create_qdro_highlight_uses_case_when_only_case_matches():
qdro = _make_qdro(form_name='Child Support', pet='Mary Jane', case_number='Case 12345')
out = create_qdro_highlight(qdro, 'case 123')
assert out.startswith('Case: ')
assert '<strong>Case</strong>' in out and '<strong>123</strong>' in out
def test_create_qdro_highlight_none_or_empty_fields_return_empty():
qdro = _make_qdro(form_name=None, pet=None, case_number=None)
assert create_qdro_highlight(qdro, 'anything') == ''
populated = _make_qdro(form_name='Form A', pet='Pet B', case_number='C-1')
assert create_qdro_highlight(populated, '') == ''
def test_create_qdro_highlight_requires_full_query_in_single_field():
# Tokens present across fields but not as a contiguous substring in any single field
qdro = _make_qdro(form_name='QDRO Plan', pet='Alpha', case_number='123')
out = create_qdro_highlight(qdro, 'plan 123')
assert out == ''

View File

@@ -0,0 +1,85 @@
import os
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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, get_admin_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
class _User:
def __init__(self, is_admin: bool):
self.id = 1 if is_admin else 2
self.username = "admin" if is_admin else "user"
self.is_admin = is_admin
self.is_active = True
self.first_name = "Test"
self.last_name = "User"
@pytest.fixture()
def client_admin():
app.dependency_overrides[get_current_user] = lambda: _User(True)
app.dependency_overrides[get_admin_user] = lambda: _User(True)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides.pop(get_admin_user, None)
@pytest.fixture()
def client_user():
app.dependency_overrides[get_current_user] = lambda: _User(False)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def test_get_inactivity_warning_minutes_requires_auth_and_returns_shape(client_user: TestClient):
# Unauthenticated should 401 envelope
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.get("/api/settings/inactivity_warning_minutes")
assert_http_error(resp, 403, "Not authenticated")
# Authenticated returns minutes field
app.dependency_overrides[get_current_user] = lambda: _User(False)
resp = c.get("/api/settings/inactivity_warning_minutes")
assert resp.status_code == 200
data = resp.json()
assert set(data.keys()) == {"minutes"}
assert isinstance(data["minutes"], int)
def test_update_theme_preference_validation_and_auth(client_user: TestClient):
# Invalid theme value
resp = client_user.post("/api/auth/theme-preference", json={"theme_preference": "blue"})
assert_http_error(resp, 400, "Theme preference must be 'light' or 'dark'")
# Valid update
resp = client_user.post("/api/auth/theme-preference", json={"theme_preference": "dark"})
assert resp.status_code == 200
body = resp.json()
assert body == {"message": "Theme preference updated successfully", "theme": "dark"}
# Unauthenticated should 401
app.dependency_overrides.pop(get_current_user, None)
c = TestClient(app)
resp = c.post("/api/auth/theme-preference", json={"theme_preference": "light"})
assert_http_error(resp, 403, "Not authenticated")
# If there are admin-only settings updates later, assert 403 for non-admin.
# Placeholder: demonstrate 403 behavior using a known admin-only endpoint (/api/auth/users)
def test_non_admin_forbidden_on_admin_endpoints(client_user: TestClient):
resp = client_user.get("/api/auth/users")
assert_http_error(resp, 403, "Not enough permissions")

122
tests/test_support_api.py Normal file
View File

@@ -0,0 +1,122 @@
import os
import uuid
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars for app import/config
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, get_admin_user # noqa: E402
from tests.helpers import assert_validation_error, assert_http_error # noqa: E402
@pytest.fixture(scope="module")
def client():
class _Admin:
def __init__(self):
self.id = 1
self.username = "admin"
self.is_admin = True
self.is_active = True
self.first_name = "Admin"
self.last_name = "User"
# For public create, current_user is optional; override admin endpoints
app.dependency_overrides[get_admin_user] = lambda: _Admin()
app.dependency_overrides[get_current_user] = lambda: _Admin()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_admin_user, None)
app.dependency_overrides.pop(get_current_user, None)
def test_create_ticket_validation_errors(client: TestClient):
# Missing required fields
resp = client.post("/api/support/tickets", json={})
assert_validation_error(resp, "subject")
# Too short subject/description and invalid email
payload = {
"subject": "Hey",
"description": "short",
"contact_name": "",
"contact_email": "not-an-email",
}
resp = client.post("/api/support/tickets", json=payload)
assert_validation_error(resp, "subject")
assert_validation_error(resp, "description")
assert_validation_error(resp, "contact_name")
assert_validation_error(resp, "contact_email")
def _valid_ticket_payload() -> dict:
token = uuid.uuid4().hex[:6]
return {
"subject": f"Support issue {token}",
"description": "A reproducible problem description long enough",
"category": "bug_report",
"priority": "medium",
"contact_name": "John Tester",
"contact_email": f"john.{token}@example.com",
"current_page": "/dashboard",
"browser_info": "pytest-agent",
}
def test_ticket_lifecycle_and_404s_with_audit(client: TestClient):
# Create ticket (public)
payload = _valid_ticket_payload()
resp = client.post("/api/support/tickets", json=payload)
assert resp.status_code == 200
body = resp.json()
ticket_id = body["ticket_id"]
assert body["status"] == "created"
# Get ticket as admin
resp = client.get(f"/api/support/tickets/{ticket_id}")
assert resp.status_code == 200
detail = resp.json()
assert detail["status"] == "open"
assert isinstance(detail.get("responses"), list)
# 404 on missing ticket get/update/respond
resp = client.get("/api/support/tickets/999999")
assert_http_error(resp, 404, "Ticket not found")
resp = client.put("/api/support/tickets/999999", json={"status": "in_progress"})
assert_http_error(resp, 404, "Ticket not found")
resp = client.post("/api/support/tickets/999999/responses", json={"message": "x"})
assert_http_error(resp, 404, "Ticket not found")
# State transitions: open -> in_progress -> resolved
resp = client.put(f"/api/support/tickets/{ticket_id}", json={"status": "in_progress"})
assert resp.status_code == 200
resp = client.get(f"/api/support/tickets/{ticket_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "in_progress"
# Add public response
resp = client.post(
f"/api/support/tickets/{ticket_id}/responses",
json={"message": "We are working on it", "is_internal": False},
)
assert resp.status_code == 200
# Resolve ticket
resp = client.put(f"/api/support/tickets/{ticket_id}", json={"status": "resolved"})
assert resp.status_code == 200
resp = client.get(f"/api/support/tickets/{ticket_id}")
data = resp.json()
assert data["status"] == "resolved"
assert data["resolved_at"] is not None
# Basic list with pagination params should 200
resp = client.get("/api/support/tickets", params={"skip": 0, "limit": 10})
assert resp.status_code == 200
assert isinstance(resp.json(), list)