Files
nextcraft/apps/ai-service/tests/api/test_defense.py
T
CIAgent dc00a4f8f5 chore(P05): type & dependency hardening — mypy + recharts 3 (REQ-7-005)
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy]
check_untyped_defs + no_implicit_optional + warn_unused_ignores +
warn_redundant_casts + warn_unreachable, no overrides (every dep ships
py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not
excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found
by the gate (LLMProvider protocol async-generator shape, SessionStore
protocol sync/async mismatch, structured_reply genericity, get_args
migrations, sandbox-agent Optional guard honesty, proctor test arg-order,
dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions
scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead
removed, 15 scoped (warn_unused_ignores self-eliminates the rest).
Wired: scripts/typecheck.sh + package.json + turbo task + root
ai:typecheck — pnpm typecheck now 5 tsc + mypy.

Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class
(Tooltip formatter ValueType typing); 2 one-line inference fixes on the
2 chart files; no rollback needed (well inside the G-106 timebox).
Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248
with chart SSR markers verified in exported employer + dashboard pages.

Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain,
all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight +
rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via
c8→test-exclude (D-052-binding coverage tool — swapping it is out of
budget). Python: no EOL packages; zero-warning regime under
filterwarnings=error is the machine-checked proof.

Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still
exactly 2.

---ci---
phase: 5
milestone: v0.7
status: execute
---/ci---
2026-09-27 10:08:07 +00:00

379 lines
16 KiB
Python

"""Defense endpoint tests (Task 5-3-01, REQ-3-006) — mock voice + mock LLM."""
from __future__ import annotations
import json
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.agents.examiner import ExaminerAgent
from ai_service.config import Settings
from ai_service.grading.store import SQLiteGradeStore
from ai_service.llm.mock import MockProvider
from ai_service.main import create_app
from ai_service.telemetry.ingest import TraceIntegrityMap
from ai_service.telemetry.store import SQLiteTraceStore
from ai_service.variants.store import SQLiteVariantStore
from ai_service.voice.defense_store import SQLiteDefenseStore
from ai_service.voice.mock import MockVoiceProvider
VERDICT = {
"verdict": "developing",
"understanding": "Explains the build clearly.",
"process_justification": "Justifies choices.",
"communication": "Clear and specific.",
"strengths": ["Grounded answers in the digest."],
"gaps": ["Did not address the edge cases."],
}
class ScriptedLLM(MockProvider):
"""Question-mode calls get a question; verdict-mode calls get D-020 JSON.
Discriminator: the verdict prompt contains "final verdict JSON" — the
question prompt says "next question".
"""
def __init__(self) -> None:
super().__init__()
async def chat(self, messages, *, model, temperature=0.7, response_format=None):
all_text = "\n".join(m.content for m in messages)
if "final verdict JSON" in all_text:
return json.dumps(VERDICT)
return "Why did you structure the fix that way?"
@pytest.fixture()
def app(tmp_path: Path):
from ai_service.identity.store import SQLiteIdentityStore
from ..conftest import SUITE_LEARNERS, seed_verified_identity
settings = Settings(
provider="mock",
voice_provider="mock",
learner_allowlist=SUITE_LEARNERS,
)
application = create_app(settings)
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
seed_verified_identity(identity_store)
application.state.identity_store = identity_store # G-9
llm = ScriptedLLM()
application.state.provider = llm
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
application.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
application.state.trace_integrity = TraceIntegrityMap()
application.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
application.state.voice_provider = MockVoiceProvider(
["the fix was in the retry loop"]
)
settings = Settings(provider="mock", voice_provider="mock")
application.state.examiner_agent = ExaminerAgent(llm, settings)
return application
@pytest.fixture()
def client(app) -> Iterator[TestClient]:
with TestClient(app) as c:
yield c
def _start(client: TestClient) -> dict:
resp = client.post(
"/v1/defense/start", json={"learner_id": "defense-learner", "task_id": "defense-task"}
)
assert resp.status_code == 200, resp.text
return resp.json()
class TestStart:
def test_start_returns_first_question_and_descriptor(self, client) -> None:
body = _start(client)
assert body["first_question"]
assert body["defense_id"]
assert body["voice_descriptor"]["mode"] == "mock"
assert body["trace_complete"] is True
stored = client.get(f"/v1/defense/{body['defense_id']}")
assert stored.status_code == 200
turns = stored.json()["turns"]
assert turns and turns[0]["role"] == "examiner"
def test_start_with_unknown_trace_is_complete_flag(self, client) -> None:
body = _start(client)
assert body["trace_complete"] is True
def test_same_millisecond_starts_do_not_collide(self, client) -> None:
"""v0.4.7 (P1): the old `dfn-{ms:x}` mint collided on same-ms starts —
the insert-only store's PK then 500'd the second start PERMANENTLY
(the identity-resubmit P0-C1 class). Nonce+counter keeps ids unique
within a process regardless of clock granularity."""
first = _start(client)
second = _start(client)
third = _start(client)
ids = {first["defense_id"], second["defense_id"], third["defense_id"]}
assert len(ids) == 3, f"defense ids collided: {ids}"
for did in ids:
stored = client.get(f"/v1/defense/{did}")
assert stored.status_code == 200, stored.text
class TestBrowserFallback:
def test_browser_mode_serves_browser_descriptor(self, tmp_path: Path) -> None:
"""Must-Have #6: AI_VOICE_PROVIDER=browser → start returns the
browser-native SR/TTS fallback descriptor (D-030), not 'mock'."""
from ai_service.identity.store import SQLiteIdentityStore
from ..conftest import SUITE_LEARNERS, seed_verified_identity
application = create_app(
Settings(
provider="mock",
voice_provider="browser",
learner_allowlist=SUITE_LEARNERS,
)
)
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity-b.db")
seed_verified_identity(identity_store)
application.state.identity_store = identity_store # G-9
application.state.provider = ScriptedLLM()
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
application.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
application.state.trace_integrity = TraceIntegrityMap()
application.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
application.state.voice_provider = MockVoiceProvider(["answer"])
llm = ScriptedLLM()
application.state.examiner_agent = ExaminerAgent(
llm, Settings(provider="mock", voice_provider="browser")
)
with TestClient(application) as c:
body = _start(c)
assert body["voice_descriptor"]["mode"] == "browser"
assert body["voice_descriptor"]["sr_available"]
assert "SpeechRecognition" in body["voice_descriptor"]["hint"]
class TestAnswer:
def test_typed_answer_yields_followup_with_latency(self, client) -> None:
defense_id = _start(client)["defense_id"]
resp = client.post(
f"/v1/defense/{defense_id}/answer", data={"text": "I fixed the loop."}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["question"]
assert body["turn_latency"]["llm_ms"] is not None
def test_audio_answer_transcribed_and_recorded(self, client) -> None:
defense_id = _start(client)["defense_id"]
wav_bytes = b"RIFF" + b"\x00" * 64
resp = client.post(
f"/v1/defense/{defense_id}/answer",
files={"audio": ("answer.wav", wav_bytes, "audio/wav")},
)
assert resp.status_code == 200, resp.text
stored = client.get(f"/v1/defense/{defense_id}").json()
learner_turns = [t for t in stored["turns"] if t["role"] == "learner"]
assert learner_turns, "learner turn missing after audio answer"
assert learner_turns[0]["text"] == "the fix was in the retry loop"
def test_empty_audio_is_422_not_500(self, client) -> None:
"""Zero-byte upload must 422 before the provider call (a real
provider would raise the same way the mock does — validate first)."""
defense_id = _start(client)["defense_id"]
resp = client.post(
f"/v1/defense/{defense_id}/answer",
files={"audio": ("answer.wav", b"", "audio/wav")},
)
assert resp.status_code == 422, resp.text
stored = client.get(f"/v1/defense/{defense_id}").json()
assert len(stored["turns"]) == 1 # nothing appended
def test_answer_after_finish_is_409(self, client) -> None:
"""A sealed transcript is append-only-no-more: the endpoints own
turn-vs-finalize sequencing (defense_store contract)."""
defense_id = _start(client)["defense_id"]
client.post(f"/v1/defense/{defense_id}/answer", data={"text": "a"})
assert client.post(f"/v1/defense/{defense_id}/finish").status_code == 200
resp = client.post(f"/v1/defense/{defense_id}/answer", data={"text": "late"})
assert resp.status_code == 409, resp.text
stored = client.get(f"/v1/defense/{defense_id}").json()
assert len(stored["turns"]) == 3 # ex, lrn, ex — no post-finish turns
def test_neither_text_nor_audio_422(self, client) -> None:
defense_id = _start(client)["defense_id"]
resp = client.post(f"/v1/defense/{defense_id}/answer")
assert resp.status_code == 422
def test_unknown_defense_404(self, client) -> None:
resp = client.post("/v1/defense/dfn-nope/answer", data={"text": "hi"})
assert resp.status_code == 404
class TestAudioEndpoint:
def test_examiner_turn_streams_wav(self, client) -> None:
defense_id = _start(client)["defense_id"]
resp = client.get(f"/v1/defense/{defense_id}/audio/0")
assert resp.status_code == 200
assert resp.content
assert resp.headers["content-type"].startswith("audio/")
def test_unknown_turn_404(self, client) -> None:
defense_id = _start(client)["defense_id"]
assert client.get(f"/v1/defense/{defense_id}/audio/42").status_code == 404
class TestFinishAndGet:
def test_full_loop_verdict_and_signals(self, client) -> None:
defense_id = _start(client)["defense_id"]
client.post(f"/v1/defense/{defense_id}/answer", data={"text": "answer one"})
finish = client.post(f"/v1/defense/{defense_id}/finish")
assert finish.status_code == 200, finish.text
body = finish.json()
assert body["verdict"]["verdict"] == "developing"
assert body["integrity_signals"]["pause_threshold_ms"]
stored = client.get(f"/v1/defense/{defense_id}").json()
assert stored["status"] == "finished"
assert stored["integrity_signals"]
# Must-Have #1: "verdict + transcript persisted" — the verdict must
# be retrievable from GET after finish, not only in the finish body.
assert stored["integrity_signals"]["verdict"]["verdict"] == "developing"
def test_long_pause_flagged(self, client, app) -> None:
from datetime import UTC, datetime
from ai_service.voice.defense_store import DefenseTurn
defense_id = _start(client)["defense_id"]
# inject a slow learner turn directly (simulated latency)
store = app.state.defense_store
store.append_turn(
defense_id,
DefenseTurn(
defense_id=defense_id,
seq=99,
role="learner",
text="slow reply",
ts=datetime.now(UTC),
latency_ms=30_000,
created_at=datetime.now(UTC),
),
)
finish = client.post(f"/v1/defense/{defense_id}/finish")
assert finish.status_code == 200
signals = finish.json()["integrity_signals"]
assert any(p["turn"] == 99 for p in signals["long_pauses"])
def test_unknown_defense_404_on_all(self, client) -> None:
assert client.post("/v1/defense/dfn-nope/finish").status_code == 404
assert client.get("/v1/defense/dfn-nope").status_code == 404
class TestServerVoiceRouteFixes:
"""MH-2c (D-041/G-16/G-12): codec-strip, size guard, format-aware TTS."""
def test_webm_codec_params_stripped_for_provider(self, client, app) -> None:
"""MediaRecorder sends 'audio/webm;codecs=opus' — the provider must
see the bare 'webm' (D-041), else a real STT endpoint 400s."""
received_fmts: list[str] = []
class ProbeVoice(MockVoiceProvider):
async def transcribe(self, audio: bytes, fmt: str):
received_fmts.append(fmt)
return await super().transcribe(audio, fmt)
app.state.voice_provider = ProbeVoice(["clean fmt seen"])
defense_id = _start(client)["defense_id"]
resp = client.post(
f"/v1/defense/{defense_id}/answer",
files={"audio": ("answer.webm", b"\x1a\x45\xa3\xdf", "audio/webm;codecs=opus")},
)
assert resp.status_code == 200, resp.text
assert received_fmts == ["webm"], (
f"codec params leaked to the provider: {received_fmts}"
)
assert resp.json()["question"]
def test_oversize_audio_413_before_provider_call(self, client, app) -> None:
"""G-12/D-041: the guard fires before any provider call — the client
renders an honest re-record prompt."""
called = {"n": 0}
class ProbeVoice(MockVoiceProvider):
async def transcribe(self, audio: bytes, fmt: str):
called["n"] += 1
return await super().transcribe(audio, fmt)
app.state.voice_provider = ProbeVoice(["x"])
defense_id = _start(client)["defense_id"]
settings = Settings()
too_big = b"\x00" * (settings.voice_max_audio_mb * 1024 * 1024 + 1)
resp = client.post(
f"/v1/defense/{defense_id}/answer",
files={"audio": ("answer.webm", too_big, "audio/webm")},
)
assert resp.status_code == 413
assert "re-record" in resp.json()["detail"]
assert called["n"] == 0, "provider must not be called for oversize audio"
def test_tts_media_type_maps_from_settings_enum(self, tmp_path: Path) -> None:
"""G-16: media_type follows voice_tts_format (was hardcoded wav)."""
from ai_service.identity.store import SQLiteIdentityStore
from ..conftest import SUITE_LEARNERS, seed_verified_identity
application = create_app(
Settings(
provider="mock",
voice_provider="mock",
voice_tts_format="opus",
learner_allowlist=SUITE_LEARNERS,
)
)
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity-opus.db")
seed_verified_identity(identity_store)
application.state.identity_store = identity_store # G-9
application.state.provider = ScriptedLLM()
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
application.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
application.state.trace_integrity = TraceIntegrityMap()
application.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
application.state.examiner_agent = ExaminerAgent(
application.state.provider,
Settings(provider="mock", voice_provider="mock"),
)
with TestClient(application) as c:
defense_id = _start(c)["defense_id"]
resp = c.get(f"/v1/defense/{defense_id}/audio/0")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("audio/opus"), (
resp.headers["content-type"]
)
def test_stt_provider_failure_is_502_never_500(self, client, app) -> None:
"""Cross-phase P0 regression (final review): a provider failure on
the audio-answer path (real endpoint outage — or the DEFAULT mock
provider's unscripted queue, which every default-configured
deployment hits on its first audio answer) must surface as an
honest 502 per the assessment/proctor house pattern — never an
unhandled 500. The transcript stays unaffected (typed answers
still work)."""
# Unscripted mock = exactly what create_app wires on default settings.
app.state.voice_provider = MockVoiceProvider()
defense_id = _start(client)["defense_id"]
resp = client.post(
f"/v1/defense/{defense_id}/answer",
files={"audio": ("answer.webm", b"\x1a\x45\xa3\xdf" * 64, "audio/webm")},
)
assert resp.status_code == 502, resp.text
assert "transcription failed" in resp.json()["detail"]
# the defense is still alive for typed answers (no poisoned state)
typed = client.post(f"/v1/defense/{defense_id}/answer", data={"text": "typed"})
assert typed.status_code == 200, typed.text