413 lines
17 KiB
Python
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"
|