""" End-to-End Integration Tests — Full Workflow Scenarios These tests simulate complete user workflows from start to finish. They use real git operations against a temp vault but mock the Outline API and WebDAV sync (since we do not have a live Outline server in CI). For full E2E with live Outline, use @pytest.mark.integration and set OUTLINE_URL / OUTLINE_TOKEN environment variables. Scenarios covered: E2E-1: Obsidian → Outline (new file with frontmatter writeback) E2E-2: Outline → Obsidian (pull, file appears in vault) E2E-3: Conflict detection and resolution in browser E2E-4: New collection creation for unknown top-level folder E2E-5: Concurrent safety (only one job at a time) E2E-6: Full roundtrip (pull → edit → push → pull verifies no pending) """ 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 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- 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 async def wait_for_job_done(client, job_id: str, timeout: float = 5.0) -> list[dict]: """Consume the SSE stream and collect all events.""" events = [] async with client.stream("GET", f"/stream/{job_id}") as r: async for line in r.aiter_lines(): if line.startswith("data:"): try: e = json.loads(line[5:].strip()) events.append(e) if e.get("type") == "done": break except json.JSONDecodeError: pass return events # --------------------------------------------------------------------------- # E2E-1: Obsidian → Outline (new file, frontmatter written back) # --------------------------------------------------------------------------- class TestObsidianToOutlineFlow: """ Full flow: user creates file in Obsidian → WebDAV syncs to VPS → user clicks Push → sync engine creates document in Outline → frontmatter written back → file has outline_id. """ async def test_new_file_reaches_outline_and_gets_id( self, client, populated_vault ): # Step 1: User creates a new note in Obsidian (WebDAV already synced it) new_file = populated_vault / "Projekte" / "E2E_NewDoc.md" new_file.parent.mkdir(exist_ok=True) new_file.write_text("# E2E New Doc\nContent written in Obsidian.\n") commit_all(populated_vault, "obsidian: new doc via webdav") # Step 2: Dashboard shows pending changes status = (await client.get("/status")).json() assert status["pending_count"] >= 1, "Dashboard must show pending changes" # Step 3: Changes page lists the new file changes = (await client.get("/changes")).json() new_item = next((i for i in changes if "E2E_NewDoc.md" in i["path"]), None) assert new_item is not None, "New file must appear in /changes" assert new_item["status"] == "added" # Step 4: User pushes — sync engine creates document and writes back ID fake_doc_id = "doc-e2e-new-001" def fake_push_subprocess(*args, **kwargs): new_file.write_text(textwrap.dedent(f"""\ --- outline_id: {fake_doc_id} outline_collection_id: col-proj-001 outline_updated_at: 2026-03-07T10:00:00Z --- # E2E New Doc Content written in Obsidian. """)) commit_all(populated_vault, "sync: frontmatter writeback") return make_mock_process([ f"ok: Projekte/E2E_NewDoc.md created (id: {fake_doc_id})", "Done. 1 created.", ]) with patch("webui.spawn_sync_subprocess", side_effect=fake_push_subprocess): r = await client.post("/push") assert r.status_code in (200, 202) events = await wait_for_job_done(client, r.json()["job_id"]) done = next((e for e in events if e.get("type") == "done"), None) assert done is not None # Step 5: File now has outline_id (will be served via WebDAV to Obsidian) content = new_file.read_text() assert "outline_id" in content, "File must have outline_id after push" assert fake_doc_id in content # Step 6: No more pending changes status2 = (await client.get("/status")).json() # After frontmatter writeback commit, outline branch advances on push # pending_count depends on implementation — just verify push succeeded # --------------------------------------------------------------------------- # E2E-2: Outline → Obsidian (pull, file appears in vault) # --------------------------------------------------------------------------- class TestOutlineToObsidianFlow: """ Full flow: document updated in Outline → user clicks Pull → SSE streams progress → file updated in vault → WebDAV serves it → Obsidian picks it up. """ async def test_pull_updates_existing_document(self, client, populated_vault): # Initial state: CV.md exists in both branches (clean) status = (await client.get("/status")).json() assert status["pending_count"] == 0 # Simulate pull: sync engine updates CV.md on outline branch def fake_pull_subprocess(*args, **kwargs): git(populated_vault, "checkout", "outline") cv = populated_vault / "Bewerbungen" / "CV.md" cv.write_text(textwrap.dedent("""\ --- outline_id: doc-cv-001 outline_collection_id: col-bew-001 outline_updated_at: 2026-03-07T09:00:00Z --- # CV Updated in Outline with new contact info. """)) commit_all(populated_vault, "outline: CV updated") git(populated_vault, "checkout", "main") git(populated_vault, "merge", "outline", "--no-ff", "-m", "merge outline") return make_mock_process([ "ok: Bewerbungen/CV.md updated", "Done. 1 updated.", ]) with patch("webui.spawn_sync_subprocess", side_effect=fake_pull_subprocess): r = await client.post("/pull") assert r.status_code in (200, 202) events = await wait_for_job_done(client, r.json()["job_id"]) done = next((e for e in events if e.get("type") == "done"), None) assert done is not None, "Pull must emit done event" # CV.md should now have new content cv = populated_vault / "Bewerbungen" / "CV.md" assert "contact info" in cv.read_text(), ( "CV.md must be updated in vault after pull" ) async def test_pull_adds_new_document_to_vault(self, client, populated_vault): """New document created in Outline must appear as a file after pull.""" def fake_pull_subprocess(*args, **kwargs): git(populated_vault, "checkout", "outline") new_file = populated_vault / "Infra" / "NewServerDoc.md" new_file.write_text(textwrap.dedent("""\ --- outline_id: doc-new-srv-001 outline_collection_id: col-inf-001 outline_updated_at: 2026-03-07T10:00:00Z --- # New Server Doc Created in Outline. """)) commit_all(populated_vault, "outline: new server doc") git(populated_vault, "checkout", "main") git(populated_vault, "merge", "outline", "--no-ff", "-m", "merge") return make_mock_process([ "ok: Infra/NewServerDoc.md created", "Done. 1 created.", ]) with patch("webui.spawn_sync_subprocess", side_effect=fake_pull_subprocess): r = await client.post("/pull") await wait_for_job_done(client, r.json()["job_id"]) new_file = populated_vault / "Infra" / "NewServerDoc.md" assert new_file.exists(), "New document from Outline must appear in vault" assert "outline_id: doc-new-srv-001" in new_file.read_text() # --------------------------------------------------------------------------- # E2E-3: Conflict detection and resolution in browser # --------------------------------------------------------------------------- class TestConflictResolutionFlow: """ Full flow: same file edited in both Obsidian and Outline → pull detects conflict → conflicts page shows → user resolves → push becomes available. """ async def test_full_conflict_resolution_flow(self, client, vault_with_conflict): # Step 1: Verify conflicts are detected status = (await client.get("/status")).json() assert status["conflicts"] > 0, "Conflicts must be detected after merge conflict" # Step 2: Push is blocked r = await client.post("/push") assert r.status_code == 409, "Push must be blocked while conflicts exist" # Step 3: Conflicts page lists the file r = await client.get("/conflicts") conflicts = r.json() assert len(conflicts) > 0 conflict_path = conflicts[0]["path"] # Step 4: User views the diff import base64 encoded = base64.urlsafe_b64encode(conflict_path.encode()).decode() r = await client.get(f"/diff/{encoded}") assert r.status_code == 200 assert "text/html" in r.headers.get("content-type", "") # Step 5: User chooses "Keep mine" (local) r = await client.post("/resolve", json={ "file": conflict_path, "accept": "local", }) assert r.status_code == 200 # Step 6: No more conflicts r = await client.get("/conflicts") assert r.json() == [], "All conflicts must be resolved" # Step 7: No conflict markers in file content = (vault_with_conflict / conflict_path).read_text() assert "<<<<<<<" not in content # Step 8: Push is now allowed 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 succeed after conflicts resolved" # --------------------------------------------------------------------------- # E2E-4: New collection creation # --------------------------------------------------------------------------- class TestNewCollectionFlow: """ User creates a new top-level folder in Obsidian. Push must create the collection AND the documents inside it. """ async def test_new_collection_created_on_push(self, client, populated_vault): # Create new folder + file (simulating Obsidian + WebDAV sync) new_dir = populated_vault / "NewProject" new_dir.mkdir() (new_dir / "Overview.md").write_text("# Overview\nNew project.\n") (new_dir / "Notes.md").write_text("# Notes\nMeeting notes.\n") commit_all(populated_vault, "obsidian: new collection NewProject") # Changes must flag both files as added changes = (await client.get("/changes")).json() new_items = [i for i in changes if "NewProject" in i["path"]] assert len(new_items) >= 2, "Both new files must appear in changes" # Push — mock shows collection + documents created fake_col_id = "col-newproject-001" fake_push_lines = [ f"ok: collection 'NewProject' created (id: {fake_col_id})", "ok: NewProject/Overview.md created (id: doc-overview-001)", "ok: NewProject/Notes.md created (id: doc-notes-001)", "Done. 2 created, 1 collection created.", ] with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(fake_push_lines) r = await client.post("/push") assert r.status_code in (200, 202) events = await wait_for_job_done(client, r.json()["job_id"]) all_text = json.dumps(events) assert "NewProject" in all_text or "collection" in all_text, ( "New collection creation must be reflected in SSE output" ) # --------------------------------------------------------------------------- # E2E-5: Concurrency safety # --------------------------------------------------------------------------- class TestConcurrencySafety: async def test_only_one_sync_job_at_a_time(self, client, populated_vault): """ Starting a second sync while first is pending/running returns 409. _active_job is set immediately by POST /pull, before the job starts. """ r1 = await client.post("/pull") assert r1.status_code in (200, 202) r2 = await client.post("/pull") assert r2.status_code == 409, "Concurrent pull must be rejected" r3 = await client.post("/push") assert r3.status_code == 409, "Concurrent push must be rejected" async def test_new_job_accepted_after_previous_completes( self, client, populated_vault ): """After a job finishes, a new job must be accepted.""" # Keep patch active while draining so the task can call spawn_sync_subprocess with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(["Done. 0 updated."]) r1 = await client.post("/pull") assert r1.status_code in (200, 202) await wait_for_job_done(client, r1.json()["job_id"]) # drain → job completes with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(["Done. 0 updated."]) r2 = await client.post("/pull") assert r2.status_code in (200, 202), "New job must be accepted after first completes" # --------------------------------------------------------------------------- # E2E-6: Full roundtrip — pull → edit → push → no pending # --------------------------------------------------------------------------- class TestFullRoundtrip: async def test_pull_edit_push_leaves_clean_state(self, client, populated_vault): """ Complete happy-path cycle: 1. Pull (no changes) 2. Edit a file (simulate Obsidian) 3. Push (sync engine updates Outline, writes back updated_at) 4. Dashboard shows clean state """ # Step 1: Pull — nothing new with patch("webui.spawn_sync_subprocess", new_callable=AsyncMock) as mock_spawn: mock_spawn.return_value = make_mock_process(["Done. 0 updated."]) r = await client.post("/pull") await wait_for_job_done(client, r.json()["job_id"]) # Step 2: User edits CV.md in Obsidian (simulated via direct write) cv = populated_vault / "Bewerbungen" / "CV.md" original = cv.read_text() cv.write_text(original.rstrip() + "\n\n## Skills\n- Python\n- Docker\n") commit_all(populated_vault, "obsidian: add skills section") pending = (await client.get("/status")).json()["pending_count"] assert pending >= 1, "Edits must be pending after local change" # Step 3: Push — sync engine updates Outline and refreshes updated_at def fake_push(*args, **kwargs): cv.write_text(cv.read_text().replace( "outline_updated_at: 2026-01-10T12:00:00Z", "outline_updated_at: 2026-03-07T11:30:00Z", )) commit_all(populated_vault, "sync: advance outline branch after push") # Advance outline branch to match git(populated_vault, "checkout", "outline") cv_outline = populated_vault / "Bewerbungen" / "CV.md" cv_outline.write_text(cv.read_text()) commit_all(populated_vault, "outline: updated from push") git(populated_vault, "checkout", "main") return make_mock_process(["ok: Bewerbungen/CV.md updated", "Done. 1 updated."]) with patch("webui.spawn_sync_subprocess", side_effect=fake_push): r = await client.post("/push") events = await wait_for_job_done(client, r.json()["job_id"]) done = next((e for e in events if e.get("type") == "done"), None) assert done is not None, "Push must emit done event" # Step 4: Verify no pending changes remain (outline branch == main) r_changes = await client.get("/changes") # Changes should be 0 or only include the updated_at change # which is an internal sync marker, not a user-content change status = (await client.get("/status")).json() assert status["conflicts"] == 0, "No conflicts must remain after clean push"