291 lines
12 KiB
Python
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"
|
|
)
|