Add per-repo cover image support for RSS feed items
Add image_url column to repos table with migration, CLI image add/remove commands, and Media RSS <media:content> elements in generated feed output. New CLI commands: ghrel image add <repo> <url>, ghrel image remove <repo>
This commit is contained in:
@@ -130,6 +130,89 @@ class TestCliDaemon:
|
||||
cmd_check_for_daemon(db_path) # Should not raise
|
||||
|
||||
|
||||
class TestCliImage:
|
||||
def _setup_db(self, tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
_create_tables(conn)
|
||||
add_repo(conn, "owner/repo")
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
def test_image_add(self, tmp_path):
|
||||
db_path = self._setup_db(tmp_path)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "add", "owner/repo", "https://example.com/logo.png"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Set image" in result.stdout
|
||||
|
||||
def test_image_add_invalid_url(self, tmp_path):
|
||||
db_path = self._setup_db(tmp_path)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "add", "owner/repo", "not-a-url"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "http:// or https://" in result.stderr
|
||||
|
||||
def test_image_add_invalid_repo(self, tmp_path):
|
||||
db_path = self._setup_db(tmp_path)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "add", "invalid", "https://example.com/logo.png"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Invalid repo format" in result.stderr
|
||||
|
||||
def test_image_add_nonexistent_repo(self, tmp_path):
|
||||
db_path = self._setup_db(tmp_path)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "add", "other/repo", "https://example.com/logo.png"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in result.stderr
|
||||
|
||||
def test_image_remove(self, tmp_path):
|
||||
db_path = self._setup_db(tmp_path)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "add", "owner/repo", "https://example.com/logo.png"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "remove", "owner/repo"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Removed image" in result.stdout
|
||||
|
||||
def test_image_remove_no_image(self, tmp_path):
|
||||
db_path = self._setup_db(tmp_path)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path,
|
||||
"image", "remove", "owner/repo"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "No image set" in result.stderr
|
||||
|
||||
def test_image_help(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "image", "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "cover image" in result.stdout.lower()
|
||||
|
||||
|
||||
class TestCliAddRemove:
|
||||
def test_add_invalid_format(self, tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
|
||||
@@ -131,6 +131,81 @@ class TestGetAllEntries:
|
||||
assert len(entries) == 3
|
||||
|
||||
|
||||
class TestRepoImage:
|
||||
def test_set_image(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
assert db.set_repo_image(conn, "owner/repo", "https://example.com/logo.png") is True
|
||||
row = conn.execute("SELECT image_url FROM repos WHERE owner_repo = 'owner/repo'").fetchone()
|
||||
assert row[0] == "https://example.com/logo.png"
|
||||
|
||||
def test_set_image_nonexistent(self, conn):
|
||||
assert db.set_repo_image(conn, "no/one", "https://example.com/logo.png") is False
|
||||
|
||||
def test_set_image_overwrites(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
db.set_repo_image(conn, "owner/repo", "https://example.com/old.png")
|
||||
db.set_repo_image(conn, "owner/repo", "https://example.com/new.png")
|
||||
row = conn.execute("SELECT image_url FROM repos WHERE owner_repo = 'owner/repo'").fetchone()
|
||||
assert row[0] == "https://example.com/new.png"
|
||||
|
||||
def test_remove_image(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
db.set_repo_image(conn, "owner/repo", "https://example.com/logo.png")
|
||||
assert db.remove_repo_image(conn, "owner/repo") is True
|
||||
row = conn.execute("SELECT image_url FROM repos WHERE owner_repo = 'owner/repo'").fetchone()
|
||||
assert row[0] is None
|
||||
|
||||
def test_remove_image_no_image_set(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
assert db.remove_repo_image(conn, "owner/repo") is False
|
||||
|
||||
def test_image_url_in_entries(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
db.set_repo_image(conn, "owner/repo", "https://example.com/logo.png")
|
||||
db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x")
|
||||
entries = db.get_all_entries(conn)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["image_url"] == "https://example.com/logo.png"
|
||||
|
||||
def test_image_url_none_by_default(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x")
|
||||
entries = db.get_all_entries(conn)
|
||||
assert entries[0]["image_url"] is None
|
||||
|
||||
def test_migration_adds_column(self):
|
||||
"""Test that _migrate_add_image_url adds the column if missing."""
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.execute("PRAGMA foreign_keys=ON")
|
||||
c.execute("""
|
||||
CREATE TABLE repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_repo TEXT NOT NULL UNIQUE,
|
||||
mode TEXT NOT NULL DEFAULT 'release'
|
||||
)
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('release', 'tag')),
|
||||
tag_name TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT DEFAULT '',
|
||||
published_at DATETIME NOT NULL,
|
||||
html_url TEXT NOT NULL,
|
||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE,
|
||||
UNIQUE(repo_id, tag_name)
|
||||
)
|
||||
""")
|
||||
c.commit()
|
||||
db._migrate_add_image_url(c)
|
||||
columns = [col[1] for col in c.execute("PRAGMA table_info(repos)").fetchall()]
|
||||
assert "image_url" in columns
|
||||
c.close()
|
||||
|
||||
|
||||
class TestGetConnection:
|
||||
def test_creates_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -132,3 +132,53 @@ class TestGenerateFeed:
|
||||
xml_str = generate_feed(entries, base_url="http://example.com:9000")
|
||||
root = ET.fromstring(xml_str)
|
||||
assert root[0].find("link").text == "http://example.com:9000/"
|
||||
|
||||
def test_media_content_with_image(self):
|
||||
entries = self._entries(1)
|
||||
entries[0]["image_url"] = "https://example.com/logo.png"
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
assert root is not None
|
||||
media_el = root[0].find("item").find("{http://search.yahoo.com/mrss/}content")
|
||||
assert media_el is not None
|
||||
assert media_el.get("url") == "https://example.com/logo.png"
|
||||
assert media_el.get("medium") == "image"
|
||||
assert "media" in xml_str or "search.yahoo.com/mrss" in xml_str
|
||||
|
||||
def test_no_media_content_without_image(self):
|
||||
entries = self._entries(1)
|
||||
entries[0]["image_url"] = None
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
media_el = root[0].find("item").find("{http://search.yahoo.com/mrss/}content")
|
||||
assert media_el is None
|
||||
|
||||
def test_no_media_content_when_key_missing(self):
|
||||
entries = self._entries(1)
|
||||
entries[0]["image_url"] = None
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
media_el = root[0].find("item").find("{http://search.yahoo.com/mrss/}content")
|
||||
assert media_el is None
|
||||
|
||||
def test_media_content_multiple_items_selective(self):
|
||||
entries = self._entries(2)
|
||||
entries[0]["image_url"] = "https://example.com/logo1.png"
|
||||
entries[1]["image_url"] = None
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
items = root[0].findall("item")
|
||||
media_1 = items[0].find("{http://search.yahoo.com/mrss/}content")
|
||||
media_2 = items[1].find("{http://search.yahoo.com/mrss/}content")
|
||||
assert media_1 is not None
|
||||
assert media_1.get("url") == "https://example.com/logo1.png"
|
||||
assert media_2 is None
|
||||
|
||||
def test_image_with_special_chars_in_url(self):
|
||||
entries = self._entries(1)
|
||||
entries[0]["image_url"] = "https://example.com/logo.png?w=200&h=200"
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
assert root is not None
|
||||
media_el = root[0].find("item").find("{http://search.yahoo.com/mrss/}content")
|
||||
assert "w=200" in media_el.get("url")
|
||||
|
||||
Reference in New Issue
Block a user