dc00a4f8f5
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---
188 lines
7.3 KiB
Python
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
|