Files
nextcraft/apps/ai-service/tests/api/test_lab.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

88 lines
3.1 KiB
Python

"""Lab endpoint tests — LIVE trace contract (REQ-3-007).
v0.3 re-grounding: POST /v1/lab/feedback takes {learner_id, task_id}; the
digest is computed from the learner's real TraceStore events. No corpus
scenarios; empty trace is a valid "no telemetry yet" coaching path.
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
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.models import TelemetryEvent
from ai_service.telemetry.store import SQLiteTraceStore
T0 = datetime(2026, 9, 12, tzinfo=UTC)
def _event(seq: int, kind: str, payload: dict, offset_s: float):
return TelemetryEvent(
learner_id="lab-learner",
task_id="lab-task",
seq=seq,
kind=kind,
payload=payload,
ts=T0 + timedelta(seconds=offset_s),
sandbox_id="sbx-lab",
)
class StreamingMock(MockProvider):
"""Deterministic token stream for the SSE path."""
def _reply_for(self, messages, response_format):
return "Feedback grounded in your live session digest."
@pytest.fixture()
def client(tmp_path: Path) -> Iterator[TestClient]:
app = create_app(Settings(provider="mock"))
app.state.provider = StreamingMock()
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
app.state.trace_integrity = TraceIntegrityMap()
with TestClient(app) as c:
yield c
def test_live_trace_streams_full_envelope(client: TestClient) -> None:
store: SQLiteTraceStore = client.app.state.trace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
for e in [
_event(0, "file_diff", {"path": "x.py"}, 0),
_event(1, "command", {"cmd": "pytest -q"}, 10),
_event(2, "test_result", {"passed": False, "exit_code": 1}, 20),
_event(3, "test_result", {"passed": True, "exit_code": 0}, 40),
]:
store.append(e)
with client.stream(
"POST", "/v1/lab/feedback", json={"learner_id": "lab-learner", "task_id": "lab-task"}
) as resp:
assert resp.status_code == 200
body = "".join(chunk.decode() for chunk in resp.iter_raw())
assert '"agent": "lab"' in body or '"agent":"lab"' in body
assert '"task_id": "lab-task"' in body
assert '"type": "delta"' in body or '"type":"delta"' in body
def test_empty_trace_coaches_the_baseline(client: TestClient) -> None:
"""No telemetry is NOT an error — Lab coaches 'run the starter test'."""
with client.stream(
"POST", "/v1/lab/feedback", json={"learner_id": "lab-learner", "task_id": "no-events"}
) as resp:
assert resp.status_code == 200
def test_missing_fields_422(client: TestClient) -> None:
resp = client.post("/v1/lab/feedback", json={"learner_id": "x"})
assert resp.status_code == 422