Add sync engine, web UI, Docker setup, and tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-07 20:54:59 +01:00
parent e4c69efd12
commit b3137a8af3
27 changed files with 7133 additions and 278 deletions

View File

@@ -0,0 +1,263 @@
"""
Phase D — Pending Changes View Tests
Tests for GET /changes (structured change list) and GET /diff/{path} (inline diff).
Git operations run against the real temp vault — no subprocess mocking needed here.
"""
import base64
import subprocess
import textwrap
from pathlib import Path
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def encode_path(path: str) -> str:
"""URL-safe base64 encoding of a path, matching what the app uses."""
return base64.urlsafe_b64encode(path.encode()).decode()
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-D1 — Changes endpoint structure
# ---------------------------------------------------------------------------
class TestChangesEndpoint:
async def test_get_changes_returns_200(self, client):
r = await client.get("/changes")
assert r.status_code == 200
async def test_changes_returns_json(self, client):
r = await client.get("/changes")
assert "application/json" in r.headers.get("content-type", "")
async def test_changes_returns_list(self, client):
r = await client.get("/changes")
data = r.json()
assert isinstance(data, list)
async def test_changes_empty_when_clean(self, client, populated_vault):
r = await client.get("/changes")
assert r.json() == []
async def test_each_change_has_required_fields(self, client, vault_with_pending):
r = await client.get("/changes")
items = r.json()
assert len(items) > 0, "Expected pending changes"
for item in items:
assert "path" in item, f"Missing 'path' in item: {item}"
assert "status" in item, f"Missing 'status' in item: {item}"
assert "action" in item, f"Missing 'action' in item: {item}"
async def test_status_values_are_valid(self, client, vault_with_pending):
valid_statuses = {"modified", "added", "deleted", "renamed"}
r = await client.get("/changes")
for item in r.json():
assert item["status"] in valid_statuses, (
f"Invalid status '{item['status']}' — must be one of {valid_statuses}"
)
# ---------------------------------------------------------------------------
# US-D2 — Change categories
# ---------------------------------------------------------------------------
class TestChangeCategories:
async def test_modified_file_shown_as_modified(self, client, populated_vault):
# Edit an existing file on main
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.write_text(cv.read_text() + "\n## Appendix\nNew content.\n")
commit_all(populated_vault, "obsidian: edit CV")
r = await client.get("/changes")
items = r.json()
cv_item = next((i for i in items if "CV.md" in i["path"]), None)
assert cv_item is not None, "CV.md must appear in changes"
assert cv_item["status"] == "modified"
async def test_new_file_shown_as_added(self, client, populated_vault):
new_file = populated_vault / "Projekte" / "NewDoc.md"
new_file.parent.mkdir(exist_ok=True)
new_file.write_text("# New Doc\nWritten in Obsidian.\n")
commit_all(populated_vault, "obsidian: new doc")
r = await client.get("/changes")
items = r.json()
new_item = next((i for i in items if "NewDoc.md" in i["path"]), None)
assert new_item is not None, "NewDoc.md must appear in changes"
assert new_item["status"] == "added"
async def test_deleted_file_shown_as_deleted(self, client, populated_vault):
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.unlink()
commit_all(populated_vault, "obsidian: delete CV")
r = await client.get("/changes")
items = r.json()
del_item = next((i for i in items if "CV.md" in i["path"]), None)
assert del_item is not None, "Deleted CV.md must appear in changes"
assert del_item["status"] == "deleted"
async def test_renamed_file_shown_as_renamed(self, client, populated_vault):
old_path = populated_vault / "Bewerbungen" / "CV.md"
new_path = populated_vault / "Bewerbungen" / "Curriculum Vitae.md"
old_path.rename(new_path)
commit_all(populated_vault, "obsidian: rename CV")
r = await client.get("/changes")
items = r.json()
# Either old or new name should appear with renamed status
renamed = [i for i in items if i["status"] == "renamed"]
assert len(renamed) >= 1, "Renamed file must appear with status=renamed"
# Check both from_path and to_path since either may contain "CV"
all_paths = " ".join(
str(i.get("from_path", "")) + " " + str(i.get("to_path", i["path"]))
for i in renamed
)
assert "CV" in all_paths, "Renamed paths must reference the original filename"
async def test_renamed_item_has_from_and_to_paths(self, client, populated_vault):
old_path = populated_vault / "Bewerbungen" / "CV.md"
new_path = populated_vault / "Bewerbungen" / "Resume.md"
old_path.rename(new_path)
commit_all(populated_vault, "obsidian: rename")
r = await client.get("/changes")
renamed = [i for i in r.json() if i["status"] == "renamed"]
assert len(renamed) >= 1
item = renamed[0]
assert "from" in item or "from_path" in item, "Rename must include source path"
assert "to" in item or "to_path" in item, "Rename must include destination path"
# ---------------------------------------------------------------------------
# US-D3 — Diff preview
# ---------------------------------------------------------------------------
class TestDiffPreview:
async def test_diff_endpoint_returns_200(self, client, populated_vault):
# Edit a file to create a diff
cv = populated_vault / "Bewerbungen" / "CV.md"
original = cv.read_text()
cv.write_text(original + "\n## New Section\n")
commit_all(populated_vault, "edit for diff")
encoded = encode_path("Bewerbungen/CV.md")
r = await client.get(f"/diff/{encoded}")
assert r.status_code == 200
async def test_diff_returns_html_fragment(self, client, populated_vault):
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.write_text(cv.read_text() + "\nExtra line.\n")
commit_all(populated_vault, "edit for diff")
encoded = encode_path("Bewerbungen/CV.md")
r = await client.get(f"/diff/{encoded}")
assert "text/html" in r.headers.get("content-type", ""), (
"Diff endpoint must return HTML"
)
async def test_diff_contains_two_columns(self, client, populated_vault):
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.write_text(cv.read_text() + "\nAdded line.\n")
commit_all(populated_vault, "edit for diff")
encoded = encode_path("Bewerbungen/CV.md")
r = await client.get(f"/diff/{encoded}")
body = r.text.lower()
# Two-column layout — check for table or grid structure
assert "table" in body or "column" in body or "diff" in body, (
"Diff HTML must contain a two-column comparison layout"
)
async def test_diff_for_unknown_file_returns_404(self, client, populated_vault):
encoded = encode_path("DoesNotExist/ghost.md")
r = await client.get(f"/diff/{encoded}")
assert r.status_code == 404
async def test_diff_added_lines_have_distinct_marking(self, client, populated_vault):
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.write_text(cv.read_text() + "\nThis line was added.\n")
commit_all(populated_vault, "add line")
encoded = encode_path("Bewerbungen/CV.md")
r = await client.get(f"/diff/{encoded}")
body = r.text
# Added lines must be visually distinct (green class, + prefix, or ins tag)
# difflib.HtmlDiff marks added lines with class="diff_add"
assert any(marker in body for marker in (
'class="diff_add"', "diff_add", 'class="add"', "<ins>", "diff-add",
)), "Added lines must be visually marked in the diff"
# ---------------------------------------------------------------------------
# US-D4 — Deleted files skipped when allow_deletions=false
# ---------------------------------------------------------------------------
class TestDeletedFilesSkipped:
async def test_deleted_file_action_is_skip_when_deletions_off(
self, client, populated_vault, settings_file
):
"""With allow_deletions=false in settings, deleted files must show action=skip."""
import json
settings = json.loads(settings_file.read_text())
settings["sync"]["allow_deletions"] = False
settings_file.write_text(json.dumps(settings))
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.unlink()
commit_all(populated_vault, "delete CV")
r = await client.get("/changes")
items = r.json()
del_item = next((i for i in items if "CV.md" in i["path"]), None)
assert del_item is not None
assert del_item["action"] in ("skip", "skipped"), (
"Deleted file must have action=skip when deletions are disabled"
)
async def test_deleted_file_action_is_delete_when_deletions_on(
self, client, populated_vault, settings_file
):
"""With allow_deletions=true, deleted file action must be delete."""
import json
settings = json.loads(settings_file.read_text())
settings["sync"]["allow_deletions"] = True
settings_file.write_text(json.dumps(settings))
cv = populated_vault / "Bewerbungen" / "CV.md"
cv.unlink()
commit_all(populated_vault, "delete CV")
r = await client.get("/changes")
items = r.json()
del_item = next((i for i in items if "CV.md" in i["path"]), None)
assert del_item is not None
assert del_item["action"] in ("delete", "archive"), (
"Deleted file must have action=delete when deletions are enabled"
)