183 lines
5.8 KiB
Python
183 lines
5.8 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_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
|