relay/app/feeds/test_scan_feeds.py
Andros Fenollosa 97ef297efe Harden feed scanning against bad birthdays and slow hosts
- Drop malformed #+BIRTHDAY values (not YYYY-MM-DD) instead of letting
  them reach the Profile DateField and abort the whole feed scan
- Use a (connect, read) timeout of (3.05, 5) when fetching feeds so dead
  hosts are dropped faster without penalizing slow-but-alive servers
- Add parser and scan_feeds regression tests
2026-06-24 09:17:21 +02:00

217 lines
7.8 KiB
Python

from unittest.mock import Mock, patch
from django.test import TestCase
from app.feeds.models import Feed, Profile, Post
from app.feeds.tasks import scan_feeds
class PostDeletionDetectionTest(TestCase):
"""Test cases for detecting and removing deleted posts."""
def setUp(self):
"""Set up test fixtures."""
self.feed_url = "https://example.com/social.org"
self.feed = Feed.objects.create(url=self.feed_url)
self.profile = Profile.objects.create(
feed=self.feed_url,
nick="test_user",
title="Test User",
)
def test_detect_deleted_posts(self):
"""Test deletion detection logic."""
# Given: Profile has 3 posts in database
Post.objects.create(
profile=self.profile, post_id="2025-01-01T10:00:00+0100", content="Post 1"
)
Post.objects.create(
profile=self.profile, post_id="2025-01-02T10:00:00+0100", content="Post 2"
)
Post.objects.create(
profile=self.profile, post_id="2025-01-03T10:00:00+0100", content="Post 3"
)
# When: Current feed only has 2 posts (simulating one was deleted)
current_post_ids = {"2025-01-01T10:00:00+0100", "2025-01-03T10:00:00+0100"}
# Get existing posts from database
existing_posts = Post.objects.filter(profile=self.profile)
existing_post_ids = set(existing_posts.values_list("post_id", flat=True))
# Find deleted posts
deleted_post_ids = existing_post_ids - current_post_ids
# Then: Should detect one deleted post
self.assertEqual(len(deleted_post_ids), 1)
self.assertIn("2025-01-02T10:00:00+0100", deleted_post_ids)
# When: Delete the posts that no longer exist
deleted_count = Post.objects.filter(
profile=self.profile, post_id__in=deleted_post_ids
).delete()[0]
# Then: One post should be deleted
self.assertEqual(deleted_count, 1)
# Then: Only 2 posts should remain
remaining_posts = Post.objects.filter(profile=self.profile)
self.assertEqual(remaining_posts.count(), 2)
# Then: Deleted post should not exist
self.assertFalse(
Post.objects.filter(
profile=self.profile, post_id="2025-01-02T10:00:00+0100"
).exists()
)
# Then: Other posts should still exist
self.assertTrue(
Post.objects.filter(
profile=self.profile, post_id="2025-01-01T10:00:00+0100"
).exists()
)
self.assertTrue(
Post.objects.filter(
profile=self.profile, post_id="2025-01-03T10:00:00+0100"
).exists()
)
def test_no_posts_deleted_when_all_present(self):
"""Test that no posts are deleted when all posts are still in feed."""
# Given: Profile has 2 posts
Post.objects.create(
profile=self.profile, post_id="2025-01-01T10:00:00+0100", content="Post 1"
)
Post.objects.create(
profile=self.profile, post_id="2025-01-02T10:00:00+0100", content="Post 2"
)
# When: Current feed still has both posts
current_post_ids = {"2025-01-01T10:00:00+0100", "2025-01-02T10:00:00+0100"}
# Get existing posts
existing_posts = Post.objects.filter(profile=self.profile)
existing_post_ids = set(existing_posts.values_list("post_id", flat=True))
# Find deleted posts
deleted_post_ids = existing_post_ids - current_post_ids
# Then: No posts should be detected as deleted
self.assertEqual(len(deleted_post_ids), 0)
# Then: Both posts should still exist
self.assertEqual(Post.objects.filter(profile=self.profile).count(), 2)
def test_all_posts_deleted(self):
"""Test handling when all posts are deleted from feed."""
# Given: Profile has 2 posts
Post.objects.create(
profile=self.profile, post_id="2025-01-01T10:00:00+0100", content="Post 1"
)
Post.objects.create(
profile=self.profile, post_id="2025-01-02T10:00:00+0100", content="Post 2"
)
# When: Current feed has no posts
current_post_ids = set()
# Get existing posts
existing_posts = Post.objects.filter(profile=self.profile)
existing_post_ids = set(existing_posts.values_list("post_id", flat=True))
# Find deleted posts
deleted_post_ids = existing_post_ids - current_post_ids
# Then: All posts should be detected as deleted
self.assertEqual(len(deleted_post_ids), 2)
# When: Delete all posts
deleted_count = Post.objects.filter(
profile=self.profile, post_id__in=deleted_post_ids
).delete()[0]
# Then: 2 posts should be deleted
self.assertEqual(deleted_count, 2)
# Then: No posts should remain
self.assertEqual(Post.objects.filter(profile=self.profile).count(), 0)
def test_multiple_posts_deleted(self):
"""Test handling when multiple posts are deleted."""
# Given: Profile has 5 posts
for i in range(1, 6):
Post.objects.create(
profile=self.profile,
post_id=f"2025-01-0{i}T10:00:00+0100",
content=f"Post {i}",
)
# When: Current feed only has 2 posts (3 were deleted)
current_post_ids = {"2025-01-01T10:00:00+0100", "2025-01-05T10:00:00+0100"}
# Get existing posts
existing_posts = Post.objects.filter(profile=self.profile)
existing_post_ids = set(existing_posts.values_list("post_id", flat=True))
# Find deleted posts
deleted_post_ids = existing_post_ids - current_post_ids
# Then: 3 posts should be detected as deleted
self.assertEqual(len(deleted_post_ids), 3)
# When: Delete them
deleted_count = Post.objects.filter(
profile=self.profile, post_id__in=deleted_post_ids
).delete()[0]
# Then: 3 posts should be deleted
self.assertEqual(deleted_count, 3)
# Then: Only 2 posts should remain
self.assertEqual(Post.objects.filter(profile=self.profile).count(), 2)
class ScanFeedsRobustnessTest(TestCase):
"""End-to-end robustness tests for the scan_feeds task."""
@patch("app.feeds.parser.requests.get")
def test_invalid_birthday_does_not_abort_scan(self, mock_get):
"""A feed with a malformed birthday must still be scanned (regression).
Previously a value like "2003/06/17" reached the Profile.birthday
DateField and raised a ValidationError, aborting the whole feed scan on
every run. The birthday must now be dropped while posts are still saved.
"""
# Given: A feed whose birthday is not in YYYY-MM-DD format
feed_url = "https://host.example.org/ali/social.org"
Feed.objects.create(url=feed_url)
content = (
"#+TITLE: Ali\n"
"#+NICK: ali\n"
"#+BIRTHDAY: 2003/06/17\n"
"\n"
"* Posts\n"
"** 2025-01-01T10:00:00+0100\n"
":PROPERTIES:\n"
":END:\n"
"\n"
"Hello world\n"
)
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = content.encode("utf-8")
mock_response.url = feed_url # No redirect
mock_response.history = []
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
# When: We scan all feeds
scan_feeds.call_local()
# Then: The profile is created with the bad birthday dropped, posts saved
profile = Profile.objects.get(feed=feed_url)
self.assertEqual(profile.nick, "ali")
self.assertIsNone(profile.birthday)
self.assertEqual(Post.objects.filter(profile=profile).count(), 1)