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