diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c97309c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +ICS_URL=https://outlook.office365.com/owa/calendar/your-calendar.ics +BAIKAL_URL=https://your-baikal.com/dav.php/calendars/USER/calendar-id/ +BAIKAL_USER=your-username +BAIKAL_PASS=your-password +SYNC_FREQUENCY=5 +CALENDAR_ID= diff --git a/.gitignore b/.gitignore index 110b0a6..3891c11 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,13 @@ wheels/ # Virtual environments .venv .env + +# Sync state database +*.db + +# Playwright MCP artifacts +.playwright-mcp/ +*.png + +# Test artifacts +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile index 12f6bc2..db4d074 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY sync_calendar.py . +COPY . . -CMD ["python", "-u", "sync_calendar.py"] +HEALTHCHECK --interval=60s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')" || exit 1 + +CMD ["python", "-u", "main.py"] diff --git a/docker-compose.yml b/docker-compose.yml index 60e3517..659d65a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,11 @@ services: - BAIKAL_URL=${BAIKAL_URL} - BAIKAL_USER=${BAIKAL_USER} - BAIKAL_PASS=${BAIKAL_PASS} - - SYNC_FREQUENCY=5 # Minutes - # If your Baikal is on the same host, you might need network_mode: "host" - # or ensure they share a network. - # network_mode: "host" + - SYNC_FREQUENCY=${SYNC_FREQUENCY:-5} + - CALENDAR_ID=${CALENDAR_ID:-} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 5s diff --git a/main.py b/main.py index 9d3c4bd..23f4e99 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,4 @@ -def main(): - print("Hello from ics-to-caldav!") - +from sync_calendar import main if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 4220eb6..81c71b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ics-to-caldav" -version = "0.1.0" -description = "Add your description here" +version = "1.0.0" +description = "Incremental CalDAV sync from Outlook ICS to Baikal" readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -9,3 +9,11 @@ dependencies = [ "icalendar>=6.3.2", "requests>=2.32.5", ] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index eeb459b..9536ef1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -caldav>=1.0.0 -icalendar>=5.0.0 -requests>=2.20.0 +caldav>=2.2.3 +icalendar>=6.3.2 +requests>=2.32.5 +pytest>=8.0 diff --git a/state.py b/state.py index 5f70e2a..d9954ae 100644 --- a/state.py +++ b/state.py @@ -25,7 +25,6 @@ class SyncState: ) """) self._conn.commit() - return self def get_event_uids(self) -> set[str]: with self._lock: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b20f0a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +import os +import sys +import pytest +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +@pytest.fixture +def ics_sample(): + """Return a sample ICS string with 2 events.""" + return """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test +BEGIN:VEVENT +UID:evt-001@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240101T100000Z +DTEND:20240101T110000Z +SUMMARY:Event One +END:VEVENT +BEGIN:VEVENT +UID:evt-002@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240102T100000Z +DTEND:20240102T110000Z +SUMMARY:Event Two +END:VEVENT +END:VCALENDAR""" + +@pytest.fixture +def ics_sample_modified(): + """Return ICS with one modified event and one new event.""" + return """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test +BEGIN:VEVENT +UID:evt-001@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240101T100000Z +DTEND:20240101T120000Z +SUMMARY:Event One Modified +END:VEVENT +BEGIN:VEVENT +UID:evt-003@test.com +DTSTAMP:20240101T000000Z +DTSTART:20240103T100000Z +DTEND:20240103T110000Z +SUMMARY:Event Three +END:VEVENT +END:VCALENDAR""" + +@pytest.fixture +def tmp_db(): + """Return a temp database path.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + yield path + if os.path.exists(path): + os.unlink(path) + +@pytest.fixture(autouse=True) +def env_vars(monkeypatch): + """Set up required env vars for config tests.""" + monkeypatch.setenv("ICS_URL", "https://example.com/cal.ics") + monkeypatch.setenv("BAIKAL_URL", "https://baikal.com/dav.php/calendars/user/cal/") + monkeypatch.setenv("BAIKAL_USER", "user") + monkeypatch.setenv("BAIKAL_PASS", "pass") + monkeypatch.setenv("SYNC_FREQUENCY", "5") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..bb315aa --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,49 @@ +import os +import pytest +from config import validate, HEADERS, Config + +class TestValidate: + def test_valid_config(self, env_vars): + cfg = validate() + assert isinstance(cfg, Config) + assert cfg.ics_url == "https://example.com/cal.ics" + assert cfg.baikal_url == "https://baikal.com/dav.php/calendars/user/cal/" + assert cfg.baikal_user == "user" + assert cfg.baikal_pass == "pass" + assert cfg.sync_frequency == 5 + + def test_default_frequency(self, monkeypatch, env_vars): + monkeypatch.delenv("SYNC_FREQUENCY", raising=False) + cfg = validate() + assert cfg.sync_frequency == 5 + + def test_missing_ics_url(self, monkeypatch, env_vars): + monkeypatch.delenv("ICS_URL", raising=False) + with pytest.raises(ValueError, match="ICS_URL"): + validate() + + def test_missing_all_required(self, monkeypatch, env_vars): + for var in ["ICS_URL", "BAIKAL_URL", "BAIKAL_USER", "BAIKAL_PASS"]: + monkeypatch.delenv(var, raising=False) + with pytest.raises(ValueError): + validate() + + def test_invalid_frequency(self, monkeypatch, env_vars): + monkeypatch.setenv("SYNC_FREQUENCY", "-1") + with pytest.raises(ValueError, match="SYNC_FREQUENCY"): + validate() + + def test_frequency_zero(self, monkeypatch, env_vars): + monkeypatch.setenv("SYNC_FREQUENCY", "0") + with pytest.raises(ValueError, match="SYNC_FREQUENCY"): + validate() + + def test_frequency_string(self, monkeypatch, env_vars): + monkeypatch.setenv("SYNC_FREQUENCY", "abc") + with pytest.raises(ValueError, match="SYNC_FREQUENCY"): + validate() + +class TestHeaders: + def test_headers_exist(self): + assert "User-Agent" in HEADERS + assert "Unraid-Sync" in HEADERS["User-Agent"] diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..913a6e2 --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,73 @@ +from diff import parse_ics_events, compute_diff, parse_ics_events_with_data + +class TestParseIcsEvents: + def test_parse_basic(self, ics_sample): + result = parse_ics_events(ics_sample) + assert "evt-001@test.com" in result + assert "evt-002@test.com" in result + assert len(result) == 2 + + def test_parse_no_uid(self): + ics = """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:20240101T000000Z +DTSTART:20240101T100000Z +SUMMARY:No UID +END:VEVENT +END:VCALENDAR""" + result = parse_ics_events(ics) + assert len(result) == 0 + + def test_hashes_are_sha256(self, ics_sample): + result = parse_ics_events(ics_sample) + for uid, h in result.items(): + assert len(h) == 64 + int(h, 16) + + def test_same_event_same_hash(self, ics_sample): + r1 = parse_ics_events(ics_sample) + r2 = parse_ics_events(ics_sample) + for uid in r1: + assert r1[uid] == r2[uid] + + def test_modified_event_different_hash(self, ics_sample, ics_sample_modified): + r1 = parse_ics_events(ics_sample) + r2 = parse_ics_events(ics_sample_modified) + assert r1["evt-001@test.com"] != r2["evt-001@test.com"] + +class TestComputeDiff: + def test_no_changes(self, ics_sample): + ics_uids = parse_ics_events(ics_sample) + deltas = compute_diff(ics_uids, ics_uids) + assert len(deltas["to_add"]) == 0 + assert len(deltas["to_update"]) == 0 + assert len(deltas["to_delete"]) == 0 + + def test_add_events(self, ics_sample, ics_sample_modified): + original = parse_ics_events(ics_sample) + modified = parse_ics_events(ics_sample_modified) + deltas = compute_diff(modified, original) + assert ("evt-003@test.com", modified["evt-003@test.com"]) in deltas["to_add"] + + def test_delete_events(self, ics_sample, ics_sample_modified): + original = parse_ics_events(ics_sample) + modified = parse_ics_events(ics_sample_modified) + deltas = compute_diff(modified, original) + assert "evt-002@test.com" in deltas["to_delete"] + + def test_update_events(self, ics_sample, ics_sample_modified): + original = parse_ics_events(ics_sample) + modified = parse_ics_events(ics_sample_modified) + deltas = compute_diff(modified, original) + uid, new_hash = deltas["to_update"][0] + assert uid == "evt-001@test.com" + assert new_hash == modified["evt-001@test.com"] + +class TestParseIcsEventsWithData: + def test_returns_bytes(self, ics_sample): + result = parse_ics_events_with_data(ics_sample) + assert len(result) == 2 + for uid, data in result.items(): + assert isinstance(data, bytes) + assert b"VEVENT" in data diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..4cf0bf5 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,71 @@ +from state import SyncState + +class TestSyncState: + def test_create_tables(self, tmp_db): + state = SyncState(tmp_db) + assert True + state.close() + + def test_upsert_and_get(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "hash-abc") + assert state.get_event_hash("uid-1") == "hash-abc" + state.close() + + def test_get_uids(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.upsert_event("uid-2", "h2") + uids = state.get_event_uids() + assert "uid-1" in uids + assert "uid-2" in uids + assert len(uids) == 2 + state.close() + + def test_delete_event(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.delete_event("uid-1") + assert state.get_event_hash("uid-1") is None + state.close() + + def test_clear_events(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.upsert_event("uid-2", "h2") + count = state.clear_events() + assert count == 2 + assert len(state.get_event_uids()) == 0 + state.close() + + def test_ics_cache(self, tmp_db): + state = SyncState(tmp_db) + state.set_ics_cache("hash-xyz", "etag-123") + h, e, _ = state.get_ics_cache() + assert h == "hash-xyz" + assert e == "etag-123" + state.clear_ics_cache() + h, e, _ = state.get_ics_cache() + assert h is None + assert e is None + state.close() + + def test_snapshot_and_restore(self, tmp_db): + state = SyncState(tmp_db) + state.upsert_event("uid-1", "h1") + state.upsert_event("uid-2", "h2") + snap = state.snapshot() + assert "uid-1" in snap["uids"] + state.clear_events() + assert len(state.get_event_uids()) == 0 + state.restore_snapshot(snap) + assert len(state.get_event_uids()) == 2 + assert state.get_event_hash("uid-1") == "h1" + state.close() + + def test_empty_state(self, tmp_db): + state = SyncState(tmp_db) + assert len(state.get_event_uids()) == 0 + h, e, _ = state.get_ics_cache() + assert h is None + state.close()