merge: T99 snapshot UX (manual trigger + list + restore + preview)
This commit is contained in:
@@ -33,6 +33,7 @@ from chat.web.kickoff import router as kickoff_router
|
|||||||
from chat.web.middleware import FirstRunRedirectMiddleware
|
from chat.web.middleware import FirstRunRedirectMiddleware
|
||||||
from chat.web.nav import router as nav_router
|
from chat.web.nav import router as nav_router
|
||||||
from chat.web.settings import router as settings_router
|
from chat.web.settings import router as settings_router
|
||||||
|
from chat.web.snapshots import router as snapshots_router
|
||||||
from chat.web.sse import router as sse_router
|
from chat.web.sse import router as sse_router
|
||||||
from chat.web.turns import router as turns_router
|
from chat.web.turns import router as turns_router
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|||||||
app.include_router(bots_router)
|
app.include_router(bots_router)
|
||||||
app.include_router(kickoff_router)
|
app.include_router(kickoff_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
|
app.include_router(snapshots_router)
|
||||||
app.include_router(nav_router)
|
app.include_router(nav_router)
|
||||||
app.include_router(chat_router)
|
app.include_router(chat_router)
|
||||||
app.include_router(drawer_router)
|
app.include_router(drawer_router)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
|
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
|
||||||
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
|
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
|
||||||
|
<li><a href="/snapshots" class="{% if active_nav == 'snapshots' %}active{% endif %}">Snapshots</a></li>
|
||||||
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
|
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}Snapshots - chat{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Snapshots</h1>
|
||||||
|
<form method="post" action="/snapshots/take" class="inline-edit">
|
||||||
|
<button type="submit">Take snapshot now</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if preview %}
|
||||||
|
<section class="snapshot-preview">
|
||||||
|
<h2>Preview: {{ preview.snapshot_id }}</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>kind</dt><dd>{{ preview.kind }}</dd>
|
||||||
|
<dt>filename</dt><dd>{{ preview.filename }}</dd>
|
||||||
|
<dt>file size (bytes)</dt><dd>{{ preview.file_size_bytes }}</dd>
|
||||||
|
<dt>snapshot last_event_id</dt><dd>{{ preview.last_event_id }}</dd>
|
||||||
|
<dt>current event_log max id</dt><dd>{{ preview.current_event_log_max_id }}</dd>
|
||||||
|
<dt>events since snapshot</dt><dd>{{ preview.event_delta }}</dd>
|
||||||
|
<dt>events stored in snapshot</dt><dd>{{ preview.event_log_rows_in_snapshot }}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if snapshots %}
|
||||||
|
<table class="snapshot-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Created (UTC)</th>
|
||||||
|
<th>Size (bytes)</th>
|
||||||
|
<th>last_event_id</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snap in snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snap.snapshot_id }}</td>
|
||||||
|
<td>{{ snap.kind }}</td>
|
||||||
|
<td>{{ snap.created_at }}</td>
|
||||||
|
<td>{{ snap.file_size_bytes }}</td>
|
||||||
|
<td>{{ snap.last_event_id if snap.last_event_id is not none else '?' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/snapshots/{{ snap.snapshot_id }}/preview?kind={{ snap.kind }}">Preview</a>
|
||||||
|
<details class="snapshot-row-restore">
|
||||||
|
<summary>Restore</summary>
|
||||||
|
<form method="post" action="/snapshots/restore/{{ snap.snapshot_id }}" class="inline-edit">
|
||||||
|
<input type="hidden" name="kind" value="{{ snap.kind }}">
|
||||||
|
<label>Type "{{ snap.snapshot_id }}" to confirm:
|
||||||
|
<input type="text" name="confirm_id" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Restore from this snapshot</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No snapshots yet. Use "Take snapshot now" to create one.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Snapshot UX routes (T99).
|
||||||
|
|
||||||
|
Surfaces the existing snapshot service (``chat/services/snapshot.py``)
|
||||||
|
through HTML so the user can see, take, restore, and preview snapshots
|
||||||
|
without dropping to a shell.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
* ``GET /snapshots`` list all snapshots (both kinds)
|
||||||
|
* ``POST /snapshots/take`` take a periodic snapshot now
|
||||||
|
* ``POST /snapshots/restore/{id}`` restore (requires matching ``confirm_id``)
|
||||||
|
* ``GET /snapshots/{id}/preview`` show metadata + delta vs current
|
||||||
|
|
||||||
|
The ``snapshot_id`` is the filename stem (the UTC timestamp written by
|
||||||
|
:func:`chat.services.snapshot.take_snapshot`) — there's no separate UUID,
|
||||||
|
and the timestamp filename is already unique per snapshot kind. Both
|
||||||
|
periodic and rewind snapshots share the same id space lookup-wise, so
|
||||||
|
the restore + preview routes accept ``kind`` as a form/query param to
|
||||||
|
disambiguate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from chat.services.snapshot import (
|
||||||
|
restore_from_snapshot,
|
||||||
|
take_snapshot,
|
||||||
|
)
|
||||||
|
from chat.web.bots import get_conn
|
||||||
|
|
||||||
|
TEMPLATES = Jinja2Templates(
|
||||||
|
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
SNAPSHOT_KINDS = ("periodic", "rewind")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_all_snapshots(data_dir: Path) -> list[dict]:
|
||||||
|
"""Walk ``data/snapshots/{kind}/`` for both kinds and collect metadata.
|
||||||
|
|
||||||
|
Each entry exposes the fields the template needs: ``snapshot_id``
|
||||||
|
(filename stem), ``kind``, ``created_at`` (file mtime as ISO), the
|
||||||
|
on-disk ``file_size_bytes``, and the snapshot's stored
|
||||||
|
``last_event_id`` (parsed from the JSON body — small enough that
|
||||||
|
listing isn't a performance concern for the handful of files we keep).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for kind in SNAPSHOT_KINDS:
|
||||||
|
snap_dir = data_dir / "snapshots" / kind
|
||||||
|
if not snap_dir.exists():
|
||||||
|
continue
|
||||||
|
for path in sorted(snap_dir.glob("*.json")):
|
||||||
|
try:
|
||||||
|
dump = json.loads(path.read_text())
|
||||||
|
last_event_id = dump.get("last_event_id", 0)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
# Corrupt or unreadable files still get listed so the
|
||||||
|
# user can see and delete them; just don't crash here.
|
||||||
|
last_event_id = None
|
||||||
|
stat = path.stat()
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"snapshot_id": path.stem,
|
||||||
|
"kind": kind,
|
||||||
|
"created_at": datetime.fromtimestamp(
|
||||||
|
stat.st_mtime, tz=timezone.utc
|
||||||
|
).isoformat(),
|
||||||
|
"file_size_bytes": stat.st_size,
|
||||||
|
"last_event_id": last_event_id,
|
||||||
|
"filename": path.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Newest first for display.
|
||||||
|
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_snapshot_path(
|
||||||
|
data_dir: Path, snapshot_id: str, kind: str
|
||||||
|
) -> Path:
|
||||||
|
"""Map an ``(id, kind)`` pair to the on-disk file, or 404."""
|
||||||
|
if kind not in SNAPSHOT_KINDS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"unknown kind: {kind}")
|
||||||
|
path = data_dir / "snapshots" / kind / f"{snapshot_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="snapshot not found")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/snapshots", response_class=HTMLResponse)
|
||||||
|
async def snapshots_list(request: Request):
|
||||||
|
settings = request.app.state.settings
|
||||||
|
rows = _list_all_snapshots(settings.data_dir)
|
||||||
|
return TEMPLATES.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"snapshots.html",
|
||||||
|
{"snapshots": rows, "active_nav": "snapshots"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshots/take")
|
||||||
|
async def snapshots_take(request: Request, conn=Depends(get_conn)):
|
||||||
|
"""Take a periodic snapshot now.
|
||||||
|
|
||||||
|
We use ``kind="periodic"`` for manual snapshots since they're
|
||||||
|
user-initiated checkpoints, not pre-rewind safety dumps. They count
|
||||||
|
against the 5-snapshot retention but that's fine — manual ones are
|
||||||
|
the most recent so they're the last to be pruned.
|
||||||
|
"""
|
||||||
|
settings = request.app.state.settings
|
||||||
|
take_snapshot(conn, data_dir=settings.data_dir, kind="periodic")
|
||||||
|
return RedirectResponse(url="/snapshots", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshots/restore/{snapshot_id}")
|
||||||
|
async def snapshots_restore(
|
||||||
|
snapshot_id: str,
|
||||||
|
request: Request,
|
||||||
|
confirm_id: str = Form(""),
|
||||||
|
kind: str = Form("periodic"),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Hard-confirm restore: ``confirm_id`` must equal the path id.
|
||||||
|
|
||||||
|
Mismatched confirm → 400 (without touching the DB). On match, the
|
||||||
|
existing :func:`restore_from_snapshot` clears projected tables and
|
||||||
|
re-loads them from the dump.
|
||||||
|
"""
|
||||||
|
if confirm_id != snapshot_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="confirm_id does not match snapshot id",
|
||||||
|
)
|
||||||
|
settings = request.app.state.settings
|
||||||
|
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
|
||||||
|
restore_from_snapshot(conn, path)
|
||||||
|
return RedirectResponse(url="/snapshots", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/snapshots/{snapshot_id}/preview", response_class=HTMLResponse)
|
||||||
|
async def snapshots_preview(
|
||||||
|
snapshot_id: str,
|
||||||
|
request: Request,
|
||||||
|
kind: str = "periodic",
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Show snapshot metadata + a basic delta against the current event log.
|
||||||
|
|
||||||
|
Phase 4 keeps this simple: the snapshot's ``last_event_id`` plus the
|
||||||
|
current ``MAX(event_log.id)`` is enough to tell the user how far the
|
||||||
|
log has moved on. A richer per-table diff is a Phase 4.5+ concern.
|
||||||
|
"""
|
||||||
|
settings = request.app.state.settings
|
||||||
|
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
|
||||||
|
dump = json.loads(path.read_text())
|
||||||
|
last_event_id = dump.get("last_event_id", 0)
|
||||||
|
|
||||||
|
cur = conn.execute("SELECT MAX(id) FROM event_log")
|
||||||
|
row = cur.fetchone()
|
||||||
|
current_max_id = row[0] if row[0] is not None else 0
|
||||||
|
|
||||||
|
stat = path.stat()
|
||||||
|
return TEMPLATES.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"snapshots.html",
|
||||||
|
{
|
||||||
|
"snapshots": _list_all_snapshots(settings.data_dir),
|
||||||
|
"active_nav": "snapshots",
|
||||||
|
"preview": {
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"kind": kind,
|
||||||
|
"filename": path.name,
|
||||||
|
"file_size_bytes": stat.st_size,
|
||||||
|
"last_event_id": last_event_id,
|
||||||
|
"current_event_log_max_id": current_max_id,
|
||||||
|
"event_delta": current_max_id - last_event_id,
|
||||||
|
"event_log_rows_in_snapshot": len(dump.get("event_log", [])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user