personal memory agent
0

Configure Feed

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

solstone / tests / test_export.py
43 kB 1188 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import hashlib 7import json 8from pathlib import Path 9from unittest.mock import MagicMock, patch 10 11import pytest 12import requests 13 14import solstone.think.utils as think_utils 15from solstone.observe.utils import compute_bytes_sha256 16 17 18def _setup_journal(tmp_path, *, include_stream_json: bool = False): 19 first_dir = tmp_path / "chronicle" / "20260413" / "laptop" / "143022_300" 20 second_dir = tmp_path / "chronicle" / "20260413" / "laptop" / "150000_600" 21 first_dir.mkdir(parents=True) 22 second_dir.mkdir(parents=True) 23 24 (first_dir / "audio.flac").write_bytes(b"audio-data-one") 25 (first_dir / "transcript.jsonl").write_bytes(b"transcript-one") 26 (second_dir / "audio.flac").write_bytes(b"audio-data-two") 27 (second_dir / "transcript.jsonl").write_bytes(b"transcript-two") 28 29 if include_stream_json: 30 (first_dir / "stream.json").write_bytes(b'{"name": "laptop"}') 31 (second_dir / "stream.json").write_bytes(b'{"name": "laptop"}') 32 33 return tmp_path 34 35 36def _set_journal_override(monkeypatch, journal_path): 37 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_path)) 38 think_utils._journal_path_cache = None 39 40 41def _make_session( 42 *, manifest_data=None, get_status=200, post_status=200, post_json=None 43): 44 mock = MagicMock(spec=requests.Session) 45 mock.headers = {} 46 47 get_response = MagicMock() 48 get_response.status_code = get_status 49 get_response.json.return_value = manifest_data if manifest_data is not None else {} 50 get_response.text = "GET error" 51 mock.get.return_value = get_response 52 53 post_response = MagicMock() 54 post_response.status_code = post_status 55 post_response.json.return_value = ( 56 post_json 57 if post_json is not None 58 else { 59 "segments_received": 1, 60 "segments_skipped": 0, 61 "segments_deconflicted": 0, 62 "errors": [], 63 } 64 ) 65 post_response.text = "POST error" 66 mock.post.return_value = post_response 67 68 return mock 69 70 71def _setup_entities(tmp_path): 72 """Create test entities in journal fixture.""" 73 entities_dir = tmp_path / "entities" 74 75 alice_dir = entities_dir / "alice_johnson" 76 alice_dir.mkdir(parents=True) 77 alice = { 78 "id": "alice_johnson", 79 "name": "Alice Johnson", 80 "type": "Person", 81 "created_at": 1000, 82 } 83 (alice_dir / "entity.json").write_text(json.dumps(alice), encoding="utf-8") 84 85 bob_dir = entities_dir / "bob_smith" 86 bob_dir.mkdir(parents=True) 87 bob = {"id": "bob_smith", "name": "Bob Smith", "type": "Person", "created_at": 2000} 88 (bob_dir / "entity.json").write_text(json.dumps(bob), encoding="utf-8") 89 90 blocked_dir = entities_dir / "blocked_user" 91 blocked_dir.mkdir(parents=True) 92 blocked = { 93 "id": "blocked_user", 94 "name": "Blocked", 95 "type": "Person", 96 "blocked": True, 97 "created_at": 3000, 98 } 99 (blocked_dir / "entity.json").write_text(json.dumps(blocked), encoding="utf-8") 100 101 return {"alice_johnson": alice, "bob_smith": bob, "blocked_user": blocked} 102 103 104def _entity_hash(entity: dict) -> str: 105 return hashlib.sha256( 106 json.dumps(entity, sort_keys=True, ensure_ascii=False).encode() 107 ).hexdigest() 108 109 110def _setup_facets(tmp_path): 111 """Create test facets in journal fixture.""" 112 facets_dir = tmp_path / "facets" 113 114 work_dir = facets_dir / "work" 115 work_dir.mkdir(parents=True) 116 (work_dir / "facet.json").write_text('{"title": "Work"}', encoding="utf-8") 117 118 ent_dir = work_dir / "entities" / "alice" 119 ent_dir.mkdir(parents=True) 120 (ent_dir / "entity.json").write_text('{"id": "alice"}', encoding="utf-8") 121 (ent_dir / "observations.jsonl").write_text('{"text": "obs1"}\n', encoding="utf-8") 122 123 det_dir = work_dir / "entities" 124 (det_dir / "20260413.jsonl").write_text('{"entity": "test"}\n', encoding="utf-8") 125 126 personal_dir = facets_dir / "personal" 127 personal_dir.mkdir(parents=True) 128 (personal_dir / "facet.json").write_text('{"title": "Personal"}', encoding="utf-8") 129 130 news_dir = personal_dir / "news" 131 news_dir.mkdir(parents=True) 132 (news_dir / "20260413.md").write_text("# News\nHello world\n", encoding="utf-8") 133 134 return facets_dir 135 136 137def _facet_file_hash(path: Path) -> str: 138 return hashlib.sha256(path.read_bytes()).hexdigest() 139 140 141def _setup_imports(tmp_path): 142 """Create test import metadata dirs in journal fixture.""" 143 imports_dir = tmp_path / "imports" 144 imports_dir.mkdir(parents=True, exist_ok=True) 145 146 dir1 = imports_dir / "20260101_090000" 147 dir1.mkdir() 148 import_json_1 = {"original_filename": "cal.zip", "file_size": 100} 149 imported_json_1 = { 150 "processed_timestamp": "20260101_090000", 151 "total_files_created": 1, 152 } 153 manifest_1 = [{"id": "event-0", "title": "Test Event"}] 154 (dir1 / "import.json").write_text(json.dumps(import_json_1), encoding="utf-8") 155 (dir1 / "imported.json").write_text(json.dumps(imported_json_1), encoding="utf-8") 156 (dir1 / "content_manifest.jsonl").write_text( 157 json.dumps(manifest_1[0]) + "\n", encoding="utf-8" 158 ) 159 160 dir2 = imports_dir / "20260102_100000" 161 dir2.mkdir() 162 import_json_2 = {"original_filename": "chat.zip", "file_size": 200} 163 imported_json_2 = { 164 "processed_timestamp": "20260102_100000", 165 "total_files_created": 2, 166 } 167 manifest_2 = [{"id": "conv-0", "title": "Test Convo"}] 168 (dir2 / "import.json").write_text(json.dumps(import_json_2), encoding="utf-8") 169 (dir2 / "imported.json").write_text(json.dumps(imported_json_2), encoding="utf-8") 170 (dir2 / "content_manifest.jsonl").write_text( 171 json.dumps(manifest_2[0]) + "\n", encoding="utf-8" 172 ) 173 174 (imports_dir / "plaud.json").write_text('{"last_sync": 123}', encoding="utf-8") 175 176 source_dir = imports_dir / "abcd1234" 177 source_dir.mkdir() 178 (source_dir / "segments").mkdir() 179 180 return { 181 "20260101_090000": { 182 "import_json": import_json_1, 183 "imported_json": imported_json_1, 184 "content_manifest": manifest_1, 185 }, 186 "20260102_100000": { 187 "import_json": import_json_2, 188 "imported_json": imported_json_2, 189 "content_manifest": manifest_2, 190 }, 191 } 192 193 194def _import_hash(import_data): 195 """Compute hash matching export_imports algorithm.""" 196 hash_input = json.dumps( 197 { 198 "import_json": import_data["import_json"], 199 "imported_json": import_data["imported_json"], 200 "content_manifest": import_data["content_manifest"], 201 }, 202 sort_keys=True, 203 ensure_ascii=False, 204 ).encode() 205 return hashlib.sha256(hash_input).hexdigest() 206 207 208def _setup_config(tmp_path): 209 """Create test config in journal fixture.""" 210 config_dir = tmp_path / "config" 211 config_dir.mkdir(parents=True, exist_ok=True) 212 config = { 213 "identity": {"name": "Test", "preferred": "Tester", "timezone": "UTC"}, 214 "convey": { 215 "allow_network_access": False, 216 "password_hash": "secret_hash", 217 "secret": "secret_val", 218 }, 219 "setup": {"completed_at": 12345}, 220 "env": {"KEY": "val"}, 221 "retention": {"days": 30}, 222 } 223 (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") 224 return config 225 226 227class TestExportSegments: 228 def test_manifest_query_and_delta(self, tmp_path, monkeypatch): 229 from solstone.observe.export import export_segments 230 231 journal = _setup_journal(tmp_path) 232 _set_journal_override(monkeypatch, journal) 233 234 manifest_data = { 235 "20260413": { 236 "laptop/143022_300": { 237 "files": [ 238 { 239 "name": "audio.flac", 240 "sha256": compute_bytes_sha256(b"audio-data-one"), 241 "size": len(b"audio-data-one"), 242 }, 243 { 244 "name": "transcript.jsonl", 245 "sha256": compute_bytes_sha256(b"transcript-one"), 246 "size": len(b"transcript-one"), 247 }, 248 ] 249 } 250 } 251 } 252 mock_session = _make_session(manifest_data=manifest_data) 253 254 with patch( 255 "solstone.observe.export.requests.Session", return_value=mock_session 256 ): 257 export_segments( 258 "https://example.com", "test-key", ["20260413"], dry_run=False 259 ) 260 261 assert mock_session.post.call_count == 1 262 metadata = json.loads(mock_session.post.call_args.kwargs["data"]["metadata"]) 263 assert metadata["segments"][0]["segment_key"] == "150000_600" 264 265 def test_dry_run_output(self, tmp_path, monkeypatch, capsys): 266 from solstone.observe.export import export_segments 267 268 journal = _setup_journal(tmp_path) 269 _set_journal_override(monkeypatch, journal) 270 271 mock_session = _make_session(manifest_data={}) 272 273 with patch( 274 "solstone.observe.export.requests.Session", return_value=mock_session 275 ): 276 export_segments( 277 "https://example.com", "test-key", ["20260413"], dry_run=True 278 ) 279 280 assert mock_session.post.call_count == 0 281 output = capsys.readouterr().out 282 assert "20260413: 2 segment(s)" in output 283 assert "Dry run: would send 2, skip 0" in output 284 285 def test_dry_run_skipped_not_double_counted(self, tmp_path, monkeypatch, capsys): 286 from solstone.observe.export import export_segments 287 288 journal = _setup_journal(tmp_path) 289 _set_journal_override(monkeypatch, journal) 290 291 manifest_data = { 292 "20260413": { 293 "laptop/143022_300": { 294 "files": [ 295 { 296 "name": "audio.flac", 297 "sha256": compute_bytes_sha256(b"audio-data-one"), 298 "size": len(b"audio-data-one"), 299 }, 300 { 301 "name": "transcript.jsonl", 302 "sha256": compute_bytes_sha256(b"transcript-one"), 303 "size": len(b"transcript-one"), 304 }, 305 ] 306 } 307 } 308 } 309 mock_session = _make_session(manifest_data=manifest_data) 310 311 with patch( 312 "solstone.observe.export.requests.Session", return_value=mock_session 313 ): 314 export_segments( 315 "https://example.com", "test-key", ["20260413"], dry_run=True 316 ) 317 318 assert mock_session.post.call_count == 0 319 output = capsys.readouterr().out 320 assert "20260413: 1 segment(s)" in output 321 assert "Dry run: would send 1, skip 1" in output 322 323 def test_retry_on_5xx(self, tmp_path, monkeypatch): 324 from solstone.observe.export import export_segments 325 326 journal = _setup_journal(tmp_path) 327 _set_journal_override(monkeypatch, journal) 328 329 second_dir = journal / "chronicle" / "20260413" / "laptop" / "150000_600" 330 for file_path in second_dir.iterdir(): 331 file_path.unlink() 332 second_dir.rmdir() 333 334 mock_session = _make_session(manifest_data={}) 335 first = MagicMock(status_code=500, text="server error") 336 second = MagicMock(status_code=500, text="server error") 337 success = MagicMock(status_code=200, text="ok") 338 success.json.return_value = { 339 "segments_received": 1, 340 "segments_skipped": 0, 341 "segments_deconflicted": 0, 342 "errors": [], 343 } 344 mock_session.post.side_effect = [first, second, success] 345 346 with ( 347 patch( 348 "solstone.observe.export.requests.Session", return_value=mock_session 349 ), 350 patch("solstone.observe.export.time.sleep") as mock_sleep, 351 ): 352 export_segments( 353 "https://example.com", "test-key", ["20260413"], dry_run=False 354 ) 355 356 assert mock_session.post.call_count == 3 357 assert mock_sleep.called 358 359 def test_auth_error_401(self): 360 from solstone.observe.export import _query_manifest 361 362 mock_session = _make_session(get_status=401) 363 364 with pytest.raises(ValueError, match="Authentication failed"): 365 _query_manifest(mock_session, "https://example.com", "test-key") 366 367 def test_auth_error_403(self): 368 from solstone.observe.export import _query_manifest 369 370 mock_session = _make_session(get_status=403) 371 372 with pytest.raises(ValueError, match="journal source revoked"): 373 _query_manifest(mock_session, "https://example.com", "test-key") 374 375 def test_connection_error(self, tmp_path, monkeypatch, capsys): 376 from solstone.observe.export import export_segments 377 378 journal = _setup_journal(tmp_path) 379 _set_journal_override(monkeypatch, journal) 380 381 mock_session = _make_session(manifest_data={}) 382 mock_session.get.side_effect = requests.ConnectionError 383 384 with patch( 385 "solstone.observe.export.requests.Session", return_value=mock_session 386 ): 387 export_segments( 388 "https://example.com", "test-key", ["20260413"], dry_run=False 389 ) 390 391 assert mock_session.post.call_count == 0 392 assert "Connection failed" in capsys.readouterr().out 393 394 def test_idempotent(self, tmp_path, monkeypatch, capsys): 395 from solstone.observe.export import export_segments 396 397 journal = _setup_journal(tmp_path) 398 _set_journal_override(monkeypatch, journal) 399 400 manifest_data = { 401 "20260413": { 402 "laptop/143022_300": { 403 "files": [ 404 { 405 "name": "audio.flac", 406 "sha256": compute_bytes_sha256(b"audio-data-one"), 407 "size": len(b"audio-data-one"), 408 }, 409 { 410 "name": "transcript.jsonl", 411 "sha256": compute_bytes_sha256(b"transcript-one"), 412 "size": len(b"transcript-one"), 413 }, 414 ] 415 }, 416 "laptop/150000_600": { 417 "files": [ 418 { 419 "name": "audio.flac", 420 "sha256": compute_bytes_sha256(b"audio-data-two"), 421 "size": len(b"audio-data-two"), 422 }, 423 { 424 "name": "transcript.jsonl", 425 "sha256": compute_bytes_sha256(b"transcript-two"), 426 "size": len(b"transcript-two"), 427 }, 428 ] 429 }, 430 } 431 } 432 mock_session = _make_session(manifest_data=manifest_data) 433 434 with patch( 435 "solstone.observe.export.requests.Session", return_value=mock_session 436 ): 437 export_segments( 438 "https://example.com", "test-key", ["20260413"], dry_run=False 439 ) 440 441 assert mock_session.post.call_count == 0 442 assert "up to date" in capsys.readouterr().out 443 444 def test_upload_error_isolation(self, tmp_path, monkeypatch, capsys): 445 from solstone.observe.export import export_segments 446 447 journal = _setup_journal(tmp_path) 448 _set_journal_override(monkeypatch, journal) 449 450 mock_session = _make_session(manifest_data={}) 451 first = MagicMock(status_code=400, text="bad request") 452 second = MagicMock(status_code=200, text="ok") 453 second.json.return_value = { 454 "segments_received": 1, 455 "segments_skipped": 0, 456 "segments_deconflicted": 0, 457 "errors": [], 458 } 459 mock_session.post.side_effect = [first, second] 460 461 with patch( 462 "solstone.observe.export.requests.Session", return_value=mock_session 463 ): 464 export_segments( 465 "https://example.com", "test-key", ["20260413"], dry_run=False 466 ) 467 468 assert mock_session.post.call_count == 2 469 output = capsys.readouterr().out 470 assert "1 sent" in output 471 assert "1 failed" in output 472 473 def test_stream_json_excluded(self, tmp_path, monkeypatch): 474 from solstone.observe.export import export_segments 475 476 journal = _setup_journal(tmp_path, include_stream_json=True) 477 _set_journal_override(monkeypatch, journal) 478 479 mock_session = _make_session(manifest_data={}) 480 481 with patch( 482 "solstone.observe.export.requests.Session", return_value=mock_session 483 ): 484 export_segments( 485 "https://example.com", "test-key", ["20260413"], dry_run=False 486 ) 487 488 for call in mock_session.post.call_args_list: 489 post_kwargs = call.kwargs 490 metadata = json.loads(post_kwargs["data"]["metadata"]) 491 assert "stream.json" not in metadata["segments"][0]["files"] 492 uploaded_names = [entry[1][0] for entry in post_kwargs["files"]] 493 assert "stream.json" not in uploaded_names 494 495 496class TestExportEntities: 497 def test_manifest_delta(self, tmp_path, monkeypatch): 498 from solstone.observe.export import export_entities 499 500 entities = _setup_entities(tmp_path) 501 _set_journal_override(monkeypatch, tmp_path) 502 503 manifest_data = { 504 "received": { 505 "alice_johnson": _entity_hash(entities["alice_johnson"]), 506 } 507 } 508 post_json = { 509 "created": 1, 510 "auto_merged": 0, 511 "staged": 0, 512 "skipped": 0, 513 "errors": [], 514 } 515 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json) 516 517 with patch( 518 "solstone.observe.export.requests.Session", return_value=mock_session 519 ): 520 export_entities("https://example.com", "test-key", dry_run=False) 521 522 assert mock_session.post.call_count == 1 523 posted_data = mock_session.post.call_args.kwargs.get( 524 "json" 525 ) or mock_session.post.call_args[1].get("json") 526 posted_entities = posted_data["entities"] 527 posted_ids = [e["id"] for e in posted_entities] 528 assert "bob_smith" in posted_ids 529 assert "alice_johnson" not in posted_ids 530 assert "blocked_user" not in posted_ids 531 532 def test_dry_run(self, tmp_path, monkeypatch, capsys): 533 from solstone.observe.export import export_entities 534 535 entities = _setup_entities(tmp_path) 536 _set_journal_override(monkeypatch, tmp_path) 537 538 manifest_data = { 539 "received": { 540 "alice_johnson": _entity_hash(entities["alice_johnson"]), 541 } 542 } 543 mock_session = _make_session(manifest_data=manifest_data) 544 545 with patch( 546 "solstone.observe.export.requests.Session", return_value=mock_session 547 ): 548 export_entities("https://example.com", "test-key", dry_run=True) 549 550 assert mock_session.post.call_count == 0 551 output = capsys.readouterr().out 552 assert "1 new" in output 553 assert "1 unchanged" in output 554 555 def test_idempotent(self, tmp_path, monkeypatch, capsys): 556 from solstone.observe.export import export_entities 557 558 entities = _setup_entities(tmp_path) 559 _set_journal_override(monkeypatch, tmp_path) 560 561 manifest_data = { 562 "received": { 563 "alice_johnson": _entity_hash(entities["alice_johnson"]), 564 "bob_smith": _entity_hash(entities["bob_smith"]), 565 } 566 } 567 mock_session = _make_session(manifest_data=manifest_data) 568 569 with patch( 570 "solstone.observe.export.requests.Session", return_value=mock_session 571 ): 572 export_entities("https://example.com", "test-key", dry_run=False) 573 574 assert mock_session.post.call_count == 0 575 output = capsys.readouterr().out 576 assert "up to date" in output 577 578 def test_changed_entity(self, tmp_path, monkeypatch): 579 from solstone.observe.export import export_entities 580 581 entities = _setup_entities(tmp_path) 582 _set_journal_override(monkeypatch, tmp_path) 583 584 manifest_data = { 585 "received": { 586 "alice_johnson": "stale_hash_that_does_not_match", 587 "bob_smith": _entity_hash(entities["bob_smith"]), 588 } 589 } 590 post_json = { 591 "created": 0, 592 "auto_merged": 1, 593 "staged": 0, 594 "skipped": 0, 595 "errors": [], 596 } 597 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json) 598 599 with patch( 600 "solstone.observe.export.requests.Session", return_value=mock_session 601 ): 602 export_entities("https://example.com", "test-key", dry_run=False) 603 604 assert mock_session.post.call_count == 1 605 posted_data = mock_session.post.call_args.kwargs.get( 606 "json" 607 ) or mock_session.post.call_args[1].get("json") 608 posted_ids = [e["id"] for e in posted_data["entities"]] 609 assert "alice_johnson" in posted_ids 610 assert "bob_smith" not in posted_ids 611 612 def test_auth_error_401(self, tmp_path, monkeypatch, capsys): 613 from solstone.observe.export import export_entities 614 615 _setup_entities(tmp_path) 616 _set_journal_override(monkeypatch, tmp_path) 617 618 mock_session = _make_session(get_status=401) 619 620 with patch( 621 "solstone.observe.export.requests.Session", return_value=mock_session 622 ): 623 export_entities("https://example.com", "test-key", dry_run=False) 624 625 assert mock_session.post.call_count == 0 626 assert "Authentication failed" in capsys.readouterr().out 627 628 def test_connection_error(self, tmp_path, monkeypatch, capsys): 629 from solstone.observe.export import export_entities 630 631 _setup_entities(tmp_path) 632 _set_journal_override(monkeypatch, tmp_path) 633 634 mock_session = _make_session() 635 mock_session.get.side_effect = requests.ConnectionError 636 637 with patch( 638 "solstone.observe.export.requests.Session", return_value=mock_session 639 ): 640 export_entities("https://example.com", "test-key", dry_run=False) 641 642 assert mock_session.post.call_count == 0 643 assert "Connection failed" in capsys.readouterr().out 644 645 def test_empty_manifest(self, tmp_path, monkeypatch): 646 from solstone.observe.export import export_entities 647 648 _setup_entities(tmp_path) 649 _set_journal_override(monkeypatch, tmp_path) 650 651 post_json = { 652 "created": 2, 653 "auto_merged": 0, 654 "staged": 0, 655 "skipped": 0, 656 "errors": [], 657 } 658 mock_session = _make_session(manifest_data={}, post_json=post_json) 659 660 with patch( 661 "solstone.observe.export.requests.Session", return_value=mock_session 662 ): 663 export_entities("https://example.com", "test-key", dry_run=False) 664 665 assert mock_session.post.call_count == 1 666 posted_data = mock_session.post.call_args.kwargs.get( 667 "json" 668 ) or mock_session.post.call_args[1].get("json") 669 posted_ids = [e["id"] for e in posted_data["entities"]] 670 assert "alice_johnson" in posted_ids 671 assert "bob_smith" in posted_ids 672 assert "blocked_user" not in posted_ids 673 674 def test_response_errors_reported(self, tmp_path, monkeypatch, capsys): 675 from solstone.observe.export import export_entities 676 677 _setup_entities(tmp_path) 678 _set_journal_override(monkeypatch, tmp_path) 679 680 post_json = { 681 "created": 1, 682 "auto_merged": 0, 683 "staged": 0, 684 "skipped": 0, 685 "errors": ["Entity bob_smith: invalid type"], 686 } 687 mock_session = _make_session(manifest_data={}, post_json=post_json) 688 689 with patch( 690 "solstone.observe.export.requests.Session", return_value=mock_session 691 ): 692 export_entities("https://example.com", "test-key", dry_run=False) 693 694 output = capsys.readouterr().out 695 assert "Entity bob_smith: invalid type" in output 696 assert "1 error" in output 697 698 699class TestExportFacets: 700 def test_full_export(self, tmp_path, monkeypatch): 701 from solstone.observe.export import export_facets 702 703 _setup_facets(tmp_path) 704 _set_journal_override(monkeypatch, tmp_path) 705 706 post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []} 707 mock_session = _make_session( 708 manifest_data={"received": {}}, post_json=post_json 709 ) 710 711 with patch( 712 "solstone.observe.export.requests.Session", return_value=mock_session 713 ): 714 export_facets("https://example.com", "test-key", dry_run=False) 715 716 assert mock_session.post.call_count == 2 717 718 calls_by_facet = {} 719 for call in mock_session.post.call_args_list: 720 metadata = json.loads(call.kwargs["data"]["metadata"]) 721 facet = metadata["facets"][0]["name"] 722 calls_by_facet[facet] = call.kwargs 723 724 assert set(calls_by_facet) == {"personal", "work"} 725 726 personal_files = calls_by_facet["personal"]["files"] 727 assert [entry[0] for entry in personal_files] == ["files_0_0", "files_0_1"] 728 personal_metadata = json.loads(calls_by_facet["personal"]["data"]["metadata"]) 729 assert personal_metadata == { 730 "facets": [ 731 { 732 "name": "personal", 733 "files": [ 734 {"path": "facet.json", "type": "facet_json"}, 735 {"path": "news/20260413.md", "type": "news"}, 736 ], 737 } 738 ] 739 } 740 741 work_files = calls_by_facet["work"]["files"] 742 assert [entry[0] for entry in work_files] == [ 743 "files_0_0", 744 "files_0_1", 745 "files_0_2", 746 "files_0_3", 747 ] 748 work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"]) 749 assert work_metadata == { 750 "facets": [ 751 { 752 "name": "work", 753 "files": [ 754 { 755 "path": "entities/20260413.jsonl", 756 "type": "detected_entities", 757 }, 758 { 759 "path": "entities/alice/entity.json", 760 "type": "entity_relationship", 761 }, 762 { 763 "path": "entities/alice/observations.jsonl", 764 "type": "entity_observations", 765 }, 766 {"path": "facet.json", "type": "facet_json"}, 767 ], 768 } 769 ] 770 } 771 772 def test_delta_mixed(self, tmp_path, monkeypatch): 773 from solstone.observe.export import export_facets 774 775 facets_dir = _setup_facets(tmp_path) 776 _set_journal_override(monkeypatch, tmp_path) 777 778 work_dir = facets_dir / "work" 779 personal_dir = facets_dir / "personal" 780 manifest_data = { 781 "received": { 782 "work/facet.json": _facet_file_hash(work_dir / "facet.json"), 783 "work/entities/alice/entity.json": _facet_file_hash( 784 work_dir / "entities" / "alice" / "entity.json" 785 ), 786 "work/entities/alice/observations.jsonl": "stale-hash", 787 "personal/facet.json": _facet_file_hash(personal_dir / "facet.json"), 788 "personal/news/20260413.md": _facet_file_hash( 789 personal_dir / "news" / "20260413.md" 790 ), 791 } 792 } 793 post_json = {"created": 0, "merged": 1, "skipped": 0, "staged": 0, "errors": []} 794 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json) 795 796 with patch( 797 "solstone.observe.export.requests.Session", return_value=mock_session 798 ): 799 export_facets("https://example.com", "test-key", dry_run=False) 800 801 assert mock_session.post.call_count == 1 802 metadata = json.loads(mock_session.post.call_args.kwargs["data"]["metadata"]) 803 assert metadata["facets"][0]["name"] == "work" 804 assert metadata["facets"][0]["files"] == [ 805 {"path": "entities/20260413.jsonl", "type": "detected_entities"}, 806 { 807 "path": "entities/alice/observations.jsonl", 808 "type": "entity_observations", 809 }, 810 ] 811 812 def test_dry_run(self, tmp_path, monkeypatch, capsys): 813 from solstone.observe.export import export_facets 814 815 _setup_facets(tmp_path) 816 _set_journal_override(monkeypatch, tmp_path) 817 818 mock_session = _make_session(manifest_data={"received": {}}) 819 820 with patch( 821 "solstone.observe.export.requests.Session", return_value=mock_session 822 ): 823 export_facets("https://example.com", "test-key", dry_run=True) 824 825 assert mock_session.post.call_count == 0 826 output = capsys.readouterr().out 827 assert "personal: 2 new, 0 changed, 0 unchanged" in output 828 assert "work: 4 new, 0 changed, 0 unchanged" in output 829 assert ( 830 "Dry run: 6 new files, 0 changed, 0 unchanged across 2 facet(s)" in output 831 ) 832 833 def test_idempotent(self, tmp_path, monkeypatch, capsys): 834 from solstone.observe.export import export_facets 835 836 facets_dir = _setup_facets(tmp_path) 837 _set_journal_override(monkeypatch, tmp_path) 838 839 manifest_received = {} 840 for facet_dir in sorted((facets_dir).iterdir()): 841 if not facet_dir.is_dir(): 842 continue 843 for file_path in sorted(facet_dir.rglob("*")): 844 if file_path.is_file(): 845 rel_path = file_path.relative_to(facet_dir).as_posix() 846 manifest_received[f"{facet_dir.name}/{rel_path}"] = ( 847 _facet_file_hash(file_path) 848 ) 849 mock_session = _make_session(manifest_data={"received": manifest_received}) 850 851 with patch( 852 "solstone.observe.export.requests.Session", return_value=mock_session 853 ): 854 export_facets("https://example.com", "test-key", dry_run=False) 855 856 assert mock_session.post.call_count == 0 857 assert "up to date" in capsys.readouterr().out 858 859 def test_error_isolation(self, tmp_path, monkeypatch, capsys): 860 from solstone.observe.export import export_facets 861 862 _setup_facets(tmp_path) 863 _set_journal_override(monkeypatch, tmp_path) 864 865 mock_session = _make_session(manifest_data={"received": {}}) 866 first = MagicMock(status_code=400, text="bad request") 867 second = MagicMock(status_code=200, text="ok") 868 second.json.return_value = { 869 "created": 1, 870 "merged": 0, 871 "skipped": 0, 872 "staged": 0, 873 "errors": [], 874 } 875 mock_session.post.side_effect = [first, second] 876 877 with patch( 878 "solstone.observe.export.requests.Session", return_value=mock_session 879 ): 880 export_facets("https://example.com", "test-key", dry_run=False) 881 882 assert mock_session.post.call_count == 2 883 output = capsys.readouterr().out 884 assert "1 sent" in output 885 assert "1 failed" in output 886 887 def test_new_facet_vs_changed(self, tmp_path, monkeypatch, capsys): 888 from solstone.observe.export import export_facets 889 890 facets_dir = _setup_facets(tmp_path) 891 _set_journal_override(monkeypatch, tmp_path) 892 893 work_dir = facets_dir / "work" 894 manifest_data = { 895 "received": { 896 f"work/{file_path.relative_to(work_dir).as_posix()}": "stale-hash" 897 for file_path in sorted(work_dir.rglob("*")) 898 if file_path.is_file() 899 } 900 } 901 mock_session = _make_session(manifest_data=manifest_data) 902 903 with patch( 904 "solstone.observe.export.requests.Session", return_value=mock_session 905 ): 906 export_facets("https://example.com", "test-key", dry_run=True) 907 908 assert mock_session.post.call_count == 0 909 output = capsys.readouterr().out 910 assert "personal: 2 new, 0 changed, 0 unchanged" in output 911 assert "work: 0 new, 4 changed, 0 unchanged" in output 912 913 def test_skips_invalid_facet_names(self, tmp_path, monkeypatch): 914 from solstone.observe.export import export_facets 915 916 _setup_facets(tmp_path) 917 bad_dir = tmp_path / "facets" / "BadName" 918 bad_dir.mkdir(parents=True) 919 (bad_dir / "facet.json").write_text('{"title": "Bad"}', encoding="utf-8") 920 _set_journal_override(monkeypatch, tmp_path) 921 922 post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []} 923 mock_session = _make_session( 924 manifest_data={"received": {}}, post_json=post_json 925 ) 926 927 with patch( 928 "solstone.observe.export.requests.Session", return_value=mock_session 929 ): 930 export_facets("https://example.com", "test-key", dry_run=False) 931 932 assert mock_session.post.call_count == 2 933 posted_facets = { 934 json.loads(call.kwargs["data"]["metadata"])["facets"][0]["name"] 935 for call in mock_session.post.call_args_list 936 } 937 assert posted_facets == {"personal", "work"} 938 939 def test_skips_events_directory(self, tmp_path, monkeypatch): 940 from solstone.observe.export import export_facets 941 942 _setup_facets(tmp_path) 943 events_dir = tmp_path / "facets" / "work" / "events" 944 events_dir.mkdir(parents=True) 945 (events_dir / "20260413.jsonl").write_text( 946 '{"event": "ignored"}\n', encoding="utf-8" 947 ) 948 _set_journal_override(monkeypatch, tmp_path) 949 950 post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []} 951 mock_session = _make_session( 952 manifest_data={"received": {}}, post_json=post_json 953 ) 954 955 with patch( 956 "solstone.observe.export.requests.Session", return_value=mock_session 957 ): 958 export_facets("https://example.com", "test-key", dry_run=False) 959 960 calls_by_facet = { 961 json.loads(call.kwargs["data"]["metadata"])["facets"][0][ 962 "name" 963 ]: call.kwargs 964 for call in mock_session.post.call_args_list 965 } 966 work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"]) 967 uploaded_paths = [ 968 entry["path"] for entry in work_metadata["facets"][0]["files"] 969 ] 970 assert "events/20260413.jsonl" not in uploaded_paths 971 972 def test_response_errors_reported(self, tmp_path, monkeypatch, capsys): 973 from solstone.observe.export import export_facets 974 975 _setup_facets(tmp_path) 976 _set_journal_override(monkeypatch, tmp_path) 977 978 post_json = { 979 "created": 1, 980 "merged": 0, 981 "skipped": 0, 982 "staged": 0, 983 "errors": [{"facet": "work", "error": "entity merge conflict"}], 984 } 985 mock_session = _make_session( 986 manifest_data={"received": {}}, post_json=post_json 987 ) 988 989 with patch( 990 "solstone.observe.export.requests.Session", return_value=mock_session 991 ): 992 export_facets("https://example.com", "test-key", dry_run=False) 993 994 output = capsys.readouterr().out 995 assert "entity merge conflict" in output 996 assert "error" in output.lower() 997 998 999class TestExportImports: 1000 def test_manifest_delta(self, tmp_path, monkeypatch): 1001 from solstone.observe.export import export_imports 1002 1003 imports = _setup_imports(tmp_path) 1004 _set_journal_override(monkeypatch, tmp_path) 1005 1006 manifest_data = { 1007 "received": {"20260101_090000": _import_hash(imports["20260101_090000"])} 1008 } 1009 post_json = {"copied": 1, "staged": 0, "skipped": 0, "errors": []} 1010 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json) 1011 1012 with patch( 1013 "solstone.observe.export.requests.Session", return_value=mock_session 1014 ): 1015 export_imports("https://example.com", "test-key", dry_run=False) 1016 1017 assert mock_session.post.call_count == 1 1018 posted_data = mock_session.post.call_args.kwargs.get( 1019 "json" 1020 ) or mock_session.post.call_args[1].get("json") 1021 posted_ids = [entry["id"] for entry in posted_data["imports"]] 1022 assert posted_ids == ["20260102_100000"] 1023 1024 def test_dry_run(self, tmp_path, monkeypatch, capsys): 1025 from solstone.observe.export import export_imports 1026 1027 _setup_imports(tmp_path) 1028 _set_journal_override(monkeypatch, tmp_path) 1029 1030 mock_session = _make_session(manifest_data={"received": {}}) 1031 1032 with patch( 1033 "solstone.observe.export.requests.Session", return_value=mock_session 1034 ): 1035 export_imports("https://example.com", "test-key", dry_run=True) 1036 1037 assert mock_session.post.call_count == 0 1038 output = capsys.readouterr().out 1039 assert "2 new" in output 1040 assert "0 changed" in output 1041 1042 def test_idempotent(self, tmp_path, monkeypatch, capsys): 1043 from solstone.observe.export import export_imports 1044 1045 imports = _setup_imports(tmp_path) 1046 _set_journal_override(monkeypatch, tmp_path) 1047 1048 manifest_data = { 1049 "received": { 1050 import_id: _import_hash(import_data) 1051 for import_id, import_data in imports.items() 1052 } 1053 } 1054 mock_session = _make_session(manifest_data=manifest_data) 1055 1056 with patch( 1057 "solstone.observe.export.requests.Session", return_value=mock_session 1058 ): 1059 export_imports("https://example.com", "test-key", dry_run=False) 1060 1061 assert mock_session.post.call_count == 0 1062 assert "up to date" in capsys.readouterr().out 1063 1064 def test_sync_state_excluded(self, tmp_path, monkeypatch): 1065 from solstone.observe.export import export_imports 1066 1067 _setup_imports(tmp_path) 1068 _set_journal_override(monkeypatch, tmp_path) 1069 1070 mock_session = _make_session( 1071 manifest_data={"received": {}}, 1072 post_json={"copied": 2, "staged": 0, "skipped": 0, "errors": []}, 1073 ) 1074 1075 with patch( 1076 "solstone.observe.export.requests.Session", return_value=mock_session 1077 ): 1078 export_imports("https://example.com", "test-key", dry_run=False) 1079 1080 posted_data = mock_session.post.call_args.kwargs.get( 1081 "json" 1082 ) or mock_session.post.call_args[1].get("json") 1083 posted_ids = {entry["id"] for entry in posted_data["imports"]} 1084 assert "plaud.json" not in posted_ids 1085 1086 def test_source_dir_excluded(self, tmp_path, monkeypatch): 1087 from solstone.observe.export import export_imports 1088 1089 _setup_imports(tmp_path) 1090 _set_journal_override(monkeypatch, tmp_path) 1091 1092 mock_session = _make_session( 1093 manifest_data={"received": {}}, 1094 post_json={"copied": 2, "staged": 0, "skipped": 0, "errors": []}, 1095 ) 1096 1097 with patch( 1098 "solstone.observe.export.requests.Session", return_value=mock_session 1099 ): 1100 export_imports("https://example.com", "test-key", dry_run=False) 1101 1102 posted_data = mock_session.post.call_args.kwargs.get( 1103 "json" 1104 ) or mock_session.post.call_args[1].get("json") 1105 posted_ids = {entry["id"] for entry in posted_data["imports"]} 1106 assert "abcd1234" not in posted_ids 1107 1108 1109class TestExportConfig: 1110 def test_config_export(self, tmp_path, monkeypatch): 1111 from solstone.observe.export import export_config 1112 1113 _setup_config(tmp_path) 1114 _set_journal_override(monkeypatch, tmp_path) 1115 1116 mock_session = _make_session( 1117 manifest_data={}, 1118 post_json={"staged": True, "skipped": False, "diff_fields": 3}, 1119 ) 1120 1121 with patch( 1122 "solstone.observe.export.requests.Session", return_value=mock_session 1123 ): 1124 export_config("https://example.com", "test-key", dry_run=False) 1125 1126 assert mock_session.post.call_count == 1 1127 1128 def test_dry_run(self, tmp_path, monkeypatch, capsys): 1129 from solstone.observe.export import export_config 1130 1131 _setup_config(tmp_path) 1132 _set_journal_override(monkeypatch, tmp_path) 1133 1134 mock_session = _make_session(manifest_data={}) 1135 1136 with patch( 1137 "solstone.observe.export.requests.Session", return_value=mock_session 1138 ): 1139 export_config("https://example.com", "test-key", dry_run=True) 1140 1141 assert mock_session.post.call_count == 0 1142 assert "would send snapshot" in capsys.readouterr().out 1143 1144 def test_idempotent(self, tmp_path, monkeypatch, capsys): 1145 from solstone.observe.export import _strip_never_transfer, export_config 1146 1147 config = _setup_config(tmp_path) 1148 _set_journal_override(monkeypatch, tmp_path) 1149 1150 stripped = _strip_never_transfer(config) 1151 content_hash = hashlib.sha256( 1152 json.dumps(stripped, sort_keys=True, ensure_ascii=False).encode() 1153 ).hexdigest() 1154 mock_session = _make_session(manifest_data={"last_hash": content_hash}) 1155 1156 with patch( 1157 "solstone.observe.export.requests.Session", return_value=mock_session 1158 ): 1159 export_config("https://example.com", "test-key", dry_run=False) 1160 1161 assert mock_session.post.call_count == 0 1162 assert "up to date" in capsys.readouterr().out 1163 1164 def test_never_transfer_stripped(self, tmp_path, monkeypatch): 1165 from solstone.observe.export import export_config 1166 1167 _setup_config(tmp_path) 1168 _set_journal_override(monkeypatch, tmp_path) 1169 1170 mock_session = _make_session( 1171 manifest_data={}, 1172 post_json={"staged": True, "skipped": False, "diff_fields": 3}, 1173 ) 1174 1175 with patch( 1176 "solstone.observe.export.requests.Session", return_value=mock_session 1177 ): 1178 export_config("https://example.com", "test-key", dry_run=False) 1179 1180 posted_data = mock_session.post.call_args.kwargs.get( 1181 "json" 1182 ) or mock_session.post.call_args[1].get("json") 1183 posted_config = posted_data["config"] 1184 assert posted_config["convey"] == {"allow_network_access": False} 1185 assert "password_hash" not in posted_config["convey"] 1186 assert "secret" not in posted_config["convey"] 1187 assert posted_config["setup"] == {"completed_at": 12345} 1188 assert posted_config["env"] == {"KEY": "val"}