Files
outline-sync/tests/test_e2e.py
2026-03-07 20:54:59 +01:00

413 lines
17 KiB
Python

"""
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"