personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Tests for ObserverClient with mocked HTTP calls."""
5
6from __future__ import annotations
7
8import json
9from unittest.mock import MagicMock, patch
10
11import pytest
12import requests
13
14
15@pytest.fixture
16def mock_session():
17 with patch("observe.observer_client.requests.Session") as mock:
18 session = MagicMock()
19 mock.return_value = session
20 yield session
21
22
23@pytest.fixture
24def mock_config():
25 with (
26 patch("observe.observer_client.get_config") as mock,
27 patch("observe.observer_client.read_service_port") as mock_port,
28 ):
29 mock.return_value = {}
30 mock_port.return_value = 8000
31 yield mock
32
33
34@pytest.fixture
35def mock_journal(tmp_path):
36 with patch("observe.observer_client.get_journal") as mock:
37 mock.return_value = str(tmp_path)
38 yield tmp_path
39
40
41def test_observer_client_init(mock_session, mock_config):
42 from observe.observer_client import ObserverClient
43
44 client = ObserverClient("main-stream")
45
46 assert client._url == "http://localhost:8000"
47 assert client._key is None
48 assert client._name == "main-stream"
49 assert client._stream == "main-stream"
50 assert client._auto_register is True
51
52
53def test_observer_client_init_no_port(mock_session):
54 """When no config URL and no convey.port file, _url is empty."""
55 from observe.observer_client import ObserverClient
56
57 with (
58 patch("observe.observer_client.get_config") as cfg,
59 patch("observe.observer_client.read_service_port") as port,
60 ):
61 cfg.return_value = {}
62 port.return_value = None
63 client = ObserverClient("main-stream")
64
65 assert client._url == ""
66
67
68def test_observer_client_init_with_config(mock_session, mock_config):
69 from observe.observer_client import ObserverClient
70
71 mock_config.return_value = {
72 "observe": {
73 "observer": {
74 "url": "https://example.test/",
75 "key": "abc123",
76 "name": "named-observer",
77 "auto_register": False,
78 }
79 }
80 }
81
82 client = ObserverClient("main-stream")
83
84 assert client._url == "https://example.test"
85 assert client._key == "abc123"
86 assert client._name == "named-observer"
87 assert client._auto_register is False
88
89
90def test_auto_registration(mock_session, mock_config, mock_journal, tmp_path):
91 from observe.observer_client import ObserverClient
92
93 file1 = tmp_path / "audio.flac"
94 file1.write_bytes(b"audio")
95
96 create_response = MagicMock()
97 create_response.status_code = 200
98 create_response.json.return_value = {"key": "registered-key"}
99
100 upload_response = MagicMock()
101 upload_response.status_code = 200
102 upload_response.json.return_value = {"files": ["audio.flac"], "bytes": 5}
103
104 mock_session.post.side_effect = [create_response, upload_response]
105
106 client = ObserverClient("main-stream")
107 result = client.upload_segment("20250103", "120000_300", [file1])
108
109 assert result.success is True
110 assert client._key == "registered-key"
111 assert mock_session.post.call_args_list[0][0][0].endswith(
112 "/app/observer/api/create"
113 )
114 config = json.loads((mock_journal / "config" / "journal.json").read_text())
115 assert config["observe"]["observer"]["key"] == "registered-key"
116
117
118def test_existing_key_skips_registration(mock_session, mock_config, tmp_path):
119 from observe.observer_client import ObserverClient
120
121 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
122
123 file1 = tmp_path / "audio.flac"
124 file1.write_bytes(b"audio")
125
126 upload_response = MagicMock()
127 upload_response.status_code = 200
128 upload_response.json.return_value = {"files": ["audio.flac"], "bytes": 5}
129 mock_session.post.return_value = upload_response
130
131 client = ObserverClient("main-stream")
132 result = client.upload_segment("20250103", "120000_300", [file1])
133
134 assert result.success is True
135 assert mock_session.post.call_count == 1
136 assert mock_session.post.call_args[0][0].endswith("/app/observer/ingest/testkey123")
137
138
139def test_registration_retry(mock_session, mock_config, mock_journal, tmp_path):
140 from observe.observer_client import ObserverClient
141
142 file1 = tmp_path / "audio.flac"
143 file1.write_bytes(b"audio")
144
145 create_response = MagicMock()
146 create_response.status_code = 200
147 create_response.json.return_value = {"key": "registered-key"}
148
149 upload_response = MagicMock()
150 upload_response.status_code = 200
151 upload_response.json.return_value = {"files": ["audio.flac"], "bytes": 5}
152
153 mock_session.post.side_effect = [
154 requests.ConnectionError("no route"),
155 create_response,
156 upload_response,
157 ]
158
159 with patch("observe.observer_client.time.sleep"):
160 client = ObserverClient("main-stream")
161 result = client.upload_segment("20250103", "120000_300", [file1])
162
163 assert result.success is True
164 assert mock_session.post.call_count == 3
165
166
167def test_registration_403(mock_session, mock_config):
168 from observe.observer_client import ObserverClient
169
170 response = MagicMock()
171 response.status_code = 403
172 mock_session.post.return_value = response
173
174 client = ObserverClient("main-stream")
175 client._ensure_registered()
176
177 assert client._revoked is True
178 assert client._key is None
179
180
181def test_upload_segment_success(mock_session, mock_config, tmp_path):
182 from observe.observer_client import ObserverClient
183
184 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
185
186 file1 = tmp_path / "audio.flac"
187 file1.write_bytes(b"audio data")
188
189 mock_response = MagicMock()
190 mock_response.status_code = 200
191 mock_response.json.return_value = {"files": ["audio.flac"], "bytes": 10}
192 mock_session.post.return_value = mock_response
193
194 client = ObserverClient("main-stream")
195 result = client.upload_segment("20250103", "120000_300", [file1])
196
197 assert result.success is True
198 assert result.duplicate is False
199
200
201def test_upload_segment_retry(mock_session, mock_config, tmp_path):
202 from observe.observer_client import ObserverClient
203
204 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
205
206 file1 = tmp_path / "audio.flac"
207 file1.write_bytes(b"audio data")
208
209 failure = MagicMock()
210 failure.status_code = 500
211 failure.text = "Server error"
212
213 success = MagicMock()
214 success.status_code = 200
215 success.json.return_value = {"files": ["audio.flac"], "bytes": 10}
216
217 mock_session.post.side_effect = [failure, success]
218
219 with patch("observe.observer_client.time.sleep"):
220 client = ObserverClient("main-stream")
221 result = client.upload_segment("20250103", "120000_300", [file1])
222
223 assert result.success is True
224 assert mock_session.post.call_count == 2
225
226
227def test_upload_segment_403(mock_session, mock_config, tmp_path):
228 from observe.observer_client import ObserverClient
229
230 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
231
232 file1 = tmp_path / "audio.flac"
233 file1.write_bytes(b"audio data")
234
235 response = MagicMock()
236 response.status_code = 403
237 response.text = "Forbidden"
238 mock_session.post.return_value = response
239
240 client = ObserverClient("main-stream")
241 result = client.upload_segment("20250103", "120000_300", [file1])
242
243 assert result.success is False
244 assert client._revoked is True
245
246
247def test_upload_segment_all_retries_fail(mock_session, mock_config, tmp_path):
248 from observe.observer_client import ObserverClient
249
250 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
251
252 file1 = tmp_path / "audio.flac"
253 file1.write_bytes(b"audio data")
254
255 failure = MagicMock()
256 failure.status_code = 500
257 failure.text = "Server error"
258 mock_session.post.return_value = failure
259
260 with patch("observe.observer_client.time.sleep"):
261 client = ObserverClient("main-stream")
262 result = client.upload_segment("20250103", "120000_300", [file1])
263
264 assert result.success is False
265 assert mock_session.post.call_count == 3
266
267
268def test_relay_event_success(mock_session, mock_config):
269 from observe.observer_client import ObserverClient
270
271 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
272
273 response = MagicMock()
274 response.status_code = 200
275 mock_session.post.return_value = response
276
277 client = ObserverClient("main-stream")
278 result = client.relay_event("observe", "status", mode="idle")
279
280 assert result is True
281 assert mock_session.post.call_args[1]["json"] == {
282 "tract": "observe",
283 "event": "status",
284 "mode": "idle",
285 }
286
287
288def test_relay_event_403(mock_session, mock_config):
289 from observe.observer_client import ObserverClient
290
291 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
292
293 response = MagicMock()
294 response.status_code = 403
295 response.text = "Forbidden"
296 mock_session.post.return_value = response
297
298 client = ObserverClient("main-stream")
299 result = client.relay_event("observe", "status", mode="idle")
300
301 assert result is False
302 assert client._revoked is True
303
304
305def test_key_persistence(mock_session, mock_config, mock_journal):
306 from observe.observer_client import ObserverClient
307
308 client = ObserverClient("main-stream")
309 client._persist_key("persisted-key")
310
311 config = json.loads((mock_journal / "config" / "journal.json").read_text())
312 assert config == {"observe": {"observer": {"key": "persisted-key"}}}
313
314
315def test_key_persistence_preserves_existing(mock_session, mock_config, mock_journal):
316 from observe.observer_client import ObserverClient
317
318 config_dir = mock_journal / "config"
319 config_dir.mkdir()
320 config_path = config_dir / "journal.json"
321 config_path.write_text(
322 json.dumps(
323 {"identity": {"name": "Jer"}, "observe": {"tmux": {"enabled": True}}}
324 )
325 )
326
327 client = ObserverClient("main-stream")
328 client._persist_key("persisted-key")
329
330 config = json.loads(config_path.read_text())
331 assert config["identity"]["name"] == "Jer"
332 assert config["observe"]["tmux"]["enabled"] is True
333 assert config["observe"]["observer"]["key"] == "persisted-key"
334
335
336def test_cleanup_draft(tmp_path):
337 from observe.observer_client import cleanup_draft
338
339 draft_dir = tmp_path / "draft"
340 draft_dir.mkdir()
341 (draft_dir / "a.txt").write_text("a")
342 (draft_dir / "b.txt").write_text("b")
343
344 cleanup_draft(str(draft_dir))
345
346 assert not draft_dir.exists()
347
348
349def test_finalize_draft(tmp_path):
350 from observe.observer_client import finalize_draft
351
352 draft_dir = tmp_path / "091551_draft"
353 draft_dir.mkdir()
354 (draft_dir / "screen.webm").write_text("video")
355 (draft_dir / "audio.flac").write_text("audio")
356
357 result = finalize_draft(str(draft_dir), "091551_300")
358
359 assert result == str(tmp_path / "091551_300")
360 assert not draft_dir.exists()
361 final = tmp_path / "091551_300"
362 assert final.exists()
363 assert (final / "screen.webm").read_text() == "video"
364 assert (final / "audio.flac").read_text() == "audio"
365
366
367def test_upload_duplicate_response(mock_session, mock_config, tmp_path):
368 from observe.observer_client import ObserverClient
369
370 mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}}
371
372 file1 = tmp_path / "audio.flac"
373 file1.write_bytes(b"audio data")
374
375 response = MagicMock()
376 response.status_code = 200
377 response.json.return_value = {
378 "status": "duplicate",
379 "existing_segment": "120000_300",
380 "message": "All files already received",
381 }
382 mock_session.post.return_value = response
383
384 client = ObserverClient("main-stream")
385 result = client.upload_segment("20250103", "120000_300", [file1])
386
387 assert result.success is True
388 assert result.duplicate is True