"""Tests for Task 99 — snapshot UX (manual trigger + list + restore + preview). Phase 4 surfaces the existing snapshot infrastructure (Phase 1 T20 / T31) through HTML routes so the user can: * see what snapshots exist, * take one on demand, * restore one with a hard confirm, * peek at metadata before restoring. The underlying service API lives in ``chat/services/snapshot.py`` and is already exercised by ``test_snapshot.py``; here we only verify the web surface wires the existing functions correctly. """ from __future__ import annotations import json from pathlib import Path import pytest from fastapi.testclient import TestClient from chat.app import app from chat.db.connection import open_db from chat.eventlog.log import append_event from chat.eventlog.projector import project def _bot_payload(bot_id: str, name: str) -> dict: return { "id": bot_id, "name": name, "persona": "fancy", "voice_samples": ["sample"], "traits": ["shy"], "backstory": "", "initial_relationship_to_you": "coworker", "kickoff_prose": "", } @pytest.fixture def client(tmp_path, monkeypatch): """A TestClient whose db + data_dir live under ``tmp_path``. ``load_settings`` derives ``data_dir`` from ``CHAT_DB_PATH``'s parent when ``CHAT_DATA_DIR`` is unset (see ``chat/config.py``), so this also isolates the ``data/snapshots/`` tree to ``tmp_path``. """ config_path = tmp_path / "config.toml" config_path.write_text('featherless_api_key = "test"\n') monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) with TestClient(app) as c: c.tmp_path = tmp_path # type: ignore[attr-defined] yield c def _seed_bot(db_path: Path, bot_id: str = "bot_a", name: str = "BotA") -> None: with open_db(db_path) as conn: append_event(conn, kind="bot_authored", payload=_bot_payload(bot_id, name)) project(conn) def _take_snapshot_via_service( db_path: Path, data_dir: Path, kind: str = "periodic" ) -> Path: from chat.services.snapshot import take_snapshot with open_db(db_path) as conn: return take_snapshot(conn, data_dir=data_dir, kind=kind) def test_list_snapshots_renders_page(client, tmp_path): _seed_bot(tmp_path / "test.db", "bot_a", "BotA") # Take two snapshots through the service so the listing has rows. p1 = _take_snapshot_via_service(tmp_path / "test.db", tmp_path, kind="periodic") p2 = _take_snapshot_via_service(tmp_path / "test.db", tmp_path, kind="rewind") response = client.get("/snapshots") assert response.status_code == 200 body = response.text # Both filenames should appear in the listing. assert p1.stem in body assert p2.stem in body # Both kinds should be visible. assert "periodic" in body assert "rewind" in body def test_take_snapshot_creates_new(client, tmp_path): _seed_bot(tmp_path / "test.db", "bot_a", "BotA") snapshot_dir = tmp_path / "snapshots" / "periodic" before = ( len(list(snapshot_dir.glob("*.json"))) if snapshot_dir.exists() else 0 ) response = client.post("/snapshots/take", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/snapshots" after = len(list(snapshot_dir.glob("*.json"))) assert after == before + 1 def test_restore_snapshot_with_correct_confirm(client, tmp_path): db_path = tmp_path / "test.db" _seed_bot(db_path, "bot_a", "BotA") snapshot_path = _take_snapshot_via_service( db_path, tmp_path, kind="periodic" ) snapshot_id = snapshot_path.stem # filename without extension # Mutate the DB after the snapshot was taken — restoring should erase # the new bot. with open_db(db_path) as conn: append_event( conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB") ) project(conn) bots_before = conn.execute( "SELECT id FROM bots ORDER BY id" ).fetchall() assert {r[0] for r in bots_before} == {"bot_a", "bot_b"} response = client.post( f"/snapshots/restore/{snapshot_id}", data={"confirm_id": snapshot_id, "kind": "periodic"}, follow_redirects=False, ) assert response.status_code == 303 with open_db(db_path) as conn: bots_after = conn.execute( "SELECT id FROM bots ORDER BY id" ).fetchall() # The post-snapshot bot should be gone. assert {r[0] for r in bots_after} == {"bot_a"} def test_restore_snapshot_wrong_confirm_400(client, tmp_path): db_path = tmp_path / "test.db" _seed_bot(db_path, "bot_a", "BotA") snapshot_path = _take_snapshot_via_service( db_path, tmp_path, kind="periodic" ) snapshot_id = snapshot_path.stem response = client.post( f"/snapshots/restore/{snapshot_id}", data={"confirm_id": "not_the_right_id", "kind": "periodic"}, follow_redirects=False, ) assert response.status_code == 400 def test_preview_renders_metadata(client, tmp_path): db_path = tmp_path / "test.db" _seed_bot(db_path, "bot_a", "BotA") snapshot_path = _take_snapshot_via_service( db_path, tmp_path, kind="periodic" ) snapshot_id = snapshot_path.stem # Append more events post-snapshot so the delta is non-zero. with open_db(db_path) as conn: append_event( conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB") ) project(conn) response = client.get( f"/snapshots/{snapshot_id}/preview", params={"kind": "periodic"} ) assert response.status_code == 200 body = response.text assert snapshot_id in body # Snapshot's last_event_id and current event_log size should appear. dump = json.loads(snapshot_path.read_text()) assert str(dump["last_event_id"]) in body