personal memory agent
0

Configure Feed

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

solstone / tests / test_import_start.py
17 kB 579 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import hashlib 7import io 8import json 9from pathlib import Path 10from types import SimpleNamespace 11 12import pytest 13from flask import Flask, g 14 15import solstone.convey.state as convey_state 16import solstone.think.utils as think_utils 17from solstone.convey.reasons import ( 18 IMPORT_CLIENT_ID_CONFLICT, 19 IMPORT_CONFLICT, 20 IMPORT_NOT_FOUND, 21 INVALID_OPERATION_FOR_STATE, 22 MISSING_REQUIRED_FIELD, 23) 24from solstone.think.importers.utils import ( 25 read_import_metadata, 26 update_import_metadata_fields, 27 write_import_metadata, 28) 29 30import_routes = __import__("solstone.apps.import.routes", fromlist=["routes"]) 31 32 33@pytest.fixture 34def journal_env(tmp_path, monkeypatch) -> Path: 35 monkeypatch.setattr(convey_state, "journal_root", str(tmp_path), raising=False) 36 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 37 think_utils._journal_path_cache = None 38 (tmp_path / "imports").mkdir() 39 return tmp_path 40 41 42@pytest.fixture(autouse=True) 43def _stable_timestamp_detection(monkeypatch): 44 monkeypatch.setattr( 45 import_routes, 46 "resolve_created_deterministic", 47 lambda *args, **kwargs: None, 48 ) 49 50 def _no_model(*args, **kwargs): 51 raise AssertionError("model timestamp detection should not run") 52 53 monkeypatch.setattr(import_routes, "detect_created", _no_model) 54 55 56@pytest.fixture 57def client(journal_env): 58 app = Flask(__name__) 59 60 @app.before_request 61 def _identity() -> None: 62 g.identity = SimpleNamespace(mode="local", fingerprint=None) 63 64 app.register_blueprint(import_routes.import_bp) 65 return app.test_client() 66 67 68def _sha(content: bytes) -> str: 69 return hashlib.sha256(content).hexdigest() 70 71 72def _import_dirs(journal_root: Path) -> list[Path]: 73 return sorted( 74 path for path in (journal_root / "imports").iterdir() if path.is_dir() 75 ) 76 77 78def _save_upload( 79 client, 80 *, 81 client_item_id: str, 82 content: bytes = b"sample audio", 83 filename: str = "sample.m4a", 84 content_type: str = "audio/mp4", 85 extra: dict | None = None, 86): 87 data = { 88 "client_item_id": client_item_id, 89 "deterministic_only": "true", 90 "file": (io.BytesIO(content), filename, content_type), 91 } 92 if extra: 93 data.update(extra) 94 return client.post( 95 "/app/import/api/save", 96 data=data, 97 content_type="multipart/form-data", 98 ) 99 100 101def _write_manifest( 102 journal_root: Path, 103 *, 104 import_id: str, 105 source_hash: str, 106 entry_count: int = 2, 107) -> None: 108 import_dir = journal_root / "imports" / import_id 109 import_dir.mkdir(parents=True) 110 (import_dir / "manifest.json").write_text( 111 json.dumps( 112 { 113 "import_id": import_id, 114 "source_type": "audio", 115 "source_hash": source_hash, 116 "entry_count": entry_count, 117 "imported_at": "2026-01-01T12:00:00", 118 "imported_via": "cli", 119 } 120 ), 121 encoding="utf-8", 122 ) 123 124 125def _write_staged_import( 126 journal_root: Path, 127 timestamp: str, 128 metadata: dict, 129) -> Path: 130 import_dir = journal_root / "imports" / timestamp 131 import_dir.mkdir(parents=True) 132 media_path = import_dir / metadata.get("original_filename", "sample.m4a") 133 media_path.write_bytes(b"sample") 134 metadata = { 135 "file_path": str(media_path), 136 "user_timestamp": timestamp, 137 "source": "audio", 138 "source_inference": "extension", 139 "client": {}, 140 "facet": None, 141 "setting": None, 142 "imported_via": "web_dashboard", 143 "observer_handle": None, 144 "source_hint": None, 145 "mime_type": "audio/mp4", 146 **metadata, 147 } 148 write_import_metadata(journal_root, timestamp, metadata) 149 return media_path 150 151 152def test_import_save_audio_upload_stages_versioned_summary(client, journal_env): 153 response = _save_upload( 154 client, 155 client_item_id="ios-audio-1", 156 content=b"audio bytes", 157 extra={"facet": "work", "client": json.dumps({"device": "ios"})}, 158 ) 159 160 assert response.status_code == 200 161 body = response.get_json() 162 assert body["schema_version"] == 1 163 assert body["status"] == "staged" 164 assert body["replay"] is False 165 assert body["source"] == "audio" 166 assert body["client_item_id"] == "ios-audio-1" 167 assert body["recommended_action"] == "start" 168 assert body["facet"] == "work" 169 assert body["metadata"] == { 170 "original_filename": "sample.m4a", 171 "mime_type": "audio/mp4", 172 "imported_via": "web_dashboard", 173 "observer_handle": None, 174 "source_hint": None, 175 "client": {"device": "ios"}, 176 } 177 assert body["diagnostics"]["source_inference"] == "extension" 178 assert "timestamp_detection_method" not in body 179 assert "dedup" not in body 180 181 metadata = read_import_metadata(journal_env, body["timestamp"]) 182 assert metadata["client_item_id"] == "ios-audio-1" 183 assert metadata["source_hash"] == _sha(b"audio bytes") 184 assert metadata["source"] == "audio" 185 assert metadata["client"] == {"device": "ios"} 186 187 188def test_import_save_missing_client_item_id_returns_missing_required(client): 189 response = client.post( 190 "/app/import/api/save", 191 data={ 192 "deterministic_only": "true", 193 "file": (io.BytesIO(b"audio"), "sample.m4a", "audio/mp4"), 194 }, 195 content_type="multipart/form-data", 196 ) 197 198 assert response.status_code == MISSING_REQUIRED_FIELD.status 199 assert response.get_json()["reason_code"] == MISSING_REQUIRED_FIELD.code 200 201 202def test_import_save_replay_same_client_and_content_does_not_stage_again( 203 client, journal_env 204): 205 first = _save_upload( 206 client, 207 client_item_id="ios-replay", 208 content=b"same bytes", 209 ) 210 before_dirs = _import_dirs(journal_env) 211 212 second = _save_upload( 213 client, 214 client_item_id="ios-replay", 215 content=b"same bytes", 216 ) 217 218 assert second.status_code == 200 219 body = second.get_json() 220 assert body["status"] == "staged" 221 assert body["replay"] is True 222 assert body["recommended_action"] == "start" 223 assert body["path"] == first.get_json()["path"] 224 assert _import_dirs(journal_env) == before_dirs 225 226 227def test_import_save_replay_started_item_does_not_recommend_start(client, journal_env): 228 first = _save_upload( 229 client, 230 client_item_id="ios-replay-started", 231 content=b"same started bytes", 232 ) 233 timestamp = first.get_json()["timestamp"] 234 update_import_metadata_fields( 235 journal_root=journal_env, 236 timestamp=timestamp, 237 updates={"task_id": "task-started"}, 238 ) 239 before_dirs = _import_dirs(journal_env) 240 241 second = _save_upload( 242 client, 243 client_item_id="ios-replay-started", 244 content=b"same started bytes", 245 ) 246 247 assert second.status_code == 200 248 body = second.get_json() 249 assert body["status"] == "staged" 250 assert body["replay"] is True 251 assert body["recommended_action"] == "do_not_start" 252 assert body["path"] == first.get_json()["path"] 253 assert _import_dirs(journal_env) == before_dirs 254 255 256def test_import_save_same_client_different_content_conflicts(client): 257 _save_upload(client, client_item_id="ios-conflict", content=b"one") 258 259 response = _save_upload(client, client_item_id="ios-conflict", content=b"two") 260 261 assert response.status_code == IMPORT_CLIENT_ID_CONFLICT.status 262 assert response.get_json()["reason_code"] == IMPORT_CLIENT_ID_CONFLICT.code 263 264 265def test_import_save_duplicate_imported_content_is_terminal(client, journal_env): 266 content = b"already imported" 267 _write_manifest( 268 journal_env, 269 import_id="20260101_120000", 270 source_hash=_sha(content), 271 entry_count=3, 272 ) 273 before_dirs = _import_dirs(journal_env) 274 275 response = _save_upload( 276 client, 277 client_item_id="ios-imported-dup", 278 content=content, 279 ) 280 281 assert response.status_code == 200 282 body = response.get_json() 283 assert body["status"] == "duplicate" 284 assert body["replay"] is False 285 assert body["recommended_action"] == "do_not_start" 286 assert body["duplicate"] == { 287 "import_id": "20260101_120000", 288 "imported_at": "2026-01-01T12:00:00", 289 "entry_count": 3, 290 "state": "imported", 291 } 292 assert _import_dirs(journal_env) == before_dirs 293 assert not any((path / "import.json").exists() for path in before_dirs) 294 295 296def test_import_save_duplicate_staged_content_is_terminal(client, journal_env): 297 content = b"already staged" 298 _write_staged_import( 299 journal_env, 300 "20260101_121500", 301 { 302 "original_filename": "existing.m4a", 303 "client_item_id": "other-client", 304 "source_hash": _sha(content), 305 }, 306 ) 307 before_dirs = _import_dirs(journal_env) 308 309 response = _save_upload( 310 client, 311 client_item_id="ios-staged-dup", 312 content=content, 313 ) 314 315 assert response.status_code == 200 316 body = response.get_json() 317 assert body["status"] == "duplicate" 318 assert body["client_item_id"] == "ios-staged-dup" 319 assert body["recommended_action"] == "do_not_start" 320 assert body["duplicate"] == { 321 "import_id": "20260101_121500", 322 "imported_at": None, 323 "entry_count": None, 324 "state": "staged", 325 } 326 assert _import_dirs(journal_env) == before_dirs 327 328 329def test_import_meta_updates_facet_and_setting(client, journal_env): 330 media_path = _write_staged_import( 331 journal_env, 332 "20260101_130000", 333 {"original_filename": "meta.m4a", "client_item_id": "meta-client"}, 334 ) 335 336 response = client.post( 337 "/app/import/api/meta", 338 json={"path": str(media_path), "facet": "work", "setting": "office"}, 339 ) 340 341 assert response.status_code == 200 342 assert response.get_json() == { 343 "status": "ok", 344 "path": str(media_path), 345 "timestamp": "20260101_130000", 346 "updated": {"facet": "work", "setting": "office"}, 347 } 348 metadata = read_import_metadata(journal_env, "20260101_130000") 349 assert metadata["facet"] == "work" 350 assert metadata["setting"] == "office" 351 352 353def test_import_meta_missing_path_returns_missing_required(client): 354 response = client.post("/app/import/api/meta", json={}) 355 356 assert response.status_code == MISSING_REQUIRED_FIELD.status 357 assert response.get_json()["reason_code"] == MISSING_REQUIRED_FIELD.code 358 359 360def test_import_facet_route_removed_no_alias(client): 361 rules = list(client.application.url_map.iter_rules()) 362 363 assert not any(rule.rule == "/app/import/api/facet" for rule in rules) 364 assert any( 365 rule.rule == "/app/import/api/meta" and "POST" in rule.methods for rule in rules 366 ) 367 368 369def test_import_meta_missing_item_returns_import_not_found(client, journal_env): 370 missing_path = journal_env / "imports" / "20260101_130001" / "sample.m4a" 371 372 response = client.post( 373 "/app/import/api/meta", 374 json={"path": str(missing_path), "facet": "work"}, 375 ) 376 377 assert response.status_code == IMPORT_NOT_FOUND.status 378 assert response.get_json()["reason_code"] == IMPORT_NOT_FOUND.code 379 380 381def test_import_meta_started_item_returns_invalid_operation(client, journal_env): 382 media_path = _write_staged_import( 383 journal_env, 384 "20260101_130002", 385 { 386 "original_filename": "started.m4a", 387 "client_item_id": "started-client", 388 "task_id": "task-1", 389 }, 390 ) 391 392 response = client.post( 393 "/app/import/api/meta", 394 json={"path": str(media_path), "facet": "work"}, 395 ) 396 397 assert response.status_code == INVALID_OPERATION_FOR_STATE.status 398 assert response.get_json()["reason_code"] == INVALID_OPERATION_FOR_STATE.code 399 400 401def test_import_start_moves_dir_uses_saved_metadata_and_omits_generic_source( 402 client, journal_env, monkeypatch 403): 404 emitted: list[dict[str, object]] = [] 405 monkeypatch.setattr( 406 import_routes, 407 "emit", 408 lambda tract, event, **payload: emitted.append( 409 {"tract": tract, "event": event, **payload} 410 ), 411 ) 412 old_ts = "20260101_120000" 413 new_ts = "20260101_121500" 414 media_path = _write_staged_import( 415 journal_env, 416 old_ts, 417 { 418 "original_filename": "sample.m4a", 419 "client_item_id": "start-client", 420 "facet": "work", 421 "setting": "office", 422 "source_hint": None, 423 }, 424 ) 425 426 response = client.post( 427 "/app/import/api/start", 428 json={ 429 "path": str(media_path), 430 "timestamp": new_ts, 431 "source": "recording", 432 "facet": "ignored", 433 "force": True, 434 }, 435 ) 436 437 assert response.status_code == 200 438 new_dir = journal_env / "imports" / new_ts 439 assert new_dir.exists() 440 assert not (journal_env / "imports" / old_ts).exists() 441 metadata = read_import_metadata(journal_env, new_ts) 442 assert metadata["file_path"] == str(new_dir / media_path.name) 443 assert emitted == [ 444 { 445 "tract": "supervisor", 446 "event": "request", 447 "ref": response.get_json()["task_id"], 448 "cmd": [ 449 "journal", 450 "importer", 451 str(new_dir / media_path.name), 452 new_ts, 453 "--facet", 454 "work", 455 "--setting", 456 "office", 457 "--force", 458 ], 459 } 460 ] 461 462 463def test_import_start_forwards_only_saved_source_hint(client, journal_env, monkeypatch): 464 emitted: list[dict[str, object]] = [] 465 monkeypatch.setattr( 466 import_routes, 467 "emit", 468 lambda tract, event, **payload: emitted.append( 469 {"tract": tract, "event": event, **payload} 470 ), 471 ) 472 ts = "20260101_122000" 473 media_path = _write_staged_import( 474 journal_env, 475 ts, 476 { 477 "original_filename": "vault", 478 "client_item_id": "source-hint-client", 479 "source_hint": "obsidian", 480 }, 481 ) 482 483 response = client.post( 484 "/app/import/api/start", 485 json={"path": str(media_path), "timestamp": ts, "source": "quick"}, 486 ) 487 488 assert response.status_code == 200 489 assert emitted[0]["cmd"] == [ 490 "journal", 491 "importer", 492 str(media_path), 493 ts, 494 "--source", 495 "obsidian", 496 ] 497 498 499def test_import_start_refuses_terminal_duplicate_even_with_force( 500 client, journal_env, monkeypatch 501): 502 emitted: list[dict[str, object]] = [] 503 monkeypatch.setattr( 504 import_routes, 505 "emit", 506 lambda tract, event, **payload: emitted.append( 507 {"tract": tract, "event": event, **payload} 508 ), 509 ) 510 content = b"terminal" 511 source_hash = _sha(content) 512 ts = "20260101_123000" 513 media_path = _write_staged_import( 514 journal_env, 515 ts, 516 { 517 "original_filename": "terminal.m4a", 518 "client_item_id": "terminal-client", 519 "source_hash": source_hash, 520 }, 521 ) 522 _write_manifest( 523 journal_env, 524 import_id="20260101_124500", 525 source_hash=source_hash, 526 ) 527 528 response = client.post( 529 "/app/import/api/start", 530 json={"path": str(media_path), "timestamp": ts, "force": True}, 531 ) 532 533 assert response.status_code == INVALID_OPERATION_FOR_STATE.status 534 assert response.get_json()["reason_code"] == INVALID_OPERATION_FOR_STATE.code 535 assert emitted == [] 536 537 538def test_import_start_missing_source_returns_import_not_found(client, journal_env): 539 old_ts = "20260101_120000" 540 new_ts = "20260101_121500" 541 missing_path = journal_env / "imports" / old_ts / "sample.m4a" 542 543 response = client.post( 544 "/app/import/api/start", 545 json={"path": str(missing_path), "timestamp": new_ts}, 546 ) 547 548 assert response.status_code == IMPORT_NOT_FOUND.status 549 body = response.get_json() 550 assert body["reason_code"] == IMPORT_NOT_FOUND.code 551 552 553def test_import_start_target_exists_returns_import_conflict(client, journal_env): 554 old_ts = "20260101_120000" 555 new_ts = "20260101_121500" 556 old_dir = journal_env / "imports" / old_ts 557 new_dir = journal_env / "imports" / new_ts 558 old_dir.mkdir() 559 new_dir.mkdir() 560 media_path = old_dir / "sample.m4a" 561 media_path.write_bytes(b"sample") 562 write_import_metadata( 563 journal_env, 564 old_ts, 565 { 566 "file_path": str(media_path), 567 "user_timestamp": old_ts, 568 "client_item_id": "conflict-client", 569 }, 570 ) 571 572 response = client.post( 573 "/app/import/api/start", 574 json={"path": str(media_path), "timestamp": new_ts}, 575 ) 576 577 assert response.status_code == IMPORT_CONFLICT.status 578 body = response.get_json() 579 assert body["reason_code"] == IMPORT_CONFLICT.code