271 lines
8.3 KiB
Python
271 lines
8.3 KiB
Python
# 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
|