- Remove references to PLAN.md, SECURITY_ANALYSIS.md, deploy/ paths - Delete test_deploy.py (tests deleted K8s manifests) - Clean up eval/ references in comments and docstrings
520 lines
21 KiB
Python
520 lines
21 KiB
Python
"""End-to-end review flows through the UI (§10, P4)."""
|
|
|
|
from dataclasses import replace
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from mdmem.embed import FakeEmbedder
|
|
from mdmem.service import MemoryService
|
|
from mdmem.ui.app import build_ui_app
|
|
from mdmem.ui.auth import SESSION_COOKIE, Principal, SessionCodec
|
|
|
|
|
|
@pytest.fixture()
|
|
async def ui(settings):
|
|
s = replace(
|
|
settings,
|
|
session_secret="x" * 32,
|
|
ui_base_url="https://memory.test",
|
|
ui_allowed_origins=("https://memory.test",),
|
|
)
|
|
svc = MemoryService.build(s, embedder=FakeEmbedder(s.embed_dim))
|
|
await svc.start(watch=False)
|
|
app = build_ui_app(svc)
|
|
cookie = SessionCodec(s.session_secret, 12).issue(Principal("u1", "Markus", ""))
|
|
client = httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url="https://memory.test",
|
|
cookies={SESSION_COOKIE: cookie},
|
|
follow_redirects=False,
|
|
# A real browser sends Origin on every state-changing fetch/XHR/form POST;
|
|
# the CSRF guard now fails closed without it (§10, §14.21).
|
|
headers={"origin": "https://memory.test"},
|
|
)
|
|
yield client, svc
|
|
await client.aclose()
|
|
await svc.stop()
|
|
|
|
|
|
async def test_inbox_lists_unreviewed_notes(ui):
|
|
client, svc = ui
|
|
await svc.write_note(title="Neue Erkenntnis", body="Inhalt", type="fact", summary="kurz")
|
|
r = await client.get("/ui/inbox")
|
|
assert r.status_code == 200
|
|
assert "Neue Erkenntnis" in r.text and "kurz" in r.text
|
|
|
|
|
|
async def test_approve_moves_the_note_to_active(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Zu prüfen", body="Inhalt", type="fact")
|
|
r = await client.post(f"/ui/n/{res['id']}/approve", data={})
|
|
assert r.status_code == 303
|
|
assert svc.get_note(res["id"]).state == "active"
|
|
assert (await svc.search("Inhalt")).results[0].verified is True
|
|
|
|
|
|
async def test_edit_and_approve_saves_the_edited_body(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Roh", body="alter Text", type="fact")
|
|
await client.post(f"/ui/n/{res['id']}/approve", data={"body": "geprüfter Text"})
|
|
note = svc.get_note(res["id"])
|
|
assert note.state == "active" and note.body.strip() == "geprüfter Text"
|
|
|
|
|
|
async def test_reject_archives_with_a_reason(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Unsinn", body="nope", type="note")
|
|
await client.post(f"/ui/n/{res['id']}/reject", data={"reason": "falsch"})
|
|
note = svc.get_note(res["id"])
|
|
assert note.state == "archive"
|
|
assert note.fm["rejected"] is True and note.fm["rejected_reason"] == "falsch"
|
|
assert (await svc.search("nope")).results == []
|
|
|
|
|
|
async def test_revision_shows_a_diff_and_merges_into_the_target(ui):
|
|
client, svc = ui
|
|
base = await svc.write_note(title="Fakt", body="Der Wert ist 10", type="fact")
|
|
await svc.approve(base["id"])
|
|
version = svc.get_note(base["id"]).file_hash
|
|
proposal = await svc.update_note(base["id"], version, body="Der Wert ist 12")
|
|
|
|
page = await client.get("/ui/inbox")
|
|
assert "Revision of" in page.text
|
|
assert "-Der Wert ist 10" in page.text and "+Der Wert ist 12" in page.text
|
|
|
|
r = await client.post(f"/ui/n/{proposal['id']}/approve", data={})
|
|
assert r.status_code == 303
|
|
assert svc.get_note(base["id"]).body.strip() == "Der Wert ist 12"
|
|
assert svc.get_note(proposal["id"]).state == "archive"
|
|
|
|
|
|
async def test_duplicates_are_offered_for_merging(ui):
|
|
client, svc = ui
|
|
first = await svc.write_note(
|
|
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
|
|
)
|
|
await svc.approve(first["id"])
|
|
second = await svc.write_note(
|
|
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
|
|
)
|
|
assert second["possible_duplicates"], "an identical note should be flagged as a duplicate"
|
|
|
|
page = await client.get("/ui/inbox")
|
|
assert "Possible duplicates" in page.text
|
|
|
|
await client.post(f"/ui/n/{second['id']}/merge", data={"target": first["id"]})
|
|
assert svc.get_note(second["id"]).state == "archive"
|
|
assert svc.get_note(first["id"]).body.count("Bifrost ist das Gateway") == 2
|
|
|
|
|
|
async def test_note_page_shows_links_backlinks_and_history(ui):
|
|
client, svc = ui
|
|
target = await svc.write_note(title="evo-x2", body="die Kiste", type="entity")
|
|
src = await svc.write_note(title="llama-swap", body="läuft auf [[evo-x2]]", type="fact")
|
|
await svc.edit(
|
|
src["id"], body="läuft auf [[evo-x2]] (v2)", expected_version=svc.get_note(src["id"]).file_hash
|
|
)
|
|
|
|
page = await client.get(f"/ui/n/{src['id']}")
|
|
assert "evo-x2" in page.text and "Backlinks" in page.text
|
|
assert "History" in page.text and "no earlier versions" not in page.text
|
|
|
|
back = await client.get(f"/ui/n/{target['id']}")
|
|
assert "llama-swap" in back.text
|
|
|
|
|
|
async def test_history_diff_for_a_note_whose_file_vanished_returns_404(ui):
|
|
"""A note can have history but no current file if it was unlinked outside of
|
|
hard_delete (e.g. by hand, or a bug elsewhere). svc.get_note then raises
|
|
NotFound — the route must turn that into 404, not an uncaught 500 (§10)."""
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Verschwunden", body="v1", type="note")
|
|
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
|
|
stamps = svc.vault.history(res["id"])
|
|
assert stamps, "editing an existing note must snapshot the previous version"
|
|
|
|
path = svc.vault.find(res["id"])
|
|
assert path is not None
|
|
path.unlink()
|
|
|
|
r = await client.get(f"/ui/n/{res['id']}/history/{stamps[0]}")
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_edit_conflict_shows_a_diff_and_keeps_both_versions(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Konflikt", body="original", type="note")
|
|
stale = svc.get_note(res["id"]).file_hash
|
|
await svc.edit(res["id"], body="von woanders geändert", expected_version=stale)
|
|
|
|
r = await client.post(
|
|
f"/ui/n/{res['id']}/edit",
|
|
data={"body": "meine Fassung", "frontmatter": "", "expected_version": stale},
|
|
)
|
|
assert r.status_code == 409
|
|
assert "Conflict" in r.text
|
|
assert "meine Fassung" in r.text and "von woanders geändert" in r.text
|
|
assert svc.get_note(res["id"]).body.strip() == "von woanders geändert"
|
|
|
|
|
|
async def test_edit_saves_body_and_frontmatter(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Alt", body="alt", type="note", keywords=["a"])
|
|
version = svc.get_note(res["id"]).file_hash
|
|
page = await client.get(f"/ui/n/{res['id']}/edit")
|
|
assert page.status_code == 200 and "Frontmatter" in page.text
|
|
|
|
r = await client.post(
|
|
f"/ui/n/{res['id']}/edit",
|
|
data={
|
|
"body": "neuer Text",
|
|
"frontmatter": f"id: {res['id']}\ntitle: Neu\ntype: note\nkeywords: [a, b]\n",
|
|
"expected_version": version,
|
|
},
|
|
)
|
|
assert r.status_code == 303
|
|
note = svc.get_note(res["id"])
|
|
assert note.title == "Neu" and note.keywords == ["a", "b"] and note.body.strip() == "neuer Text"
|
|
|
|
|
|
async def test_invalid_frontmatter_is_rejected_with_400(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="T", body="b", type="note")
|
|
version = svc.get_note(res["id"]).file_hash
|
|
r = await client.post(
|
|
f"/ui/n/{res['id']}/edit",
|
|
data={"body": "x", "frontmatter": "title: [unclosed", "expected_version": version},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
async def test_live_preview_renders_markdown_without_html(ui):
|
|
client, _ = ui
|
|
r = await client.post("/ui/preview", data={"body": "**fett** <script>x</script>"})
|
|
assert "<strong>fett</strong>" in r.text and "<script>" not in r.text
|
|
|
|
|
|
async def test_archive_and_restore(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Hin und her", body="x", type="note")
|
|
await svc.approve(res["id"])
|
|
await client.post(f"/ui/n/{res['id']}/state", data={"state": "archive"})
|
|
assert svc.get_note(res["id"]).state == "archive"
|
|
await client.post(f"/ui/n/{res['id']}/state", data={"state": "active"})
|
|
assert svc.get_note(res["id"]).state == "active"
|
|
|
|
|
|
async def test_hard_delete_requires_the_id_and_removes_everything(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Weg", body="v1", type="note")
|
|
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
|
|
|
|
bad = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": "nope"})
|
|
assert bad.status_code == 400 and svc.vault.find(res["id"]) is not None
|
|
|
|
ok = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": res["id"]})
|
|
assert ok.status_code == 303
|
|
assert svc.vault.find(res["id"]) is None
|
|
assert svc.vault.history(res["id"]) == []
|
|
assert svc.db.read().execute("SELECT count(*) FROM notes").fetchone()[0] == 0
|
|
|
|
|
|
async def test_browse_filters_by_tag_and_shows_the_cloud(ui):
|
|
client, svc = ui
|
|
a = await svc.write_note(title="Mit Tag", body="x", type="note", tags=["homelab"])
|
|
await svc.write_note(title="Ohne Tag", body="y", type="note")
|
|
r = await client.get("/ui/browse?tag=homelab")
|
|
assert "Mit Tag" in r.text and "Ohne Tag" not in r.text
|
|
assert "#homelab" in r.text
|
|
assert a["id"] in r.text
|
|
|
|
|
|
async def test_search_page_shows_debug_scores(ui):
|
|
client, svc = ui
|
|
await svc.write_note(title="Suchbar", body="Bifrost Gateway", type="fact")
|
|
r = await client.get("/ui/search?q=Bifrost")
|
|
assert "Suchbar" in r.text and "via" in r.text and "fused" in r.text
|
|
|
|
|
|
async def test_health_page_reports_state_and_reindexes(ui):
|
|
client, svc = ui
|
|
await svc.write_note(title="Eins", body="x", type="note")
|
|
r = await client.get("/ui/health")
|
|
assert "Embeddings" in r.text and svc.settings.embed_model in r.text
|
|
|
|
r2 = await client.post("/ui/health/reindex", data={"full": "1"})
|
|
assert r2.status_code == 303
|
|
|
|
r3 = await client.post("/ui/health/reembed", data={})
|
|
assert r3.status_code == 303
|
|
assert svc.db.read().execute("SELECT count(*) FROM chunks WHERE vec_pending=1").fetchone()[0] == 0
|
|
|
|
|
|
async def test_note_body_cannot_inject_script_into_the_page(ui):
|
|
client, svc = ui
|
|
await svc.write_note(
|
|
title="Böse", body="<script>alert('xss')</script>\n\n<img src=x onerror=alert(1)>", type="note"
|
|
)
|
|
r = await client.get("/ui/inbox")
|
|
assert "<script>alert" not in r.text
|
|
assert "<img" not in r.text # escaped, so no live tag reaches the DOM
|
|
assert "<img src=x onerror=alert(1)>" in r.text
|
|
|
|
|
|
async def test_unknown_note_returns_404(ui):
|
|
client, _ = ui
|
|
r = await client.get("/ui/n/01JZZZZZZZZZZZZZZZZZZZZZZZ")
|
|
assert r.status_code == 404 and "NOT_FOUND" in r.text
|
|
|
|
|
|
# -- M-17: required Prometheus signals ---------------------------------------
|
|
|
|
|
|
async def test_metrics_exports_required_families_with_bounded_labels(ui):
|
|
client, svc = ui
|
|
note = await svc.write_note(title="Geheimer Titel", body="streng geheimer Inhalt", type="fact")
|
|
await svc.approve(note["id"])
|
|
await client.get("/ui/search?q=Geheimer")
|
|
svc.audit.record(tool="memory_search", args="deadbeef", source="mcp", ok=True, ids=[note["id"]], ms=1.2)
|
|
svc.audit.record(tool="memory_write", args="c0ffee", source="mcp", ok=False, error="INVALID", ms=0.5)
|
|
|
|
r = await client.get("/metrics")
|
|
assert r.status_code == 200
|
|
body = r.text
|
|
|
|
for line in (
|
|
"# TYPE mdmem_notes gauge",
|
|
"# TYPE mdmem_searches_total counter",
|
|
"# TYPE mdmem_writes_total counter",
|
|
"# TYPE mdmem_embed_errors_total counter",
|
|
"# TYPE mdmem_search_latency_seconds histogram",
|
|
"# TYPE mdmem_mcp_calls_total counter",
|
|
):
|
|
assert line in body, line
|
|
|
|
assert "mdmem_search_latency_seconds_count 1.0" in body
|
|
assert 'mdmem_mcp_calls_total{outcome="ok",tool="memory_search"} 1.0' in body
|
|
assert 'mdmem_mcp_calls_total{outcome="error",tool="memory_write"} 1.0' in body
|
|
# bounded cardinality: exactly the (tool, outcome) pairs recorded above, never a per-note label
|
|
assert body.count("mdmem_mcp_calls_total{") == 2
|
|
|
|
assert "Geheimer Titel" not in body and "streng geheimer Inhalt" not in body
|
|
assert note["id"] not in body
|
|
|
|
|
|
async def test_metrics_totals_only_ever_advance_across_scrapes(ui):
|
|
client, svc = ui
|
|
await client.get("/metrics")
|
|
await svc.write_note(title="Noch einer", body="x", type="note")
|
|
body = (await client.get("/metrics")).text
|
|
assert "mdmem_writes_total 1.0" in body
|
|
|
|
|
|
# -- M-18 (UI half): search state filter exactness ---------------------------
|
|
|
|
|
|
async def test_search_state_filter_returns_exactly_that_lifecycle_state(ui):
|
|
client, svc = ui
|
|
await svc.write_note(title="Ampel Inbox", body="Ampel steht", type="note")
|
|
active_note = await svc.write_note(title="Ampel Aktiv", body="Ampel steht", type="note")
|
|
await svc.approve(active_note["id"])
|
|
archive_note = await svc.write_note(title="Ampel Archiv", body="Ampel steht", type="note")
|
|
await svc.approve(archive_note["id"])
|
|
await svc.set_state(archive_note["id"], "archive")
|
|
|
|
r_inbox = await client.get("/ui/search?q=Ampel&state=inbox")
|
|
assert "Ampel Inbox" in r_inbox.text
|
|
assert "Ampel Aktiv" not in r_inbox.text and "Ampel Archiv" not in r_inbox.text
|
|
|
|
r_active = await client.get("/ui/search?q=Ampel&state=active")
|
|
assert "Ampel Aktiv" in r_active.text
|
|
assert "Ampel Inbox" not in r_active.text and "Ampel Archiv" not in r_active.text
|
|
|
|
r_archive = await client.get("/ui/search?q=Ampel&state=archive")
|
|
assert "Ampel Archiv" in r_archive.text
|
|
assert "Ampel Inbox" not in r_archive.text and "Ampel Aktiv" not in r_archive.text
|
|
|
|
r_default = await client.get("/ui/search?q=Ampel")
|
|
assert "Ampel Inbox" in r_default.text and "Ampel Aktiv" in r_default.text
|
|
assert "Ampel Archiv" not in r_default.text
|
|
|
|
|
|
# -- lower-severity plan drift ------------------------------------------------
|
|
|
|
|
|
async def test_edit_and_approve_with_emptied_body_saves_empty_body(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Leerbar", body="ursprünglicher Text", type="note")
|
|
r = await client.post(f"/ui/n/{res['id']}/approve", data={"body": ""})
|
|
assert r.status_code == 303
|
|
note = svc.get_note(res["id"])
|
|
assert note.state == "active"
|
|
assert note.body.strip() == ""
|
|
|
|
|
|
async def test_approve_without_a_body_field_keeps_the_original_body(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Unverändert", body="bleibt", type="note")
|
|
r = await client.post(f"/ui/n/{res['id']}/approve", data={})
|
|
assert r.status_code == 303
|
|
assert svc.get_note(res["id"]).body.strip() == "bleibt"
|
|
|
|
|
|
async def test_history_compare_between_any_two_revisions(ui):
|
|
client, svc = ui
|
|
res = await svc.write_note(title="Verlaufstest", body="v1", type="note")
|
|
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
|
|
await svc.edit(res["id"], body="v3", expected_version=svc.get_note(res["id"]).file_hash)
|
|
stamps = svc.vault.history(res["id"])
|
|
assert len(stamps) == 2
|
|
|
|
r = await client.get(f"/ui/n/{res['id']}/history/{stamps[0]}?other={stamps[1]}")
|
|
assert r.status_code == 200
|
|
assert "-v1" in r.text and "+v2" in r.text
|
|
|
|
r2 = await client.get(f"/ui/n/{res['id']}/history-diff?a={stamps[0]}&b={stamps[1]}")
|
|
assert r2.status_code == 200
|
|
assert "-v1" in r2.text and "+v2" in r2.text
|
|
|
|
page = await client.get(f"/ui/n/{res['id']}")
|
|
assert 'name="a"' in page.text and 'name="b"' in page.text and "history-diff" in page.text
|
|
|
|
|
|
async def test_browse_sorts_by_tags(ui):
|
|
client, svc = ui
|
|
await svc.write_note(title="Zeta Note", body="x", type="note", tags=["zzz"])
|
|
await svc.write_note(title="Beta Note", body="x", type="note", tags=["aaa"])
|
|
await svc.write_note(title="Kein Tag", body="x", type="note")
|
|
r = await client.get("/ui/browse?order=tags")
|
|
text = r.text
|
|
assert text.index("Kein Tag") < text.index("Beta Note") < text.index("Zeta Note")
|
|
|
|
|
|
async def test_health_page_shows_the_audit_source_and_eval_action(ui):
|
|
client, svc = ui
|
|
svc.audit.record(tool="memory_write", args="deadbeef", source="mcp", ok=True, ids=[], ms=3.0)
|
|
r = await client.get("/ui/health")
|
|
assert "mcp" in r.text
|
|
assert "Run eval" in r.text
|
|
|
|
|
|
async def test_run_eval_is_auth_and_csrf_protected_like_the_other_health_actions(ui):
|
|
client, svc = ui
|
|
ok = await client.post("/ui/health/eval", data={})
|
|
assert ok.status_code == 303
|
|
|
|
app = client._transport.app
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url="https://memory.test",
|
|
follow_redirects=False,
|
|
headers={"origin": "https://memory.test"},
|
|
) as anon:
|
|
no_session = await anon.post("/ui/health/eval", data={})
|
|
assert no_session.status_code == 302 and "/ui/auth/login" in no_session.headers["location"]
|
|
|
|
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url="https://memory.test",
|
|
cookies={SESSION_COOKIE: cookie},
|
|
follow_redirects=False,
|
|
) as bad_origin:
|
|
refused = await bad_origin.post(
|
|
"/ui/health/eval", data={}, headers={"origin": "https://evil.example.com"}
|
|
)
|
|
assert refused.status_code == 403
|
|
|
|
|
|
async def test_run_eval_keeps_its_files_on_the_data_volume_and_reports_status(ui, monkeypatch):
|
|
"""The pod runs with readOnlyRootFilesystem, so a relative results path
|
|
would fail at the write with only a log line. Both sides of the run must sit
|
|
next to index.db on the mounted volume."""
|
|
client, svc = ui
|
|
data_dir = svc.settings.index_db.parent
|
|
|
|
# no labelled query set yet: say so on the page instead of starting a job
|
|
# that exits 2 into the log
|
|
missing = await client.post("/ui/health/eval", data={})
|
|
assert missing.status_code == 303
|
|
assert "eval=missing" in missing.headers["location"]
|
|
assert str(data_dir) in missing.headers["location"]
|
|
|
|
queries = data_dir / "eval" / "queries.yaml"
|
|
queries.parent.mkdir(parents=True, exist_ok=True)
|
|
queries.write_text("- q: irgendwas\n relevant: []\n", encoding="utf-8")
|
|
|
|
called: dict[str, object] = {}
|
|
monkeypatch.setattr(
|
|
"mdmem.evalharness.run_eval_cli",
|
|
lambda settings, args: called.setdefault("args", args) and 0,
|
|
)
|
|
started = await client.post("/ui/health/eval", data={})
|
|
assert started.status_code == 303
|
|
assert "eval=started" in started.headers["location"]
|
|
|
|
args = called["args"]
|
|
assert args.queries == str(queries)
|
|
assert args.out is not None and args.out.startswith(str(data_dir))
|
|
|
|
# and the health page renders the status rather than swallowing it
|
|
page = await client.get("/ui/health", params={"eval": f"started:{data_dir.name}.json"})
|
|
assert page.status_code == 200 and "Eval started" in page.text
|
|
|
|
|
|
async def test_search_state_filter_still_finds_matches_below_the_first_page(ui):
|
|
"""Filtering a 20-row page down to one lifecycle state would hide a matching
|
|
inbox note whenever 20 active notes outrank it — the filter has to narrow a
|
|
wider candidate pool, not the page (M-18)."""
|
|
client, svc = ui
|
|
for i in range(25):
|
|
w = await svc.write_note(title=f"Bifrost Notiz {i}", body="Bifrost routet Modelle", type="fact")
|
|
await svc.approve(w["id"])
|
|
needle = await svc.write_note(title="Bifrost Posteingang", body="Bifrost routet Modelle", type="fact")
|
|
|
|
r = await client.get("/ui/search", params={"q": "Bifrost routet Modelle", "state": "inbox"})
|
|
assert r.status_code == 200
|
|
assert needle["id"] in r.text
|
|
|
|
|
|
async def test_an_inbox_note_can_be_reviewed_from_its_own_page(ui):
|
|
"""Fixing an LLM's frontmatter happens on the note page; without approve here
|
|
the owner has to navigate back to the inbox to act on what they just fixed."""
|
|
client, svc = ui
|
|
w = await svc.write_note(title="Needs a fix", body="v1", type="fact")
|
|
|
|
page = await client.get(f"/ui/n/{w['id']}")
|
|
assert "Approve" in page.text and "Reject" in page.text
|
|
|
|
r = await client.post(f"/ui/n/{w['id']}/approve", data={})
|
|
assert r.status_code == 303
|
|
assert svc.get_note(w["id"]).state == "active"
|
|
|
|
# and an active note offers no review buttons, only archive/edit/delete
|
|
page = await client.get(f"/ui/n/{w['id']}")
|
|
assert "/approve" not in page.text and "/reject" not in page.text
|
|
|
|
|
|
async def test_frontmatter_fixed_on_the_note_page_survives_approval(ui):
|
|
"""The whole point of the button: correct what the agent got wrong, then
|
|
accept it, without the fix being lost on the way."""
|
|
client, svc = ui
|
|
w = await svc.write_note(title="Wrong type", body="v1", type="note", tags=["bogus"])
|
|
note = svc.get_note(w["id"])
|
|
fm = note.to_text().split("---\n")[1].replace("type: note", "type: fact")
|
|
fm = fm.replace("- bogus", "- homelab")
|
|
|
|
saved = await client.post(
|
|
f"/ui/n/{w['id']}/edit",
|
|
data={"body": "v1 corrected", "frontmatter": fm, "expected_version": note.file_hash},
|
|
)
|
|
assert saved.status_code in (200, 303)
|
|
|
|
assert (await client.post(f"/ui/n/{w['id']}/approve", data={})).status_code == 303
|
|
approved = svc.get_note(w["id"])
|
|
assert approved.state == "active"
|
|
assert approved.type == "fact"
|
|
assert approved.tags == ["homelab"]
|
|
assert approved.body.strip() == "v1 corrected"
|