kin/tests/test_obsidian_sync.py
Gros Frumos 71c697bf68 kin: KIN-070 Исправить sync с Obsidian: auto-create vault dir + корректный vault_path
- obsidian_sync.py: заменить проверку is_dir() на mkdir(parents=True, exist_ok=True)
  вместо ошибки при отсутствующей директории — автоматически создаём её
- test_obsidian_sync.py: обновить тест #9 под новое поведение (директория создаётся)
- БД fix: исправлен obsidian_vault_path (убраны лишние кавычки и /kin суффикс),
  теперь путь указывает на vault root, а не на подпапку проекта

Результат: Exported: 79 decisions, errors: []

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:50:52 +02:00

259 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for core/obsidian_sync.py — KIN-013."""
import sqlite3
import tempfile
from pathlib import Path
import pytest
from core.db import init_db
from core.obsidian_sync import (
export_decisions_to_md,
parse_task_checkboxes,
sync_obsidian,
)
from core import models
# ---------------------------------------------------------------------------
# 0. Migration — obsidian_vault_path column must exist after init_db
# ---------------------------------------------------------------------------
def test_migration_obsidian_vault_path_column_exists():
"""init_db создаёт или мигрирует колонку obsidian_vault_path в таблице projects."""
conn = init_db(db_path=":memory:")
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
conn.close()
assert "obsidian_vault_path" in cols
@pytest.fixture
def tmp_vault(tmp_path):
"""Returns a temporary vault root directory."""
return tmp_path / "vault"
@pytest.fixture
def db(tmp_path):
"""Returns an in-memory SQLite connection with schema + test data."""
db_path = tmp_path / "test.db"
conn = init_db(db_path)
models.create_project(conn, "proj1", "Test Project", "/tmp/proj1")
yield conn
conn.close()
# ---------------------------------------------------------------------------
# 1. export creates files with correct frontmatter
# ---------------------------------------------------------------------------
def test_export_decisions_creates_md_files(tmp_vault):
decisions = [
{
"id": 42,
"project_id": "proj1",
"type": "gotcha",
"category": "testing",
"title": "Proxy через SSH не работает без ssh-agent",
"description": "При подключении через ProxyJump ssh-agent должен быть запущен.",
"tags": ["testing", "mock", "subprocess"],
"created_at": "2026-03-10T12:00:00",
}
]
tmp_vault.mkdir(parents=True)
created = export_decisions_to_md("proj1", decisions, tmp_vault)
assert len(created) == 1
md_file = created[0]
assert md_file.exists()
content = md_file.read_text(encoding="utf-8")
assert "kin_decision_id: 42" in content
assert "project: proj1" in content
assert "type: gotcha" in content
assert "category: testing" in content
assert "2026-03-10" in content
assert "# Proxy через SSH не работает без ssh-agent" in content
assert "При подключении через ProxyJump" in content
# ---------------------------------------------------------------------------
# 2. export is idempotent (overwrite, not duplicate)
# ---------------------------------------------------------------------------
def test_export_idempotent(tmp_vault):
decisions = [
{
"id": 1,
"project_id": "p",
"type": "decision",
"category": None,
"title": "Use SQLite",
"description": "SQLite is the source of truth.",
"tags": [],
"created_at": "2026-01-01",
}
]
tmp_vault.mkdir(parents=True)
export_decisions_to_md("p", decisions, tmp_vault)
export_decisions_to_md("p", decisions, tmp_vault)
out_dir = tmp_vault / "p" / "decisions"
files = list(out_dir.glob("*.md"))
assert len(files) == 1
# ---------------------------------------------------------------------------
# 3. parse_task_checkboxes — done checkbox
# ---------------------------------------------------------------------------
def test_parse_task_checkboxes_done(tmp_vault):
tasks_dir = tmp_vault / "proj1" / "tasks"
tasks_dir.mkdir(parents=True)
(tasks_dir / "kanban.md").write_text(
"- [x] KIN-001 Implement login\n- [ ] KIN-002 Add tests\n",
encoding="utf-8",
)
results = parse_task_checkboxes(tmp_vault, "proj1")
done_items = [r for r in results if r["task_id"] == "KIN-001"]
assert len(done_items) == 1
assert done_items[0]["done"] is True
assert done_items[0]["title"] == "Implement login"
# ---------------------------------------------------------------------------
# 4. parse_task_checkboxes — pending checkbox
# ---------------------------------------------------------------------------
def test_parse_task_checkboxes_pending(tmp_vault):
tasks_dir = tmp_vault / "proj1" / "tasks"
tasks_dir.mkdir(parents=True)
(tasks_dir / "kanban.md").write_text(
"- [ ] KIN-002 Add tests\n",
encoding="utf-8",
)
results = parse_task_checkboxes(tmp_vault, "proj1")
pending = [r for r in results if r["task_id"] == "KIN-002"]
assert len(pending) == 1
assert pending[0]["done"] is False
# ---------------------------------------------------------------------------
# 5. parse_task_checkboxes — lines without task ID are skipped
# ---------------------------------------------------------------------------
def test_parse_task_checkboxes_no_id(tmp_vault):
tasks_dir = tmp_vault / "proj1" / "tasks"
tasks_dir.mkdir(parents=True)
(tasks_dir / "notes.md").write_text(
"- [x] Some task without ID\n"
"- [ ] Another line without identifier\n"
"- [x] KIN-003 With ID\n",
encoding="utf-8",
)
results = parse_task_checkboxes(tmp_vault, "proj1")
assert all(r["task_id"].startswith("KIN-") for r in results)
assert len(results) == 1
assert results[0]["task_id"] == "KIN-003"
# ---------------------------------------------------------------------------
# 6. sync_obsidian updates task status when done=True
# ---------------------------------------------------------------------------
def test_sync_updates_task_status(db, tmp_vault):
tmp_vault.mkdir(parents=True)
models.update_project(db, "proj1", obsidian_vault_path=str(tmp_vault))
task = models.create_task(db, "PROJ1-001", "proj1", "Do something", status="in_progress")
assert task["status"] == "in_progress"
# Write checkbox file
tasks_dir = tmp_vault / "proj1" / "tasks"
tasks_dir.mkdir(parents=True)
(tasks_dir / "sprint.md").write_text(
"- [x] PROJ1-001 Do something\n",
encoding="utf-8",
)
result = sync_obsidian(db, "proj1")
assert result["tasks_updated"] == 1
assert not result["errors"]
updated = models.get_task(db, "PROJ1-001")
assert updated["status"] == "done"
# ---------------------------------------------------------------------------
# 7. sync_obsidian raises ValueError when vault_path not set
# ---------------------------------------------------------------------------
def test_sync_no_vault_path(db):
# project exists but obsidian_vault_path is NULL
with pytest.raises(ValueError, match="obsidian_vault_path not set"):
sync_obsidian(db, "proj1")
# ---------------------------------------------------------------------------
# 8. export — frontmatter обёрнут в разделители ---
# ---------------------------------------------------------------------------
def test_export_frontmatter_has_yaml_delimiters(tmp_vault):
"""Экспортированный файл начинается с '---' и содержит закрывающий '---'."""
decisions = [
{
"id": 99,
"project_id": "p",
"type": "decision",
"category": None,
"title": "YAML Delimiter Test",
"description": "Verifying frontmatter delimiters.",
"tags": [],
"created_at": "2026-01-01",
}
]
tmp_vault.mkdir(parents=True)
created = export_decisions_to_md("p", decisions, tmp_vault)
content = created[0].read_text(encoding="utf-8")
assert content.startswith("---\n"), "Frontmatter должен начинаться с '---\\n'"
# первые --- открывают, вторые --- закрывают frontmatter
parts = content.split("---\n")
assert len(parts) >= 3, "Должно быть минимум два разделителя '---'"
# ---------------------------------------------------------------------------
# 9. sync_obsidian — несуществующий vault_path → директория создаётся автоматически
# ---------------------------------------------------------------------------
def test_sync_nonexistent_vault_creates_directory(db, tmp_path):
"""Если vault_path не существует, sync автоматически создаёт директорию."""
nonexistent = tmp_path / "ghost_vault"
models.update_project(db, "proj1", obsidian_vault_path=str(nonexistent))
result = sync_obsidian(db, "proj1")
assert result["errors"] == []
assert nonexistent.is_dir() # директория автоматически создана
assert result["exported_decisions"] == 0 # нет decisions в DB
assert result["tasks_updated"] == 0
# ---------------------------------------------------------------------------
# 10. sync_obsidian — пустой vault → 0 экспортов, 0 обновлений, нет ошибок
# ---------------------------------------------------------------------------
def test_sync_empty_vault_no_errors(db, tmp_vault):
"""Пустой vault (нет decisions, нет task-файлов) → exported=0, updated=0, errors=[]."""
tmp_vault.mkdir(parents=True)
models.update_project(db, "proj1", obsidian_vault_path=str(tmp_vault))
result = sync_obsidian(db, "proj1")
assert result["exported_decisions"] == 0
assert result["tasks_updated"] == 0
assert result["errors"] == []