"""Unit tests for src/cache.py.""" import json import os import tempfile from pathlib import Path import pytest from src.cache import Cache, CacheError, MANIFEST_VERSION @pytest.fixture def tmp_cache(tmp_path): return Cache(tmp_path) class TestIsCached: def test_miss_when_no_entry(self, tmp_cache): assert tmp_cache.is_cached("claude", "conv-abc", "2024-01-01T00:00:00Z") is False def test_hit_after_mark_exported(self, tmp_cache): tmp_cache.mark_exported("claude", "conv-abc", {"updated_at": "2024-01-01T00:00:00Z"}) assert tmp_cache.is_cached("claude", "conv-abc", "2024-01-01T00:00:00Z") is True def test_stale_when_provider_has_newer_date(self, tmp_cache): tmp_cache.mark_exported("claude", "conv-abc", {"updated_at": "2024-01-01T00:00:00Z"}) assert tmp_cache.is_cached("claude", "conv-abc", "2024-06-01T00:00:00Z") is False def test_hit_when_provider_has_same_date(self, tmp_cache): tmp_cache.mark_exported("chatgpt", "conv-xyz", {"updated_at": "2024-06-01T00:00:00Z"}) assert tmp_cache.is_cached("chatgpt", "conv-xyz", "2024-06-01T00:00:00Z") is True def test_miss_for_different_provider(self, tmp_cache): tmp_cache.mark_exported("claude", "conv-abc", {"updated_at": "2024-01-01T00:00:00Z"}) assert tmp_cache.is_cached("chatgpt", "conv-abc", "2024-01-01T00:00:00Z") is False class TestAtomicWrite: def test_manifest_has_600_permissions(self, tmp_path): c = Cache(tmp_path) c.mark_exported("claude", "x", {"updated_at": "2024-01-01"}) manifest = tmp_path / "manifest.json" mode = oct(os.stat(manifest).st_mode)[-3:] assert mode == "600" def test_no_tmp_file_left_after_write(self, tmp_path): c = Cache(tmp_path) c.mark_exported("claude", "x", {"updated_at": "2024-01-01"}) tmp_files = list(tmp_path.glob("*.tmp")) assert tmp_files == [] def test_manifest_is_valid_json(self, tmp_path): c = Cache(tmp_path) c.mark_exported("claude", "x", {}) manifest = tmp_path / "manifest.json" data = json.loads(manifest.read_text()) assert isinstance(data, dict) assert "claude" in data class TestStats: def test_empty_stats(self, tmp_cache): stats = tmp_cache.stats() assert stats["chatgpt"] == 0 assert stats["claude"] == 0 def test_stats_after_exports(self, tmp_cache): tmp_cache.mark_exported("claude", "c1", {}) tmp_cache.mark_exported("claude", "c2", {}) tmp_cache.mark_exported("chatgpt", "g1", {}) stats = tmp_cache.stats() assert stats["claude"] == 2 assert stats["chatgpt"] == 1 class TestClear: def test_clear_single_provider(self, tmp_cache): tmp_cache.mark_exported("claude", "c1", {}) tmp_cache.mark_exported("chatgpt", "g1", {}) tmp_cache.clear("claude") assert tmp_cache.stats()["claude"] == 0 assert tmp_cache.stats()["chatgpt"] == 1 def test_clear_all(self, tmp_cache): tmp_cache.mark_exported("claude", "c1", {}) tmp_cache.mark_exported("chatgpt", "g1", {}) tmp_cache.clear() assert tmp_cache.stats()["claude"] == 0 assert tmp_cache.stats()["chatgpt"] == 0 class TestCorruptManifestRecovery: def test_recovers_from_invalid_json(self, tmp_path): manifest = tmp_path / "manifest.json" manifest.write_text("{invalid json!!!", encoding="utf-8") # Should not raise, should start fresh c = Cache(tmp_path) assert c.stats()["claude"] == 0 # Backup should exist backup = tmp_path / "manifest.json.bak" assert backup.exists() assert backup.read_text() == "{invalid json!!!" def test_raises_on_future_version(self, tmp_path): manifest = tmp_path / "manifest.json" manifest.write_text( json.dumps({"version": MANIFEST_VERSION + 99, "chatgpt": {}, "claude": {}}), encoding="utf-8", ) with pytest.raises(CacheError, match="Unsupported manifest version"): Cache(tmp_path) class TestTosAcknowledgement: def test_not_acknowledged_by_default(self, tmp_cache): assert tmp_cache.is_tos_acknowledged() is False def test_acknowledged_after_call(self, tmp_cache): tmp_cache.acknowledge_tos() assert tmp_cache.is_tos_acknowledged() is True def test_acknowledgement_persists_across_instances(self, tmp_path): c1 = Cache(tmp_path) c1.acknowledge_tos() c2 = Cache(tmp_path) assert c2.is_tos_acknowledged() is True class TestGetNewOrUpdated: def test_returns_all_when_cache_empty(self, tmp_cache): convs = [ {"id": "a", "updated_at": "2024-01-01T00:00:00Z"}, {"id": "b", "updated_at": "2024-01-02T00:00:00Z"}, ] result = tmp_cache.get_new_or_updated("claude", convs) assert len(result) == 2 def test_skips_cached_unchanged(self, tmp_cache): tmp_cache.mark_exported("claude", "a", {"updated_at": "2024-01-01T00:00:00Z"}) convs = [ {"id": "a", "updated_at": "2024-01-01T00:00:00Z"}, {"id": "b", "updated_at": "2024-01-02T00:00:00Z"}, ] result = tmp_cache.get_new_or_updated("claude", convs) assert len(result) == 1 assert result[0]["id"] == "b" def test_includes_stale_conversations(self, tmp_cache): tmp_cache.mark_exported("claude", "a", {"updated_at": "2024-01-01T00:00:00Z"}) convs = [{"id": "a", "updated_at": "2024-06-01T00:00:00Z"}] result = tmp_cache.get_new_or_updated("claude", convs) assert len(result) == 1 def test_force_returns_all_cached_and_unchanged(self, tmp_cache): tmp_cache.mark_exported("claude", "a", {"updated_at": "2024-01-01T00:00:00Z"}) convs = [ {"id": "a", "updated_at": "2024-01-01T00:00:00Z"}, # cached, unchanged {"id": "b", "updated_at": "2024-01-02T00:00:00Z"}, # new ] assert len(tmp_cache.get_new_or_updated("claude", convs)) == 1 assert len(tmp_cache.get_new_or_updated("claude", convs, force=True)) == 2 def test_force_orders_least_recently_exported_first(self, tmp_cache): # 'a' was exported long ago; 'b' just now; 'c' never. tmp_cache.mark_exported("claude", "a", {"updated_at": "2024-01-01T00:00:00Z"}) tmp_cache._data["claude"]["a"]["exported_at"] = "2024-01-01T00:00:00Z" tmp_cache.mark_exported("claude", "b", {"updated_at": "2024-01-01T00:00:00Z"}) tmp_cache._data["claude"]["b"]["exported_at"] = "2026-06-12T00:00:00Z" convs = [{"id": "a"}, {"id": "b"}, {"id": "c"}] ordered = [c["id"] for c in tmp_cache.get_new_or_updated("claude", convs, force=True)] # never-exported ('c', exported_at "") and oldest ('a') come before 'b' assert ordered == ["c", "a", "b"] def test_force_capped_run_makes_progress(self, tmp_cache): """The bug: force + cap repeated the same head every run. Now each capped run re-exports the next-oldest batch and converges.""" convs = [{"id": f"c{i}", "updated_at": "2024-01-01T00:00:00Z"} for i in range(5)] seen = set() for _ in range(3): # cap=2 over 3 runs should cover all 5 batch = tmp_cache.get_new_or_updated("claude", convs, force=True)[:2] for c in batch: seen.add(c["id"]) tmp_cache.mark_exported("claude", c["id"], {"file_path": f"/{c['id']}.md"}) assert seen == {f"c{i}" for i in range(5)} def test_campaign_excludes_already_refreshed(self, tmp_cache): """With a campaign stamp, conversations re-exported during the campaign (exported_at >= stamp) drop out, so the remaining count shrinks to 0.""" convs = [{"id": f"c{i}"} for i in range(5)] campaign = "2026-06-12T00:00:00+00:00" # Nothing exported yet → all 5 are candidates. assert len(tmp_cache.get_new_or_updated("claude", convs, force=True, campaign_at=campaign)) == 5 # Re-export 2 "during" the campaign (after the stamp). for cid in ("c0", "c1"): tmp_cache.mark_exported("claude", cid, {"file_path": f"/{cid}.md"}) tmp_cache._data["claude"][cid]["exported_at"] = "2026-06-12T01:00:00+00:00" remaining = tmp_cache.get_new_or_updated("claude", convs, force=True, campaign_at=campaign) assert {c["id"] for c in remaining} == {"c2", "c3", "c4"} # Finish them → none left. for cid in ("c2", "c3", "c4"): tmp_cache.mark_exported("claude", cid, {"file_path": f"/{cid}.md"}) tmp_cache._data["claude"][cid]["exported_at"] = "2026-06-12T02:00:00+00:00" assert tmp_cache.get_new_or_updated("claude", convs, force=True, campaign_at=campaign) == [] def test_force_campaign_marker_roundtrip(self, tmp_cache): assert tmp_cache.get_force_campaign() is None tmp_cache.set_force_campaign("2026-06-12T00:00:00+00:00") assert tmp_cache.get_force_campaign() == "2026-06-12T00:00:00+00:00" # Survives reload, and is not mistaken for a provider by stats(). reopened = Cache(tmp_cache._dir) assert reopened.get_force_campaign() == "2026-06-12T00:00:00+00:00" assert "force_campaign_at" not in reopened.stats() tmp_cache.clear_force_campaign() assert tmp_cache.get_force_campaign() is None class TestReexportPreservesJoplinLink: """A re-export must not drop the Joplin note link, or re-syncing would create duplicate notes instead of updating the existing ones.""" def test_joplin_fields_carried_across_reexport(self, tmp_cache): tmp_cache.mark_exported("chatgpt", "c1", { "title": "T", "updated_at": "2024-01-01T00:00:00Z", "file_path": "/x.md", }) tmp_cache.mark_joplin_synced("chatgpt", "c1", "note-123") tmp_cache.set_joplin_resources("chatgpt", "c1", {"media/a.png": "res-1"}) # Re-export the same conversation (new render, new file_path). tmp_cache.mark_exported("chatgpt", "c1", { "title": "T", "updated_at": "2024-01-01T00:00:00Z", "file_path": "/x2.md", }) entry = tmp_cache.get_all_entries("chatgpt")["c1"] assert entry["joplin_note_id"] == "note-123" assert entry["joplin_resources"] == {"media/a.png": "res-1"} assert entry["file_path"] == "/x2.md" def test_reexport_marks_note_for_update_not_create(self, tmp_cache): tmp_cache.mark_exported("chatgpt", "c1", { "updated_at": "2024-01-01T00:00:00Z", "file_path": "/x.md", }) tmp_cache.mark_joplin_synced("chatgpt", "c1", "note-123") # Nothing pending right after sync. assert tmp_cache.get_joplin_pending("chatgpt") == [] # Re-export bumps exported_at past joplin_synced_at → pending for update, # carrying the existing note id so the sync updates rather than creates. tmp_cache.mark_exported("chatgpt", "c1", { "updated_at": "2024-01-01T00:00:00Z", "file_path": "/x.md", }) pending = tmp_cache.get_joplin_pending("chatgpt") assert len(pending) == 1 assert pending[0][1]["joplin_note_id"] == "note-123"