Files
breakpilot-compliance/backend-compliance/tests/test_audit_routes_integration.py
Sharang Parnerkar 883ef702ac tech-debt: mypy --strict config + integration tests for audit routes
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>
2026-04-07 18:39:40 +02:00

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"