You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

103 rivejä
3.7 KiB

  1. import os
  2. import tempfile
  3. import unittest
  4. from datetime import datetime, timezone
  5. from unittest.mock import patch, MagicMock
  6. from services.feeds import looks_like_feed, parse_feed
  7. from services.sources import get_sources
  8. from services.headlines import prepare_headlines
  9. from structs.headline import Headline
  10. RSS_2_0 = """<?xml version="1.0" encoding="UTF-8"?>
  11. <rss version="2.0"><channel>
  12. <title>Test feed</title>
  13. <item><title>First news story</title><link>https://x/1</link><pubDate>Mon, 14 Sep 2026 12:00:00 GMT</pubDate></item>
  14. <item><title>Second news story</title><link>https://x/2</link></item>
  15. </channel></rss>
  16. """
  17. ATOM = """<?xml version="1.0" encoding="utf-8"?>
  18. <feed xmlns="http://www.w3.org/2005/Atom">
  19. <entry><title>Atom story one</title><published>2026-09-14T14:30:00Z</published></entry>
  20. <entry><title>Atom story two</title><updated>2026-09-13T10:00:00Z</updated></entry>
  21. </feed>
  22. """
  23. class TestLooksLikeFeed(unittest.TestCase):
  24. def test_rss_and_atom_detected(self):
  25. self.assertTrue(looks_like_feed(RSS_2_0))
  26. self.assertTrue(looks_like_feed(ATOM))
  27. def test_html_not_detected(self):
  28. self.assertFalse(looks_like_feed("<html><body><a>Story</a></body></html>"))
  29. def test_empty_not_detected(self):
  30. self.assertFalse(looks_like_feed(""))
  31. self.assertFalse(looks_like_feed(None))
  32. class TestParseFeed(unittest.TestCase):
  33. def test_rss_2_0(self):
  34. items = parse_feed(RSS_2_0)
  35. titles = [t for t, _ in items]
  36. self.assertEqual(titles, ["First news story", "Second news story"])
  37. self.assertEqual(items[0][1].year, 2026)
  38. self.assertIsNone(items[1][1])
  39. def test_atom(self):
  40. items = parse_feed(ATOM)
  41. titles = [t for t, _ in items]
  42. self.assertEqual(titles, ["Atom story one", "Atom story two"])
  43. self.assertIsNotNone(items[0][1])
  44. self.assertIsNotNone(items[1][1])
  45. def test_malformed_returns_empty(self):
  46. self.assertEqual(parse_feed("not xml at all <<<"), [])
  47. class TestGetSources(unittest.TestCase):
  48. def test_skips_comments_and_blanks(self):
  49. path = None
  50. try:
  51. with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
  52. f.write("# a comment\n\nhttps://a.com\n\nhttps://b.com\n# another\n")
  53. path = f.name
  54. self.assertEqual(get_sources(path), ["https://a.com", "https://b.com"])
  55. finally:
  56. if path:
  57. os.unlink(path)
  58. class TestFeedDomainNormalization(unittest.TestCase):
  59. def test_feed_subdomain_stripped(self):
  60. self.assertEqual(Headline("a", ["a"], "https://feeds.npr.org/1001/rss.xml").domain, "npr.org")
  61. self.assertEqual(Headline("a", ["a"], "https://rss.nytimes.com/x").domain, "nytimes.com")
  62. self.assertEqual(Headline("a", ["a"], "https://www.vox.com/rss/index.xml").domain, "vox.com")
  63. class TestPrepareHeadlinesFeed(unittest.TestCase):
  64. def setUp(self):
  65. self.stopwords = {"the", "a", "an", "in", "on", "and", "of", "to"}
  66. @patch("services.headlines.requests.get")
  67. def test_feed_source_is_parsed(self, mock_get):
  68. mock_response = MagicMock()
  69. mock_response.status_code = 200
  70. mock_response.content = RSS_2_0.encode("utf-8")
  71. mock_response.text = RSS_2_0
  72. mock_get.return_value = mock_response
  73. headlines = prepare_headlines(["https://feeds.bbci.co.uk/news/world/rss.xml"], self.stopwords)
  74. titles = [h.display_text for h in headlines]
  75. self.assertEqual(titles, ["First news story", "Second news story"])
  76. # Domain comes from the feed host, normalized to the outlet.
  77. self.assertEqual(headlines[0].domain, "bbci.co.uk")
  78. self.assertIsNotNone(headlines[0].published_at)
  79. if __name__ == "__main__":
  80. unittest.main()