personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_convey_utils.py
7.8 kB 265 lines
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)