personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4import math
5import os
6from datetime import datetime
7
8from flask import Flask
9
10from solstone.convey.utils import (
11 created,
12 format_date,
13 format_date_short,
14 format_month_day,
15 relative_time,
16 respond_collection,
17 safe_day_path,
18 safe_journal_path,
19 time_since,
20)
21from solstone.think.utils import day_path
22
23
24def _app_context():
25 return Flask(__name__).app_context()
26
27
28def test_format_date():
29 assert "2024" not in format_date("20240102")
30 assert format_date("bad") == "bad"
31
32
33def test_format_date_short(monkeypatch):
34 from datetime import datetime
35
36 # Mock today as Nov 29, 2025
37 class MockDatetime(datetime):
38 @classmethod
39 def now(cls):
40 return datetime(2025, 11, 29, 12, 0, 0)
41
42 monkeypatch.setattr("solstone.convey.utils.datetime", MockDatetime)
43
44 # Test relative dates
45 assert format_date_short("20251129") == "Today"
46 assert format_date_short("20251128") == "Yesterday"
47 assert format_date_short("20251130") == "Tomorrow"
48
49 # Test within past 6 days - should return day name
50 assert format_date_short("20251127") == "Thursday"
51 assert format_date_short("20251124") == "Monday"
52
53 # Test older date same year - short format without year
54 result = format_date_short("20250815")
55 assert "Aug" in result
56 assert "15" in result
57 assert "'" not in result # No year suffix
58
59 # Test date >6 months ago in different year - should have year suffix
60 result = format_date_short("20240301")
61 assert "Mar" in result
62 assert "'24" in result
63
64 # Test invalid date - should return input unchanged
65 assert format_date_short("bad") == "bad"
66
67
68def test_format_month_day():
69 two_digit = datetime(2026, 6, 22, 12, 0, 0).timestamp() * 1000
70 assert format_month_day(two_digit) == "jun 22"
71 one_digit = datetime(2026, 6, 2, 12, 0, 0).timestamp() * 1000
72 assert format_month_day(one_digit) == "jun 2"
73
74
75def test_time_since(monkeypatch):
76 monkeypatch.setattr("time.time", lambda: 120)
77 assert time_since(60) == "1 minute ago"
78
79
80def test_relative_time():
81 cases = [
82 (-1, "0 seconds"),
83 (math.inf, "0 seconds"),
84 (0, "0 seconds"),
85 (1, "1 second"),
86 (59, "59 seconds"),
87 (60, "1 minute"),
88 (119, "1 minute"),
89 (120, "2 minutes"),
90 (3599, "59 minutes"),
91 (3600, "1 hour"),
92 (7199, "1 hour"),
93 (7200, "2 hours"),
94 (86399, "23 hours"),
95 (86400, "1 day"),
96 (604799, "6 days"),
97 (604800, "1 week"),
98 (1209600, "2 weeks"),
99 (2419199, "3 weeks"),
100 (2419200, "1 month"),
101 (5183999, "1 month"),
102 (5184000, "2 months"),
103 (31536000, "12 months"),
104 ]
105 for seconds, expected in cases:
106 assert relative_time(seconds) == expected
107
108
109def test_list_day_folders(tmp_path, monkeypatch):
110 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
111 from solstone.think.utils import day_dirs
112
113 day_path("20240101")
114 day_path("20240103")
115 days = sorted(day_dirs().keys())
116 assert days == ["20240101", "20240103"]
117
118
119def test_respond_collection_default_total():
120 with _app_context():
121 response, status = respond_collection([{"id": 1}, {"id": 2}])
122 assert status == 200
123 body = response.get_json()
124 assert body == {"items": [{"id": 1}, {"id": 2}], "total": 2}
125 assert "next_cursor" not in body
126
127
128def test_respond_collection_explicit_total_omits_cursor():
129 with _app_context():
130 response, status = respond_collection([{"id": 1}], total=57)
131 assert status == 200
132 body = response.get_json()
133 assert body == {"items": [{"id": 1}], "total": 57}
134 assert "next_cursor" not in body
135
136
137def test_respond_collection_with_cursor():
138 with _app_context():
139 response, status = respond_collection([], total=99, cursor="20240102")
140 assert status == 200
141 assert response.get_json() == {
142 "items": [],
143 "total": 99,
144 "next_cursor": "20240102",
145 }
146
147
148def test_created_returns_201_without_location():
149 with _app_context():
150 response, status = created({"id": "abc", "name": "thing"})
151 assert status == 201
152 assert response.get_json() == {"id": "abc", "name": "thing"}
153 assert "Location" not in response.headers
154
155
156def test_created_sets_location_header():
157 with _app_context():
158 response, status = created({"id": "abc"}, location="/app/things/abc")
159 assert status == 201
160 assert response.headers["Location"] == "/app/things/abc"
161 assert response.get_json() == {"id": "abc"}
162
163
164def _assert_invalid_path_error(error):
165 assert error is not None
166 response, status = error
167 assert status == 400
168 assert response.get_json() == {
169 "error": "I couldn't use that path.",
170 "reason_code": "invalid_path",
171 "detail": "",
172 }
173
174
175def _assert_invalid_path_error_403(error):
176 assert error is not None
177 response, status = error
178 assert status == 403
179 assert response.get_json() == {
180 "error": "I couldn't use that path.",
181 "reason_code": "invalid_path",
182 "detail": "",
183 }
184
185
186def test_safe_journal_path_accepts_contained_path(tmp_path, monkeypatch):
187 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
188
189 path, error = safe_journal_path("facets/work/facet.json")
190
191 assert error is None
192 assert path == tmp_path / "facets" / "work" / "facet.json"
193 assert path.is_absolute()
194
195
196def test_safe_journal_path_rejects_invalid_relpaths(tmp_path, monkeypatch):
197 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
198
199 with _app_context():
200 for relpath in ("..", "../escape", "/etc/passwd", "a\\b", ""):
201 path, error = safe_journal_path(relpath)
202
203 assert path is None
204 _assert_invalid_path_error(error)
205
206
207def test_safe_journal_path_rejects_symlink_escape(tmp_path, monkeypatch):
208 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
209 outside = tmp_path.parent / f"{tmp_path.name}_outside"
210 outside.mkdir()
211 os.symlink(outside, tmp_path / "out")
212
213 with _app_context():
214 path, error = safe_journal_path("out/secret.txt")
215
216 assert path is None
217 _assert_invalid_path_error(error)
218
219
220def test_safe_day_path_accepts_contained_path(tmp_path, monkeypatch):
221 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
222 day = "20240101"
223 day_dir = day_path(day)
224
225 path, error = safe_day_path(day, "test/143022_300/mic_audio.flac")
226
227 assert error is None
228 assert path == day_dir / "test" / "143022_300" / "mic_audio.flac"
229 assert path.is_absolute()
230
231
232def test_safe_day_path_rejects_invalid_relpaths(tmp_path, monkeypatch):
233 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
234
235 with _app_context():
236 for relpath in ("..", "../escape", "/etc/passwd", "a\\b", ""):
237 path, error = safe_day_path("20240101", relpath)
238
239 assert path is None
240 _assert_invalid_path_error_403(error)
241
242
243def test_safe_day_path_rejects_symlink_escape(tmp_path, monkeypatch):
244 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
245 day_dir = day_path("20240101")
246 outside = tmp_path.parent / f"{tmp_path.name}_outside"
247 outside.mkdir()
248 os.symlink(outside, day_dir / "out")
249
250 with _app_context():
251 path, error = safe_day_path("20240101", "out/secret.txt")
252
253 assert path is None
254 _assert_invalid_path_error_403(error)
255
256
257def test_safe_day_path_keeps_date_like_first_segment_contained(tmp_path, monkeypatch):
258 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
259 day_dir = day_path("20240101")
260
261 path, error = safe_day_path("20240101", "20260101/foo.flac")
262
263 assert error is None
264 assert path is not None
265 assert path.is_relative_to(day_dir)