···
2
+
Tests for Main Orchestration Script.
4
+
Tests the complete flow: fetch → parse → format → dedupe → post → update state.
7
+
from pathlib import Path
8
+
from datetime import datetime
9
+
from unittest.mock import Mock, MagicMock, patch, call
12
+
from src.main import Aggregator
13
+
from src.models import KagiStory, AggregatorConfig, FeedConfig, Perspective, Quote, Source
18
+
"""Mock aggregator configuration."""
19
+
return AggregatorConfig(
20
+
coves_api_url="https://api.coves.social",
24
+
url="https://news.kagi.com/world.xml",
25
+
community_handle="world-news.coves.social",
30
+
url="https://news.kagi.com/tech.xml",
31
+
community_handle="tech.coves.social",
35
+
name="Disabled Feed",
36
+
url="https://news.kagi.com/disabled.xml",
37
+
community_handle="disabled.coves.social",
47
+
"""Sample KagiStory for testing."""
50
+
link="https://kite.kagi.com/test/world/1",
51
+
guid="https://kite.kagi.com/test/world/1",
52
+
pub_date=datetime(2024, 1, 15, 12, 0, 0),
53
+
categories=["World"],
54
+
summary="Test summary",
55
+
highlights=["Highlight 1", "Highlight 2"],
59
+
description="Test description",
60
+
source_url="https://example.com/source"
63
+
quote=Quote(text="Test quote", attribution="Test Author"),
65
+
Source(title="Source 1", url="https://example.com/1", domain="example.com")
67
+
image_url="https://example.com/image.jpg",
68
+
image_alt="Test image"
73
+
def mock_rss_feed():
74
+
"""Mock RSS feed with sample entries."""
80
+
link="https://kite.kagi.com/test/world/1",
81
+
guid="https://kite.kagi.com/test/world/1",
82
+
published_parsed=(2024, 1, 15, 12, 0, 0, 0, 15, 0),
83
+
tags=[MagicMock(term="World")],
84
+
description="<p>Story 1 description</p>"
88
+
link="https://kite.kagi.com/test/world/2",
89
+
guid="https://kite.kagi.com/test/world/2",
90
+
published_parsed=(2024, 1, 15, 13, 0, 0, 0, 15, 0),
91
+
tags=[MagicMock(term="World")],
92
+
description="<p>Story 2 description</p>"
98
+
class TestAggregator:
99
+
"""Test suite for Aggregator orchestration."""
101
+
def test_initialize_aggregator(self, mock_config, tmp_path):
102
+
"""Test aggregator initialization."""
103
+
state_file = tmp_path / "state.json"
105
+
with patch('src.main.ConfigLoader') as MockConfigLoader:
106
+
mock_loader = Mock()
107
+
mock_loader.load.return_value = mock_config
108
+
MockConfigLoader.return_value = mock_loader
110
+
aggregator = Aggregator(
111
+
config_path=Path("config.yaml"),
112
+
state_file=state_file,
113
+
coves_client=Mock()
116
+
assert aggregator.config == mock_config
117
+
assert aggregator.state_file == state_file
119
+
def test_process_enabled_feeds_only(self, mock_config, tmp_path):
120
+
"""Test that only enabled feeds are processed."""
121
+
state_file = tmp_path / "state.json"
122
+
mock_client = Mock()
124
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
125
+
patch('src.main.RSSFetcher') as MockRSSFetcher:
127
+
mock_loader = Mock()
128
+
mock_loader.load.return_value = mock_config
129
+
MockConfigLoader.return_value = mock_loader
131
+
mock_fetcher = Mock()
132
+
MockRSSFetcher.return_value = mock_fetcher
134
+
aggregator = Aggregator(
135
+
config_path=Path("config.yaml"),
136
+
state_file=state_file,
137
+
coves_client=mock_client
141
+
mock_fetcher.fetch_feed.return_value = MagicMock(bozo=0, entries=[])
145
+
# Should only fetch enabled feeds (2)
146
+
assert mock_fetcher.fetch_feed.call_count == 2
148
+
def test_full_successful_flow(self, mock_config, mock_rss_feed, sample_story, tmp_path):
149
+
"""Test complete flow: fetch → parse → format → post → update state."""
150
+
state_file = tmp_path / "state.json"
151
+
mock_client = Mock()
152
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
154
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
155
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
156
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
157
+
patch('src.main.RichTextFormatter') as MockFormatter:
160
+
mock_loader = Mock()
161
+
mock_loader.load.return_value = mock_config
162
+
MockConfigLoader.return_value = mock_loader
164
+
mock_fetcher = Mock()
165
+
mock_fetcher.fetch_feed.return_value = mock_rss_feed
166
+
MockRSSFetcher.return_value = mock_fetcher
168
+
mock_parser = Mock()
169
+
mock_parser.parse_to_story.return_value = sample_story
170
+
MockHTMLParser.return_value = mock_parser
172
+
mock_formatter = Mock()
173
+
mock_formatter.format_full.return_value = {
174
+
"content": "Test content",
177
+
MockFormatter.return_value = mock_formatter
180
+
aggregator = Aggregator(
181
+
config_path=Path("config.yaml"),
182
+
state_file=state_file,
183
+
coves_client=mock_client
187
+
# Verify RSS fetching
188
+
assert mock_fetcher.fetch_feed.call_count == 2
190
+
# Verify parsing (2 entries per feed * 2 feeds = 4 total)
191
+
assert mock_parser.parse_to_story.call_count == 4
193
+
# Verify formatting
194
+
assert mock_formatter.format_full.call_count == 4
196
+
# Verify posting (should call create_post for each story)
197
+
assert mock_client.create_post.call_count == 4
199
+
def test_deduplication_skips_posted_stories(self, mock_config, mock_rss_feed, sample_story, tmp_path):
200
+
"""Test that already-posted stories are skipped."""
201
+
state_file = tmp_path / "state.json"
202
+
mock_client = Mock()
203
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
205
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
206
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
207
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
208
+
patch('src.main.RichTextFormatter') as MockFormatter:
211
+
mock_loader = Mock()
212
+
mock_loader.load.return_value = mock_config
213
+
MockConfigLoader.return_value = mock_loader
215
+
mock_fetcher = Mock()
216
+
mock_fetcher.fetch_feed.return_value = mock_rss_feed
217
+
MockRSSFetcher.return_value = mock_fetcher
219
+
mock_parser = Mock()
220
+
mock_parser.parse_to_story.return_value = sample_story
221
+
MockHTMLParser.return_value = mock_parser
223
+
mock_formatter = Mock()
224
+
mock_formatter.format_full.return_value = {
225
+
"content": "Test content",
228
+
MockFormatter.return_value = mock_formatter
230
+
# First run: posts all stories
231
+
aggregator = Aggregator(
232
+
config_path=Path("config.yaml"),
233
+
state_file=state_file,
234
+
coves_client=mock_client
238
+
# Verify first run posted stories
239
+
first_run_posts = mock_client.create_post.call_count
240
+
assert first_run_posts == 4
242
+
# Second run: should skip all (already posted)
243
+
mock_client.reset_mock()
244
+
aggregator2 = Aggregator(
245
+
config_path=Path("config.yaml"),
246
+
state_file=state_file,
247
+
coves_client=mock_client
251
+
# Should not post any (all duplicates)
252
+
assert mock_client.create_post.call_count == 0
254
+
def test_continue_on_feed_error(self, mock_config, tmp_path):
255
+
"""Test that processing continues if one feed fails."""
256
+
state_file = tmp_path / "state.json"
257
+
mock_client = Mock()
259
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
260
+
patch('src.main.RSSFetcher') as MockRSSFetcher:
262
+
mock_loader = Mock()
263
+
mock_loader.load.return_value = mock_config
264
+
MockConfigLoader.return_value = mock_loader
266
+
mock_fetcher = Mock()
267
+
# First feed fails, second succeeds
268
+
mock_fetcher.fetch_feed.side_effect = [
269
+
Exception("Network error"),
270
+
MagicMock(bozo=0, entries=[])
272
+
MockRSSFetcher.return_value = mock_fetcher
274
+
aggregator = Aggregator(
275
+
config_path=Path("config.yaml"),
276
+
state_file=state_file,
277
+
coves_client=mock_client
280
+
# Should not raise exception
283
+
# Should have attempted both feeds
284
+
assert mock_fetcher.fetch_feed.call_count == 2
286
+
def test_handle_empty_feed(self, mock_config, tmp_path):
287
+
"""Test handling of empty RSS feeds."""
288
+
state_file = tmp_path / "state.json"
289
+
mock_client = Mock()
291
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
292
+
patch('src.main.RSSFetcher') as MockRSSFetcher:
294
+
mock_loader = Mock()
295
+
mock_loader.load.return_value = mock_config
296
+
MockConfigLoader.return_value = mock_loader
298
+
mock_fetcher = Mock()
299
+
mock_fetcher.fetch_feed.return_value = MagicMock(bozo=0, entries=[])
300
+
MockRSSFetcher.return_value = mock_fetcher
302
+
aggregator = Aggregator(
303
+
config_path=Path("config.yaml"),
304
+
state_file=state_file,
305
+
coves_client=mock_client
309
+
# Should not post anything
310
+
assert mock_client.create_post.call_count == 0
312
+
def test_dont_update_state_on_failed_post(self, mock_config, mock_rss_feed, sample_story, tmp_path):
313
+
"""Test that state is not updated if posting fails."""
314
+
state_file = tmp_path / "state.json"
315
+
mock_client = Mock()
316
+
mock_client.create_post.side_effect = Exception("Post failed")
318
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
319
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
320
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
321
+
patch('src.main.RichTextFormatter') as MockFormatter:
324
+
mock_loader = Mock()
325
+
mock_loader.load.return_value = mock_config
326
+
MockConfigLoader.return_value = mock_loader
328
+
mock_fetcher = Mock()
329
+
mock_fetcher.fetch_feed.return_value = mock_rss_feed
330
+
MockRSSFetcher.return_value = mock_fetcher
332
+
mock_parser = Mock()
333
+
mock_parser.parse_to_story.return_value = sample_story
334
+
MockHTMLParser.return_value = mock_parser
336
+
mock_formatter = Mock()
337
+
mock_formatter.format_full.return_value = {
338
+
"content": "Test content",
341
+
MockFormatter.return_value = mock_formatter
343
+
# Run aggregator (posts will fail)
344
+
aggregator = Aggregator(
345
+
config_path=Path("config.yaml"),
346
+
state_file=state_file,
347
+
coves_client=mock_client
351
+
# Reset client to succeed
352
+
mock_client.reset_mock()
353
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
355
+
# Second run: should try to post again (state wasn't updated)
356
+
aggregator2 = Aggregator(
357
+
config_path=Path("config.yaml"),
358
+
state_file=state_file,
359
+
coves_client=mock_client
363
+
# Should post stories (they weren't marked as posted)
364
+
assert mock_client.create_post.call_count == 4
366
+
def test_update_last_run_timestamp(self, mock_config, tmp_path):
367
+
"""Test that last_run timestamp is updated after successful processing."""
368
+
state_file = tmp_path / "state.json"
369
+
mock_client = Mock()
371
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
372
+
patch('src.main.RSSFetcher') as MockRSSFetcher:
374
+
mock_loader = Mock()
375
+
mock_loader.load.return_value = mock_config
376
+
MockConfigLoader.return_value = mock_loader
378
+
mock_fetcher = Mock()
379
+
mock_fetcher.fetch_feed.return_value = MagicMock(bozo=0, entries=[])
380
+
MockRSSFetcher.return_value = mock_fetcher
382
+
aggregator = Aggregator(
383
+
config_path=Path("config.yaml"),
384
+
state_file=state_file,
385
+
coves_client=mock_client
389
+
# Verify last_run was updated for both feeds
390
+
feed1_last_run = aggregator.state_manager.get_last_run(
391
+
"https://news.kagi.com/world.xml"
393
+
feed2_last_run = aggregator.state_manager.get_last_run(
394
+
"https://news.kagi.com/tech.xml"
397
+
assert feed1_last_run is not None
398
+
assert feed2_last_run is not None
400
+
def test_create_post_with_image_embed(self, mock_config, mock_rss_feed, sample_story, tmp_path):
401
+
"""Test that posts include external image embeds."""
402
+
state_file = tmp_path / "state.json"
403
+
mock_client = Mock()
404
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
406
+
# Mock create_external_embed to return proper embed structure
407
+
mock_client.create_external_embed.return_value = {
408
+
"$type": "social.coves.embed.external",
410
+
"uri": sample_story.link,
411
+
"title": sample_story.title,
412
+
"description": sample_story.summary,
413
+
"thumb": sample_story.image_url
417
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
418
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
419
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
420
+
patch('src.main.RichTextFormatter') as MockFormatter:
423
+
mock_loader = Mock()
424
+
mock_loader.load.return_value = mock_config
425
+
MockConfigLoader.return_value = mock_loader
427
+
mock_fetcher = Mock()
428
+
# Only one entry for simplicity
429
+
single_entry_feed = MagicMock(bozo=0, entries=[mock_rss_feed.entries[0]])
430
+
mock_fetcher.fetch_feed.return_value = single_entry_feed
431
+
MockRSSFetcher.return_value = mock_fetcher
433
+
mock_parser = Mock()
434
+
mock_parser.parse_to_story.return_value = sample_story
435
+
MockHTMLParser.return_value = mock_parser
437
+
mock_formatter = Mock()
438
+
mock_formatter.format_full.return_value = {
439
+
"content": "Test content",
442
+
MockFormatter.return_value = mock_formatter
445
+
aggregator = Aggregator(
446
+
config_path=Path("config.yaml"),
447
+
state_file=state_file,
448
+
coves_client=mock_client
452
+
# Verify create_post was called with embed
453
+
mock_client.create_post.assert_called()
454
+
call_kwargs = mock_client.create_post.call_args.kwargs
456
+
assert "embed" in call_kwargs
457
+
assert call_kwargs["embed"]["$type"] == "social.coves.embed.external"
458
+
assert call_kwargs["embed"]["external"]["uri"] == sample_story.link
459
+
assert call_kwargs["embed"]["external"]["title"] == sample_story.title
460
+
assert call_kwargs["embed"]["external"]["thumb"] == sample_story.image_url