weeb-cli 2.8.0__tar.gz → 2.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/PKG-INFO +8 -1
  2. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/README.md +7 -0
  3. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/pyproject.toml +1 -1
  4. weeb_cli-2.8.1/tests/test_anilist_tracker.py +222 -0
  5. weeb_cli-2.8.1/tests/test_kitsu_tracker.py +179 -0
  6. weeb_cli-2.8.1/tests/test_mal_tracker.py +322 -0
  7. weeb_cli-2.8.1/weeb_cli/__init__.py +1 -0
  8. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/downloads.py +8 -1
  9. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/watch_flow.py +2 -2
  10. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_trackers.py +123 -1
  11. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/locales/en.json +15 -1
  12. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/locales/tr.json +15 -1
  13. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/main.py +8 -1
  14. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/tracker.py +255 -0
  15. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli.egg-info/PKG-INFO +8 -1
  16. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli.egg-info/SOURCES.txt +3 -0
  17. weeb_cli-2.8.0/weeb_cli/__init__.py +0 -1
  18. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/LICENSE +0 -0
  19. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/setup.cfg +0 -0
  20. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/tests/test_api.py +0 -0
  21. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/tests/test_cache.py +0 -0
  22. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/tests/test_exceptions.py +0 -0
  23. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/tests/test_providers.py +0 -0
  24. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/tests/test_sanitizer.py +0 -0
  25. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/__main__.py +0 -0
  26. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/api.py +0 -0
  27. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/__init__.py +0 -0
  28. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/anime_details.py +0 -0
  29. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/download_flow.py +0 -0
  30. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/episode_utils.py +0 -0
  31. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/search_handlers.py +0 -0
  32. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search/stream_utils.py +0 -0
  33. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/search.py +0 -0
  34. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/serve.py +0 -0
  35. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/__init__.py +0 -0
  36. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_backup.py +0 -0
  37. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_cache.py +0 -0
  38. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_config.py +0 -0
  39. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_download.py +0 -0
  40. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_drives.py +0 -0
  41. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_menu.py +0 -0
  42. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings/settings_shortcuts.py +0 -0
  43. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/settings.py +0 -0
  44. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/setup.py +0 -0
  45. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/commands/watchlist.py +0 -0
  46. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/config.py +0 -0
  47. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/exceptions.py +0 -0
  48. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/i18n.py +0 -0
  49. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/__init__.py +0 -0
  50. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/allanime.py +0 -0
  51. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/animecix.py +0 -0
  52. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/anizle.py +0 -0
  53. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/base.py +0 -0
  54. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/extractors/__init__.py +0 -0
  55. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/extractors/megacloud.py +0 -0
  56. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/hianime.py +0 -0
  57. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/registry.py +0 -0
  58. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/providers/turkanime.py +0 -0
  59. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/__init__.py +0 -0
  60. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/_base.py +0 -0
  61. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/_tracker_base.py +0 -0
  62. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/cache.py +0 -0
  63. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/database.py +0 -0
  64. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/dependency_manager.py +0 -0
  65. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/details.py +0 -0
  66. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/discord_rpc.py +0 -0
  67. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/downloader.py +0 -0
  68. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/error_handler.py +0 -0
  69. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/headless_downloader.py +0 -0
  70. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/local_library.py +0 -0
  71. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/logger.py +0 -0
  72. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/notifier.py +0 -0
  73. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/player.py +0 -0
  74. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/progress.py +0 -0
  75. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/scraper.py +0 -0
  76. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/search.py +0 -0
  77. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/shortcuts.py +0 -0
  78. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/stream_validator.py +0 -0
  79. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/updater.py +0 -0
  80. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/services/watch.py +0 -0
  81. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/templates/anilist_error.html +0 -0
  82. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/templates/anilist_success.html +0 -0
  83. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/templates/mal_error.html +0 -0
  84. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/templates/mal_success.html +0 -0
  85. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/ui/__init__.py +0 -0
  86. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/ui/header.py +0 -0
  87. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/ui/menu.py +0 -0
  88. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/ui/prompt.py +0 -0
  89. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/utils/__init__.py +0 -0
  90. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli/utils/sanitizer.py +0 -0
  91. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli.egg-info/dependency_links.txt +0 -0
  92. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli.egg-info/entry_points.txt +0 -0
  93. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli.egg-info/requires.txt +0 -0
  94. {weeb_cli-2.8.0 → weeb_cli-2.8.1}/weeb_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weeb-cli
3
- Version: 2.8.0
3
+ Version: 2.8.1
4
4
  Summary: Tarayıcı yok, reklam yok, dikkat dağıtıcı unsur yok. Sadece siz ve eşsiz bir anime izleme deneyimi.
5
5
  Author-email: ewgsta <ewgst@proton.me>
6
6
  License-Expression: CC-BY-NC-ND-4.0
@@ -83,6 +83,13 @@ Dynamic: license-file
83
83
  - Resume interrupted downloads
84
84
  - Smart file naming (`Anime Name - S1E1.mp4`)
85
85
 
86
+ ### Tracking & Sync
87
+ - **AniList** integration with OAuth
88
+ - **MyAnimeList** integration with OAuth
89
+ - **Kitsu** integration with email/password
90
+ - Automatic progress sync
91
+ - Offline queue for pending updates
92
+
86
93
  ### Local Library
87
94
  - Auto-scan downloaded anime
88
95
  - External drive support (USB, HDD)
@@ -45,6 +45,13 @@
45
45
  - Resume interrupted downloads
46
46
  - Smart file naming (`Anime Name - S1E1.mp4`)
47
47
 
48
+ ### Tracking & Sync
49
+ - **AniList** integration with OAuth
50
+ - **MyAnimeList** integration with OAuth
51
+ - **Kitsu** integration with email/password
52
+ - Automatic progress sync
53
+ - Offline queue for pending updates
54
+
48
55
  ### Local Library
49
56
  - Auto-scan downloaded anime
50
57
  - External drive support (USB, HDD)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weeb-cli"
7
- version = "2.8.0"
7
+ version = "2.8.1"
8
8
  description = "Tarayıcı yok, reklam yok, dikkat dağıtıcı unsur yok. Sadece siz ve eşsiz bir anime izleme deneyimi."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "ewgsta", email = "ewgst@proton.me" }]
@@ -0,0 +1,222 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from weeb_cli.services.tracker import AniListTracker
4
+
5
+
6
+ @pytest.fixture
7
+ def anilist_tracker():
8
+ tracker = AniListTracker()
9
+ tracker._db = MagicMock()
10
+ return tracker
11
+
12
+
13
+ class TestAniListAuthentication:
14
+
15
+ def test_authenticate_success(self, anilist_tracker):
16
+ mock_viewer = {"id": 123, "name": "TestUser"}
17
+
18
+ with patch.object(anilist_tracker, "_get_viewer", return_value=mock_viewer):
19
+ result = anilist_tracker.authenticate("test_token_123")
20
+
21
+ assert result is True
22
+ anilist_tracker.db.set_config.assert_any_call("anilist_token", "test_token_123")
23
+ anilist_tracker.db.set_config.assert_any_call("anilist_user_id", "123")
24
+ anilist_tracker.db.set_config.assert_any_call("anilist_username", "TestUser")
25
+
26
+ def test_authenticate_failure(self, anilist_tracker):
27
+ with patch.object(anilist_tracker, "_get_viewer", return_value=None):
28
+ result = anilist_tracker.authenticate("invalid_token")
29
+
30
+ assert result is False
31
+
32
+ def test_is_authenticated(self, anilist_tracker):
33
+ anilist_tracker.db.get_config.return_value = "test_token"
34
+ assert anilist_tracker.is_authenticated() is True
35
+
36
+ anilist_tracker.db.get_config.return_value = None
37
+ anilist_tracker._token = None
38
+ assert anilist_tracker.is_authenticated() is False
39
+
40
+ def test_logout(self, anilist_tracker):
41
+ anilist_tracker._token = "test_token"
42
+ anilist_tracker._user_id = "123"
43
+
44
+ anilist_tracker.logout()
45
+
46
+ assert anilist_tracker._token is None
47
+ assert anilist_tracker._user_id is None
48
+ anilist_tracker.db.set_config.assert_any_call("anilist_token", None)
49
+ anilist_tracker.db.set_config.assert_any_call("anilist_user_id", None)
50
+ anilist_tracker.db.set_config.assert_any_call("anilist_username", None)
51
+
52
+ def test_get_username(self, anilist_tracker):
53
+ anilist_tracker.db.get_config.return_value = "TestUser"
54
+ username = anilist_tracker.get_username()
55
+ assert username == "TestUser"
56
+ anilist_tracker.db.get_config.assert_called_with("anilist_username")
57
+
58
+
59
+ class TestAniListGraphQL:
60
+
61
+ def test_graphql_success(self, anilist_tracker):
62
+ anilist_tracker._token = "test_token"
63
+
64
+ mock_resp = MagicMock()
65
+ mock_resp.status_code = 200
66
+ mock_resp.json.return_value = {"data": {"Media": {"id": 1}}}
67
+
68
+ with patch("requests.post", return_value=mock_resp):
69
+ result = anilist_tracker._graphql("query { Media { id } }")
70
+
71
+ assert result == {"Media": {"id": 1}}
72
+
73
+ def test_graphql_no_token(self, anilist_tracker):
74
+ anilist_tracker._token = None
75
+ result = anilist_tracker._graphql("query { Media { id } }")
76
+ assert result is None
77
+
78
+ def test_graphql_error(self, anilist_tracker):
79
+ anilist_tracker._token = "test_token"
80
+
81
+ mock_resp = MagicMock()
82
+ mock_resp.status_code = 400
83
+
84
+ with patch("requests.post", return_value=mock_resp):
85
+ result = anilist_tracker._graphql("invalid query")
86
+
87
+ assert result is None
88
+
89
+
90
+ class TestAniListSearch:
91
+
92
+ def test_search_anime_success(self, anilist_tracker):
93
+ mock_data = {
94
+ "Media": {
95
+ "id": 6547,
96
+ "title": {"romaji": "Angel Beats!", "english": "Angel Beats!"},
97
+ "episodes": 13
98
+ }
99
+ }
100
+
101
+ with patch.object(anilist_tracker, "_graphql", return_value=mock_data):
102
+ result = anilist_tracker.search_anime("Angel Beats")
103
+
104
+ assert result is not None
105
+ assert result["id"] == 6547
106
+ assert result["episodes"] == 13
107
+
108
+ def test_search_anime_not_found(self, anilist_tracker):
109
+ with patch.object(anilist_tracker, "_graphql", return_value=None):
110
+ result = anilist_tracker.search_anime("NonexistentAnime")
111
+
112
+ assert result is None
113
+
114
+
115
+ class TestAniListProgressUpdate:
116
+
117
+ def test_update_progress_not_authenticated(self, anilist_tracker):
118
+ anilist_tracker.db.get_config.return_value = None
119
+ anilist_tracker._token = None
120
+
121
+ result = anilist_tracker.update_progress("Angel Beats", 5, 13)
122
+
123
+ assert result is False
124
+ pending_calls = [call for call in anilist_tracker.db.set_config.call_args_list
125
+ if "anilist_pending" in str(call)]
126
+ assert len(pending_calls) > 0
127
+
128
+ def test_update_progress_anime_not_found(self, anilist_tracker):
129
+ anilist_tracker._token = "test_token"
130
+ anilist_tracker.db.get_config.return_value = "test_token"
131
+
132
+ with patch.object(anilist_tracker, "search_anime", return_value=None):
133
+ result = anilist_tracker.update_progress("NonexistentAnime", 1, 12)
134
+
135
+ assert result is False
136
+
137
+ def test_update_progress_success_current(self, anilist_tracker):
138
+ anilist_tracker._token = "test_token"
139
+ anilist_tracker.db.get_config.return_value = "test_token"
140
+
141
+ mock_anime = {"id": 6547, "episodes": 13}
142
+ mock_result = {"SaveMediaListEntry": {"id": 1, "progress": 5, "status": "CURRENT"}}
143
+
144
+ with patch.object(anilist_tracker, "search_anime", return_value=mock_anime):
145
+ with patch.object(anilist_tracker, "_graphql", return_value=mock_result):
146
+ result = anilist_tracker.update_progress("Angel Beats", 5, 13)
147
+
148
+ assert result is True
149
+
150
+ def test_update_progress_success_completed(self, anilist_tracker):
151
+ anilist_tracker._token = "test_token"
152
+ anilist_tracker.db.get_config.return_value = "test_token"
153
+
154
+ mock_anime = {"id": 6547, "episodes": 13}
155
+ mock_result = {"SaveMediaListEntry": {"id": 1, "progress": 13, "status": "COMPLETED"}}
156
+
157
+ with patch.object(anilist_tracker, "search_anime", return_value=mock_anime):
158
+ with patch.object(anilist_tracker, "_graphql", return_value=mock_result):
159
+ result = anilist_tracker.update_progress("Angel Beats", 13, 13)
160
+
161
+ assert result is True
162
+
163
+
164
+ class TestAniListPendingSync:
165
+
166
+ def test_queue_update(self, anilist_tracker):
167
+ anilist_tracker.db.get_config.return_value = []
168
+
169
+ anilist_tracker._queue_update("Angel Beats", 5, 13)
170
+
171
+ set_calls = anilist_tracker.db.set_config.call_args_list
172
+ pending_call = [call for call in set_calls if "anilist_pending" in str(call)][0]
173
+ pending_data = pending_call[0][1]
174
+
175
+ assert len(pending_data) == 1
176
+ assert pending_data[0]["title"] == "Angel Beats"
177
+ assert pending_data[0]["episode"] == 5
178
+ assert pending_data[0]["total"] == 13
179
+
180
+ def test_sync_pending_success(self, anilist_tracker):
181
+ anilist_tracker._token = "test_token"
182
+ anilist_tracker.db.get_config.side_effect = lambda key: {
183
+ "anilist_token": "test_token",
184
+ "anilist_pending": [
185
+ {"title": "Anime1", "episode": 5, "total": 12, "timestamp": 123456},
186
+ {"title": "Anime2", "episode": 3, "total": 24, "timestamp": 123457}
187
+ ]
188
+ }.get(key, None)
189
+
190
+ with patch.object(anilist_tracker, "update_progress", return_value=True):
191
+ synced = anilist_tracker.sync_pending()
192
+
193
+ assert synced == 2
194
+
195
+ def test_sync_pending_partial_failure(self, anilist_tracker):
196
+ anilist_tracker._token = "test_token"
197
+ anilist_tracker.db.get_config.side_effect = lambda key: {
198
+ "anilist_token": "test_token",
199
+ "anilist_pending": [
200
+ {"title": "Anime1", "episode": 5, "total": 12, "timestamp": 123456},
201
+ {"title": "Anime2", "episode": 3, "total": 24, "timestamp": 123457}
202
+ ]
203
+ }.get(key, None)
204
+
205
+ with patch.object(anilist_tracker, "update_progress", side_effect=[True, False]):
206
+ synced = anilist_tracker.sync_pending()
207
+
208
+ assert synced == 1
209
+
210
+ def test_get_pending_count(self, anilist_tracker):
211
+ anilist_tracker.db.get_config.return_value = [
212
+ {"title": "Anime1", "episode": 5},
213
+ {"title": "Anime2", "episode": 3}
214
+ ]
215
+
216
+ count = anilist_tracker.get_pending_count()
217
+ assert count == 2
218
+
219
+ def test_get_pending_count_empty(self, anilist_tracker):
220
+ anilist_tracker.db.get_config.return_value = []
221
+ count = anilist_tracker.get_pending_count()
222
+ assert count == 0
@@ -0,0 +1,179 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from weeb_cli.services.tracker import KitsuTracker
4
+
5
+
6
+ @pytest.fixture
7
+ def kitsu_tracker():
8
+ tracker = KitsuTracker()
9
+ tracker._db = MagicMock()
10
+ return tracker
11
+
12
+
13
+ class TestKitsuAuthentication:
14
+
15
+ def test_authenticate_success(self, kitsu_tracker):
16
+ mock_resp = MagicMock()
17
+ mock_resp.status_code = 200
18
+ mock_resp.json.return_value = {"access_token": "test_token"}
19
+
20
+ mock_user_resp = MagicMock()
21
+ mock_user_resp.status_code = 200
22
+ mock_user_resp.json.return_value = {
23
+ "data": [{
24
+ "id": "123",
25
+ "attributes": {"name": "TestUser"}
26
+ }]
27
+ }
28
+
29
+ with patch("requests.post", return_value=mock_resp):
30
+ with patch("requests.get", return_value=mock_user_resp):
31
+ result = kitsu_tracker.authenticate("test@example.com", "password")
32
+
33
+ assert result is True
34
+ kitsu_tracker.db.set_config.assert_any_call("kitsu_access_token", "test_token")
35
+ kitsu_tracker.db.set_config.assert_any_call("kitsu_user_id", "123")
36
+ kitsu_tracker.db.set_config.assert_any_call("kitsu_username", "TestUser")
37
+
38
+ def test_authenticate_failure(self, kitsu_tracker):
39
+ mock_resp = MagicMock()
40
+ mock_resp.status_code = 401
41
+
42
+ with patch("requests.post", return_value=mock_resp):
43
+ result = kitsu_tracker.authenticate("test@example.com", "wrong_password")
44
+
45
+ assert result is False
46
+
47
+ def test_is_authenticated(self, kitsu_tracker):
48
+ kitsu_tracker.db.get_config.return_value = "test_token"
49
+ assert kitsu_tracker.is_authenticated() is True
50
+
51
+ kitsu_tracker.db.get_config.return_value = None
52
+ kitsu_tracker._access_token = None
53
+ assert kitsu_tracker.is_authenticated() is False
54
+
55
+ def test_logout(self, kitsu_tracker):
56
+ kitsu_tracker._access_token = "test_token"
57
+ kitsu_tracker._user_id = "123"
58
+
59
+ kitsu_tracker.logout()
60
+
61
+ assert kitsu_tracker._access_token is None
62
+ assert kitsu_tracker._user_id is None
63
+ kitsu_tracker.db.set_config.assert_any_call("kitsu_access_token", None)
64
+ kitsu_tracker.db.set_config.assert_any_call("kitsu_user_id", None)
65
+ kitsu_tracker.db.set_config.assert_any_call("kitsu_username", None)
66
+
67
+
68
+ class TestKitsuSearch:
69
+
70
+ def test_search_anime_success(self, kitsu_tracker):
71
+ mock_resp = MagicMock()
72
+ mock_resp.status_code = 200
73
+ mock_resp.json.return_value = {
74
+ "data": [{
75
+ "id": "1",
76
+ "attributes": {"canonicalTitle": "Cowboy Bebop"}
77
+ }]
78
+ }
79
+
80
+ with patch("requests.get", return_value=mock_resp):
81
+ result = kitsu_tracker.search_anime("Cowboy Bebop")
82
+
83
+ assert result is not None
84
+ assert result["id"] == "1"
85
+
86
+ def test_search_anime_no_results(self, kitsu_tracker):
87
+ mock_resp = MagicMock()
88
+ mock_resp.status_code = 200
89
+ mock_resp.json.return_value = {"data": []}
90
+
91
+ with patch("requests.get", return_value=mock_resp):
92
+ result = kitsu_tracker.search_anime("NonexistentAnime")
93
+
94
+ assert result is None
95
+
96
+
97
+ class TestKitsuProgressUpdate:
98
+
99
+ def test_update_progress_not_authenticated(self, kitsu_tracker):
100
+ kitsu_tracker.db.get_config.return_value = None
101
+ kitsu_tracker._access_token = None
102
+
103
+ result = kitsu_tracker.update_progress("Cowboy Bebop", 5, 26)
104
+
105
+ assert result is False
106
+ pending = kitsu_tracker.db.set_config.call_args_list
107
+ assert any("kitsu_pending" in str(call) for call in pending)
108
+
109
+ def test_update_progress_anime_not_found(self, kitsu_tracker):
110
+ kitsu_tracker._access_token = "test_token"
111
+ kitsu_tracker._user_id = "123"
112
+ kitsu_tracker.db.get_config.side_effect = lambda key: {
113
+ "kitsu_access_token": "test_token",
114
+ "kitsu_user_id": "123"
115
+ }.get(key)
116
+
117
+ mock_search_resp = MagicMock()
118
+ mock_search_resp.status_code = 200
119
+ mock_search_resp.json.return_value = {"data": []}
120
+
121
+ with patch("requests.get", return_value=mock_search_resp):
122
+ result = kitsu_tracker.update_progress("NonexistentAnime", 1, 12)
123
+
124
+ assert result is False
125
+
126
+ def test_update_progress_create_entry(self, kitsu_tracker):
127
+ kitsu_tracker._access_token = "test_token"
128
+ kitsu_tracker._user_id = "123"
129
+ kitsu_tracker.db.get_config.side_effect = lambda key: {
130
+ "kitsu_access_token": "test_token",
131
+ "kitsu_user_id": "123"
132
+ }.get(key)
133
+
134
+ mock_search_resp = MagicMock()
135
+ mock_search_resp.status_code = 200
136
+ mock_search_resp.json.return_value = {
137
+ "data": [{"id": "1", "attributes": {"canonicalTitle": "Cowboy Bebop"}}]
138
+ }
139
+
140
+ mock_entry_resp = MagicMock()
141
+ mock_entry_resp.status_code = 200
142
+ mock_entry_resp.json.return_value = {"data": []}
143
+
144
+ mock_update_resp = MagicMock()
145
+ mock_update_resp.status_code = 201
146
+
147
+ with patch("requests.get", side_effect=[mock_search_resp, mock_entry_resp]):
148
+ with patch("requests.post", return_value=mock_update_resp):
149
+ result = kitsu_tracker.update_progress("Cowboy Bebop", 5, 26)
150
+
151
+ assert result is True
152
+
153
+
154
+ class TestKitsuPendingSync:
155
+
156
+ def test_sync_pending_success(self, kitsu_tracker):
157
+ kitsu_tracker._access_token = "test_token"
158
+ kitsu_tracker._user_id = "123"
159
+ kitsu_tracker.db.get_config.side_effect = lambda key: {
160
+ "kitsu_access_token": "test_token",
161
+ "kitsu_user_id": "123",
162
+ "kitsu_pending": [
163
+ {"title": "Anime1", "episode": 5, "total": 12, "timestamp": 123456}
164
+ ]
165
+ }.get(key, None)
166
+
167
+ with patch.object(kitsu_tracker, "update_progress", return_value=True):
168
+ synced = kitsu_tracker.sync_pending()
169
+
170
+ assert synced == 1
171
+
172
+ def test_get_pending_count(self, kitsu_tracker):
173
+ kitsu_tracker.db.get_config.return_value = [
174
+ {"title": "Anime1", "episode": 5},
175
+ {"title": "Anime2", "episode": 3}
176
+ ]
177
+
178
+ count = kitsu_tracker.get_pending_count()
179
+ assert count == 2