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