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

188 lines
7.3 KiB
Python

"""Test suite — conftest: mock provider only, zero network (enforced)."""
import shutil
import tempfile
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.llm.mock import MockProvider
from ai_service.main import create_app
# Sandbox workdirs need a filesystem that supports bind mounts from a user
# namespace: pytest's default tmpdir (/tmp) is overlayfs here and mount(2)
# fails with ENODEV ("special device ... does not exist"). Home is real ext4.
# P2 (Task 2-1-03, G-110): the root stays HOME-anchored (NOT tmp_path) —
# the same tmpfs-mount fabric constraint that applies in-repo applies here.
# The per-test `sandbox_dir` fixture removes its own mkdtemp dir on teardown;
# a crash mid-test can still leak one, so `_sweep_home_test_sandboxes`
# (session-scoped, below) sweeps the root itself at session end.
_HOME_ROOT = Path.home() / ".nextcraft" / "test-sandboxes"
#: App-dir-anchored DEFAULT sandbox root (``resolve_sandbox_dir`` anchors the
#: .env's relative ``AI_SANDBOX_DIR=sandboxes`` here): real sandboxes created
#: by suites that build Settings() without a sandbox_dir pin land under this
#: tree — untracked + gitignored runtime droppings, swept at session end.
_APP_DIR_SANDBOX_ROOT = Path(__file__).resolve().parents[1] / "ai_service" / "sandboxes"
# P1 harness wiring (D-054, Task 1-1-02): real-server and user-namespace
# tests legitimately poll 20-30s under full-suite load (uvicorn boot waits,
# namespace spawn/reap, telemetry round-trips). The 60s global timeout
# would clobber them; this hook widens ONLY the marked items to 180s.
# Marker-level timeouts beat the global default, so the override is
# structural, not config-ordering luck.
_SLOW_TIER_TIMEOUT_S = 180
def pytest_collection_modifyitems(config, items) -> None:
for item in items:
if item.get_closest_marker("real_server") or item.get_closest_marker("userns"):
item.add_marker(pytest.mark.timeout(_SLOW_TIER_TIMEOUT_S))
@pytest.fixture()
def sandbox_dir(tmp_path: Path) -> Iterator[Path]:
"""Per-test sandbox workdir root on a bind-mount-safe filesystem."""
_HOME_ROOT.mkdir(parents=True, exist_ok=True)
d = Path(tempfile.mkdtemp(prefix=f"nc-test-{tmp_path.name}-", dir=_HOME_ROOT))
yield d
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture(autouse=True, scope="session")
def _sweep_home_test_sandboxes() -> Iterator[None]:
"""P2 (Task 2-1-03, G-110): session-end sweep of the anchored test roots.
Removes ONLY test-created roots, never a user's real state:
* ``~/.nextcraft/test-sandboxes`` — the HOME-anchored root the
``sandbox_dir`` fixture creates (catches dirs leaked by a mid-test
crash where the per-test teardown never ran). The production root
``~/.nextcraft/sandboxes`` is a sibling this sweep never touches.
* ``ai_service/sandboxes/`` — the app-dir-anchored DEFAULT sandbox root
(``resolve_sandbox_dir`` anchors relative ``AI_SANDBOX_DIR=sandboxes``
from the .env against the app dir). Real-server suites that build
``Settings()`` without pinning ``sandbox_dir`` (e.g. the e2e credential
flow) create REAL sandboxes here; the tree is untracked + gitignored
(``apps/ai-service/**/sandboxes/``) runtime droppings.
``ignore_errors=True`` so a foreign file under a root (or a still-mounted
namespace) can never fail the run's exit path.
"""
yield
shutil.rmtree(_HOME_ROOT, ignore_errors=True)
shutil.rmtree(_APP_DIR_SANDBOX_ROOT, ignore_errors=True)
# G-9 (grill): the v0.5 identity gates land on variants/sandboxes/defense —
# routes every API suite exercises. The suite-wide learner roster + a
# seeded verified identity keep pre-existing tests green while the gate
# tests (test_identity.py) prove the 403/CTA composition on unverified ids.
SUITE_LEARNERS = [
"pilot-learner",
"pilot-learner-2",
"api-learner",
"defense-learner",
"grade-learner",
"lab-learner",
"p-learner",
"variant-learner-a",
"variant-learner-b",
"learner-001",
"lat-learner",
"ghost-learner",
]
def seed_verified_identity(store, learner_ids=SUITE_LEARNERS) -> None:
"""Insert a verified 18+ identity record per learner id (mock-marked).
A-304: records carry mock=True — the seed never masquerades as
production verification.
Idempotency is check-first (not exception-swallowed): a learner whose
seed record already exists (shared store) is skipped via get(); the
duplicate-id IntegrityError is the ONLY tolerated failure — any other
exception propagates, so a broken seeding can never masquerade as a
seeded store (P1 Task 1-2-03, MH-1f).
"""
from datetime import UTC, datetime
from sqlalchemy.exc import IntegrityError
from ai_service.identity.store import IdentityRecord
for lid in learner_ids:
seed_id = f"seed-{lid}"
if store.get(seed_id) is not None:
continue # already seeded (shared store)
try:
store.insert(
IdentityRecord(
id=seed_id,
learner_id=lid,
status="verified",
provider="mock",
verdict={"status": "verified", "age_band": "18+", "mock": True},
age_band="18+",
submitted_at=datetime.now(UTC),
verified_at=datetime.now(UTC),
)
)
except IntegrityError:
# Concurrent seeding won the insert race for this id — the
# record exists, which is all this fixture guarantees.
continue
@pytest.fixture()
def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
# P1 leak fix (Task 1-2-03, MH-1f): monkeypatch restores AI_PROVIDER on
# teardown — the run never leaks provider state into the process env.
monkeypatch.setenv("AI_PROVIDER", "mock")
# Hermetic stores (v0.3.6): db_path/sandbox_dir default to ~/.nextcraft —
# tests must never read or write real state; pin to the pytest tmp dir.
return Settings(
provider="mock",
model="gemma4:31b",
port=8421,
db_path=tmp_path / "nextcraft-test.db",
sandbox_dir=tmp_path / "sandboxes",
learner_allowlist=SUITE_LEARNERS,
)
@pytest.fixture()
def identity_store(tmp_path: Path) -> Iterator[Any]:
# P5: the yield-fixture's Iterator return is what mypy needs; the
# concrete type is imported INSIDE (lazy-import pattern preserved).
from ai_service.identity.store import SQLiteIdentityStore
store = SQLiteIdentityStore(db_path=tmp_path / "identity-test.db")
seed_verified_identity(store)
yield store
store.close()
@pytest.fixture()
def app(settings: Settings, identity_store):
application = create_app(settings)
application.state.identity_store = identity_store # state-injection (G-9)
return application
@pytest.fixture()
def client(app):
with TestClient(app) as c:
# Mechanical cloud-free guard (GRILL advisory a): the app under test
# MUST be wired to the deterministic mock provider.
assert isinstance(app.state.provider, MockProvider), (
f"tests must run against MockProvider, got {type(app.state.provider).__name__}"
)
yield c