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

291 lines
12 KiB
Python

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