"""T72: deferred v1 drawer edits + witness flag inline-edit. T25 shipped affinity / significance / pin. T72.1 fills in the rest of the §6.4 editable surface whose ``manual_edit`` projector dispatch was already in place (or, for ``edge_knowledge_fact``, added alongside the route): * ``POST /chats/{chat_id}/drawer/edge/trust`` — slider 0..100. * ``POST /chats/{chat_id}/drawer/edge/summary`` — textarea, capped 2000. * ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped. * ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact. T72.3 layers a witness-flag toggle on top: * ``POST /chats/{chat_id}/drawer/memory/witness`` — ``manual_edit`` with ``target_kind`` = ``memory_witness`` and a ``{flag, value}`` payload. Each test asserts (a) the ``manual_edit`` event lands in the log, (b) the projected table reflects the new value, and (c) the response is the refreshed drawer partial. """ 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 @pytest.fixture def client(tmp_path, monkeypatch): cfg = tmp_path / "config.toml" cfg.write_text('featherless_api_key = "test"\n') monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) db = tmp_path / "test.db" monkeypatch.setenv("CHAT_DB_PATH", str(db)) with TestClient(app) as c: if hasattr(app.state, "background_worker"): app.state.background_worker.enabled = False yield c def _seed(db: Path) -> None: """Seed a chat with one host bot, one host->you edge with a fact and summary already set, and one memory authored by ``bot_a`` witnessed by all three roles. Tests reach into projected state to verify edits. """ with open_db(db) as conn: append_event( conn, kind="bot_authored", payload={ "id": "bot_a", "name": "BotA", "persona": "...", "voice_samples": [], "traits": [], "backstory": "", "initial_relationship_to_you": "", "kickoff_prose": "", }, ) append_event( conn, kind="chat_created", payload={ "id": "chat_bot_a", "host_bot_id": "bot_a", "initial_time": "2026-04-26T20:00:00+00:00", "narrative_anchor": "Day 1", "weather": "", }, ) # Materialise edge bot_a -> you with a knowledge_fact already on it # so the remove path has something to consume. append_event( conn, kind="edge_update", payload={ "source_id": "bot_a", "target_id": "you", "chat_id": "chat_bot_a", "affinity_delta": 0, "knowledge_facts": ["studied physics together"], }, ) append_event( conn, kind="memory_written", payload={ "owner_id": "bot_a", "chat_id": "chat_bot_a", "pov_summary": "Original summary text.", "witness_you": 1, "witness_host": 1, "witness_guest": 0, "significance": 1, }, ) project(conn) # --- T72.1 tests ---------------------------------------------------------- def test_edit_edge_trust_emits_manual_edit_and_updates(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/trust", data={"source_id": "bot_a", "target_id": "you", "new_value": "73"}, ) assert response.status_code == 200 # Refresh shows the new trust value somewhere in the partial. assert "73" in response.text with open_db(tmp_path / "test.db") as conn: rows = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(rows) == 1 payload = json.loads(rows[0][0]) assert payload["target_kind"] == "edge_trust" assert payload["prior_value"] == 50 assert payload["new_value"] == 73 assert payload["target_id"] == { "source_id": "bot_a", "target_id": "you", } from chat.state.edges import get_edge edge = get_edge(conn, "bot_a", "you") assert edge["trust"] == 73 def test_edit_edge_trust_400_on_out_of_range(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/trust", data={"source_id": "bot_a", "target_id": "you", "new_value": "150"}, ) assert response.status_code == 400 def test_edit_edge_summary_emits_manual_edit_and_updates(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/summary", data={ "source_id": "bot_a", "target_id": "you", "new_summary": "BotA respects you and shares lab notes.", }, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: rows = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(rows) == 1 payload = json.loads(rows[0][0]) assert payload["target_kind"] == "edge_summary" assert payload["new_value"].startswith("BotA respects") assert payload["target_id"] == { "source_id": "bot_a", "target_id": "you", } summary = conn.execute( "SELECT summary FROM edges " "WHERE source_id = ? AND target_id = ?", ("bot_a", "you"), ).fetchone()[0] assert "respects" in summary # And the refreshed partial echoes the new summary back. assert "respects" in response.text def test_edit_edge_summary_400_on_overflow(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/summary", data={ "source_id": "bot_a", "target_id": "you", "new_summary": "x" * 2001, }, ) assert response.status_code == 400 def test_edit_memory_pov_summary_emits_manual_edit_and_updates( client, tmp_path ): _seed(tmp_path / "test.db") with open_db(tmp_path / "test.db") as conn: memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] response = client.post( "/chats/chat_bot_a/drawer/memory/pov-summary", data={ "memory_id": str(memory_id), "new_summary": "Cleaner per-POV restatement of the moment.", }, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: rows = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(rows) == 1 payload = json.loads(rows[0][0]) assert payload["target_kind"] == "memory_pov_summary" assert payload["prior_value"] == "Original summary text." assert payload["new_value"].startswith("Cleaner per-POV") assert payload["target_id"] == memory_id pov = conn.execute( "SELECT pov_summary FROM memories WHERE id = ?", (memory_id,) ).fetchone()[0] assert pov.startswith("Cleaner per-POV") assert "Cleaner per-POV" in response.text def test_edit_memory_pov_summary_404_when_wrong_chat(client, tmp_path): _seed(tmp_path / "test.db") with open_db(tmp_path / "test.db") as conn: memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] # Re-home the memory to a different chat to confirm the route's # cross-chat guard fires. conn.execute( "UPDATE memories SET chat_id = 'other_chat' WHERE id = ?", (memory_id,), ) conn.commit() response = client.post( "/chats/chat_bot_a/drawer/memory/pov-summary", data={"memory_id": str(memory_id), "new_summary": "..."}, ) assert response.status_code == 404 def test_edit_edge_knowledge_facts_add_emits_event_and_appends(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/knowledge-facts", data={ "source_id": "bot_a", "target_id": "you", "action": "add", "fact": "lent you a textbook", }, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: rows = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(rows) == 1 payload = json.loads(rows[0][0]) assert payload["target_kind"] == "edge_knowledge_fact" assert payload["new_value"] == { "action": "add", "fact": "lent you a textbook", } # Prior value snapshots the entire knowledge list before the edit. assert payload["prior_value"] == ["studied physics together"] from chat.state.edges import get_edge edge = get_edge(conn, "bot_a", "you") assert "lent you a textbook" in edge["knowledge"] assert "studied physics together" in edge["knowledge"] assert "lent you a textbook" in response.text def test_edit_edge_knowledge_facts_remove_drops_matching_fact(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/knowledge-facts", data={ "source_id": "bot_a", "target_id": "you", "action": "remove", "fact": "studied physics together", }, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: from chat.state.edges import get_edge edge = get_edge(conn, "bot_a", "you") assert "studied physics together" not in edge["knowledge"] rows = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() payload = json.loads(rows[0][0]) assert payload["target_kind"] == "edge_knowledge_fact" assert payload["new_value"]["action"] == "remove" def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/knowledge-facts", data={ "source_id": "bot_a", "target_id": "you", "action": "delete", "fact": "x", }, ) assert response.status_code == 400 # --- T72.3 tests (witness flag inline-edit) ------------------------------- def test_witness_flag_toggle_updates_memory_row(client, tmp_path): """Memory seeded with witness [you=1, host=1, guest=0]; toggling ``guest`` to 1 lands as ``witness_guest = 1`` after projection. """ _seed(tmp_path / "test.db") with open_db(tmp_path / "test.db") as conn: memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] response = client.post( "/chats/chat_bot_a/drawer/memory/witness", data={ "memory_id": str(memory_id), "flag": "guest", "new_value": "1", }, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: row = conn.execute( "SELECT witness_you, witness_host, witness_guest " "FROM memories WHERE id = ?", (memory_id,), ).fetchone() assert row == (1, 1, 1) def test_witness_flag_toggle_emits_manual_edit_event(client, tmp_path): _seed(tmp_path / "test.db") with open_db(tmp_path / "test.db") as conn: memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] response = client.post( "/chats/chat_bot_a/drawer/memory/witness", data={ "memory_id": str(memory_id), "flag": "guest", "new_value": "1", }, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: rows = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(rows) == 1 payload = json.loads(rows[0][0]) assert payload["target_kind"] == "memory_witness" assert payload["target_id"] == memory_id assert payload["prior_value"] == {"flag": "guest", "value": 0} assert payload["new_value"] == {"flag": "guest", "value": 1} def test_witness_flag_toggle_400_on_bad_flag(client, tmp_path): _seed(tmp_path / "test.db") with open_db(tmp_path / "test.db") as conn: memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0] response = client.post( "/chats/chat_bot_a/drawer/memory/witness", data={ "memory_id": str(memory_id), "flag": "narrator", "new_value": "1", }, ) assert response.status_code == 400