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

287 lines
11 KiB
Python

"""Variant API tests — generation, cache, distinctness over HTTP (Task 4-3-01).
Contract under test (api/variants.py, REQ-3-005):
POST /v1/variants {learner_id, template_id}
first request → 200 generated variant (task_id, seed, params,
statement, starter_files, competency_id)
repeat request → 200 the SAME cached variant with ZERO LLM
calls (D-029 reproducibility through the
whole HTTP stack)
unknown template → 404
POST /v1/variants {learner_id, competency_id}
bound competency → 200 the first template for that competency
unknown competency → 404
neither id given → 422
GET /v1/variants/{task_id}
generated earlier → 200 the stored variant (generate→get roundtrip)
unknown task → 404
GET /v1/variants?learner_id=...
→ 200 that learner's variants only (scoping); [] for a learner
with none.
Distinctness (REQ-3-005, API level): two learners POSTing the same
template receive distinct statements, seeds and task_ids, and GET by
task_id hands each back their own variant.
Wiring: per-test tmp-path SQLiteVariantStore + a pre-set VariantGenerator
(state-injection override — the lifespan adopts variant_store /
variant_generator from app.state instead of constructing them; same
pattern as test_grading.py / test_telemetry_ingest.py). The generator
binds a ScriptedRenderProvider so each test controls the render LLM
exactly, and counts calls so the cache test can assert the LLM was never
reached on the second POST.
Zero network: providers are MockProvider family members only (conftest
rule, enforced in _make_client).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.identity.store import SQLiteIdentityStore
from ai_service.llm.mock import MockProvider
from ai_service.main import create_app
from ai_service.variants.generator import VariantGenerator
from ai_service.variants.store import SQLiteVariantStore
from ai_service.variants.templates import get_template
from ..conftest import SUITE_LEARNERS, seed_verified_identity
LEARNER_A = "variant-learner-a"
LEARNER_B = "variant-learner-b"
TEMPLATE = "tpl-llm-judge"
COMPETENCY = "stack-orchestration-c007" # tpl-llm-judge's D-021 binding
class ScriptedRenderProvider(MockProvider):
"""Deterministic render whose statement embeds the params (distinct per
draw); counts calls so the cache test asserts zero LLM calls on the
second POST. Mirrors tests/variants/test_generator.py."""
def __init__(self) -> None:
super().__init__()
self.calls = 0
async def chat(self, messages, *, model, temperature=0.7, response_format=None):
self.calls += 1
import json
user = next(m.content for m in reversed(messages) if m.role == "user")
# Distinct per distinct params: hash the seeded slot lines.
fingerprint = abs(hash(user)) % 10_000
return json.dumps({"statement": f"Scripted variant #{fingerprint} — build it."})
@pytest.fixture()
def store(tmp_path: Path) -> Iterator[SQLiteVariantStore]:
s = SQLiteVariantStore(db_path=tmp_path / "variants.db")
yield s
s.close()
@pytest.fixture()
def provider() -> ScriptedRenderProvider:
return ScriptedRenderProvider()
def _make_client(
tmp_path: Path, store: SQLiteVariantStore, provider: MockProvider
) -> TestClient:
"""App + TestClient with a pre-set store + generator.
The lifespan adopts both (state-injection override); the generator
binds OUR provider, so the fixture — not Settings — scripts the LLM.
Cloud-free guard: the provider must be a MockProvider family member
(conftest rule, enforced here because this module builds its own
client rather than consuming the conftest one).
"""
assert isinstance(provider, MockProvider)
settings = Settings(
provider="mock",
db_path=tmp_path / "variant-test.db",
sandbox_dir=tmp_path / "sandboxes",
learner_allowlist=SUITE_LEARNERS,
)
app = create_app(settings)
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
seed_verified_identity(identity_store)
app.state.identity_store = identity_store # G-9
app.state.variant_store = store
if getattr(app.state, "variant_generator", None) is None:
app.state.variant_generator = VariantGenerator(
store, provider, model="gemma4:31b"
)
return TestClient(app)
def _post(
client: TestClient,
learner_id: str,
template_id: str | None = TEMPLATE,
competency_id: str | None = None,
):
return client.post(
"/v1/variants",
json={
"learner_id": learner_id,
**({"template_id": template_id} if template_id is not None else {}),
**({"competency_id": competency_id} if competency_id is not None else {}),
},
)
# -- POST: generation + response shape --------------------------------------------
class TestGenerate:
def test_post_returns_full_variant_payload(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(client, LEARNER_A)
assert response.status_code == 200
body = response.json()
assert body["learner_id"] == LEARNER_A
assert body["template_id"] == TEMPLATE
assert body["competency_id"] == COMPETENCY # enrichment from get_template
assert body["task_id"].startswith("task-") and len(body["task_id"]) == len("task-") + 16
assert body["seed"] # non-empty D-029 seed
assert body["statement"] # rendered, non-empty
template = get_template(TEMPLATE)
assert template is not None, "seed template must resolve"
assert body["starter_files"] == template.starter_files
assert body["params"] # seeded slot values
assert body["created_at"] # ISO timestamp travels
assert provider.calls == 1 # first POST renders exactly once
def test_post_then_get_roundtrip(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
posted = _post(client, LEARNER_A)
assert posted.status_code == 200
fetched = client.get(f"/v1/variants/{posted.json()['task_id']}")
assert fetched.status_code == 200
assert fetched.json() == posted.json() # identical stored variant
def test_post_regenerate_is_cache_hit_no_llm_call(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
first = _post(client, LEARNER_A)
assert first.status_code == 200
assert provider.calls == 1
second = _post(client, LEARNER_A) # same (learner, template) → cache
assert second.status_code == 200
assert second.json() == first.json() # D-029: the SAME variant
assert provider.calls == 1, "cached regenerate must not render again"
# -- POST: distinctness (REQ-3-005, API level) ------------------------------------
class TestDistinctLearners:
def test_two_learners_distinct_statements_and_task_ids(
self, tmp_path, store, provider
):
with _make_client(tmp_path, store, provider) as client:
a = _post(client, LEARNER_A)
b = _post(client, LEARNER_B)
assert a.status_code == 200 and b.status_code == 200
a_body, b_body = a.json(), b.json()
# ...and each learner GETs back exactly their own variant
a_fetched = client.get(f"/v1/variants/{a_body['task_id']}")
b_fetched = client.get(f"/v1/variants/{b_body['task_id']}")
assert a_body["statement"] != b_body["statement"]
assert a_body["seed"] != b_body["seed"]
assert a_body["task_id"] != b_body["task_id"]
assert a_body["competency_id"] == b_body["competency_id"] # same bar (a-5)
assert a_fetched.status_code == 200 and a_fetched.json() == a_body
assert b_fetched.status_code == 200 and b_fetched.json() == b_body
# -- POST: template / competency resolution ---------------------------------------
class TestTemplateResolution:
def test_unknown_template_404(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(client, LEARNER_A, template_id="tpl-does-not-exist")
assert response.status_code == 404
assert "no task template" in response.json()["detail"]
def test_competency_lookup_uses_first_bound_template(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(
client, LEARNER_A, template_id=None, competency_id=COMPETENCY
)
assert response.status_code == 200
body = response.json()
assert body["template_id"] == TEMPLATE # the competency's first template
assert body["competency_id"] == COMPETENCY
def test_unknown_competency_404(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(
client, LEARNER_A, template_id=None, competency_id="stack-none-c999"
)
assert response.status_code == 404
assert "no task template for competency" in response.json()["detail"]
def test_neither_id_given_422(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(client, LEARNER_A, template_id=None, competency_id=None)
assert response.status_code == 422
# -- GET: stored reads ------------------------------------------------------------
class TestStoredReads:
def test_get_unknown_task_404(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = client.get("/v1/variants/task-0000000000000000")
assert response.status_code == 404
assert "no stored variant" in response.json()["detail"]
def test_list_scoped_to_one_learner(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
assert _post(client, LEARNER_A).status_code == 200
assert _post(
client, LEARNER_A, template_id="tpl-guardrail-schema"
).status_code == 200
assert _post(client, LEARNER_B).status_code == 200
listed = client.get("/v1/variants", params={"learner_id": LEARNER_A})
empty = client.get("/v1/variants", params={"learner_id": "nobody"})
assert listed.status_code == 200
variants = listed.json()["variants"]
assert len(variants) == 2 # LEARNER_A's two, LEARNER_B's excluded
assert {v["learner_id"] for v in variants} == {LEARNER_A}
assert {v["template_id"] for v in variants} == {TEMPLATE, "tpl-guardrail-schema"}
# chronological ordering; both carry the competency enrichment
assert all(v["competency_id"] for v in variants)
assert empty.status_code == 200
assert empty.json()["variants"] == []
def test_list_missing_learner_param_422(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = client.get("/v1/variants")
assert response.status_code == 422