Files
chat/tests/test_snapshot_ux.py

205 lines
6.6 KiB
Python

"""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_restore_without_kind_returns_400(client, tmp_path):
"""T105: Missing or empty ``kind`` must be rejected with 400.
Previously ``kind`` defaulted to ``"periodic"``, which silently 404'd
when the caller meant a rewind snapshot. Tighten the contract so the
client must always pass an explicit, valid ``kind``.
"""
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": snapshot_id}, # no `kind`
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