Add sync engine, web UI, Docker setup, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
270
tests/conftest.py
Normal file
270
tests/conftest.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# Ensure the project root (webui.py) is importable before any test collection.
|
||||
import sys
|
||||
from pathlib import Path
|
||||
_ROOT = Path(__file__).parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
"""
|
||||
Shared fixtures for the Outline Sync Web UI test suite.
|
||||
|
||||
All tests work against the FastAPI app defined in webui.py (Phase B+).
|
||||
Phase A tests (WebDAV) are integration tests marked with @pytest.mark.integration
|
||||
and require a running WebDAV container — they are skipped in unit-test runs.
|
||||
|
||||
Run unit tests only:
|
||||
pytest tests/ -m "not integration"
|
||||
|
||||
Run integration tests (requires running WebDAV container):
|
||||
pytest tests/ -m integration
|
||||
|
||||
Run everything:
|
||||
pytest tests/
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pytest configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytest_plugins = ["pytest_asyncio"]
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "integration: requires running infrastructure (WebDAV, Outline)")
|
||||
config.addinivalue_line("markers", "slow: longer-running tests")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def git(vault: Path, *args) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(vault), *args],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def git_config(vault: Path):
|
||||
git(vault, "config", "user.email", "test@sync.local")
|
||||
git(vault, "config", "user.name", "Test Runner")
|
||||
|
||||
|
||||
def commit_all(vault: Path, message: str):
|
||||
"""Stage everything in vault and commit."""
|
||||
git(vault, "add", "-A")
|
||||
try:
|
||||
git(vault, "commit", "-m", message)
|
||||
except subprocess.CalledProcessError:
|
||||
pass # nothing to commit — that's fine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-reset webui module state between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_webui_state():
|
||||
"""Clear _jobs and _active_job on webui module after every test."""
|
||||
yield
|
||||
try:
|
||||
import webui as _webui
|
||||
_webui._jobs.clear()
|
||||
_webui._active_job = None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def vault_dir(tmp_path) -> Path:
|
||||
"""
|
||||
Temporary directory simulating /outline-vault/.
|
||||
Initialised as a git repo with 'outline' and 'main' branches.
|
||||
"""
|
||||
vault = tmp_path / "outline-vault"
|
||||
vault.mkdir()
|
||||
|
||||
subprocess.run(["git", "init", str(vault)], check=True, capture_output=True)
|
||||
git_config(vault)
|
||||
git(vault, "checkout", "-b", "outline")
|
||||
# Initial commit so branches can diverge
|
||||
(vault / ".gitkeep").touch()
|
||||
commit_all(vault, "initial")
|
||||
git(vault, "checkout", "-b", "main")
|
||||
|
||||
return vault
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_vault(vault_dir: Path) -> Path:
|
||||
"""
|
||||
Vault pre-loaded with sample markdown files on both branches,
|
||||
simulating a state after a previous successful sync.
|
||||
"""
|
||||
# Create files on 'outline' branch — last known Outline state
|
||||
git(vault_dir, "checkout", "outline")
|
||||
(vault_dir / "Bewerbungen").mkdir()
|
||||
(vault_dir / "Bewerbungen" / "CV.md").write_text(textwrap.dedent("""\
|
||||
---
|
||||
outline_id: doc-cv-001
|
||||
outline_collection_id: col-bew-001
|
||||
outline_updated_at: 2026-01-10T12:00:00Z
|
||||
---
|
||||
# CV
|
||||
Original content.
|
||||
"""))
|
||||
(vault_dir / "Infra").mkdir()
|
||||
(vault_dir / "Infra" / "HomeLab.md").write_text(textwrap.dedent("""\
|
||||
---
|
||||
outline_id: doc-hl-002
|
||||
outline_collection_id: col-inf-001
|
||||
outline_updated_at: 2026-01-10T12:00:00Z
|
||||
---
|
||||
# HomeLab
|
||||
Server setup notes.
|
||||
"""))
|
||||
commit_all(vault_dir, "outline: sync 2026-01-10")
|
||||
|
||||
# Merge into 'main' so both branches are identical at start
|
||||
git(vault_dir, "checkout", "main")
|
||||
git(vault_dir, "merge", "outline", "--no-ff", "-m", "merge outline into main")
|
||||
|
||||
return vault_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_file(vault_dir: Path, tmp_path: Path) -> Path:
|
||||
"""settings.json pointing at the temp vault and a test Outline URL."""
|
||||
settings = {
|
||||
"source": {
|
||||
"url": "http://outline:3000",
|
||||
"token": "test-api-token-abc123",
|
||||
},
|
||||
"sync": {
|
||||
"vault_dir": str(vault_dir),
|
||||
"allow_deletions": False,
|
||||
},
|
||||
}
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(json.dumps(settings, indent=2))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_log(vault_dir: Path) -> Path:
|
||||
"""Pre-populate _sync_log.md with a few history entries."""
|
||||
log = vault_dir / "_sync_log.md"
|
||||
log.write_text(textwrap.dedent("""\
|
||||
# Sync Log
|
||||
|
||||
| Timestamp | Direction | Files | Status |
|
||||
|-----------|-----------|-------|--------|
|
||||
| 2026-03-04 08:00 | pull | 0 changes | ok |
|
||||
| 2026-03-05 09:10 | push | 2 updated, 1 created | ok |
|
||||
| 2026-03-06 14:32 | pull | 3 updated | ok |
|
||||
"""))
|
||||
return log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App fixtures (Phases B–G)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def app(vault_dir: Path, settings_file: Path):
|
||||
"""
|
||||
FastAPI app instance with VAULT_DIR and SETTINGS_PATH overridden.
|
||||
Skips the fixture (and all tests using it) if webui.py is not yet written.
|
||||
"""
|
||||
webui = pytest.importorskip("webui", reason="webui.py not yet implemented")
|
||||
webui.VAULT_DIR = vault_dir
|
||||
webui.SETTINGS_PATH = settings_file
|
||||
return webui.app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app) -> Generator[AsyncClient, None, None]:
|
||||
"""Async HTTP test client bound to the FastAPI app."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://testserver"
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vault state helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def vault_with_pending(populated_vault: Path) -> Path:
|
||||
"""
|
||||
Vault where main branch has local edits not yet pushed to Outline.
|
||||
Simulates Obsidian having written files via WebDAV.
|
||||
"""
|
||||
cv = populated_vault / "Bewerbungen" / "CV.md"
|
||||
cv.write_text(cv.read_text() + "\n## New Section\nAdded in Obsidian.\n")
|
||||
|
||||
new_file = populated_vault / "Projekte" / "NewNote.md"
|
||||
new_file.parent.mkdir(exist_ok=True)
|
||||
new_file.write_text("# New Note\nWritten in Obsidian.\n")
|
||||
|
||||
commit_all(populated_vault, "obsidian: local edits")
|
||||
return populated_vault
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault_with_conflict(populated_vault: Path) -> Path:
|
||||
"""
|
||||
Vault in a post-merge-conflict state: CV.md has conflict markers on main.
|
||||
"""
|
||||
cv = populated_vault / "Bewerbungen" / "CV.md"
|
||||
|
||||
# Edit on outline branch
|
||||
git(populated_vault, "checkout", "outline")
|
||||
cv.write_text(textwrap.dedent("""\
|
||||
---
|
||||
outline_id: doc-cv-001
|
||||
outline_collection_id: col-bew-001
|
||||
outline_updated_at: 2026-03-06T11:03:00Z
|
||||
---
|
||||
# CV
|
||||
Outline version with contact info updated.
|
||||
"""))
|
||||
commit_all(populated_vault, "outline: CV contact update")
|
||||
|
||||
# Conflicting edit on main branch
|
||||
git(populated_vault, "checkout", "main")
|
||||
cv.write_text(textwrap.dedent("""\
|
||||
---
|
||||
outline_id: doc-cv-001
|
||||
outline_collection_id: col-bew-001
|
||||
outline_updated_at: 2026-01-10T12:00:00Z
|
||||
---
|
||||
# CV
|
||||
Local version with new section added.
|
||||
"""))
|
||||
commit_all(populated_vault, "obsidian: CV new section")
|
||||
|
||||
# Attempt merge — this will produce a conflict
|
||||
try:
|
||||
git(populated_vault, "merge", "outline")
|
||||
except subprocess.CalledProcessError:
|
||||
pass # expected: merge conflict
|
||||
|
||||
return populated_vault
|
||||
Reference in New Issue
Block a user