Manage Atom feeds in a persistent git repository
1"""Tests for pydantic models."""
2
3from datetime import datetime
4
5import pytest
6from pydantic import HttpUrl, ValidationError
7
8from thicket.models import (
9 AtomEntry,
10 DuplicateMap,
11 FeedMetadata,
12 GitStoreIndex,
13 ThicketConfig,
14 UserConfig,
15 UserMetadata,
16 ZulipAssociation,
17)
18
19
20class TestUserConfig:
21 """Test UserConfig model."""
22
23 def test_valid_user_config(self):
24 """Test creating valid user config."""
25 config = UserConfig(
26 username="testuser",
27 feeds=["https://example.com/feed.xml"],
28 email="test@example.com",
29 homepage="https://example.com",
30 display_name="Test User",
31 )
32
33 assert config.username == "testuser"
34 assert len(config.feeds) == 1
35 assert config.feeds[0] == HttpUrl("https://example.com/feed.xml")
36 assert config.email == "test@example.com"
37 assert config.display_name == "Test User"
38
39 def test_invalid_email(self):
40 """Test validation of invalid email."""
41 with pytest.raises(ValidationError):
42 UserConfig(
43 username="testuser",
44 feeds=["https://example.com/feed.xml"],
45 email="invalid-email",
46 )
47
48 def test_invalid_feed_url(self):
49 """Test validation of invalid feed URL."""
50 with pytest.raises(ValidationError):
51 UserConfig(
52 username="testuser",
53 feeds=["not-a-url"],
54 )
55
56 def test_optional_fields(self):
57 """Test optional fields with None values."""
58 config = UserConfig(
59 username="testuser",
60 feeds=["https://example.com/feed.xml"],
61 )
62
63 assert config.email is None
64 assert config.homepage is None
65 assert config.icon is None
66 assert config.display_name is None
67
68
69class TestThicketConfig:
70 """Test ThicketConfig model."""
71
72 def test_valid_config(self, temp_dir):
73 """Test creating valid configuration."""
74 config = ThicketConfig(
75 git_store=temp_dir / "git_store",
76 cache_dir=temp_dir / "cache",
77 users=[
78 UserConfig(
79 username="testuser",
80 feeds=["https://example.com/feed.xml"],
81 )
82 ],
83 )
84
85 assert config.git_store == temp_dir / "git_store"
86 assert config.cache_dir == temp_dir / "cache"
87 assert len(config.users) == 1
88 assert config.users[0].username == "testuser"
89
90 def test_find_user(self, temp_dir):
91 """Test finding user by username."""
92 config = ThicketConfig(
93 git_store=temp_dir / "git_store",
94 cache_dir=temp_dir / "cache",
95 users=[
96 UserConfig(username="user1", feeds=["https://example.com/feed1.xml"]),
97 UserConfig(username="user2", feeds=["https://example.com/feed2.xml"]),
98 ],
99 )
100
101 user = config.find_user("user1")
102 assert user is not None
103 assert user.username == "user1"
104
105 non_user = config.find_user("nonexistent")
106 assert non_user is None
107
108 def test_add_user(self, temp_dir):
109 """Test adding a new user."""
110 config = ThicketConfig(
111 git_store=temp_dir / "git_store",
112 cache_dir=temp_dir / "cache",
113 users=[],
114 )
115
116 new_user = UserConfig(
117 username="newuser",
118 feeds=["https://example.com/feed.xml"],
119 )
120
121 config.add_user(new_user)
122 assert len(config.users) == 1
123 assert config.users[0].username == "newuser"
124
125 def test_add_feed_to_user(self, temp_dir):
126 """Test adding feed to existing user."""
127 config = ThicketConfig(
128 git_store=temp_dir / "git_store",
129 cache_dir=temp_dir / "cache",
130 users=[
131 UserConfig(username="testuser", feeds=["https://example.com/feed1.xml"]),
132 ],
133 )
134
135 result = config.add_feed_to_user("testuser", HttpUrl("https://example.com/feed2.xml"))
136 assert result is True
137
138 user = config.find_user("testuser")
139 assert len(user.feeds) == 2
140 assert HttpUrl("https://example.com/feed2.xml") in user.feeds
141
142 # Test adding to non-existent user
143 result = config.add_feed_to_user("nonexistent", HttpUrl("https://example.com/feed.xml"))
144 assert result is False
145
146
147class TestAtomEntry:
148 """Test AtomEntry model."""
149
150 def test_valid_entry(self):
151 """Test creating valid Atom entry."""
152 entry = AtomEntry(
153 id="https://example.com/entry/1",
154 title="Test Entry",
155 link=HttpUrl("https://example.com/entry/1"),
156 updated=datetime.now(),
157 published=datetime.now(),
158 summary="Test summary",
159 content="<p>Test content</p>",
160 content_type="html",
161 author={"name": "Test Author"},
162 categories=["test", "example"],
163 )
164
165 assert entry.id == "https://example.com/entry/1"
166 assert entry.title == "Test Entry"
167 assert entry.summary == "Test summary"
168 assert entry.content == "<p>Test content</p>"
169 assert entry.content_type == "html"
170 assert entry.author["name"] == "Test Author"
171 assert "test" in entry.categories
172
173 def test_minimal_entry(self):
174 """Test creating minimal Atom entry."""
175 entry = AtomEntry(
176 id="https://example.com/entry/1",
177 title="Test Entry",
178 link=HttpUrl("https://example.com/entry/1"),
179 updated=datetime.now(),
180 )
181
182 assert entry.id == "https://example.com/entry/1"
183 assert entry.title == "Test Entry"
184 assert entry.published is None
185 assert entry.summary is None
186 assert entry.content is None
187 assert entry.content_type == "html" # default
188 assert entry.author is None
189 assert entry.categories == []
190
191
192class TestDuplicateMap:
193 """Test DuplicateMap model."""
194
195 def test_empty_duplicates(self):
196 """Test empty duplicate map."""
197 dup_map = DuplicateMap()
198 assert len(dup_map.duplicates) == 0
199 assert not dup_map.is_duplicate("test")
200 assert dup_map.get_canonical("test") == "test"
201
202 def test_add_duplicate(self):
203 """Test adding duplicate mapping."""
204 dup_map = DuplicateMap()
205 dup_map.add_duplicate("dup1", "canonical1")
206
207 assert len(dup_map.duplicates) == 1
208 assert dup_map.is_duplicate("dup1")
209 assert dup_map.get_canonical("dup1") == "canonical1"
210 assert dup_map.get_canonical("canonical1") == "canonical1"
211
212 def test_remove_duplicate(self):
213 """Test removing duplicate mapping."""
214 dup_map = DuplicateMap()
215 dup_map.add_duplicate("dup1", "canonical1")
216
217 result = dup_map.remove_duplicate("dup1")
218 assert result is True
219 assert len(dup_map.duplicates) == 0
220 assert not dup_map.is_duplicate("dup1")
221
222 # Test removing non-existent duplicate
223 result = dup_map.remove_duplicate("nonexistent")
224 assert result is False
225
226 def test_get_duplicates_for_canonical(self):
227 """Test getting all duplicates for a canonical ID."""
228 dup_map = DuplicateMap()
229 dup_map.add_duplicate("dup1", "canonical1")
230 dup_map.add_duplicate("dup2", "canonical1")
231 dup_map.add_duplicate("dup3", "canonical2")
232
233 dups = dup_map.get_duplicates_for_canonical("canonical1")
234 assert len(dups) == 2
235 assert "dup1" in dups
236 assert "dup2" in dups
237
238 dups = dup_map.get_duplicates_for_canonical("canonical2")
239 assert len(dups) == 1
240 assert "dup3" in dups
241
242 dups = dup_map.get_duplicates_for_canonical("nonexistent")
243 assert len(dups) == 0
244
245
246class TestFeedMetadata:
247 """Test FeedMetadata model."""
248
249 def test_valid_metadata(self):
250 """Test creating valid feed metadata."""
251 metadata = FeedMetadata(
252 title="Test Feed",
253 author_name="Test Author",
254 author_email="author@example.com",
255 author_uri=HttpUrl("https://example.com/author"),
256 link=HttpUrl("https://example.com"),
257 description="Test description",
258 )
259
260 assert metadata.title == "Test Feed"
261 assert metadata.author_name == "Test Author"
262 assert metadata.author_email == "author@example.com"
263 assert metadata.link == HttpUrl("https://example.com")
264
265 def test_to_user_config(self):
266 """Test converting metadata to user config."""
267 metadata = FeedMetadata(
268 title="Test Feed",
269 author_name="Test Author",
270 author_email="author@example.com",
271 author_uri=HttpUrl("https://example.com/author"),
272 link=HttpUrl("https://example.com"),
273 logo=HttpUrl("https://example.com/logo.png"),
274 )
275
276 feed_url = HttpUrl("https://example.com/feed.xml")
277 user_config = metadata.to_user_config("testuser", feed_url)
278
279 assert user_config.username == "testuser"
280 assert user_config.feeds == [feed_url]
281 assert user_config.display_name == "Test Author"
282 assert user_config.email == "author@example.com"
283 assert user_config.homepage == HttpUrl("https://example.com/author")
284 assert user_config.icon == HttpUrl("https://example.com/logo.png")
285
286 def test_to_user_config_fallbacks(self):
287 """Test fallback logic in to_user_config."""
288 metadata = FeedMetadata(
289 title="Test Feed",
290 link=HttpUrl("https://example.com"),
291 icon=HttpUrl("https://example.com/icon.png"),
292 )
293
294 feed_url = HttpUrl("https://example.com/feed.xml")
295 user_config = metadata.to_user_config("testuser", feed_url)
296
297 assert user_config.display_name == "Test Feed" # Falls back to title
298 assert user_config.homepage == HttpUrl("https://example.com") # Falls back to link
299 assert user_config.icon == HttpUrl("https://example.com/icon.png")
300 assert user_config.email is None
301
302
303class TestUserMetadata:
304 """Test UserMetadata model."""
305
306 def test_valid_metadata(self):
307 """Test creating valid user metadata."""
308 now = datetime.now()
309 metadata = UserMetadata(
310 username="testuser",
311 directory="testuser",
312 created=now,
313 last_updated=now,
314 feeds=["https://example.com/feed.xml"],
315 entry_count=5,
316 )
317
318 assert metadata.username == "testuser"
319 assert metadata.directory == "testuser"
320 assert metadata.entry_count == 5
321 assert len(metadata.feeds) == 1
322
323 def test_update_timestamp(self):
324 """Test updating timestamp."""
325 now = datetime.now()
326 metadata = UserMetadata(
327 username="testuser",
328 directory="testuser",
329 created=now,
330 last_updated=now,
331 )
332
333 original_time = metadata.last_updated
334 metadata.update_timestamp()
335
336 assert metadata.last_updated > original_time
337
338 def test_increment_entry_count(self):
339 """Test incrementing entry count."""
340 metadata = UserMetadata(
341 username="testuser",
342 directory="testuser",
343 created=datetime.now(),
344 last_updated=datetime.now(),
345 entry_count=5,
346 )
347
348 original_count = metadata.entry_count
349 original_time = metadata.last_updated
350
351 metadata.increment_entry_count(3)
352
353 assert metadata.entry_count == original_count + 3
354 assert metadata.last_updated > original_time
355
356 def test_zulip_associations(self):
357 """Test Zulip association methods."""
358 metadata = UserMetadata(
359 username="testuser",
360 directory="testuser",
361 created=datetime.now(),
362 last_updated=datetime.now(),
363 )
364
365 # Test adding association
366 result = metadata.add_zulip_association("example.zulipchat.com", "alice")
367 assert result is True
368 assert len(metadata.zulip_associations) == 1
369 assert metadata.zulip_associations[0].server == "example.zulipchat.com"
370 assert metadata.zulip_associations[0].user_id == "alice"
371
372 # Test adding duplicate association
373 result = metadata.add_zulip_association("example.zulipchat.com", "alice")
374 assert result is False
375 assert len(metadata.zulip_associations) == 1
376
377 # Test adding different association
378 result = metadata.add_zulip_association("other.zulipchat.com", "alice")
379 assert result is True
380 assert len(metadata.zulip_associations) == 2
381
382 # Test get_zulip_mention
383 mention = metadata.get_zulip_mention("example.zulipchat.com")
384 assert mention == "alice"
385
386 mention = metadata.get_zulip_mention("other.zulipchat.com")
387 assert mention == "alice"
388
389 mention = metadata.get_zulip_mention("nonexistent.zulipchat.com")
390 assert mention is None
391
392 # Test removing association
393 result = metadata.remove_zulip_association("example.zulipchat.com", "alice")
394 assert result is True
395 assert len(metadata.zulip_associations) == 1
396
397 # Test removing non-existent association
398 result = metadata.remove_zulip_association("example.zulipchat.com", "alice")
399 assert result is False
400 assert len(metadata.zulip_associations) == 1
401
402
403class TestZulipAssociation:
404 """Test ZulipAssociation model."""
405
406 def test_valid_association(self):
407 """Test creating valid Zulip association."""
408 assoc = ZulipAssociation(
409 server="example.zulipchat.com",
410 user_id="alice@example.com"
411 )
412
413 assert assoc.server == "example.zulipchat.com"
414 assert assoc.user_id == "alice@example.com"
415
416 def test_association_hash(self):
417 """Test that associations are hashable."""
418 assoc1 = ZulipAssociation(
419 server="example.zulipchat.com",
420 user_id="alice"
421 )
422 assoc2 = ZulipAssociation(
423 server="example.zulipchat.com",
424 user_id="alice"
425 )
426 assoc3 = ZulipAssociation(
427 server="other.zulipchat.com",
428 user_id="alice"
429 )
430
431 # Same associations should have same hash
432 assert hash(assoc1) == hash(assoc2)
433
434 # Different associations should have different hash
435 assert hash(assoc1) != hash(assoc3)
436
437 # Can be used in sets
438 assoc_set = {assoc1, assoc2, assoc3}
439 assert len(assoc_set) == 2 # assoc1 and assoc2 are considered the same