test: cover routers, auth, rate limit, config_manager, imgur extractor
- tests/test_auth.py: enable/disable via env, valid/invalid creds - tests/test_rate_limit.py: bucket math + window expiry - tests/test_router_media.py: path traversal (file/thumb), paginated subreddits/authors, blacklist preview shape, DELETE flow, cleanup-by-type 400 paths - tests/test_router_stats.py: cache TTL, .json exclusion, missing dir - tests/test_router_favorites.py: paginated favorites/authors, no N+1 (query count), favorites_only filter - tests/test_router_config.py: CRUD subreddits/users/blacklist - tests/test_config_manager.py: case-insensitive dedupe, blacklist removes from collection, domain normalization - tests/test_extractors_imgur.py: i.imgur direct, gifv->mp4, albums/galleries -> None src/database.py: resolve RMC_DB_PATH lazily in Database() so tests can monkeypatch the env per case (no more module-import capture) Result: 135 passed (was 77). Coverage 55% global; 100% on auth/imgur, 96% rate_limit, 84% stats router, 80% config_manager.
This commit is contained in:
parent
5f9c6023c1
commit
34c2b16e19
9 changed files with 651 additions and 2 deletions
|
|
@ -10,6 +10,11 @@ from pathlib import Path
|
||||||
DEFAULT_DB_PATH = os.environ.get("RMC_DB_PATH", "media.db")
|
DEFAULT_DB_PATH = os.environ.get("RMC_DB_PATH", "media.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_db_path() -> str:
|
||||||
|
"""Read RMC_DB_PATH lazily so tests can monkeypatch the env per case."""
|
||||||
|
return os.environ.get("RMC_DB_PATH", "media.db")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PostRecord:
|
class PostRecord:
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -32,8 +37,8 @@ class PostRecord:
|
||||||
class Database:
|
class Database:
|
||||||
"""SQLite database wrapper for tracking downloaded posts."""
|
"""SQLite database wrapper for tracking downloaded posts."""
|
||||||
|
|
||||||
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
def __init__(self, db_path: str | None = None):
|
||||||
self.db_path = Path(db_path)
|
self.db_path = Path(db_path if db_path is not None else _resolve_db_path())
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
|
|
|
||||||
64
tests/test_auth.py
Normal file
64
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Tests for optional HTTP Basic auth (src.web.auth)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.web.app import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic(user: str, password: str) -> dict[str, str]:
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
return {"Authorization": f"Basic {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_auth_when_env_unset(client, monkeypatch):
|
||||||
|
"""Without RMC_AUTH_USER/RMC_AUTH_PASS, all routes are public."""
|
||||||
|
monkeypatch.delenv("RMC_AUTH_USER", raising=False)
|
||||||
|
monkeypatch.delenv("RMC_AUTH_PASS", raising=False)
|
||||||
|
response = client.get("/api/stats")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_env_does_not_enable_auth(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("RMC_AUTH_USER", "alice")
|
||||||
|
monkeypatch.delenv("RMC_AUTH_PASS", raising=False)
|
||||||
|
response = client.get("/api/stats")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_required_when_env_set(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("RMC_AUTH_USER", "alice")
|
||||||
|
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
|
||||||
|
response = client.get("/api/stats")
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.headers.get("www-authenticate", "").lower().startswith("basic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_accepts_valid_credentials(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("RMC_AUTH_USER", "alice")
|
||||||
|
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
|
||||||
|
response = client.get("/api/stats", headers=_basic("alice", "s3cret"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_rejects_invalid_credentials(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("RMC_AUTH_USER", "alice")
|
||||||
|
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
|
||||||
|
response = client.get("/api/stats", headers=_basic("alice", "wrong"))
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_rejects_wrong_username(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("RMC_AUTH_USER", "alice")
|
||||||
|
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
|
||||||
|
response = client.get("/api/stats", headers=_basic("bob", "s3cret"))
|
||||||
|
assert response.status_code == 401
|
||||||
92
tests/test_config_manager.py
Normal file
92
tests/test_config_manager.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""Tests for src.web.config_manager (CRUD on YAML config)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.web import config_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_config(tmp_path, monkeypatch):
|
||||||
|
"""Point config_manager at a fresh YAML file inside tmp_path."""
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
monkeypatch.setattr(config_manager, "CONFIG_PATH", path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> dict:
|
||||||
|
return yaml.safe_load(path.read_text()) or {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubreddits:
|
||||||
|
def test_get_empty_when_missing_file(self, isolated_config):
|
||||||
|
assert config_manager.get_subreddits() == []
|
||||||
|
|
||||||
|
def test_add_subreddit_creates_file(self, isolated_config):
|
||||||
|
added = config_manager.add_subreddit("pics", limit=25, sort="hot")
|
||||||
|
assert added is True
|
||||||
|
data = _read(isolated_config)
|
||||||
|
assert data["targets"]["subreddits"][0]["name"] == "pics"
|
||||||
|
|
||||||
|
def test_add_subreddit_duplicate_case_insensitive(self, isolated_config):
|
||||||
|
config_manager.add_subreddit("Pics")
|
||||||
|
assert config_manager.add_subreddit("pics") is False
|
||||||
|
assert len(_read(isolated_config)["targets"]["subreddits"]) == 1
|
||||||
|
|
||||||
|
def test_remove_subreddit_when_missing_returns_false(self, isolated_config):
|
||||||
|
assert config_manager.remove_subreddit("ghost") is False
|
||||||
|
|
||||||
|
def test_remove_subreddit(self, isolated_config):
|
||||||
|
config_manager.add_subreddit("pics")
|
||||||
|
assert config_manager.remove_subreddit("pics") is True
|
||||||
|
assert config_manager.get_subreddits() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsers:
|
||||||
|
def test_add_user(self, isolated_config):
|
||||||
|
assert config_manager.add_user("alice") is True
|
||||||
|
assert config_manager.get_users()[0]["name"] == "alice"
|
||||||
|
|
||||||
|
def test_remove_user(self, isolated_config):
|
||||||
|
config_manager.add_user("alice")
|
||||||
|
assert config_manager.remove_user("alice") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlacklist:
|
||||||
|
def test_get_blacklist_has_all_keys(self, isolated_config):
|
||||||
|
bl = config_manager.get_blacklist()
|
||||||
|
assert set(bl.keys()) == {"authors", "subreddits", "title_keywords", "domains"}
|
||||||
|
|
||||||
|
def test_blacklist_author_removes_from_users(self, isolated_config):
|
||||||
|
config_manager.add_user("spammer")
|
||||||
|
assert config_manager.add_blacklist_author("spammer") is True
|
||||||
|
# User should be removed from collection
|
||||||
|
assert all(u["name"] != "spammer" for u in config_manager.get_users())
|
||||||
|
# Blacklist now contains the author
|
||||||
|
assert "spammer" in config_manager.get_blacklist()["authors"]
|
||||||
|
|
||||||
|
def test_blacklist_author_duplicate_case_insensitive(self, isolated_config):
|
||||||
|
config_manager.add_blacklist_author("Bob")
|
||||||
|
assert config_manager.add_blacklist_author("bob") is False
|
||||||
|
|
||||||
|
def test_blacklist_subreddit_removes_from_targets(self, isolated_config):
|
||||||
|
config_manager.add_subreddit("spam_sub")
|
||||||
|
assert config_manager.add_blacklist_subreddit("spam_sub") is True
|
||||||
|
assert all(s["name"] != "spam_sub" for s in config_manager.get_subreddits())
|
||||||
|
assert "spam_sub" in config_manager.get_blacklist()["subreddits"]
|
||||||
|
|
||||||
|
def test_blacklist_keyword_roundtrip(self, isolated_config):
|
||||||
|
assert config_manager.add_blacklist_keyword("buy now") is True
|
||||||
|
assert "buy now" in config_manager.get_blacklist()["title_keywords"]
|
||||||
|
assert config_manager.remove_blacklist_keyword("buy now") is True
|
||||||
|
assert "buy now" not in config_manager.get_blacklist()["title_keywords"]
|
||||||
|
|
||||||
|
def test_blacklist_domain_normalizes_scheme(self, isolated_config):
|
||||||
|
assert config_manager.add_blacklist_domain("https://Malware.com") is True
|
||||||
|
assert "malware.com" in config_manager.get_blacklist()["domains"]
|
||||||
|
# Adding again (different scheme) should be a no-op
|
||||||
|
assert config_manager.add_blacklist_domain("http://malware.com/") is False
|
||||||
42
tests/test_extractors_imgur.py
Normal file
42
tests/test_extractors_imgur.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Tests for the Imgur URL extractor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.extractors.imgur import extract_imgur_url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,expected_url,expected_type",
|
||||||
|
[
|
||||||
|
("https://i.imgur.com/abc123.jpg", "https://i.imgur.com/abc123.jpg", "image"),
|
||||||
|
("https://i.imgur.com/abc123.png", "https://i.imgur.com/abc123.png", "image"),
|
||||||
|
(
|
||||||
|
"https://i.imgur.com/abc123.gifv",
|
||||||
|
"https://i.imgur.com/abc123.mp4",
|
||||||
|
"video",
|
||||||
|
),
|
||||||
|
("https://imgur.com/abc123", "https://i.imgur.com/abc123.jpg", "image"),
|
||||||
|
("https://imgur.com/abc123.jpg", "https://i.imgur.com/abc123.jpg", "image"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extract_imgur_url_variants(url, expected_url, expected_type):
|
||||||
|
result, media_type = extract_imgur_url(url)
|
||||||
|
assert result == expected_url
|
||||||
|
assert media_type == expected_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_returns_none():
|
||||||
|
result, _ = extract_imgur_url("https://imgur.com/a/abc123")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_returns_none():
|
||||||
|
result, _ = extract_imgur_url("https://imgur.com/gallery/abc123")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_with_no_id_returns_none():
|
||||||
|
result, _ = extract_imgur_url("https://imgur.com/")
|
||||||
|
assert result is None
|
||||||
61
tests/test_rate_limit.py
Normal file
61
tests/test_rate_limit.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Tests for the in-memory rate limiter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.web.rate_limit import _reset_buckets, rate_limit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_buckets():
|
||||||
|
_reset_buckets()
|
||||||
|
yield
|
||||||
|
_reset_buckets()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(max_calls: int, window: float) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/limited", dependencies=[pytest.importorskip("fastapi").Depends(rate_limit(max_calls, window))])
|
||||||
|
async def _route():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_within_window():
|
||||||
|
app = _make_app(3, 60.0)
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(3):
|
||||||
|
assert client.get("/limited").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocks_when_exceeded():
|
||||||
|
app = _make_app(2, 60.0)
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/limited").status_code == 200
|
||||||
|
assert client.get("/limited").status_code == 200
|
||||||
|
response = client.get("/limited")
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert response.headers.get("retry-after") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resets_after_window():
|
||||||
|
app = _make_app(1, 0.1)
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/limited").status_code == 200
|
||||||
|
assert client.get("/limited").status_code == 429
|
||||||
|
time.sleep(0.15)
|
||||||
|
assert client.get("/limited").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_dependency_callable():
|
||||||
|
"""Direct unit test on the closure (no FastAPI dependency injection)."""
|
||||||
|
dep = rate_limit(2, 60.0)
|
||||||
|
assert asyncio.iscoroutinefunction(dep)
|
||||||
57
tests/test_router_config.py
Normal file
57
tests/test_router_config.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Tests for /api/subreddits, /api/users, /api/blacklist CRUD endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.web import config_manager
|
||||||
|
from src.web.app import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config_manager, "CONFIG_PATH", tmp_path / "config.yaml")
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubredditsCRUD:
|
||||||
|
def test_post_then_delete(self, client):
|
||||||
|
response = client.post("/api/subreddits", json={"name": "pics", "limit": 50, "sort": "hot"})
|
||||||
|
assert response.status_code in (200, 201)
|
||||||
|
response = client.get("/api/subreddits")
|
||||||
|
assert any(s["name"] == "pics" for s in response.json())
|
||||||
|
response = client.delete("/api/subreddits/pics")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_duplicate_returns_409(self, client):
|
||||||
|
client.post("/api/subreddits", json={"name": "pics"})
|
||||||
|
response = client.post("/api/subreddits", json={"name": "pics"})
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_delete_missing_returns_404(self, client):
|
||||||
|
response = client.delete("/api/subreddits/ghost")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsersCRUD:
|
||||||
|
def test_post_then_delete(self, client):
|
||||||
|
response = client.post("/api/users", json={"name": "alice"})
|
||||||
|
assert response.status_code in (200, 201)
|
||||||
|
response = client.delete("/api/users/alice")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlacklistCRUD:
|
||||||
|
def test_authors_add_remove(self, client):
|
||||||
|
client.post("/api/blacklist/authors", json={"value": "spammer"})
|
||||||
|
response = client.get("/api/blacklist")
|
||||||
|
assert "spammer" in response.json().get("authors", [])
|
||||||
|
response = client.delete("/api/blacklist/authors/spammer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_keywords_add(self, client):
|
||||||
|
response = client.post("/api/blacklist/keywords", json={"value": "buy now"})
|
||||||
|
assert response.status_code in (200, 201)
|
||||||
|
response = client.get("/api/blacklist")
|
||||||
|
assert "buy now" in response.json().get("title_keywords", [])
|
||||||
98
tests/test_router_favorites.py
Normal file
98
tests/test_router_favorites.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Tests for /api/favorites and /api/authors routers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.database import Database, PostRecord
|
||||||
|
from src.web.app import app
|
||||||
|
|
||||||
|
|
||||||
|
def _make_post(pid: str, **overrides) -> PostRecord:
|
||||||
|
defaults = dict(
|
||||||
|
id=pid,
|
||||||
|
subreddit="testsub",
|
||||||
|
author="alice",
|
||||||
|
title=f"Post {pid}",
|
||||||
|
url=f"https://reddit.com/{pid}",
|
||||||
|
media_url=f"https://i.example.com/{pid}.jpg",
|
||||||
|
media_type="image",
|
||||||
|
score=42,
|
||||||
|
created_utc=1_700_000_000.0,
|
||||||
|
downloaded_at=datetime.now(timezone.utc),
|
||||||
|
local_path=f"./downloads/{pid}.jpg",
|
||||||
|
file_hash=f"hash{pid}",
|
||||||
|
permalink=f"/r/testsub/comments/{pid}",
|
||||||
|
source_type="subreddit",
|
||||||
|
flair=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return PostRecord(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_with_data(tmp_path, monkeypatch):
|
||||||
|
db_path = tmp_path / "media.db"
|
||||||
|
monkeypatch.setenv("RMC_DB_PATH", str(db_path))
|
||||||
|
db = Database(db_path=str(db_path))
|
||||||
|
for i, author in enumerate(["alice", "alice", "bob", "carol"]):
|
||||||
|
post = _make_post(f"p{i}", author=author, score=10 + i)
|
||||||
|
db.add_post(post)
|
||||||
|
db.add_favorite("p0")
|
||||||
|
db.add_favorite("p2")
|
||||||
|
yield db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(db_with_data):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorites_authors_paginated_shape(client):
|
||||||
|
response = client.get("/api/favorites/authors")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
# 2 favorited posts → 2 distinct authors (alice, bob)
|
||||||
|
assert set(data["items"]) == {"alice", "bob"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_endpoint_uses_single_query_no_n_plus_1(client, db_with_data):
|
||||||
|
"""get_authors_with_stats should run a single SELECT — no per-author lookup."""
|
||||||
|
seen_queries: list[str] = []
|
||||||
|
real_connect = sqlite3.connect
|
||||||
|
|
||||||
|
def tracking_connect(*args, **kwargs):
|
||||||
|
conn = real_connect(*args, **kwargs)
|
||||||
|
conn.set_trace_callback(lambda sql: seen_queries.append(sql))
|
||||||
|
return conn
|
||||||
|
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
with mock.patch("sqlite3.connect", side_effect=tracking_connect):
|
||||||
|
response = client.get("/api/authors?limit=50")
|
||||||
|
assert response.status_code == 200
|
||||||
|
select_queries = [q for q in seen_queries if q.strip().lower().startswith("select")]
|
||||||
|
# Expect a small constant: get_authors_with_stats + count_authors. The old N+1
|
||||||
|
# path issued one extra SELECT per author (3 authors → 5+ selects).
|
||||||
|
assert len(select_queries) <= 4, f"Too many SELECTs: {select_queries}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_favorite_404_when_missing(client):
|
||||||
|
response = client.delete("/api/favorites/does-not-exist")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_with_favorites_only_filters(client):
|
||||||
|
response = client.get("/api/authors?favorites_only=true")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Only authors with at least one favorited post: alice (p0) + bob (p2)
|
||||||
|
names = {a["author"] for a in data["authors"]}
|
||||||
|
assert names == {"alice", "bob"}
|
||||||
|
assert all(a["is_favorite"] for a in data["authors"])
|
||||||
159
tests/test_router_media.py
Normal file
159
tests/test_router_media.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""Tests for /api/media router (security, pagination, delete, cleanup)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.database import Database, PostRecord
|
||||||
|
from src.web import deps
|
||||||
|
from src.web.app import app
|
||||||
|
|
||||||
|
|
||||||
|
def _make_post(post_id: str, **overrides) -> PostRecord:
|
||||||
|
defaults = dict(
|
||||||
|
id=post_id,
|
||||||
|
subreddit="testsub",
|
||||||
|
author="alice",
|
||||||
|
title=f"Post {post_id}",
|
||||||
|
url=f"https://reddit.com/{post_id}",
|
||||||
|
media_url=f"https://i.example.com/{post_id}.jpg",
|
||||||
|
media_type="image",
|
||||||
|
score=42,
|
||||||
|
created_utc=1_700_000_000.0,
|
||||||
|
downloaded_at=datetime.now(timezone.utc),
|
||||||
|
local_path=None,
|
||||||
|
file_hash="hash" + post_id,
|
||||||
|
permalink=f"/r/testsub/comments/{post_id}",
|
||||||
|
source_type="subreddit",
|
||||||
|
flair=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return PostRecord(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_db_and_dir(tmp_path, monkeypatch):
|
||||||
|
"""Point Database, DOWNLOADS_DIR, and THUMBS_DIR at tmp_path."""
|
||||||
|
downloads = tmp_path / "downloads"
|
||||||
|
downloads.mkdir()
|
||||||
|
thumbs = downloads / ".thumbs"
|
||||||
|
thumbs.mkdir()
|
||||||
|
db_path = tmp_path / "media.db"
|
||||||
|
|
||||||
|
monkeypatch.setattr(deps, "DOWNLOADS_DIR", downloads)
|
||||||
|
monkeypatch.setattr(deps, "THUMBS_DIR", thumbs)
|
||||||
|
# The media router reads these names directly at call time via `from ..deps import …`
|
||||||
|
from src.web.routers import media as media_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(media_router, "DOWNLOADS_DIR", downloads)
|
||||||
|
monkeypatch.setattr(media_router, "THUMBS_DIR", thumbs)
|
||||||
|
|
||||||
|
monkeypatch.setenv("RMC_DB_PATH", str(db_path))
|
||||||
|
# Force a fresh Database() everywhere
|
||||||
|
Database(db_path=str(db_path))
|
||||||
|
yield downloads, db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(isolated_db_and_dir):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPathTraversal:
|
||||||
|
def test_file_relative_path_blocked(self, client):
|
||||||
|
response = client.get("/api/media/file/..%2Fsomefile")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_thumb_relative_path_blocked(self, client):
|
||||||
|
response = client.get("/api/media/thumb/..%2Fsomefile.mp4")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_file_with_null_byte_blocked(self, client):
|
||||||
|
response = client.get("/api/media/file/abc%00def.jpg")
|
||||||
|
# FastAPI may already reject this at the framework level (400 or 404)
|
||||||
|
assert response.status_code in (400, 404, 422)
|
||||||
|
|
||||||
|
def test_file_legitimate_filename_404_for_missing(self, client):
|
||||||
|
response = client.get("/api/media/file/legit.jpg")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubredditsAndAuthors:
|
||||||
|
def test_subreddits_returns_paginated_shape(self, client):
|
||||||
|
response = client.get("/api/media/subreddits")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
|
||||||
|
def test_authors_returns_paginated_shape(self, client):
|
||||||
|
response = client.get("/api/media/authors")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
|
||||||
|
def test_subreddits_respects_limit(self, client, isolated_db_and_dir):
|
||||||
|
_, db_path = isolated_db_and_dir
|
||||||
|
db = Database(db_path=str(db_path))
|
||||||
|
for i in range(5):
|
||||||
|
db.add_post(_make_post(f"p{i}", subreddit=f"sub{i}"))
|
||||||
|
db.mark_downloaded(f"p{i}", f"./downloads/p{i}.jpg", f"hash{i}")
|
||||||
|
response = client.get("/api/media/subreddits?limit=2&offset=0")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 2
|
||||||
|
assert data["total"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlacklistPreview:
|
||||||
|
def test_returns_total_count_key(self, client):
|
||||||
|
response = client.get("/api/media/blacklist-preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "total_count" in data
|
||||||
|
assert "author_count" in data
|
||||||
|
assert "subreddit_count" in data
|
||||||
|
assert "authors" in data
|
||||||
|
assert "subreddits" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteMedia:
|
||||||
|
def test_delete_missing_returns_404(self, client):
|
||||||
|
response = client.delete("/api/media/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_existing_returns_ok(self, client, isolated_db_and_dir):
|
||||||
|
_, db_path = isolated_db_and_dir
|
||||||
|
db = Database(db_path=str(db_path))
|
||||||
|
db.add_post(_make_post("p1"))
|
||||||
|
response = client.delete("/api/media/p1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "deleted" in response.json().get("message", "")
|
||||||
|
assert db.get_post("p1") is None
|
||||||
|
|
||||||
|
def test_delete_with_blacklist_author(self, client, isolated_db_and_dir):
|
||||||
|
_, db_path = isolated_db_and_dir
|
||||||
|
db = Database(db_path=str(db_path))
|
||||||
|
db.add_post(_make_post("p1", author="bob"))
|
||||||
|
response = client.delete("/api/media/p1?blacklist_author=true")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
# blacklisted only included when something was actually blacklisted
|
||||||
|
assert "blacklisted" in body
|
||||||
|
assert any("bob" in b for b in body["blacklisted"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupByType:
|
||||||
|
def test_invalid_type_returns_400(self, client):
|
||||||
|
response = client.post("/api/media/cleanup-by-type?media_type=banana")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_preview_invalid_type_returns_400(self, client):
|
||||||
|
response = client.get("/api/media/cleanup-preview?media_type=banana")
|
||||||
|
assert response.status_code == 400
|
||||||
71
tests/test_router_stats.py
Normal file
71
tests/test_router_stats.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Tests for /api/stats router (cache, async scan)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.web import deps
|
||||||
|
from src.web.app import app
|
||||||
|
from src.web.routers import stats as stats_router
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
downloads = tmp_path / "downloads"
|
||||||
|
downloads.mkdir()
|
||||||
|
monkeypatch.setattr(deps, "DOWNLOADS_DIR", downloads)
|
||||||
|
monkeypatch.setattr(stats_router, "DOWNLOADS_DIR", downloads)
|
||||||
|
stats_router._reset_stats_cache()
|
||||||
|
monkeypatch.setenv("RMC_DB_PATH", str(tmp_path / "media.db"))
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_endpoint_returns_expected_keys(client):
|
||||||
|
response = client.get("/api/stats")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for key in ("file_count", "disk_size_bytes", "disk_size_mb", "disk_size_gb", "disk_free_gb"):
|
||||||
|
assert key in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_counts_files_excluding_json(client, tmp_path):
|
||||||
|
# Create some media + a sidecar
|
||||||
|
downloads = tmp_path / "downloads"
|
||||||
|
(downloads / "a.jpg").write_bytes(b"x" * 100)
|
||||||
|
(downloads / "b.mp4").write_bytes(b"y" * 200)
|
||||||
|
(downloads / "a.jpg.json").write_bytes(b"{}") # sidecar should not count
|
||||||
|
stats_router._reset_stats_cache()
|
||||||
|
response = client.get("/api/stats")
|
||||||
|
data = response.json()
|
||||||
|
assert data["file_count"] == 2
|
||||||
|
assert data["disk_size_bytes"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_cache_serves_stale_within_ttl(client, tmp_path):
|
||||||
|
downloads = tmp_path / "downloads"
|
||||||
|
(downloads / "a.jpg").write_bytes(b"x" * 100)
|
||||||
|
stats_router._reset_stats_cache()
|
||||||
|
|
||||||
|
first = client.get("/api/stats").json()
|
||||||
|
assert first["file_count"] == 1
|
||||||
|
|
||||||
|
# Add another file but don't reset cache; expect cached value
|
||||||
|
(downloads / "b.jpg").write_bytes(b"y" * 50)
|
||||||
|
second = client.get("/api/stats").json()
|
||||||
|
assert second["file_count"] == 1 # cached
|
||||||
|
|
||||||
|
# After cache reset, the new file is reflected
|
||||||
|
stats_router._reset_stats_cache()
|
||||||
|
third = client.get("/api/stats").json()
|
||||||
|
assert third["file_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_downloads_handles_missing_dir(tmp_path):
|
||||||
|
stats_router._reset_stats_cache()
|
||||||
|
missing = tmp_path / "nope"
|
||||||
|
size, count = asyncio.run(stats_router._scan_downloads_cached(missing))
|
||||||
|
assert size == 0
|
||||||
|
assert count == 0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue