"""T25: drawer edits with manual_edit event capture. Each editable field on the drawer is exposed as a small POST endpoint. Edits emit either a ``manual_edit`` event (snapshotting the prior value for §6.4 reversibility) or, for pin toggles, a ``memory_pin_changed`` event with ``auto_pinned=0`` so manual pins survive auto-eviction. Phase 1 narrowed scope: affinity slider, significance dropdown, pin toggle. Other §6.4 fields (activity, edge_summary, edge_trust, pov_summary, knowledge_facts list) are deferred to a Phase 1.5 follow-up. """ 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: 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": "", }, ) # Edge bot_a -> you with affinity_delta=0 to materialise the row at # default 50/50. append_event( conn, kind="edge_update", payload={ "source_id": "bot_a", "target_id": "you", "chat_id": "chat_bot_a", "affinity_delta": 0, }, ) append_event( conn, kind="memory_written", payload={ "owner_id": "bot_a", "chat_id": "chat_bot_a", "pov_summary": "A memory", "witness_you": 1, "witness_host": 1, "witness_guest": 0, "significance": 1, }, ) project(conn) def test_edit_edge_affinity_emits_manual_edit_and_updates(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/edge/bot_a/you/affinity", data={"affinity": "75"}, ) assert response.status_code == 200 # returns refreshed drawer partial # Refresh shows the new affinity value. assert "75" in response.text with open_db(tmp_path / "test.db") as conn: cur = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(cur) == 1 payload = json.loads(cur[0][0]) assert payload["target_kind"] == "edge_affinity" assert payload["prior_value"] == 50 assert payload["new_value"] == 75 assert payload["target_id"]["source_id"] == "bot_a" assert payload["target_id"]["target_id"] == "you" from chat.state.edges import get_edge edge = get_edge(conn, "bot_a", "you") assert edge["affinity"] == 75 def test_edit_memory_significance_emits_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( f"/chats/chat_bot_a/drawer/memory/{memory_id}/significance", data={"significance": "3"}, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: sig = conn.execute( "SELECT significance FROM memories WHERE id = ?", (memory_id,) ).fetchone()[0] assert sig == 3 cur = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" ).fetchall() assert len(cur) == 1 payload = json.loads(cur[0][0]) assert payload["target_kind"] == "memory_significance" assert payload["prior_value"] == 1 assert payload["new_value"] == 3 assert payload["target_id"] == memory_id def test_toggle_memory_pin_manual_emits_event_with_auto_pinned_0(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( f"/chats/chat_bot_a/drawer/memory/{memory_id}/pin", data={"pinned": "1"}, ) assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: row = conn.execute( "SELECT pinned, auto_pinned FROM memories WHERE id = ?", (memory_id,) ).fetchone() assert row[0] == 1 assert row[1] == 0 # NOT auto-pinned (manual pin survives auto-eviction) # The pin toggle uses memory_pin_changed (not manual_edit). cur = conn.execute( "SELECT payload_json FROM event_log " "WHERE kind = 'memory_pin_changed' ORDER BY id DESC LIMIT 1" ).fetchone() payload = json.loads(cur[0]) assert payload["pinned"] == 1 assert payload["auto_pinned"] == 0 assert payload["memory_id"] == memory_id def test_edit_404_when_chat_missing(client): response = client.post( "/chats/no_such/drawer/edge/bot_a/you/affinity", data={"affinity": "75"}, ) assert response.status_code == 404 def test_edit_404_when_target_missing(client, tmp_path): _seed(tmp_path / "test.db") response = client.post( "/chats/chat_bot_a/drawer/memory/99999/significance", data={"significance": "2"}, ) assert response.status_code == 404