diff --git a/packages/studyloop/tests/_sessions_db_template.py b/packages/studyloop/tests/_sessions_db_template.py new file mode 100644 index 000000000..ab7e8576f --- /dev/null +++ b/packages/studyloop/tests/_sessions_db_template.py @@ -0,0 +1,79 @@ +"""One migrated ``sessions.db`` template per process, copied per test. + +``studyloop.history._connection._connect`` creates the sessions database from +``SCHEMA_FILE`` and runs every migration the first time any caller opens a +path that does not exist yet. A test module that points ``STUDYLOOP_DB`` at a +fresh ``tmp_path / "sessions.db"`` therefore pays that bootstrap on its first +write — ~60 ms and a 1 MB, 75-table file per test on a laptop, and on a slow +runner disk the first step to stall (CI run 35350636500 timed out two seam +tests inside ``plan new`` creating the database). Seeding the path from a +template turns the bootstrap into one buffered file copy. + +This is deliberately *not* a shared live database. ``test_plan_application.py`` +(council review 1, F6) needs "no history" to be a fact about the test rather +than about what a shared file holds, so every test still gets its own file at +its own path; only the schema bootstrap is shared. + +The template is built through the production ``_connect`` — not a copy of its +steps — so a seeded database is what a genuine first connect would have +produced (``test_sessions_db_template.py`` pins the schema and +``user_version`` against a fresh bootstrap). ``connect_db`` sets +``journal_mode=WAL`` on it; the connection is closed before the first copy so +SQLite has checkpointed and removed the ``-wal``/``-shm`` sidecars, and the +main file is the whole database. + +Kept out of ``conftest.py`` for the same reason ``_readiness.py`` and +``_vault_isolation.py`` are: the helper is the subject of a guard test, and a +test cannot import symbols from a conftest in a way a type checker resolves. +""" + +from __future__ import annotations + +import atexit +import shutil +import tempfile +from pathlib import Path + +import pytest + +_TEMPLATE: Path | None = None + + +def _build(destination: Path) -> Path: + """Bootstrap ``destination`` exactly as the first production connect would.""" + from studyloop.history import _connection + + with pytest.MonkeyPatch.context() as env: + env.setenv("STUDYLOOP_DB", str(destination)) + # An absent config file falls through to defaults, so the config's own + # ``session_db`` key — which outranks STUDYLOOP_DB by design — cannot + # route the bootstrap at a test's or the learner's database. + env.setenv("STUDYLOOP_CONFIG", str(destination.parent / "no-config.yaml")) + conn = _connection._connect() + if conn is None: # pragma: no cover - agent-session-tools is a hard dependency here + raise RuntimeError("could not bootstrap the sessions.db template") + conn.close() + return destination + + +def template_path() -> Path: + """The process-wide migrated template; built on first use.""" + global _TEMPLATE + if _TEMPLATE is None: + root = Path(tempfile.mkdtemp(prefix="studyloop-test-sessions-template-")) + atexit.register(shutil.rmtree, root, ignore_errors=True) + _TEMPLATE = _build(root / "sessions.db") + return _TEMPLATE + + +def seed_sessions_db(destination: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Copy the template to ``destination`` and point ``STUDYLOOP_DB`` at it. + + The one-line replacement for ``monkeypatch.setenv("STUDYLOOP_DB", + str(tmp_path / "sessions.db"))``: same path, same isolation, no bootstrap + on the test's first write. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(template_path(), destination) + monkeypatch.setenv("STUDYLOOP_DB", str(destination)) + return destination diff --git a/packages/studyloop/tests/test_agent_prompt_contract.py b/packages/studyloop/tests/test_agent_prompt_contract.py index 6fecede4a..f18ca00ff 100644 --- a/packages/studyloop/tests/test_agent_prompt_contract.py +++ b/packages/studyloop/tests/test_agent_prompt_contract.py @@ -19,6 +19,7 @@ import click import pytest import typer +from _sessions_db_template import seed_sessions_db from typer.testing import CliRunner runner = CliRunner() @@ -455,7 +456,7 @@ def test_bridge_add_persists_to_the_database_not_config_yaml(tmp_path, monkeypat ) before = config_path.read_bytes() monkeypatch.setenv("STUDYLOOP_CONFIG", str(config_path)) - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) result = click_runner.invoke( cli, diff --git a/packages/studyloop/tests/test_cli_doctor.py b/packages/studyloop/tests/test_cli_doctor.py index d27f0a18a..ce1a49a23 100644 --- a/packages/studyloop/tests/test_cli_doctor.py +++ b/packages/studyloop/tests/test_cli_doctor.py @@ -7,6 +7,7 @@ import click import pytest +from _sessions_db_template import seed_sessions_db from click.testing import CliRunner from studyloop.doctor.models import CheckResult @@ -201,7 +202,7 @@ def _isolated_plans(self, tmp_path, monkeypatch): from studyloop.planning import store monkeypatch.setenv(store.PLANS_DIR_ENV, str(tmp_path / "study-plans")) - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) self.plans_dir = store.plans_dir() def test_doctor_names_each_active_but_unready_plan_with_its_blockers(self) -> None: diff --git a/packages/studyloop/tests/test_cli_plan_seam.py b/packages/studyloop/tests/test_cli_plan_seam.py index 070cdb649..da3bd97f3 100644 --- a/packages/studyloop/tests/test_cli_plan_seam.py +++ b/packages/studyloop/tests/test_cli_plan_seam.py @@ -25,6 +25,7 @@ import re import pytest +from _sessions_db_template import seed_sessions_db from click.testing import CliRunner from studyloop.cli import cli @@ -51,7 +52,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): @pytest.fixture(autouse=True) def isolated_checkpoint_db(tmp_path, monkeypatch): - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) @pytest.fixture diff --git a/packages/studyloop/tests/test_mcp_plan_record_seam.py b/packages/studyloop/tests/test_mcp_plan_record_seam.py index 4e7eadc7b..f77521006 100644 --- a/packages/studyloop/tests/test_mcp_plan_record_seam.py +++ b/packages/studyloop/tests/test_mcp_plan_record_seam.py @@ -15,6 +15,7 @@ pytest.importorskip("mcp") +from _sessions_db_template import seed_sessions_db from mcp.server.fastmcp.exceptions import ToolError from studyloop.planning import ( @@ -38,7 +39,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): def isolated_checkpoint_db(tmp_path, monkeypatch): """``store.create_plan`` refreshes the derived index in the sessions database; keep that off any developer database (council review 2, F10).""" - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) def _tool(): diff --git a/packages/studyloop/tests/test_mcp_plan_tools.py b/packages/studyloop/tests/test_mcp_plan_tools.py index 489c27ddd..f37ddd990 100644 --- a/packages/studyloop/tests/test_mcp_plan_tools.py +++ b/packages/studyloop/tests/test_mcp_plan_tools.py @@ -42,6 +42,7 @@ pytest.importorskip("mcp") +from _sessions_db_template import seed_sessions_db from mcp.server.fastmcp.exceptions import ToolError from studyloop.planning import ( @@ -121,7 +122,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): def isolated_checkpoint_db(tmp_path, monkeypatch): """``store.create_plan`` refreshes the derived index in the sessions database; keep that off any developer database (council review 2, F10).""" - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) @pytest.fixture diff --git a/packages/studyloop/tests/test_now_plan_guidance.py b/packages/studyloop/tests/test_now_plan_guidance.py index 74d26728e..2bd3d24df 100644 --- a/packages/studyloop/tests/test_now_plan_guidance.py +++ b/packages/studyloop/tests/test_now_plan_guidance.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING import pytest +from _sessions_db_template import seed_sessions_db from studyloop.learning import decision from studyloop.learning.decision import PlanRef, _Candidate, build_now_plan @@ -76,7 +77,7 @@ def isolate_now_world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: encoding="utf-8", ) monkeypatch.setenv("STUDYLOOP_CONFIG", str(config)) - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) monkeypatch.setenv("STUDYLOOP_STATE_DIR", str(tmp_path / "state")) monkeypatch.setenv(store.PLANS_DIR_ENV, str(tmp_path / "study-plans")) monkeypatch.setattr(decision, "datetime", _FrozenDatetime) diff --git a/packages/studyloop/tests/test_plan_application.py b/packages/studyloop/tests/test_plan_application.py index dd3053bf5..b8fbe5bfc 100644 --- a/packages/studyloop/tests/test_plan_application.py +++ b/packages/studyloop/tests/test_plan_application.py @@ -17,6 +17,7 @@ import json import pytest +from _sessions_db_template import seed_sessions_db from studyloop.planning import store from studyloop.planning.application import PlanApplication @@ -49,7 +50,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): def isolated_checkpoint_db(tmp_path, monkeypatch): """A fresh checkpoint database per test, so "no history" is a fact about this test rather than about what the suite's shared database holds (F6).""" - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) return tmp_path / "sessions.db" diff --git a/packages/studyloop/tests/test_plan_application_mutations.py b/packages/studyloop/tests/test_plan_application_mutations.py index d671ceeff..2e4db03b5 100644 --- a/packages/studyloop/tests/test_plan_application_mutations.py +++ b/packages/studyloop/tests/test_plan_application_mutations.py @@ -23,6 +23,7 @@ import json import pytest +from _sessions_db_template import seed_sessions_db from studyloop.planning import index as index_module from studyloop.planning import store @@ -63,7 +64,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): @pytest.fixture(autouse=True) def isolated_checkpoint_db(tmp_path, monkeypatch): """A fresh checkpoint database per test (council review 1, F6).""" - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) return tmp_path / "sessions.db" diff --git a/packages/studyloop/tests/test_plan_guidance.py b/packages/studyloop/tests/test_plan_guidance.py index f61d5a7a1..58c47b7aa 100644 --- a/packages/studyloop/tests/test_plan_guidance.py +++ b/packages/studyloop/tests/test_plan_guidance.py @@ -21,6 +21,7 @@ from datetime import UTC, date, datetime, timedelta import pytest +from _sessions_db_template import seed_sessions_db from studyloop.planning import store from studyloop.planning.application import PlanApplication @@ -44,7 +45,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): @pytest.fixture(autouse=True) def isolated_checkpoint_db(tmp_path, monkeypatch): - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) @pytest.fixture diff --git a/packages/studyloop/tests/test_plan_intent_snapshots.py b/packages/studyloop/tests/test_plan_intent_snapshots.py index 9aadb0527..b05564e1b 100644 --- a/packages/studyloop/tests/test_plan_intent_snapshots.py +++ b/packages/studyloop/tests/test_plan_intent_snapshots.py @@ -18,6 +18,7 @@ from collections.abc import Mapping import pytest +from _sessions_db_template import seed_sessions_db from studyloop.planning import CreatePlan, PlanApplication, store @@ -25,7 +26,7 @@ @pytest.fixture(autouse=True) def isolated_world(tmp_path, monkeypatch): monkeypatch.setenv(store.PLANS_DIR_ENV, str(tmp_path / "study-plans")) - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) def _answers() -> dict[str, object]: diff --git a/packages/studyloop/tests/test_plan_journey_combined.py b/packages/studyloop/tests/test_plan_journey_combined.py index 1c04c51f3..6c90e7a83 100644 --- a/packages/studyloop/tests/test_plan_journey_combined.py +++ b/packages/studyloop/tests/test_plan_journey_combined.py @@ -46,6 +46,7 @@ pytest.importorskip("fastapi") pytest.importorskip("mcp") +from _sessions_db_template import seed_sessions_db from fastapi.testclient import TestClient # pyright: ignore[reportMissingImports] from studyloop.planning import store @@ -111,7 +112,7 @@ def plans_dir(tmp_path, monkeypatch) -> Path: """One plans directory for BOTH the Web routes and the MCP tools — the point of the journey is that they see the same documents.""" monkeypatch.setenv(store.PLANS_DIR_ENV, str(tmp_path / "study-plans")) - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) return tmp_path / "study-plans" diff --git a/packages/studyloop/tests/test_plan_recording_failures.py b/packages/studyloop/tests/test_plan_recording_failures.py index 971337242..333a1f9dc 100644 --- a/packages/studyloop/tests/test_plan_recording_failures.py +++ b/packages/studyloop/tests/test_plan_recording_failures.py @@ -15,6 +15,7 @@ from __future__ import annotations import pytest +from _sessions_db_template import seed_sessions_db from studyloop.planning import evaluation as evaluation_module from studyloop.planning import index as index_module @@ -33,8 +34,8 @@ def isolated_plans_dir(tmp_path, monkeypatch): @pytest.fixture(autouse=True) def isolated_checkpoint_db(tmp_path, monkeypatch): - """A fresh sessions database per test; the schema is created on first connect.""" - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + """A fresh sessions database per test, seeded from the migrated template.""" + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) return tmp_path / "sessions.db" diff --git a/packages/studyloop/tests/test_session_start_purpose.py b/packages/studyloop/tests/test_session_start_purpose.py index 839aafc52..65c0ae66d 100644 --- a/packages/studyloop/tests/test_session_start_purpose.py +++ b/packages/studyloop/tests/test_session_start_purpose.py @@ -30,6 +30,7 @@ pytest.importorskip("fastapi") +from _sessions_db_template import seed_sessions_db from fastapi.testclient import TestClient # pyright: ignore[reportMissingImports] from studyloop.planning import store @@ -112,7 +113,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): @pytest.fixture(autouse=True) def _isolated_db(tmp_path, monkeypatch): - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) @pytest.fixture() diff --git a/packages/studyloop/tests/test_sessions_db_template.py b/packages/studyloop/tests/test_sessions_db_template.py new file mode 100644 index 000000000..570d8287f --- /dev/null +++ b/packages/studyloop/tests/test_sessions_db_template.py @@ -0,0 +1,146 @@ +"""The per-test ``sessions.db`` is a copy of one migrated template, not a bootstrap. + +``studyloop.history._connection._connect`` creates the sessions database from +``SCHEMA_FILE`` and runs every migration the first time any caller opens a +path that does not exist. Fourteen test modules pointed ``STUDYLOOP_DB`` at a +fresh ``tmp_path / "sessions.db"`` per test, so each test's first write — +usually ``plan new`` indexing its document — paid that bootstrap (~60 ms and +a 1 MB file locally, 241 times across those modules). On a slow runner disk +that step is the first to stall: CI run 35350636500 timed out two ``plan +close`` seam tests at 60 s each inside ``plan new`` *creating the database*, +before ``plan close`` ran. + +The fix keeps per-test isolation — ``test_plan_application.py`` (council +review 1, F6) needs "no history" to be a fact about the test, not about a +shared database — and removes the bootstrap from every test's critical path: +one template is built once per process through the production ``_connect``, +and each test receives its own copy at its own path. +""" + +from __future__ import annotations + +import os +import re +import sqlite3 +from pathlib import Path + +import _sessions_db_template as template_module +from _sessions_db_template import seed_sessions_db, template_path + + +def _schema(db: Path) -> set[tuple[str, str, str]]: + conn = sqlite3.connect(db) + try: + rows = conn.execute( + "SELECT type, name, COALESCE(sql, '') FROM sqlite_master " + "WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name" + ).fetchall() + return {(t, n, s) for t, n, s in rows} + finally: + conn.close() + + +def _user_version(db: Path) -> int: + conn = sqlite3.connect(db) + try: + return conn.execute("PRAGMA user_version").fetchone()[0] + finally: + conn.close() + + +def test_template_is_what_the_production_bootstrap_produces(tmp_path, monkeypatch) -> None: + """Same schema, same user_version as a genuine first ``_connect`` on a fresh path.""" + from agent_session_tools.migrations import CURRENT_VERSION + from studyloop.history import _connection + + fresh = tmp_path / "fresh" / "sessions.db" + monkeypatch.setenv("STUDYLOOP_DB", str(fresh)) + monkeypatch.setenv("STUDYLOOP_CONFIG", str(tmp_path / "no-config.yaml")) + conn = _connection._connect() + assert conn is not None + conn.close() + + template = template_path() + assert template.is_file() + assert _user_version(template) == CURRENT_VERSION == _user_version(fresh) + assert _schema(template) == _schema(fresh) + + +def test_seed_gives_each_test_its_own_file_and_skips_the_bootstrap( + tmp_path, monkeypatch, caplog +) -> None: + from studyloop.history import _connection + + first = seed_sessions_db(tmp_path / "a" / "sessions.db", monkeypatch) + second = seed_sessions_db(tmp_path / "b" / "sessions.db", monkeypatch) + + assert first != second and first.is_file() and second.is_file() + assert os.environ["STUDYLOOP_DB"] == str(second), "the last seed wins the env var" + + with caplog.at_level("INFO", logger="studyloop.history._connection"): + conn = _connection._connect() + assert conn is not None + conn.execute( + "INSERT INTO study_sessions (id, topic, started_at) VALUES ('s1', 'sql', '2026-01-01')" + ) + conn.commit() + conn.close() + assert "Created sessions DB" not in caplog.text, "a seeded path must not be bootstrapped" + + other = sqlite3.connect(first) + try: + assert other.execute("SELECT count(*) FROM study_sessions").fetchone()[0] == 0, ( + "a write to one test's database must not be visible from another's" + ) + finally: + other.close() + + +def test_template_is_built_once_per_process(monkeypatch) -> None: + template_path() # ensure built + + def must_not_rebuild(): # pragma: no cover - the assertion is that it is never called + raise AssertionError("template rebuilt") + + monkeypatch.setattr(template_module, "_build", must_not_rebuild) + assert template_path() == template_path() + + +def test_seed_copies_a_single_checkpointed_file(tmp_path, monkeypatch) -> None: + """No ``-wal`` / ``-shm`` sidecar travels with the template or the copy.""" + template = template_path() + assert sorted(p.name for p in template.parent.iterdir()) == ["sessions.db"] + + dest = seed_sessions_db(tmp_path / "sessions.db", monkeypatch) + assert sorted(p.name for p in dest.parent.glob("sessions.db*")) == ["sessions.db"] + conn = sqlite3.connect(dest) + try: + assert conn.execute("PRAGMA journal_mode").fetchone()[0] == "wal" + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + finally: + conn.close() + + +_FRESH_PATH_PATTERN = re.compile( + r'setenv\(\s*"STUDYLOOP_DB"\s*,\s*str\(tmp_path\s*/\s*"sessions\.db"\)' +) + + +def test_no_test_module_bootstraps_a_fresh_sessions_db_per_test() -> None: + """Lint: the pattern this module retires must not come back. + + A module that needs a database at ``tmp_path / "sessions.db"`` seeds it + with :func:`seed_sessions_db`; a module that builds its own database (a + drifted FTS index, a scope fixture) uses its own file name and is not + matched. + """ + tests_dir = Path(__file__).parent + offenders = sorted( + str(path.relative_to(tests_dir)) + for path in tests_dir.rglob("test_*.py") + if path != Path(__file__) and _FRESH_PATH_PATTERN.search(path.read_text(encoding="utf-8")) + ) + assert offenders == [], ( + "these modules bootstrap a fresh sessions.db per test; " + f"seed it from the template instead: {offenders}" + ) diff --git a/packages/studyloop/tests/test_web_plans_seam.py b/packages/studyloop/tests/test_web_plans_seam.py index 4abfa98f9..bf61296a6 100644 --- a/packages/studyloop/tests/test_web_plans_seam.py +++ b/packages/studyloop/tests/test_web_plans_seam.py @@ -20,6 +20,7 @@ pytest.importorskip("fastapi") +from _sessions_db_template import seed_sessions_db from fastapi.testclient import TestClient from studyloop.planning import PlanApplication, store @@ -35,7 +36,7 @@ def isolated_plans_dir(tmp_path, monkeypatch): @pytest.fixture(autouse=True) def isolated_checkpoint_db(tmp_path, monkeypatch): - monkeypatch.setenv("STUDYLOOP_DB", str(tmp_path / "sessions.db")) + seed_sessions_db(tmp_path / "sessions.db", monkeypatch) @pytest.fixture