personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4from __future__ import annotations
5
6import json
7
8from solstone.convey import create_app
9from tests._baseline_harness import make_test_client
10from tests.test_surfaces_ledger import (
11 _commitment,
12 _minimal_facet_tree,
13 _utc_ms,
14 _write_story_activity,
15)
16
17PREFIX = "/api/ledger"
18
19
20def _assert_error(response, status: int) -> dict:
21 assert response.status_code == status
22 data = response.get_json()
23 assert data["reason_code"]
24 if status == 400:
25 assert data["detail"]
26 return data
27
28
29def _configure_journal(tmp_path, monkeypatch) -> None:
30 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
31 config_dir = tmp_path / "config"
32 config_dir.mkdir(parents=True, exist_ok=True)
33 (config_dir / "journal.json").write_text(
34 json.dumps({"setup": {"completed_at": 1}}),
35 encoding="utf-8",
36 )
37
38
39def _configure_unset_journal(tmp_path, monkeypatch) -> None:
40 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
41
42
43def _decision(
44 *,
45 owner: str = "Mina",
46 owner_entity_id: str | None = "mina",
47 action: str = "move launch review",
48 context: str = "Decision context.",
49) -> dict:
50 return {
51 "owner": owner,
52 "owner_entity_id": owner_entity_id,
53 "action": action,
54 "context": context,
55 }
56
57
58def test_ledger_list_collection_envelope_and_bound(tmp_path, monkeypatch):
59 _configure_journal(tmp_path, monkeypatch)
60 _minimal_facet_tree(tmp_path)
61 for index in range(25):
62 _write_story_activity(
63 "work",
64 "20260410",
65 f"meeting_{index:06d}_300",
66 _utc_ms(f"2026-04-10T09:{index:02d}:00Z"),
67 commitments=[_commitment(action=f"action number {index}")],
68 )
69 client = make_test_client(tmp_path)
70
71 response = client.get(PREFIX)
72
73 assert response.status_code == 200
74 data = response.get_json()
75 assert isinstance(data, dict)
76 assert len(data["items"]) == 20
77 assert data["total"] == 25
78
79
80def test_ledger_list_counterparty_filter_narrows_total(tmp_path, monkeypatch):
81 _configure_journal(tmp_path, monkeypatch)
82 _minimal_facet_tree(tmp_path)
83 for index in range(3):
84 _write_story_activity(
85 "work",
86 "20260410",
87 f"ravi_{index:06d}_300",
88 _utc_ms(f"2026-04-10T09:{index:02d}:00Z"),
89 commitments=[_commitment(action=f"ravi action {index}")],
90 )
91 for index in range(2):
92 _write_story_activity(
93 "work",
94 "20260410",
95 f"imani_{index:06d}_300",
96 _utc_ms(f"2026-04-10T10:{index:02d}:00Z"),
97 commitments=[
98 _commitment(
99 action=f"imani action {index}",
100 counterparty="Imani",
101 counterparty_entity_id="imani",
102 )
103 ],
104 )
105 client = make_test_client(tmp_path)
106
107 all_response = client.get(PREFIX)
108 filtered_response = client.get(f"{PREFIX}?counterparty=Ravi")
109
110 assert all_response.status_code == 200
111 assert filtered_response.status_code == 200
112 assert filtered_response.get_json()["total"] < all_response.get_json()["total"]
113
114
115def test_ledger_list_bad_state_returns_invalid_request_value(tmp_path, monkeypatch):
116 _configure_journal(tmp_path, monkeypatch)
117 _minimal_facet_tree(tmp_path)
118 client = make_test_client(tmp_path)
119
120 data = _assert_error(client.get(f"{PREFIX}?state=bogus"), 400)
121
122 assert data["reason_code"] == "invalid_request_value"
123
124
125def test_ledger_list_bad_sort_returns_invalid_request_value(tmp_path, monkeypatch):
126 _configure_journal(tmp_path, monkeypatch)
127 _minimal_facet_tree(tmp_path)
128 client = make_test_client(tmp_path)
129
130 data = _assert_error(client.get(f"{PREFIX}?sort=bogus"), 400)
131
132 assert data["reason_code"] == "invalid_request_value"
133
134
135def test_ledger_list_bad_age_days_gte_returns_invalid_request_value(
136 tmp_path, monkeypatch
137):
138 _configure_journal(tmp_path, monkeypatch)
139 _minimal_facet_tree(tmp_path)
140 client = make_test_client(tmp_path)
141
142 data = _assert_error(client.get(f"{PREFIX}?age_days_gte=abc"), 400)
143
144 assert data["reason_code"] == "invalid_request_value"
145
146
147def test_ledger_list_bad_closed_since_returns_invalid_day(tmp_path, monkeypatch):
148 _configure_journal(tmp_path, monkeypatch)
149 _minimal_facet_tree(tmp_path)
150 client = make_test_client(tmp_path)
151
152 notaday = _assert_error(client.get(f"{PREFIX}?closed_since=notaday"), 400)
153 bad_month = _assert_error(client.get(f"{PREFIX}?closed_since=20261301"), 400)
154
155 assert notaday["reason_code"] == "invalid_day"
156 assert bad_month["reason_code"] == "invalid_day"
157
158
159def test_ledger_decisions_collection_envelope_and_routing(tmp_path, monkeypatch):
160 _configure_journal(tmp_path, monkeypatch)
161 _minimal_facet_tree(tmp_path)
162 _write_story_activity(
163 "work",
164 "20260410",
165 "meeting_090000_300",
166 _utc_ms("2026-04-10T09:00:00Z"),
167 decisions=[_decision(action="move launch review")],
168 )
169 client = make_test_client(tmp_path)
170
171 response = client.get(f"{PREFIX}/decisions")
172
173 assert response.status_code == 200
174 data = response.get_json()
175 assert isinstance(data, dict)
176 assert data["total"] == 1
177 assert data["items"][0]["action"] == "move launch review"
178
179
180def test_ledger_decisions_bad_since_returns_invalid_day(tmp_path, monkeypatch):
181 _configure_journal(tmp_path, monkeypatch)
182 _minimal_facet_tree(tmp_path)
183 client = make_test_client(tmp_path)
184
185 data = _assert_error(client.get(f"{PREFIX}/decisions?since=bad"), 400)
186
187 assert data["reason_code"] == "invalid_day"
188
189
190def test_ledger_get_item_returns_recursive_dataclass_dict(tmp_path, monkeypatch):
191 _configure_journal(tmp_path, monkeypatch)
192 _minimal_facet_tree(tmp_path)
193 _write_story_activity(
194 "work",
195 "20260410",
196 "meeting_090000_300",
197 _utc_ms("2026-04-10T09:00:00Z"),
198 commitments=[_commitment()],
199 )
200 client = make_test_client(tmp_path)
201 item_id = client.get(PREFIX).get_json()["items"][0]["id"]
202
203 response = client.get(f"{PREFIX}/{item_id}")
204
205 assert response.status_code == 200
206 data = response.get_json()
207 assert data["id"] == item_id
208 assert data["sources"]
209 assert "facet" in data["sources"][0]
210
211
212def test_ledger_get_item_missing_returns_ledger_item_not_found(tmp_path, monkeypatch):
213 _configure_journal(tmp_path, monkeypatch)
214 _minimal_facet_tree(tmp_path)
215 client = make_test_client(tmp_path)
216
217 data = _assert_error(client.get(f"{PREFIX}/does-not-exist"), 404)
218
219 assert data["reason_code"] == "ledger_item_not_found"
220
221
222def test_ledger_close_flips_state(tmp_path, monkeypatch):
223 _configure_journal(tmp_path, monkeypatch)
224 _minimal_facet_tree(tmp_path)
225 _write_story_activity(
226 "work",
227 "20260410",
228 "meeting_090000_300",
229 _utc_ms("2026-04-10T09:00:00Z"),
230 commitments=[_commitment()],
231 )
232 client = make_test_client(tmp_path)
233 item_id = client.get(PREFIX).get_json()["items"][0]["id"]
234
235 closed_response = client.post(f"{PREFIX}/{item_id}/close", json={"note": "done"})
236 dropped_response = client.post(
237 f"{PREFIX}/{item_id}/close",
238 json={"note": "drop", "as_state": "dropped"},
239 )
240
241 assert closed_response.status_code == 200
242 assert closed_response.get_json()["state"] == "closed"
243 assert dropped_response.status_code == 200
244 assert dropped_response.get_json()["state"] == "dropped"
245
246
247def test_ledger_close_decision_id_returns_ledger_item_not_found(tmp_path, monkeypatch):
248 _configure_journal(tmp_path, monkeypatch)
249 _minimal_facet_tree(tmp_path)
250 _write_story_activity(
251 "work",
252 "20260410",
253 "meeting_090000_300",
254 _utc_ms("2026-04-10T09:00:00Z"),
255 decisions=[_decision()],
256 )
257 client = make_test_client(tmp_path)
258 decision_id = client.get(f"{PREFIX}/decisions").get_json()["items"][0]["id"]
259
260 data = _assert_error(
261 client.post(f"{PREFIX}/{decision_id}/close", json={"note": "x"}), 404
262 )
263
264 assert data["reason_code"] == "ledger_item_not_found"
265
266
267def test_ledger_close_no_body_returns_missing_request_body(tmp_path, monkeypatch):
268 _configure_journal(tmp_path, monkeypatch)
269 _minimal_facet_tree(tmp_path)
270 client = make_test_client(tmp_path)
271
272 data = _assert_error(client.post(f"{PREFIX}/does-not-exist/close"), 400)
273
274 assert data["reason_code"] == "missing_request_body"
275
276
277def test_ledger_close_non_json_returns_invalid_json_request(tmp_path, monkeypatch):
278 _configure_journal(tmp_path, monkeypatch)
279 _minimal_facet_tree(tmp_path)
280 client = make_test_client(tmp_path)
281
282 data = _assert_error(
283 client.post(
284 f"{PREFIX}/does-not-exist/close",
285 data="not json",
286 content_type="application/json",
287 ),
288 400,
289 )
290
291 assert data["reason_code"] == "invalid_json_request"
292
293
294def test_ledger_close_empty_note_returns_missing_required_field(tmp_path, monkeypatch):
295 _configure_journal(tmp_path, monkeypatch)
296 _minimal_facet_tree(tmp_path)
297 client = make_test_client(tmp_path)
298
299 data = _assert_error(
300 client.post(f"{PREFIX}/does-not-exist/close", json={"note": " "}), 400
301 )
302
303 assert data["reason_code"] == "missing_required_field"
304
305
306def test_ledger_close_bad_as_state_returns_invalid_request_value(tmp_path, monkeypatch):
307 _configure_journal(tmp_path, monkeypatch)
308 _minimal_facet_tree(tmp_path)
309 client = make_test_client(tmp_path)
310
311 data = _assert_error(
312 client.post(
313 f"{PREFIX}/does-not-exist/close",
314 json={"note": "x", "as_state": "weird"},
315 ),
316 400,
317 )
318
319 assert data["reason_code"] == "invalid_request_value"
320
321
322def test_ledger_redirects_to_init_when_setup_incomplete(tmp_path, monkeypatch):
323 _configure_unset_journal(tmp_path, monkeypatch)
324 app = create_app(journal=str(tmp_path))
325 app.config["TESTING"] = True
326 client = app.test_client()
327
328 response = client.get(PREFIX)
329
330 assert response.status_code == 302
331 assert "/init" in response.headers["Location"]