""" Phase E — Push with Live Output Tests Tests for POST /push (job start) and the SSE stream. The Outline API is mocked so tests do not require a live Outline instance. """ import asyncio import json import subprocess import sys import textwrap from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest sys.path.insert(0, str(Path(__file__).parent)) from helpers import make_mock_process # noqa: E402 pytestmark = pytest.mark.asyncio async def consume_sse(client, job_id: str, max_events: int = 100) -> list[dict]: events = [] async with client.stream("GET", f"/stream/{job_id}") as r: async for line in r.aiter_lines(): if line.startswith("data:"): try: events.append(json.loads(line[5:].strip())) except json.JSONDecodeError: events.append({"raw": line[5:].strip()}) if events and events[-1].get("type") == "done": break return events def git(vault: Path, *args) -> str: return subprocess.run( ["git", "-C", str(vault), *args], check=True, capture_output=True, text=True, ).stdout.strip() def commit_all(vault: Path, message: str): subprocess.run(["git", "-C", str(vault), "add", "-A"], check=True, capture_output=True) try: subprocess.run(["git", "-C", str(vault), "commit", "-m", message], check=True, capture_output=True) except subprocess.CalledProcessError: pass # --------------------------------------------------------------------------- # US-E1 — Push streaming # --------------------------------------------------------------------------- class TestPushStreaming: async def test_post_push_returns_job_id(self, client, vault_with_pending): with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(["Done. 2 updated, 1 created."]) r = await client.post("/push") assert r.status_code in (200, 202) assert "job_id" in r.json() async def test_stream_content_type_is_sse(self, client, vault_with_pending): with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(["Done."]) r = await client.post("/push") job_id = r.json()["job_id"] async with client.stream("GET", f"/stream/{job_id}") as stream: assert "text/event-stream" in stream.headers.get("content-type", "") async def test_stream_ends_with_done_event(self, client, vault_with_pending): push_lines = [ "ok: Bewerbungen/CV.md updated", "ok: Projekte/NewNote.md created (id: abc123)", "Done. 1 updated, 1 created.", ] with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(push_lines) r = await client.post("/push") job_id = r.json()["job_id"] events = await consume_sse(client, job_id) done_events = [e for e in events if e.get("type") == "done"] assert len(done_events) == 1 async def test_done_event_contains_summary_counts(self, client, vault_with_pending): push_lines = ["Done. 1 updated, 1 created, 0 skipped, 0 errors."] with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(push_lines) r = await client.post("/push") events = await consume_sse(client, r.json()["job_id"]) done = next(e for e in events if e.get("type") == "done") summary = json.dumps(done) # Summary counts must appear somewhere in the done event assert any(k in summary for k in ("updated", "created", "skipped", "errors")), ( "Done event must include summary counts" ) async def test_per_file_events_emitted(self, client, vault_with_pending): push_lines = [ "processing: Bewerbungen/CV.md", "ok: Bewerbungen/CV.md updated", "processing: Projekte/NewNote.md", "ok: Projekte/NewNote.md created (id: xyz789)", "Done.", ] with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(push_lines) r = await client.post("/push") events = await consume_sse(client, r.json()["job_id"]) all_text = json.dumps(events) assert "CV.md" in all_text, "Events should mention CV.md" assert "NewNote.md" in all_text, "Events should mention NewNote.md" # --------------------------------------------------------------------------- # US-E2 — New file frontmatter writeback # --------------------------------------------------------------------------- class TestNewFileCreation: async def test_new_file_appears_in_pending_changes(self, client, populated_vault): new_file = populated_vault / "Projekte" / "BrandNew.md" new_file.parent.mkdir(exist_ok=True) new_file.write_text("# Brand New\nContent without frontmatter.\n") commit_all(populated_vault, "obsidian: new file") r = await client.get("/changes") items = r.json() new_item = next((i for i in items if "BrandNew.md" in i["path"]), None) assert new_item is not None assert new_item["status"] == "added" async def test_push_writes_frontmatter_back_to_new_file( self, client, populated_vault ): """ After push, a new file must have frontmatter with outline_id injected. The mock simulates the sync engine writing back the ID. """ new_file = populated_vault / "Projekte" / "FrontmatterTest.md" new_file.parent.mkdir(exist_ok=True) new_file.write_text("# Frontmatter Test\nNo ID yet.\n") commit_all(populated_vault, "obsidian: new file no frontmatter") fake_id = "doc-new-frontmatter-001" def fake_push(*args, **kwargs): # Simulate sync engine writing frontmatter back new_file.write_text(textwrap.dedent(f"""\ --- outline_id: {fake_id} outline_collection_id: col-proj-001 --- # Frontmatter Test No ID yet. """)) commit_all(populated_vault, "sync: write back frontmatter") return make_mock_process([ f"ok: Projekte/FrontmatterTest.md created (id: {fake_id})", "Done. 1 created.", ]) with patch("webui.spawn_sync_subprocess", side_effect=fake_push): r = await client.post("/push") assert r.status_code in (200, 202) await consume_sse(client, r.json()["job_id"]) content = new_file.read_text() assert "outline_id" in content, "Sync engine must write outline_id back to new file" # --------------------------------------------------------------------------- # US-E3 — Push blocked by conflicts # --------------------------------------------------------------------------- class TestPushBlockedByConflicts: async def test_push_returns_409_when_conflicts_exist( self, client, vault_with_conflict ): r = await client.post("/push") assert r.status_code == 409, ( "Push must return 409 Conflict when unresolved merge conflicts exist" ) async def test_push_409_response_includes_conflict_paths( self, client, vault_with_conflict ): r = await client.post("/push") assert r.status_code == 409 body = r.json() assert "conflicts" in body or "files" in body or "message" in body, ( "409 response must explain which files are conflicted" ) async def test_push_allowed_after_conflicts_resolved( self, client, vault_with_conflict ): """Resolve the conflict, then push must be accepted.""" # Resolve: check out local version subprocess.run( ["git", "-C", str(vault_with_conflict), "checkout", "--ours", "Bewerbungen/CV.md"], check=True, capture_output=True, ) commit_all(vault_with_conflict, "resolve: keep ours") subprocess.run( ["git", "-C", str(vault_with_conflict), "merge", "--abort"], capture_output=True, ) with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(["Done. 1 updated."]) r = await client.post("/push") assert r.status_code in (200, 202), ( "Push must be allowed after conflicts are resolved" ) # --------------------------------------------------------------------------- # US-E4 — New collection creation # --------------------------------------------------------------------------- class TestNewCollectionCreation: async def test_new_top_level_folder_detected_as_new_collection( self, client, populated_vault ): """A new folder at the top level must appear in changes as a new collection.""" new_doc = populated_vault / "NewCollection" / "FirstDoc.md" new_doc.parent.mkdir() new_doc.write_text("# First Doc\nNew collection content.\n") commit_all(populated_vault, "obsidian: new collection") r = await client.get("/changes") items = r.json() new_item = next((i for i in items if "FirstDoc.md" in i["path"]), None) assert new_item is not None # The action or a note must indicate a new collection will be created item_str = json.dumps(new_item) assert "collection" in item_str.lower() or new_item["status"] == "added", ( "New file in unknown folder must be flagged as requiring new collection" ) # --------------------------------------------------------------------------- # US-E5 — Rename handling # --------------------------------------------------------------------------- class TestRenameHandling: async def test_renamed_file_shown_in_changes(self, client, populated_vault): old = populated_vault / "Bewerbungen" / "CV.md" new = populated_vault / "Bewerbungen" / "Resume.md" old.rename(new) commit_all(populated_vault, "obsidian: rename CV to Resume") r = await client.get("/changes") items = r.json() renamed = [i for i in items if i["status"] == "renamed"] assert len(renamed) >= 1 async def test_push_rename_uses_update_not_create(self, client, populated_vault): """ The sync engine must call documents.update (not delete+create) for renames, preserving the Outline document ID. """ old = populated_vault / "Bewerbungen" / "CV.md" new = populated_vault / "Bewerbungen" / "Resume.md" old.rename(new) commit_all(populated_vault, "obsidian: rename") push_lines = [ "ok: Bewerbungen/Resume.md → title updated (id: doc-cv-001)", "Done. 1 renamed.", ] with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(push_lines) r = await client.post("/push") events_raw = await consume_sse(client, r.json()["job_id"]) all_text = json.dumps(events_raw) # Should not see "created" for a renamed document assert "doc-cv-001" in all_text or "renamed" in all_text or "updated" in all_text, ( "Rename should update the existing document, not create a new one" )