media-rip/backend/tests/test_themes.py
xpltd efc2ead796 M001: media.rip() v1.0 — complete application
Full-featured self-hosted yt-dlp web frontend:
- Python 3.12+ / FastAPI backend with async SQLite, SSE transport, session isolation
- Vue 3 / TypeScript / Pinia frontend with real-time progress, theme picker
- 3 built-in themes (cyberpunk/dark/light) + drop-in custom theme system
- Admin auth (bcrypt), purge system, cookie upload, file serving
- Docker multi-stage build, GitHub Actions CI/CD
- 179 backend tests, 29 frontend tests (208 total)

Slices: S01 (Foundation), S02 (SSE+Sessions), S03 (Frontend),
        S04 (Admin+Auth), S05 (Themes), S06 (Docker+CI)
2026-03-18 20:00:17 -05:00

174 lines
5.6 KiB
Python

"""Tests for theme loader service and API."""
from __future__ import annotations
import json
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.core.config import AppConfig
from app.core.database import close_db, init_db
from app.middleware.session import SessionMiddleware
from app.routers.themes import router as themes_router
from app.services.theme_loader import get_theme_css, scan_themes
class TestScanThemes:
"""Theme directory scanner tests."""
def test_empty_directory(self, tmp_path):
themes = scan_themes(tmp_path)
assert themes == []
def test_nonexistent_directory(self, tmp_path):
themes = scan_themes(tmp_path / "nonexistent")
assert themes == []
def test_valid_theme(self, tmp_path):
theme_dir = tmp_path / "my-theme"
theme_dir.mkdir()
(theme_dir / "metadata.json").write_text(
json.dumps({"name": "My Theme", "author": "Test"})
)
(theme_dir / "theme.css").write_text("[data-theme='my-theme'] { --color-bg: red; }")
themes = scan_themes(tmp_path)
assert len(themes) == 1
assert themes[0]["id"] == "my-theme"
assert themes[0]["name"] == "My Theme"
assert themes[0]["author"] == "Test"
def test_missing_metadata_skipped(self, tmp_path):
theme_dir = tmp_path / "bad-theme"
theme_dir.mkdir()
(theme_dir / "theme.css").write_text("body {}")
themes = scan_themes(tmp_path)
assert themes == []
def test_missing_css_skipped(self, tmp_path):
theme_dir = tmp_path / "no-css"
theme_dir.mkdir()
(theme_dir / "metadata.json").write_text('{"name": "No CSS"}')
themes = scan_themes(tmp_path)
assert themes == []
def test_invalid_json_skipped(self, tmp_path):
theme_dir = tmp_path / "bad-json"
theme_dir.mkdir()
(theme_dir / "metadata.json").write_text("not json")
(theme_dir / "theme.css").write_text("body {}")
themes = scan_themes(tmp_path)
assert themes == []
def test_preview_detected(self, tmp_path):
theme_dir = tmp_path / "with-preview"
theme_dir.mkdir()
(theme_dir / "metadata.json").write_text('{"name": "Preview"}')
(theme_dir / "theme.css").write_text("body {}")
(theme_dir / "preview.png").write_bytes(b"PNG")
themes = scan_themes(tmp_path)
assert themes[0]["has_preview"] is True
def test_multiple_themes_sorted(self, tmp_path):
for name in ["beta", "alpha", "gamma"]:
d = tmp_path / name
d.mkdir()
(d / "metadata.json").write_text(f'{{"name": "{name}"}}')
(d / "theme.css").write_text("body {}")
themes = scan_themes(tmp_path)
assert [t["id"] for t in themes] == ["alpha", "beta", "gamma"]
def test_files_in_root_ignored(self, tmp_path):
(tmp_path / "readme.txt").write_text("not a theme")
themes = scan_themes(tmp_path)
assert themes == []
class TestGetThemeCSS:
"""Theme CSS retrieval tests."""
def test_returns_css(self, tmp_path):
theme_dir = tmp_path / "my-theme"
theme_dir.mkdir()
css_content = "[data-theme='my-theme'] { --color-bg: #fff; }"
(theme_dir / "theme.css").write_text(css_content)
result = get_theme_css(tmp_path, "my-theme")
assert result == css_content
def test_missing_theme_returns_none(self, tmp_path):
result = get_theme_css(tmp_path, "nonexistent")
assert result is None
def test_path_traversal_blocked(self, tmp_path):
result = get_theme_css(tmp_path, "../../etc")
assert result is None
@pytest_asyncio.fixture()
async def theme_client(tmp_path):
"""Client with theme API router."""
db_path = str(tmp_path / "theme_test.db")
themes_dir = tmp_path / "themes"
themes_dir.mkdir()
# Create a sample custom theme
custom = themes_dir / "neon"
custom.mkdir()
(custom / "metadata.json").write_text(
json.dumps({"name": "Neon", "author": "Test", "description": "Bright neon"})
)
(custom / "theme.css").write_text("[data-theme='neon'] { --color-accent: #ff00ff; }")
config = AppConfig(
server={"db_path": db_path},
themes_dir=str(themes_dir),
)
db_conn = await init_db(db_path)
app = FastAPI()
app.add_middleware(SessionMiddleware)
app.include_router(themes_router, prefix="/api")
app.state.config = config
app.state.db = db_conn
app.state.start_time = datetime.now(timezone.utc)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await close_db(db_conn)
class TestThemeAPI:
"""Theme API endpoint tests."""
@pytest.mark.anyio
async def test_list_themes(self, theme_client):
resp = await theme_client.get("/api/themes")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["themes"][0]["id"] == "neon"
assert data["themes"][0]["name"] == "Neon"
@pytest.mark.anyio
async def test_get_theme_css(self, theme_client):
resp = await theme_client.get("/api/themes/neon/theme.css")
assert resp.status_code == 200
assert "text/css" in resp.headers["content-type"]
assert "--color-accent: #ff00ff" in resp.text
@pytest.mark.anyio
async def test_get_missing_theme_returns_404(self, theme_client):
resp = await theme_client.get("/api/themes/nonexistent/theme.css")
assert resp.status_code == 404