diff --git a/src/database.py b/src/database.py index 1971ac6..fce2619 100644 --- a/src/database.py +++ b/src/database.py @@ -10,6 +10,11 @@ from pathlib import Path 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 class PostRecord: id: str @@ -32,8 +37,8 @@ class PostRecord: class Database: """SQLite database wrapper for tracking downloaded posts.""" - def __init__(self, db_path: str = DEFAULT_DB_PATH): - self.db_path = Path(db_path) + def __init__(self, db_path: str | None = None): + self.db_path = Path(db_path if db_path is not None else _resolve_db_path()) self._init_db() def _init_db(self): diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..15c290a --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..1818245 --- /dev/null +++ b/tests/test_config_manager.py @@ -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 diff --git a/tests/test_extractors_imgur.py b/tests/test_extractors_imgur.py new file mode 100644 index 0000000..6542869 --- /dev/null +++ b/tests/test_extractors_imgur.py @@ -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 diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..0d37a4b --- /dev/null +++ b/tests/test_rate_limit.py @@ -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) diff --git a/tests/test_router_config.py b/tests/test_router_config.py new file mode 100644 index 0000000..4d3399a --- /dev/null +++ b/tests/test_router_config.py @@ -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", []) diff --git a/tests/test_router_favorites.py b/tests/test_router_favorites.py new file mode 100644 index 0000000..c670282 --- /dev/null +++ b/tests/test_router_favorites.py @@ -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"]) diff --git a/tests/test_router_media.py b/tests/test_router_media.py new file mode 100644 index 0000000..9d37390 --- /dev/null +++ b/tests/test_router_media.py @@ -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 diff --git a/tests/test_router_stats.py b/tests/test_router_stats.py new file mode 100644 index 0000000..90e5b8b --- /dev/null +++ b/tests/test_router_stats.py @@ -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