Add initial version of feeds app
All checks were successful
Deploy to Server / deploy (push) Successful in 11s

This commit is contained in:
Joakim Hellsén 2026-03-24 03:58:08 +01:00
commit a02b5d5f66
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
17 changed files with 993 additions and 15 deletions

1
feeds/tests/__init__.py Normal file
View file

@ -0,0 +1 @@
# This file marks the directory as a Python package.

View file

@ -0,0 +1,117 @@
import os
import threading
from http.server import HTTPServer
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from feeds.models import Entry
from feeds.models import Feed
from feeds.services import fetch_and_archive_feed
if TYPE_CHECKING:
from pathlib import Path
@pytest.mark.django_db
def test_entry_id_string_guid_dict(tmp_path: Path) -> None:
"""Test that entry_id is always a string, even if guid is a dict."""
# Prepare a fake RSS feed with guid as dict (attributes)
feed_content = """
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>http://example.com/</link>
<description>Test feed description</description>
<item>
<title>Item 1</title>
<link>http://example.com/item1</link>
<guid isPermaLink="true">http://example.com/item1</guid>
</item>
</channel>
</rss>
"""
feed_path: Path = tmp_path / "test_feed.xml"
feed_path.write_text(feed_content, encoding="utf-8")
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
fetch_and_archive_feed(feed)
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert isinstance(entry.entry_id, str)
assert entry.entry_id == "http://example.com/item1"
server.shutdown()
@pytest.mark.django_db
def test_entry_id_string_guid_string(tmp_path: Path) -> None:
"""Test that entry_id is a string when guid is a plain string."""
feed_content = """
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>http://example.com/</link>
<description>Test feed description</description>
<item>
<title>Item 2</title>
<link>http://example.com/item2</link>
<guid>http://example.com/item2</guid>
</item>
</channel>
</rss>
"""
feed_path: Path = tmp_path / "test_feed.xml"
feed_path.write_text(feed_content, encoding="utf-8")
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
fetch_and_archive_feed(feed)
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert isinstance(entry.entry_id, str)
assert entry.entry_id == "http://example.com/item2"
server.shutdown()
@pytest.mark.django_db
def test_entry_id_fallback_to_link(tmp_path: Path) -> None:
"""Test that entry_id falls back to link if guid/id missing."""
feed_content = """
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>http://example.com/</link>
<description>Test feed description</description>
<item>
<title>Item 3</title>
<link>http://example.com/item3</link>
</item>
</channel>
</rss>
"""
feed_path: Path = tmp_path / "test_feed.xml"
feed_path.write_text(feed_content, encoding="utf-8")
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
fetch_and_archive_feed(feed)
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert isinstance(entry.entry_id, str)
assert entry.entry_id == "http://example.com/item3"
server.shutdown()

View file

@ -0,0 +1,111 @@
import os
import threading
from http.server import HTTPServer
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from feeds.models import Entry
from feeds.models import Feed
from feeds.services import fetch_and_archive_feed
if TYPE_CHECKING:
from pathlib import Path
@pytest.mark.django_db
def test_entry_id_id_dict(tmp_path: Path) -> None:
"""Test that entry_id is a string when id is a dict."""
feed_content = """
<feed xmlns='http://www.w3.org/2005/Atom'>
<title>Test Atom Feed</title>
<id>http://example.com/feed</id>
<entry>
<title>Entry 1</title>
<id scheme='urn:uuid'>urn:uuid:1234</id>
<link href='http://example.com/entry1'/>
</entry>
</feed>
"""
feed_path: Path = tmp_path / "test_feed.xml"
feed_path.write_text(feed_content, encoding="utf-8")
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
fetch_and_archive_feed(feed)
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert isinstance(entry.entry_id, str)
assert "urn:uuid:1234" in entry.entry_id
server.shutdown()
@pytest.mark.django_db
def test_entry_id_all_fields_missing(tmp_path: Path) -> None:
"""Test that entry_id falls back to content_hash if guid/id/link missing."""
feed_content = """
<rss version='2.0'>
<channel>
<title>Test Feed</title>
<item>
<title>Item with no id</title>
</item>
</channel>
</rss>
"""
feed_path: Path = tmp_path / "test_feed.xml"
feed_path.write_text(feed_content, encoding="utf-8")
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
fetch_and_archive_feed(feed)
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert isinstance(entry.entry_id, str)
# Should be a hash string (digits only)
assert entry.entry_id.isdigit() or entry.entry_id.lstrip("-").isdigit()
server.shutdown()
@pytest.mark.django_db
def test_entry_id_malformed_guid(tmp_path: Path) -> None:
"""Test that entry_id handles malformed guid/id gracefully."""
feed_content = """
<rss version='2.0'>
<channel>
<title>Test Feed</title>
<item>
<title>Malformed guid</title>
<guid></guid>
</item>
</channel>
</rss>
"""
feed_path: Path = tmp_path / "test_feed.xml"
feed_path.write_text(feed_content, encoding="utf-8")
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
fetch_and_archive_feed(feed)
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert isinstance(entry.entry_id, str)
# Should fallback to content_hash
assert entry.entry_id.isdigit() or entry.entry_id.lstrip("-").isdigit()
server.shutdown()

View file

@ -0,0 +1,64 @@
import os
import threading
from http.server import HTTPServer
from http.server import SimpleHTTPRequestHandler
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
import pytest
from feeds.models import Entry
from feeds.models import Feed
from feeds.services import fetch_and_archive_feed
@pytest.mark.django_db
def test_fetch_and_archive_feed_xml(tmp_path: Path) -> None:
"""Test fetching and archiving a simple XML feed using a local HTTP server."""
# Use a local test XML file as a feed source
test_feed_path: Path = tmp_path / "test_feed.xml"
test_feed_path.write_text(
encoding="utf-8",
data="""
<rss version='2.0'>
<channel>
<title>Test Feed</title>
<link>http://example.com/</link>
<description>Test feed description</description>
<item>
<title>Item 1</title>
<link>http://example.com/item1</link>
<description>Item 1 description</description>
</item>
</channel>
</rss>
""",
)
# Serve the file using a simple HTTP server
os.chdir(tmp_path)
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
port: int = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url: str = f"http://localhost:{port}/test_feed.xml"
feed: Feed = Feed.objects.create(url=url, domain="localhost")
new_entries: int = fetch_and_archive_feed(feed)
assert new_entries == 1
# Check that the entry was archived and contains the expected data
entry: Entry | None = Entry.objects.filter(feed=feed).first()
assert entry is not None
assert entry.data is not None
assert entry.data["title"] == "Item 1"
assert Entry.objects.filter(feed=feed).count() == 1
# Clean up: stop the server and wait for the thread to finish
server.shutdown()
# Wait until the thread terminates.
# This ensures the server is fully stopped before the test ends.
thread.join()

39
feeds/tests/twitch-campaigns.xml vendored Normal file

File diff suppressed because one or more lines are too long