Files
dropbox_helper/tests/test_core.py
T
jared e363020080
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
feat: initial Dropbox tag sync tool
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
2026-04-20 14:16:50 -04:00

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"