coming together
This commit is contained in:
11
tests/conftest.py
Normal file
11
tests/conftest.py
Normal 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
25
tests/helpers.py
Normal 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
101
tests/test_admin_api.py
Normal 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"}
|
||||
|
||||
|
||||
168
tests/test_customers_edge_cases.py
Normal file
168
tests/test_customers_edge_cases.py
Normal 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")
|
||||
|
||||
|
||||
130
tests/test_document_upload_envelope.py
Normal file
130
tests/test_document_upload_envelope.py
Normal 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
155
tests/test_documents_api.py
Normal 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
119
tests/test_files_api.py
Normal 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
263
tests/test_financial_api.py
Normal 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
|
||||
|
||||
|
||||
79
tests/test_flexible_batch_import.py
Normal file
79
tests/test_flexible_batch_import.py
Normal 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()
|
||||
|
||||
|
||||
103
tests/test_flexible_import.py
Normal file
103
tests/test_flexible_import.py
Normal 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
132
tests/test_import_api.py
Normal 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
286
tests/test_search_api.py
Normal 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"]
|
||||
186
tests/test_search_highlight_utils.py
Normal file
186
tests/test_search_highlight_utils.py
Normal 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 == ''
|
||||
|
||||
85
tests/test_settings_api.py
Normal file
85
tests/test_settings_api.py
Normal 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
122
tests/test_support_api.py
Normal 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user