diff --git a/aggregators/kagi-news/tests/__init__.py b/aggregators/kagi-news/tests/__init__.py
new file mode 100644
index 0000000..14816db
--- /dev/null
+++ b/aggregators/kagi-news/tests/__init__.py
@@ -0,0 +1 @@
+"""Test suite for Kagi News aggregator."""
diff --git a/aggregators/kagi-news/tests/fixtures/sample_rss_item.xml b/aggregators/kagi-news/tests/fixtures/sample_rss_item.xml
new file mode 100644
index 0000000..fc9fda0
--- /dev/null
+++ b/aggregators/kagi-news/tests/fixtures/sample_rss_item.xml
@@ -0,0 +1,12 @@
+
+
+
+ Trump to meet Xi in South Korea on Oct 30
+ https://kite.kagi.com/96cf948f-8a1b-4281-9ba4-8a9e1ad7b3c6/world/10
+ <p>The White House confirmed President Trump will hold a bilateral meeting with Chinese President Xi Jinping in South Korea on October 30, at the end of an Asia trip that includes Malaysia and Japan . The administration said the meeting will take place Thursday morning local time, and Mr Trump indicated his first question to Xi would concern fentanyl and other bilateral issues . The talks come amid heightened trade tensions after Beijing expanded export curbs on rare-earth minerals and following Mr Trump's recent threat of additional tariffs on Chinese goods, making the meeting a focal point for discussions on trade, technology supply chains and energy .</p><img src='https://kagiproxy.com/img/Q2SRXQtwTYBIiQeI0FG-X6taF_wHSJaXDiFUzju2kbCWGuOYIFUX--8L0BqE4VKxpbOJY3ylFPJkDpfSnyQYZ1qdOLXbphHTnsOK4jb7gqC4KCn5nf3ANbWCuaFD5ZUSijiK0k7wOLP2fyX6tynu2mPtXlCbotLo2lTrEswZl4-No2AI4mI4lkResfnRdp-YjpoEfCOHkNfbN1-0cNcHt9T2dmgBSXrQ2w' alt='News image associated with coverage of President Trump's Asia trip and planned meeting with President Xi' /><br /><h3>Highlights:</h3><ul><li>Itinerary details: The Asia swing begins in Malaysia, continues to Japan and ends with the bilateral meeting in South Korea on Thursday morning local time, White House press secretary Karoline Leavitt said at a briefing .</li><li>APEC context: US officials indicated the leaders will meet on the sidelines of the Asia-Pacific Economic Cooperation gathering, shaping expectations for short, high-level talks rather than a lengthy summit .</li><li>Tariff escalation: President Trump recently threatened an additional 100% tariff on Chinese goods starting in November, a step he has described as unsustainable but that has heightened urgency for talks .</li><li>Rare-earth impact: Beijing's expanded curbs on rare-earth exports have exposed supply vulnerabilities because US high-tech firms rely heavily on those materials, raising strategic and economic stakes for the meeting .</li></ul><blockquote>Work out a lot of our doubts and questions - President Trump</blockquote><h3>Perspectives:</h3><ul><li>President Trump: He said his first question to President Xi would be about fentanyl and indicated he hoped to resolve bilateral doubts and questions in the talks. (<a href='https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing'>The Straits Times</a>)</li><li>White House (press secretary): Karoline Leavitt confirmed the bilateral meeting will occur Thursday morning local time during a White House briefing. (<a href='https://www.scmp.com/news/us/diplomacy/article/3330131/donald-trump-meet-chinas-xi-jinping-next-thursday-south-korea-crunch-talks'>South China Morning Post</a>)</li><li>Beijing/Chinese authorities: Officials have defended tighter export controls on rare-earths, a move described in reporting as not explicitly targeting the US though it has raised tensions. (<a href='https://www.rt.com/news/626890-white-house-announces-trump-xi-meeting/'>RT</a>)</li></ul><h3>Sources:</h3><ul><li><a href='https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing'>Trump to meet Xi in South Korea on Oct 30 as part of Asia swing</a> - straitstimes.com</li><li><a href='https://www.scmp.com/news/us/diplomacy/article/3330131/donald-trump-meet-chinas-xi-jinping-next-thursday-south-korea-crunch-talks'>Trump to meet Xi in South Korea next Thursday as part of key Asia trip</a> - scmp.com</li><li><a href='https://www.rt.com/news/626890-white-house-announces-trump-xi-meeting/'>White House announces Trump-Xi meeting</a> - rt.com</li><li><a href='https://www.thehindu.com/news/international/trump-to-meet-xi-in-south-korea-as-part-of-asia-swing/article70195667.ece'>Trump to meet Xi in South Korea as part of Asia swing</a> - thehindu.com</li><li><a href='https://www.aljazeera.com/news/2025/10/24/white-house-confirms-trump-to-meet-xi-in-south-korea-as-part-of-asia-tour'>White House confirms Trump to meet Xi in South Korea as part of Asia tour</a> - aljazeera.com</li></ul>
+ https://kite.kagi.com/96cf948f-8a1b-4281-9ba4-8a9e1ad7b3c6/world/10
+ World
+ World/Diplomacy
+ Diplomacy
+ Thu, 23 Oct 2025 20:56:00 +0000
+
diff --git a/aggregators/kagi-news/tests/test_config.py b/aggregators/kagi-news/tests/test_config.py
new file mode 100644
index 0000000..d1469fe
--- /dev/null
+++ b/aggregators/kagi-news/tests/test_config.py
@@ -0,0 +1,246 @@
+"""
+Tests for Configuration Loader.
+
+Tests loading and validating aggregator configuration.
+"""
+import pytest
+import tempfile
+from pathlib import Path
+
+from src.config import ConfigLoader, ConfigError
+from src.models import AggregatorConfig, FeedConfig
+
+
+@pytest.fixture
+def valid_config_yaml():
+ """Valid configuration YAML."""
+ return """
+coves_api_url: "https://api.coves.social"
+
+feeds:
+ - name: "World News"
+ url: "https://news.kagi.com/world.xml"
+ community_handle: "world-news.coves.social"
+ enabled: true
+
+ - name: "Tech News"
+ url: "https://news.kagi.com/tech.xml"
+ community_handle: "tech.coves.social"
+ enabled: true
+
+ - name: "Science News"
+ url: "https://news.kagi.com/science.xml"
+ community_handle: "science.coves.social"
+ enabled: false
+
+log_level: "info"
+"""
+
+
+@pytest.fixture
+def temp_config_file(valid_config_yaml):
+ """Create a temporary config file."""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(valid_config_yaml)
+ temp_path = Path(f.name)
+ yield temp_path
+ # Cleanup
+ if temp_path.exists():
+ temp_path.unlink()
+
+
+class TestConfigLoader:
+ """Test suite for ConfigLoader."""
+
+ def test_load_valid_config(self, temp_config_file):
+ """Test loading valid configuration."""
+ loader = ConfigLoader(temp_config_file)
+ config = loader.load()
+
+ assert isinstance(config, AggregatorConfig)
+ assert config.coves_api_url == "https://api.coves.social"
+ assert config.log_level == "info"
+ assert len(config.feeds) == 3
+
+ def test_parse_feed_configs(self, temp_config_file):
+ """Test parsing feed configurations."""
+ loader = ConfigLoader(temp_config_file)
+ config = loader.load()
+
+ # Check first feed
+ feed1 = config.feeds[0]
+ assert isinstance(feed1, FeedConfig)
+ assert feed1.name == "World News"
+ assert feed1.url == "https://news.kagi.com/world.xml"
+ assert feed1.community_handle == "world-news.coves.social"
+ assert feed1.enabled is True
+
+ # Check disabled feed
+ feed3 = config.feeds[2]
+ assert feed3.name == "Science News"
+ assert feed3.enabled is False
+
+ def test_get_enabled_feeds_only(self, temp_config_file):
+ """Test getting only enabled feeds."""
+ loader = ConfigLoader(temp_config_file)
+ config = loader.load()
+
+ enabled_feeds = [f for f in config.feeds if f.enabled]
+ assert len(enabled_feeds) == 2
+ assert all(f.enabled for f in enabled_feeds)
+
+ def test_missing_config_file_raises_error(self):
+ """Test that missing config file raises error."""
+ with pytest.raises(ConfigError, match="not found"):
+ loader = ConfigLoader(Path("nonexistent.yaml"))
+ loader.load()
+
+ def test_invalid_yaml_raises_error(self):
+ """Test that invalid YAML raises error."""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write("invalid: yaml: content: [[[")
+ temp_path = Path(f.name)
+
+ try:
+ with pytest.raises(ConfigError, match="Failed to parse"):
+ loader = ConfigLoader(temp_path)
+ loader.load()
+ finally:
+ temp_path.unlink()
+
+ def test_missing_required_field_raises_error(self):
+ """Test that missing required fields raise error."""
+ invalid_yaml = """
+feeds:
+ - name: "Test"
+ url: "https://test.xml"
+ # Missing community_handle!
+"""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(invalid_yaml)
+ temp_path = Path(f.name)
+
+ try:
+ with pytest.raises(ConfigError, match="Missing required field"):
+ loader = ConfigLoader(temp_path)
+ loader.load()
+ finally:
+ temp_path.unlink()
+
+ def test_missing_coves_api_url_raises_error(self):
+ """Test that missing coves_api_url raises error."""
+ invalid_yaml = """
+feeds:
+ - name: "Test"
+ url: "https://test.xml"
+ community_handle: "test.coves.social"
+"""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(invalid_yaml)
+ temp_path = Path(f.name)
+
+ try:
+ with pytest.raises(ConfigError, match="coves_api_url"):
+ loader = ConfigLoader(temp_path)
+ loader.load()
+ finally:
+ temp_path.unlink()
+
+ def test_default_log_level(self):
+ """Test that log_level defaults to 'info' if not specified."""
+ minimal_yaml = """
+coves_api_url: "https://api.coves.social"
+feeds:
+ - name: "Test"
+ url: "https://test.xml"
+ community_handle: "test.coves.social"
+"""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(minimal_yaml)
+ temp_path = Path(f.name)
+
+ try:
+ loader = ConfigLoader(temp_path)
+ config = loader.load()
+ assert config.log_level == "info"
+ finally:
+ temp_path.unlink()
+
+ def test_default_enabled_true(self):
+ """Test that feed enabled defaults to True if not specified."""
+ yaml_content = """
+coves_api_url: "https://api.coves.social"
+feeds:
+ - name: "Test"
+ url: "https://test.xml"
+ community_handle: "test.coves.social"
+ # No 'enabled' field
+"""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(yaml_content)
+ temp_path = Path(f.name)
+
+ try:
+ loader = ConfigLoader(temp_path)
+ config = loader.load()
+ assert config.feeds[0].enabled is True
+ finally:
+ temp_path.unlink()
+
+ def test_invalid_url_format_raises_error(self):
+ """Test that invalid URLs raise error."""
+ invalid_yaml = """
+coves_api_url: "https://api.coves.social"
+feeds:
+ - name: "Test"
+ url: "not-a-valid-url"
+ community_handle: "test.coves.social"
+"""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(invalid_yaml)
+ temp_path = Path(f.name)
+
+ try:
+ with pytest.raises(ConfigError, match="Invalid URL"):
+ loader = ConfigLoader(temp_path)
+ loader.load()
+ finally:
+ temp_path.unlink()
+
+ def test_empty_feeds_list_raises_error(self):
+ """Test that empty feeds list raises error."""
+ invalid_yaml = """
+coves_api_url: "https://api.coves.social"
+feeds: []
+"""
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+ f.write(invalid_yaml)
+ temp_path = Path(f.name)
+
+ try:
+ with pytest.raises(ConfigError, match="at least one feed"):
+ loader = ConfigLoader(temp_path)
+ loader.load()
+ finally:
+ temp_path.unlink()
+
+ def test_load_from_env_override(self, temp_config_file, monkeypatch):
+ """Test that environment variables can override config values."""
+ # Set environment variable
+ monkeypatch.setenv("COVES_API_URL", "https://test.coves.social")
+
+ loader = ConfigLoader(temp_config_file)
+ config = loader.load()
+
+ # Should use env var instead of config file
+ assert config.coves_api_url == "https://test.coves.social"
+
+ def test_get_feed_by_url(self, temp_config_file):
+ """Test helper to get feed config by URL."""
+ loader = ConfigLoader(temp_config_file)
+ config = loader.load()
+
+ feed = next((f for f in config.feeds if f.url == "https://news.kagi.com/tech.xml"), None)
+ assert feed is not None
+ assert feed.name == "Tech News"
+ assert feed.community_handle == "tech.coves.social"
diff --git a/aggregators/kagi-news/tests/test_e2e.py b/aggregators/kagi-news/tests/test_e2e.py
new file mode 100644
index 0000000..e536857
--- /dev/null
+++ b/aggregators/kagi-news/tests/test_e2e.py
@@ -0,0 +1,433 @@
+"""
+End-to-End Integration Tests.
+
+Tests the complete aggregator workflow against live infrastructure:
+- Real HTTP mocking (Kagi RSS)
+- Real PDS (Coves test PDS via Docker)
+- Real community posting
+- Real state management
+
+Requires:
+- Coves test PDS running on localhost:3001
+- Test database with community: e2e-95206.community.coves.social
+- Aggregator account: kagi-news.local.coves.dev
+"""
+import os
+import pytest
+import responses
+from pathlib import Path
+from datetime import datetime
+
+from src.main import Aggregator
+from src.coves_client import CovesClient
+from src.config import ConfigLoader
+
+
+# Skip E2E tests by default (require live infrastructure)
+pytestmark = pytest.mark.skipif(
+ os.getenv('RUN_E2E_TESTS') != '1',
+ reason="E2E tests require RUN_E2E_TESTS=1 and live PDS"
+)
+
+
+@pytest.fixture
+def test_community(aggregator_credentials):
+ """Create a test community for E2E testing."""
+ import time
+ import requests
+
+ handle, password = aggregator_credentials
+
+ # Authenticate
+ auth_response = requests.post(
+ "http://localhost:3001/xrpc/com.atproto.server.createSession",
+ json={"identifier": handle, "password": password}
+ )
+ token = auth_response.json()["accessJwt"]
+
+ # Create community (use short name to avoid handle length limits)
+ community_name = f"e2e-{int(time.time()) % 10000}" # Last 4 digits only
+ create_response = requests.post(
+ "http://localhost:8081/xrpc/social.coves.community.create",
+ headers={"Authorization": f"Bearer {token}"},
+ json={
+ "name": community_name,
+ "displayName": "E2E Test Community",
+ "description": "Temporary community for aggregator E2E testing",
+ "visibility": "public"
+ }
+ )
+
+ if create_response.ok:
+ community = create_response.json()
+ community_handle = f"{community_name}.community.coves.social"
+ print(f"\n✅ Created test community: {community_handle}")
+ return community_handle
+ else:
+ raise Exception(f"Failed to create community: {create_response.text}")
+
+
+@pytest.fixture
+def test_config_file(tmp_path, test_community):
+ """Create test configuration file with dynamic community."""
+ config_content = f"""
+coves_api_url: http://localhost:8081
+
+feeds:
+ - name: "Kagi World News"
+ url: "https://news.kagi.com/world.xml"
+ community_handle: "{test_community}"
+ enabled: true
+
+log_level: debug
+"""
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(config_content)
+ return config_file
+
+
+@pytest.fixture
+def test_state_file(tmp_path):
+ """Create temporary state file."""
+ return tmp_path / "state.json"
+
+
+@pytest.fixture
+def mock_kagi_feed():
+ """Load real Kagi RSS feed fixture."""
+ # Load from data directory (where actual feed is stored)
+ fixture_path = Path(__file__).parent.parent / "data" / "world.xml"
+ if not fixture_path.exists():
+ # Fallback to tests/fixtures if moved
+ fixture_path = Path(__file__).parent / "fixtures" / "world.xml"
+ return fixture_path.read_text()
+
+
+@pytest.fixture
+def aggregator_credentials():
+ """Get aggregator credentials from environment."""
+ handle = os.getenv('AGGREGATOR_HANDLE', 'kagi-news.local.coves.dev')
+ password = os.getenv('AGGREGATOR_PASSWORD', 'kagi-aggregator-2024-secure-pass')
+ return handle, password
+
+
+class TestEndToEnd:
+ """Full end-to-end integration tests."""
+
+ @responses.activate
+ def test_full_aggregator_workflow(
+ self,
+ test_config_file,
+ test_state_file,
+ mock_kagi_feed,
+ aggregator_credentials
+ ):
+ """
+ Test complete workflow: fetch → parse → format → post → verify.
+
+ This test:
+ 1. Mocks Kagi RSS HTTP request
+ 2. Authenticates with real PDS
+ 3. Parses real Kagi HTML content
+ 4. Formats with rich text facets
+ 5. Posts to real community
+ 6. Verifies post was created
+ 7. Tests deduplication (no repost)
+ """
+ # Mock Kagi RSS feed
+ responses.add(
+ responses.GET,
+ "https://news.kagi.com/world.xml",
+ body=mock_kagi_feed,
+ status=200,
+ content_type="application/xml"
+ )
+
+ # Allow passthrough for localhost (PDS)
+ responses.add_passthru("http://localhost")
+
+ # Set up environment
+ handle, password = aggregator_credentials
+ os.environ['AGGREGATOR_HANDLE'] = handle
+ os.environ['AGGREGATOR_PASSWORD'] = password
+ os.environ['PDS_URL'] = 'http://localhost:3001' # Auth through PDS
+
+ # Create aggregator
+ aggregator = Aggregator(
+ config_path=test_config_file,
+ state_file=test_state_file
+ )
+
+ # Run first time: should post stories
+ print("\n" + "="*60)
+ print("🚀 Running first aggregator pass (should post stories)")
+ print("="*60)
+ aggregator.run()
+
+ # Verify state was updated (stories marked as posted)
+ posted_count = aggregator.state_manager.get_posted_count(
+ "https://news.kagi.com/world.xml"
+ )
+ print(f"\n✅ First pass: {posted_count} stories posted and tracked")
+ assert posted_count > 0, "Should have posted at least one story"
+
+ # Create new aggregator instance (simulates CRON re-run)
+ aggregator2 = Aggregator(
+ config_path=test_config_file,
+ state_file=test_state_file
+ )
+
+ # Run second time: should skip duplicates
+ print("\n" + "="*60)
+ print("🔄 Running second aggregator pass (should skip duplicates)")
+ print("="*60)
+ aggregator2.run()
+
+ # Verify count didn't change (deduplication worked)
+ posted_count2 = aggregator2.state_manager.get_posted_count(
+ "https://news.kagi.com/world.xml"
+ )
+ print(f"\n✅ Second pass: Still {posted_count2} stories (duplicates skipped)")
+ assert posted_count2 == posted_count, "Should not post duplicates"
+
+ @responses.activate
+ def test_post_with_external_embed(
+ self,
+ test_config_file,
+ test_state_file,
+ mock_kagi_feed,
+ aggregator_credentials
+ ):
+ """
+ Test that posts include external embeds with images.
+
+ Verifies:
+ - External embed is created
+ - Thumbnail URL is included
+ - Title and description are set
+ """
+ # Mock Kagi RSS feed
+ responses.add(
+ responses.GET,
+ "https://news.kagi.com/world.xml",
+ body=mock_kagi_feed,
+ status=200
+ )
+
+ # Allow passthrough for localhost (PDS)
+ responses.add_passthru("http://localhost")
+
+ # Set up environment
+ handle, password = aggregator_credentials
+ os.environ['AGGREGATOR_HANDLE'] = handle
+ os.environ['AGGREGATOR_PASSWORD'] = password
+ os.environ['PDS_URL'] = 'http://localhost:3001' # Auth through PDS
+
+ # Run aggregator
+ aggregator = Aggregator(
+ config_path=test_config_file,
+ state_file=test_state_file
+ )
+
+ print("\n" + "="*60)
+ print("🖼️ Testing external embed creation")
+ print("="*60)
+ aggregator.run()
+
+ # Verify posts were created
+ posted_count = aggregator.state_manager.get_posted_count(
+ "https://news.kagi.com/world.xml"
+ )
+ print(f"\n✅ Posted {posted_count} stories with external embeds")
+ assert posted_count > 0
+
+ def test_authentication_with_live_pds(self, aggregator_credentials):
+ """
+ Test authentication against live PDS.
+
+ Verifies:
+ - Can authenticate with aggregator account
+ - Receives valid JWT tokens
+ - DID matches expected format
+ """
+ handle, password = aggregator_credentials
+
+ print("\n" + "="*60)
+ print(f"🔐 Testing authentication: {handle}")
+ print("="*60)
+
+ # Create client and authenticate
+ client = CovesClient(
+ api_url="http://localhost:8081", # AppView for posting
+ handle=handle,
+ password=password,
+ pds_url="http://localhost:3001" # PDS for auth
+ )
+
+ client.authenticate()
+
+ print(f"\n✅ Authentication successful")
+ print(f" Handle: {client.handle}")
+ print(f" Authenticated: {client._authenticated}")
+
+ assert client._authenticated is True
+ assert hasattr(client, 'did')
+ assert client.did.startswith("did:plc:")
+
+ def test_state_persistence_across_runs(
+ self,
+ test_config_file,
+ test_state_file,
+ aggregator_credentials
+ ):
+ """
+ Test that state persists correctly across multiple runs.
+
+ Verifies:
+ - State file is created
+ - Posted GUIDs are tracked
+ - Last run timestamp is updated
+ - State survives aggregator restart
+ """
+ # Mock empty feed (to avoid posting)
+ import responses as resp
+ resp.start()
+ resp.add(
+ resp.GET,
+ "https://news.kagi.com/world.xml",
+ body='',
+ status=200
+ )
+
+ handle, password = aggregator_credentials
+ os.environ['AGGREGATOR_HANDLE'] = handle
+ os.environ['AGGREGATOR_PASSWORD'] = password
+
+ print("\n" + "="*60)
+ print("💾 Testing state persistence")
+ print("="*60)
+
+ # First run
+ aggregator1 = Aggregator(
+ config_path=test_config_file,
+ state_file=test_state_file
+ )
+ aggregator1.run()
+
+ # Verify state file was created
+ assert test_state_file.exists(), "State file should be created"
+ print(f"\n✅ State file created: {test_state_file}")
+
+ # Verify last run was recorded
+ last_run1 = aggregator1.state_manager.get_last_run(
+ "https://news.kagi.com/world.xml"
+ )
+ assert last_run1 is not None, "Last run should be recorded"
+ print(f" Last run: {last_run1}")
+
+ # Second run (new instance)
+ aggregator2 = Aggregator(
+ config_path=test_config_file,
+ state_file=test_state_file
+ )
+ aggregator2.run()
+
+ # Verify state persisted
+ last_run2 = aggregator2.state_manager.get_last_run(
+ "https://news.kagi.com/world.xml"
+ )
+ assert last_run2 >= last_run1, "Last run should be updated"
+ print(f" Last run (after restart): {last_run2}")
+ print(f"\n✅ State persisted across aggregator restarts")
+
+ resp.stop()
+ resp.reset()
+
+ def test_error_recovery(
+ self,
+ test_config_file,
+ test_state_file,
+ aggregator_credentials
+ ):
+ """
+ Test that aggregator handles errors gracefully.
+
+ Verifies:
+ - Continues processing on feed errors
+ - Doesn't crash on network failures
+ - Logs errors appropriately
+ """
+ # Mock feed failure
+ import responses as resp
+ resp.start()
+ resp.add(
+ resp.GET,
+ "https://news.kagi.com/world.xml",
+ body="Internal Server Error",
+ status=500
+ )
+
+ handle, password = aggregator_credentials
+ os.environ['AGGREGATOR_HANDLE'] = handle
+ os.environ['AGGREGATOR_PASSWORD'] = password
+
+ print("\n" + "="*60)
+ print("🛡️ Testing error recovery")
+ print("="*60)
+
+ # Should not crash
+ aggregator = Aggregator(
+ config_path=test_config_file,
+ state_file=test_state_file
+ )
+
+ try:
+ aggregator.run()
+ print(f"\n✅ Aggregator handled feed error gracefully")
+ except Exception as e:
+ pytest.fail(f"Aggregator should handle errors gracefully: {e}")
+
+ resp.stop()
+ resp.reset()
+
+
+def test_coves_client_external_embed_format(aggregator_credentials):
+ """
+ Test external embed formatting.
+
+ Verifies:
+ - Embed structure matches social.coves.embed.external
+ - All required fields are present
+ - Optional thumbnail is included when provided
+ """
+ handle, password = aggregator_credentials
+
+ client = CovesClient(
+ api_url="http://localhost:8081",
+ handle=handle,
+ password=password
+ )
+
+ # Test with thumbnail
+ embed = client.create_external_embed(
+ uri="https://example.com/story",
+ title="Test Story",
+ description="Test description",
+ thumb="https://example.com/image.jpg"
+ )
+
+ assert embed["$type"] == "social.coves.embed.external"
+ assert embed["external"]["uri"] == "https://example.com/story"
+ assert embed["external"]["title"] == "Test Story"
+ assert embed["external"]["description"] == "Test description"
+ assert embed["external"]["thumb"] == "https://example.com/image.jpg"
+
+ # Test without thumbnail
+ embed_no_thumb = client.create_external_embed(
+ uri="https://example.com/story2",
+ title="Test Story 2",
+ description="Test description 2"
+ )
+
+ assert "thumb" not in embed_no_thumb["external"]
+ print("\n✅ External embed format correct")
diff --git a/aggregators/kagi-news/tests/test_html_parser.py b/aggregators/kagi-news/tests/test_html_parser.py
new file mode 100644
index 0000000..008fe68
--- /dev/null
+++ b/aggregators/kagi-news/tests/test_html_parser.py
@@ -0,0 +1,122 @@
+"""
+Tests for Kagi HTML description parser.
+"""
+import pytest
+from pathlib import Path
+from datetime import datetime
+import html
+
+from src.html_parser import KagiHTMLParser
+from src.models import KagiStory, Perspective, Quote, Source
+
+
+@pytest.fixture
+def sample_html_description():
+ """Load sample HTML from RSS item fixture."""
+ # This is the escaped HTML from the RSS description field
+ html_content = """
The White House confirmed President Trump will hold a bilateral meeting with Chinese President Xi Jinping in South Korea on October 30, at the end of an Asia trip that includes Malaysia and Japan . The administration said the meeting will take place Thursday morning local time, and Mr Trump indicated his first question to Xi would concern fentanyl and other bilateral issues . The talks come amid heightened trade tensions after Beijing expanded export curbs on rare-earth minerals and following Mr Trump's recent threat of additional tariffs on Chinese goods, making the meeting a focal point for discussions on trade, technology supply chains and energy .
Highlights:
Itinerary details: The Asia swing begins in Malaysia, continues to Japan and ends with the bilateral meeting in South Korea on Thursday morning local time, White House press secretary Karoline Leavitt said at a briefing .
APEC context: US officials indicated the leaders will meet on the sidelines of the Asia-Pacific Economic Cooperation gathering, shaping expectations for short, high-level talks rather than a lengthy summit .
Work out a lot of our doubts and questions - President Trump
Perspectives:
President Trump: He said his first question to President Xi would be about fentanyl and indicated he hoped to resolve bilateral doubts and questions in the talks. (The Straits Times)
White House (press secretary): Karoline Leavitt confirmed the bilateral meeting will occur Thursday morning local time during a White House briefing. (South China Morning Post)