Phase 1 Step 4 follow-up addressing the debt flagged in the worked-example
commit (4a91814).
## mypy --strict policy
Adds backend-compliance/mypy.ini declaring the strict-mode scope:
Fully strict (enforced today):
- compliance/domain/
- compliance/schemas/
- compliance/api/_http_errors.py
- compliance/api/audit_routes.py (refactored in Step 4)
- compliance/services/audit_session_service.py
- compliance/services/audit_signoff_service.py
Loose (ignore_errors=True) with a migration path:
- compliance/db/* — SQLAlchemy 1.x Column[] vs
runtime T; unblocks Phase 1
until a Mapped[T] migration.
- compliance/api/<route>.py — each route file flips to
strict as its own Step 4
refactor lands.
- compliance/services/<legacy util> — 14 utility services
(llm_provider, pdf_extractor,
seeder, ...) that predate the
clean-arch refactor.
- compliance/tests/ — excluded (legacy placeholder
style). The new TestClient-
based integration suite is
type-annotated.
The two new service files carry a scoped `# mypy: disable-error-code="arg-type,assignment"`
header for the ORM Column[T] issue — same underlying SQLAlchemy limitation,
narrowly scoped rather than wholesale ignore_errors.
Flow: `cd backend-compliance && mypy compliance/` -> clean on 119 files.
CI yaml updated to use the config instead of ad-hoc package lists.
## Bugs fixed while enabling strict
mypy --strict surfaced two latent bugs in the pre-refactor code. Both
were invisible because the old `compliance/tests/test_audit_routes.py`
is a placeholder suite that asserts on request-data shape and never
calls the handlers:
- AuditSessionResponse.updated_at is a required field in the schema,
but the original handler didn't pass it. Fixed in
AuditSessionService._to_response.
- PaginationMeta requires has_next + has_prev. The original audit
checklist handler didn't compute them. Fixed in
AuditSignOffService.get_checklist.
Both are behavior-preserving at the HTTP level because the old code
would have raised Pydantic ValidationError at response serialization
had the endpoint actually been exercised.
## Integration test suite
Adds backend-compliance/tests/test_audit_routes_integration.py — 26
real TestClient tests against an in-memory sqlite backend (StaticPool).
Replaces the coverage gap left by the placeholder suite.
Covers:
- Session CRUD + lifecycle transitions (draft -> in_progress -> completed
-> archived), including the 409 paths for illegal transitions
- Checklist pagination, filtering, search
- Sign-off create / update / auto-start-session / count-flipping
- Sign-off 400 (invalid result), 404 (missing requirement), 409 (completed session)
- Get-signoff 404 / 200 round-trip
Uses a module-scoped schema fixture + per-test DELETE-sweep so the
suite runs in ~2.3s despite the ~50-table ORM surface.
Verified:
- 199/199 pytest (173 original + 26 new audit integration) pass
- tests/contracts/test_openapi_baseline.py green, OpenAPI 360/484 unchanged
- mypy compliance/ -> Success: no issues found in 119 source files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""
|
|
Integration tests for compliance audit session & sign-off routes.
|
|
|
|
Phase 1 Step 4 follow-up. The legacy ``compliance/tests/test_audit_routes.py``
|
|
contains placeholder tests that only assert on request-body shape — they do
|
|
not exercise the handler functions. This module uses a real FastAPI TestClient
|
|
against a sqlite-backed app so that handler logic, service delegation, domain
|
|
error translation, and response serialization are all covered end-to-end.
|
|
|
|
Covers:
|
|
- POST/GET/PUT/DELETE /audit/sessions (and lifecycle transitions)
|
|
- GET /audit/checklist/{session_id} (pagination + filters)
|
|
- PUT /audit/checklist/{session_id}/items/{requirement_id}/sign-off
|
|
- GET /audit/checklist/{session_id}/items/{requirement_id}
|
|
- Error cases: 404 (not found), 409 (invalid state transition), 400 (bad input)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import uuid
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from classroom_engine.database import Base, get_db # noqa: E402
|
|
from compliance.api.audit_routes import router as audit_router # noqa: E402
|
|
from compliance.db.models import ( # noqa: E402
|
|
ControlDB,
|
|
ControlDomainEnum,
|
|
ControlStatusEnum,
|
|
ControlTypeEnum,
|
|
RegulationDB,
|
|
RegulationTypeEnum,
|
|
RequirementDB,
|
|
)
|
|
|
|
engine = create_engine(
|
|
"sqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
app = FastAPI()
|
|
app.include_router(audit_router, prefix="/api/compliance")
|
|
|
|
|
|
def override_get_db():
|
|
db = TestingSessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
app.dependency_overrides[get_db] = override_get_db
|
|
client = TestClient(app)
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _schema():
|
|
Base.metadata.create_all(bind=engine)
|
|
yield
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _wipe_data():
|
|
"""Wipe all rows between tests without recreating the schema."""
|
|
yield
|
|
with engine.begin() as conn:
|
|
for table in reversed(Base.metadata.sorted_tables):
|
|
conn.execute(table.delete())
|
|
|
|
|
|
# ============================================================================
|
|
# Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_requirements():
|
|
"""Seed a regulation + 3 requirements so audit sessions have scope."""
|
|
db = TestingSessionLocal()
|
|
try:
|
|
reg = RegulationDB(
|
|
id=str(uuid.uuid4()),
|
|
code="GDPR",
|
|
name="GDPR",
|
|
regulation_type=RegulationTypeEnum.EU_REGULATION,
|
|
)
|
|
db.add(reg)
|
|
db.flush()
|
|
req_ids = []
|
|
for i in range(3):
|
|
req = RequirementDB(
|
|
id=str(uuid.uuid4()),
|
|
regulation_id=reg.id,
|
|
article=f"Art. {i + 1}",
|
|
title=f"Requirement {i + 1}",
|
|
description=f"Desc {i + 1}",
|
|
implementation_status="not_started",
|
|
priority=2,
|
|
)
|
|
db.add(req)
|
|
req_ids.append(req.id)
|
|
db.commit()
|
|
yield {"regulation_id": reg.id, "requirement_ids": req_ids}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _create_session(name="Test Audit", codes=None):
|
|
r = client.post(
|
|
"/api/compliance/audit/sessions",
|
|
json={
|
|
"name": name,
|
|
"description": "Integration test",
|
|
"auditor_name": "Dr. Test",
|
|
"auditor_email": "test@example.com",
|
|
"regulation_codes": codes,
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
# ============================================================================
|
|
# Session lifecycle
|
|
# ============================================================================
|
|
|
|
|
|
class TestSessionCreate:
|
|
def test_create_session_without_scope_ok(self):
|
|
r = client.post(
|
|
"/api/compliance/audit/sessions",
|
|
json={"name": "No scope", "auditor_name": "Someone"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["name"] == "No scope"
|
|
assert body["status"] == "draft"
|
|
assert body["total_items"] == 0
|
|
assert body["completion_percentage"] == 0.0
|
|
|
|
def test_create_session_with_regulation_filter_counts_requirements(
|
|
self, seeded_requirements
|
|
):
|
|
body = _create_session(codes=["GDPR"])
|
|
assert body["total_items"] == 3
|
|
assert body["regulation_ids"] == ["GDPR"]
|
|
|
|
|
|
class TestSessionList:
|
|
def test_list_empty(self):
|
|
r = client.get("/api/compliance/audit/sessions")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_list_filters_by_status(self):
|
|
a = _create_session("A")
|
|
_create_session("B")
|
|
# Start one -> in_progress
|
|
client.put(f"/api/compliance/audit/sessions/{a['id']}/start")
|
|
r = client.get("/api/compliance/audit/sessions?status=draft")
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 1
|
|
assert r.json()[0]["name"] == "B"
|
|
|
|
def test_list_invalid_status_returns_400(self):
|
|
r = client.get("/api/compliance/audit/sessions?status=bogus")
|
|
assert r.status_code == 400
|
|
assert "Invalid status" in r.json()["detail"]
|
|
|
|
|
|
class TestSessionGet:
|
|
def test_get_not_found_returns_404(self):
|
|
r = client.get("/api/compliance/audit/sessions/missing")
|
|
assert r.status_code == 404
|
|
|
|
def test_get_existing_returns_details_with_stats(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
r = client.get(f"/api/compliance/audit/sessions/{s['id']}")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["id"] == s["id"]
|
|
assert body["statistics"]["total"] == 3
|
|
assert body["statistics"]["pending"] == 3
|
|
|
|
|
|
class TestSessionTransitions:
|
|
def test_start_from_draft_ok(self):
|
|
s = _create_session()
|
|
r = client.put(f"/api/compliance/audit/sessions/{s['id']}/start")
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "in_progress"
|
|
|
|
def test_start_from_completed_returns_409(self):
|
|
s = _create_session()
|
|
client.put(f"/api/compliance/audit/sessions/{s['id']}/start")
|
|
client.put(f"/api/compliance/audit/sessions/{s['id']}/complete")
|
|
r = client.put(f"/api/compliance/audit/sessions/{s['id']}/start")
|
|
assert r.status_code == 409
|
|
|
|
def test_complete_from_draft_returns_409(self):
|
|
s = _create_session()
|
|
r = client.put(f"/api/compliance/audit/sessions/{s['id']}/complete")
|
|
assert r.status_code == 409
|
|
|
|
def test_full_lifecycle_draft_inprogress_completed_archived(self):
|
|
s = _create_session()
|
|
assert client.put(f"/api/compliance/audit/sessions/{s['id']}/start").status_code == 200
|
|
assert client.put(f"/api/compliance/audit/sessions/{s['id']}/complete").status_code == 200
|
|
assert client.put(f"/api/compliance/audit/sessions/{s['id']}/archive").status_code == 200
|
|
r = client.get(f"/api/compliance/audit/sessions/{s['id']}")
|
|
assert r.json()["status"] == "archived"
|
|
|
|
def test_archive_from_inprogress_returns_409(self):
|
|
s = _create_session()
|
|
client.put(f"/api/compliance/audit/sessions/{s['id']}/start")
|
|
r = client.put(f"/api/compliance/audit/sessions/{s['id']}/archive")
|
|
assert r.status_code == 409
|
|
|
|
|
|
class TestSessionDelete:
|
|
def test_delete_draft_ok(self):
|
|
s = _create_session()
|
|
r = client.delete(f"/api/compliance/audit/sessions/{s['id']}")
|
|
assert r.status_code == 200
|
|
assert client.get(f"/api/compliance/audit/sessions/{s['id']}").status_code == 404
|
|
|
|
def test_delete_in_progress_returns_409(self):
|
|
s = _create_session()
|
|
client.put(f"/api/compliance/audit/sessions/{s['id']}/start")
|
|
r = client.delete(f"/api/compliance/audit/sessions/{s['id']}")
|
|
assert r.status_code == 409
|
|
|
|
def test_delete_missing_returns_404(self):
|
|
r = client.delete("/api/compliance/audit/sessions/missing")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ============================================================================
|
|
# Checklist & sign-off
|
|
# ============================================================================
|
|
|
|
|
|
class TestChecklist:
|
|
def test_checklist_returns_paginated_items(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
r = client.get(f"/api/compliance/audit/checklist/{s['id']}?page=1&page_size=2")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert len(body["items"]) == 2
|
|
assert body["pagination"]["total"] == 3
|
|
assert body["pagination"]["has_next"] is True
|
|
assert body["pagination"]["has_prev"] is False
|
|
assert body["statistics"]["pending"] == 3
|
|
|
|
def test_checklist_session_not_found_returns_404(self):
|
|
r = client.get("/api/compliance/audit/checklist/nope")
|
|
assert r.status_code == 404
|
|
|
|
def test_checklist_search_filters_by_title(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
r = client.get(
|
|
f"/api/compliance/audit/checklist/{s['id']}?search=Requirement 2"
|
|
)
|
|
assert r.status_code == 200
|
|
titles = [i["title"] for i in r.json()["items"]]
|
|
assert titles == ["Requirement 2"]
|
|
|
|
|
|
class TestSignOff:
|
|
def test_sign_off_creates_record_and_auto_starts_session(
|
|
self, seeded_requirements
|
|
):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
r = client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "compliant", "notes": "all good", "sign": False},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["result"] == "compliant"
|
|
# Session auto-starts on first sign-off
|
|
got = client.get(f"/api/compliance/audit/sessions/{s['id']}").json()
|
|
assert got["status"] == "in_progress"
|
|
assert got["statistics"]["compliant"] == 1
|
|
assert got["statistics"]["pending"] == 2
|
|
|
|
def test_sign_off_with_signature_creates_hash(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
r = client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "compliant", "sign": True},
|
|
)
|
|
body = r.json()
|
|
assert body["is_signed"] is True
|
|
assert body["signature_hash"] and len(body["signature_hash"]) == 64
|
|
assert body["signed_by"] == "Dr. Test"
|
|
|
|
def test_sign_off_update_existing_record_flips_counts(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "compliant"},
|
|
)
|
|
client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "non_compliant"},
|
|
)
|
|
stats = client.get(f"/api/compliance/audit/sessions/{s['id']}").json()["statistics"]
|
|
assert stats["compliant"] == 0
|
|
assert stats["non_compliant"] == 1
|
|
|
|
def test_sign_off_invalid_result_returns_400(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
r = client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "bogus"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "Invalid result" in r.json()["detail"]
|
|
|
|
def test_sign_off_missing_requirement_returns_404(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
r = client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/nope/sign-off",
|
|
json={"result": "compliant"},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
def test_sign_off_on_completed_session_returns_409(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
client.put(f"/api/compliance/audit/sessions/{s['id']}/start")
|
|
client.put(f"/api/compliance/audit/sessions/{s['id']}/complete")
|
|
r = client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "compliant"},
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
def test_get_sign_off_returns_404_when_missing(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
r = client.get(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}"
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
def test_get_sign_off_returns_existing(self, seeded_requirements):
|
|
s = _create_session(codes=["GDPR"])
|
|
req_id = seeded_requirements["requirement_ids"][0]
|
|
client.put(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off",
|
|
json={"result": "compliant", "notes": "ok"},
|
|
)
|
|
r = client.get(
|
|
f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}"
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["result"] == "compliant"
|
|
assert r.json()["notes"] == "ok"
|