CI / Tests (Linux, Python 3.12) (push) Failing after 4m51s
CI / Tests (Linux, Python 3.11) (push) Failing after 5m19s
CI / Tests (Linux, Python 3.9) (push) Failing after 5m3s
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
Cross-platform CLI that mirrors Dropbox web tags to OS-native tags: - macOS: Finder xattrs (content-preserving, no re-upload) - Windows: XMP:Subject via ExifTool (re-upload expected once per file) Includes an interactive wizard that auto-detects the OS and Dropbox folder, walks the user through read-only token creation, previews tagged files, applies tags, and installs a background scheduler (launchd on macOS, Task Scheduler on Windows). Ships with: - 47-test suite (pytest) covering config, state DB, run_sync, writers - Gitea Actions CI for Linux / macOS / Windows with coverage reporting
269 lines
7.9 KiB
Python
269 lines
7.9 KiB
Python
"""Tests for tagsync.core: state DB + run_sync main loop.
|
|
|
|
Dropbox client calls are monkeypatched via the `patched_core` fixture, so
|
|
these tests don't require network or the dropbox SDK to be installed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tagsync import core
|
|
from tests.conftest import FakeFileMetadata
|
|
|
|
|
|
# --- State DB ---------------------------------------------------------------
|
|
|
|
|
|
def test_open_state_creates_schema(tmp_path):
|
|
db = tmp_path / "state.db"
|
|
conn = core.open_state(db)
|
|
try:
|
|
tables = [
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
)
|
|
]
|
|
finally:
|
|
conn.close()
|
|
assert "file_tags" in tables
|
|
|
|
|
|
def test_open_state_is_idempotent(tmp_path):
|
|
db = tmp_path / "state.db"
|
|
core.open_state(db).close()
|
|
# Re-opening an existing DB should not raise.
|
|
core.open_state(db).close()
|
|
|
|
|
|
def test_open_state_creates_parent_dir(tmp_path):
|
|
db = tmp_path / "nested" / "dir" / "state.db"
|
|
conn = core.open_state(db)
|
|
conn.close()
|
|
assert db.exists()
|
|
|
|
|
|
# --- SyncStats --------------------------------------------------------------
|
|
|
|
|
|
def test_sync_stats_summary_format():
|
|
s = core.SyncStats(applied=3, cleared=1, errors=0)
|
|
summary = s.summary()
|
|
for key in ("applied", "cleared", "skipped", "missing", "errors"):
|
|
assert key in summary
|
|
|
|
|
|
# --- Writer implementations used by run_sync --------------------------------
|
|
|
|
|
|
class RecordingWriter:
|
|
"""Fake Writer that records every call for assertions."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
supports_all: bool = True,
|
|
online_paths: set[str] | None = None,
|
|
fail_paths: set[str] | None = None,
|
|
):
|
|
self.supports_all = supports_all
|
|
self.online_paths = online_paths or set()
|
|
self.fail_paths = fail_paths or set()
|
|
self.written: list[tuple[Path, list[str]]] = []
|
|
|
|
def supports(self, path: Path) -> bool:
|
|
return self.supports_all
|
|
|
|
def is_online_only(self, path: Path) -> bool:
|
|
return str(path) in self.online_paths
|
|
|
|
def write_tags(self, path: Path, tags: list[str]) -> None:
|
|
if str(path) in self.fail_paths:
|
|
raise RuntimeError("simulated tag failure")
|
|
self.written.append((path, list(tags)))
|
|
|
|
|
|
# --- run_sync happy paths ---------------------------------------------------
|
|
|
|
|
|
def _make_local_file(local_root: Path, rel: str) -> Path:
|
|
p = local_root / rel
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.touch()
|
|
return p
|
|
|
|
|
|
def test_run_sync_applies_new_tags(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
_make_local_file(local_root, "Report.pdf")
|
|
|
|
patched_core["files"] = [
|
|
FakeFileMetadata(name="Report.pdf", path_lower="/report.pdf",
|
|
path_display="/Report.pdf"),
|
|
]
|
|
patched_core["tags_by_path"] = {"/report.pdf": ["urgent", "client"]}
|
|
|
|
writer = RecordingWriter()
|
|
stats = core.run_sync(
|
|
token="fake",
|
|
local_root=local_root,
|
|
state_db=tmp_path / "state.db",
|
|
writer=writer,
|
|
)
|
|
assert stats.applied == 1
|
|
assert stats.errors == 0
|
|
assert writer.written == [
|
|
(local_root / "Report.pdf", ["client", "urgent"]), # sorted
|
|
]
|
|
|
|
|
|
def test_run_sync_skips_unchanged_tags(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
_make_local_file(local_root, "a.pdf")
|
|
|
|
patched_core["files"] = [
|
|
FakeFileMetadata("a.pdf", "/a.pdf", "/a.pdf")
|
|
]
|
|
patched_core["tags_by_path"] = {"/a.pdf": ["x"]}
|
|
|
|
writer = RecordingWriter()
|
|
state_db = tmp_path / "state.db"
|
|
|
|
first = core.run_sync("t", local_root, state_db, writer)
|
|
assert first.applied == 1
|
|
|
|
# Second run with the same Dropbox state should skip.
|
|
second = core.run_sync("t", local_root, state_db, writer)
|
|
assert second.applied == 0
|
|
assert second.skipped == 1
|
|
assert len(writer.written) == 1 # unchanged from first run
|
|
|
|
|
|
def test_run_sync_clears_tags_when_removed_upstream(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
_make_local_file(local_root, "b.pdf")
|
|
|
|
patched_core["files"] = [FakeFileMetadata("b.pdf", "/b.pdf", "/b.pdf")]
|
|
patched_core["tags_by_path"] = {"/b.pdf": ["temp"]}
|
|
|
|
writer = RecordingWriter()
|
|
state_db = tmp_path / "state.db"
|
|
core.run_sync("t", local_root, state_db, writer)
|
|
|
|
# Upstream removes the tag.
|
|
patched_core["tags_by_path"] = {"/b.pdf": []}
|
|
stats = core.run_sync("t", local_root, state_db, writer)
|
|
assert stats.cleared == 1
|
|
assert writer.written[-1] == (local_root / "b.pdf", [])
|
|
|
|
|
|
def test_run_sync_skips_missing_local_files(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
# Don't create the local file.
|
|
|
|
patched_core["files"] = [FakeFileMetadata("c.pdf", "/c.pdf", "/c.pdf")]
|
|
patched_core["tags_by_path"] = {"/c.pdf": ["x"]}
|
|
|
|
writer = RecordingWriter()
|
|
stats = core.run_sync(
|
|
"t", local_root, tmp_path / "state.db", writer
|
|
)
|
|
assert stats.missing == 1
|
|
assert writer.written == []
|
|
|
|
|
|
def test_run_sync_respects_unsupported_files(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
_make_local_file(local_root, "data.zip")
|
|
|
|
patched_core["files"] = [
|
|
FakeFileMetadata("data.zip", "/data.zip", "/data.zip")
|
|
]
|
|
patched_core["tags_by_path"] = {"/data.zip": ["archive"]}
|
|
|
|
writer = RecordingWriter(supports_all=False)
|
|
stats = core.run_sync(
|
|
"t", local_root, tmp_path / "state.db", writer
|
|
)
|
|
assert stats.unsupported == 1
|
|
assert stats.applied == 0
|
|
|
|
|
|
def test_run_sync_skips_online_only_files_when_requested(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
local_file = _make_local_file(local_root, "cloud.pdf")
|
|
|
|
patched_core["files"] = [FakeFileMetadata("cloud.pdf", "/cloud.pdf", "/cloud.pdf")]
|
|
patched_core["tags_by_path"] = {"/cloud.pdf": ["tag"]}
|
|
|
|
writer = RecordingWriter(online_paths={str(local_file)})
|
|
stats = core.run_sync(
|
|
"t", local_root, tmp_path / "state.db", writer,
|
|
skip_online_only=True,
|
|
)
|
|
assert stats.online_only == 1
|
|
assert stats.applied == 0
|
|
|
|
|
|
def test_run_sync_dry_run_touches_nothing(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
_make_local_file(local_root, "d.pdf")
|
|
|
|
patched_core["files"] = [FakeFileMetadata("d.pdf", "/d.pdf", "/d.pdf")]
|
|
patched_core["tags_by_path"] = {"/d.pdf": ["x"]}
|
|
|
|
writer = RecordingWriter()
|
|
stats = core.run_sync(
|
|
"t", local_root, tmp_path / "state.db", writer, dry_run=True,
|
|
)
|
|
assert stats.applied == 1
|
|
assert writer.written == [] # nothing actually written
|
|
|
|
|
|
def test_run_sync_counts_writer_errors(tmp_path, patched_core):
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
local_file = _make_local_file(local_root, "e.pdf")
|
|
|
|
patched_core["files"] = [FakeFileMetadata("e.pdf", "/e.pdf", "/e.pdf")]
|
|
patched_core["tags_by_path"] = {"/e.pdf": ["x"]}
|
|
|
|
writer = RecordingWriter(fail_paths={str(local_file)})
|
|
stats = core.run_sync(
|
|
"t", local_root, tmp_path / "state.db", writer,
|
|
)
|
|
assert stats.errors == 1
|
|
assert stats.applied == 0
|
|
|
|
|
|
# --- Path translation -------------------------------------------------------
|
|
|
|
|
|
def test_path_display_to_local(tmp_path, patched_core):
|
|
"""Dropbox's /A/B/C.pdf must map under the configured local root."""
|
|
local_root = tmp_path / "Dropbox"
|
|
local_root.mkdir()
|
|
_make_local_file(local_root, "A/B/C.pdf")
|
|
|
|
patched_core["files"] = [
|
|
FakeFileMetadata("C.pdf", "/a/b/c.pdf", "/A/B/C.pdf")
|
|
]
|
|
patched_core["tags_by_path"] = {"/a/b/c.pdf": ["t"]}
|
|
|
|
writer = RecordingWriter()
|
|
core.run_sync(
|
|
"t", local_root, tmp_path / "state.db", writer,
|
|
)
|
|
assert writer.written
|
|
written_path, _ = writer.written[0]
|
|
assert written_path == local_root / "A" / "B" / "C.pdf"
|