diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6923956 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,20 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: [main] + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: pip install -r tests/requirements.txt + - name: Run tests + run: pytest diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e2c5a32 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -v diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4708f1b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +""" +Shared pytest fixtures and stubs. + +google-auth / google-api-python-client are not installed in the test +environment (they are only needed for the optional Drive upload feature). +We stub them out before any test file imports drive_upload so the module +can be imported without errors. +""" + +import sys +import types +import unittest.mock as mock + +# ── Stub google packages ────────────────────────────────────────────────────── +def _stub_google(): + google = types.ModuleType("google") + google.oauth2 = types.ModuleType("google.oauth2") + creds_mod = types.ModuleType("google.oauth2.service_account") + + class FakeCreds: + @classmethod + def from_service_account_info(cls, info, scopes=None): + return cls() + + creds_mod.Credentials = FakeCreds + google.oauth2.service_account = creds_mod + + googleapiclient = types.ModuleType("googleapiclient") + discovery_mod = types.ModuleType("googleapiclient.discovery") + discovery_mod.build = mock.MagicMock(return_value=mock.MagicMock()) + googleapiclient.discovery = discovery_mod + + http_mod = types.ModuleType("googleapiclient.http") + http_mod.MediaFileUpload = mock.MagicMock() + googleapiclient.http = http_mod + + sys.modules.setdefault("google", google) + sys.modules.setdefault("google.oauth2", google.oauth2) + sys.modules.setdefault("google.oauth2.service_account", creds_mod) + sys.modules.setdefault("googleapiclient", googleapiclient) + sys.modules.setdefault("googleapiclient.discovery", discovery_mod) + sys.modules.setdefault("googleapiclient.http", http_mod) + + +_stub_google() diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..56f9cff --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +pytest +pytest-mock +flask +praw diff --git a/tests/test_drive_upload.py b/tests/test_drive_upload.py new file mode 100644 index 0000000..e89806e --- /dev/null +++ b/tests/test_drive_upload.py @@ -0,0 +1,47 @@ +"""Tests for drive_upload.maybe_upload_logs.""" + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from drive_upload import maybe_upload_logs + + +class TestMaybeUploadLogs: + def test_skips_when_no_key_env_var(self, monkeypatch): + monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_KEY", raising=False) + monkeypatch.delenv("GOOGLE_DRIVE_FOLDER_ID", raising=False) + result = maybe_upload_logs("some_file.txt") + assert result == [] + + def test_skips_when_no_folder_id(self, monkeypatch): + monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_KEY", '{"type": "service_account"}') + monkeypatch.delenv("GOOGLE_DRIVE_FOLDER_ID", raising=False) + result = maybe_upload_logs("some_file.txt") + assert result == [] + + def test_skips_when_key_set_but_folder_missing(self, monkeypatch): + monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_KEY", '{"type": "service_account"}') + monkeypatch.setenv("GOOGLE_DRIVE_FOLDER_ID", "") + result = maybe_upload_logs("some_file.txt") + assert result == [] + + def test_returns_empty_list_on_upload_exception(self, monkeypatch): + monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_KEY", '{"type": "service_account"}') + monkeypatch.setenv("GOOGLE_DRIVE_FOLDER_ID", "folder123") + with patch("drive_upload.upload_logs", side_effect=Exception("Network error")): + result = maybe_upload_logs("some_file.txt") + assert result == [] + + def test_calls_upload_logs_when_credentials_present(self, monkeypatch, tmp_path): + monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_KEY", '{"type": "service_account"}') + monkeypatch.setenv("GOOGLE_DRIVE_FOLDER_ID", "folder123") + fake_result = [{"name": "deleted_comments.txt", "url": "https://drive.google.com/file/d/abc/view"}] + with patch("drive_upload.upload_logs", return_value=fake_result) as mock_upload: + result = maybe_upload_logs("deleted_comments.txt") + mock_upload.assert_called_once_with("folder123", "deleted_comments.txt") + assert result == fake_result diff --git a/tests/test_web_app.py b/tests/test_web_app.py new file mode 100644 index 0000000..1d1fe9f --- /dev/null +++ b/tests/test_web_app.py @@ -0,0 +1,138 @@ +"""Tests for the Flask web application (web/app.py).""" + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure repo root on path so drive_upload is importable from web/app.py +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# web/app.py does sys.path manipulation itself, but we need the module importable +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web")) + +from app import app as flask_app + + +@pytest.fixture +def client(): + flask_app.config["TESTING"] = True + flask_app.config["SECRET_KEY"] = "test-secret" + with flask_app.test_client() as c: + yield c + + +@pytest.fixture +def authed_client(client): + """A test client with a valid-looking session.""" + with flask_app.test_request_context(): + with client.session_transaction() as sess: + sess["username"] = "testuser" + sess["client_id"] = "cid" + sess["client_secret"] = "csecret" + sess["password"] = "pw" + return client + + +# ── / (index) ───────────────────────────────────────────────────────────────── + +class TestIndex: + def test_shows_login_page_without_session(self, client): + resp = client.get("/") + assert resp.status_code == 200 + + def test_redirects_to_dashboard_with_session(self, authed_client): + resp = authed_client.get("/") + assert resp.status_code == 302 + assert "/dashboard" in resp.headers["Location"] + + +# ── /logout ─────────────────────────────────────────────────────────────────── + +class TestLogout: + def test_clears_session_and_redirects(self, authed_client): + resp = authed_client.get("/logout") + assert resp.status_code == 302 + # After logout the session should be gone → / shows login again + resp2 = authed_client.get("/") + assert resp2.status_code == 200 + + def test_logout_without_session_still_redirects(self, client): + resp = client.get("/logout") + assert resp.status_code == 302 + + +# ── /dashboard ──────────────────────────────────────────────────────────────── + +class TestDashboard: + def test_redirects_to_index_without_session(self, client): + resp = client.get("/dashboard") + assert resp.status_code == 302 + assert "/" in resp.headers["Location"] + + def test_accessible_with_session(self, authed_client): + resp = authed_client.get("/dashboard") + assert resp.status_code == 200 + + +# ── /api/items ──────────────────────────────────────────────────────────────── + +class TestApiItems: + def test_returns_401_without_session(self, client): + resp = client.get("/api/items") + assert resp.status_code == 401 + assert resp.get_json()["error"] == "Not authenticated" + + def test_returns_items_with_session(self, authed_client): + mock_comment = MagicMock() + mock_comment.id = "abc" + mock_comment.body = "hello world" + mock_comment.score = 5 + mock_comment.subreddit = MagicMock(__str__=lambda s: "python") + mock_comment.created_utc = 1700000000.0 + mock_comment.permalink = "/r/python/comments/abc/hello" + + mock_reddit = MagicMock() + mock_reddit.redditor.return_value.comments.new.return_value = [mock_comment] + mock_reddit.redditor.return_value.submissions.new.return_value = [] + + with patch("app.praw.Reddit", return_value=mock_reddit): + resp = authed_client.get("/api/items") + + assert resp.status_code == 200 + data = resp.get_json() + assert len(data["comments"]) == 1 + assert data["comments"][0]["id"] == "abc" + assert data["posts"] == [] + + +# ── /api/delete ─────────────────────────────────────────────────────────────── + +class TestApiDelete: + def test_returns_401_without_session(self, client): + resp = client.post("/api/delete", json={"comment_ids": [], "post_ids": []}) + assert resp.status_code == 401 + + def test_deletes_comment(self, authed_client, tmp_path, monkeypatch): + monkeypatch.setattr("app.DELETED_COMMENTS_FILE", str(tmp_path / "deleted_comments.txt")) + monkeypatch.setattr("app.DELETED_POSTS_FILE", str(tmp_path / "deleted_posts.txt")) + + mock_comment = MagicMock() + mock_comment.created_utc = 1700000000.0 + mock_comment.score = -1 + mock_comment.body = "bad comment" + + mock_reddit = MagicMock() + mock_reddit.comment.return_value = mock_comment + + with patch("app.praw.Reddit", return_value=mock_reddit), \ + patch("app.maybe_upload_logs", return_value=[]): + resp = authed_client.post("/api/delete", json={"comment_ids": ["abc"], "post_ids": []}) + + assert resp.status_code == 200 + data = resp.get_json() + assert data["deleted_comments"] == 1 + assert data["deleted_posts"] == 0 + mock_comment.edit.assert_called_once_with(".") + mock_comment.delete.assert_called_once() diff --git a/tests/test_weekly_cleanup.py b/tests/test_weekly_cleanup.py new file mode 100644 index 0000000..2cd2ede --- /dev/null +++ b/tests/test_weekly_cleanup.py @@ -0,0 +1,107 @@ +"""Tests for weekly_cleanup.py — _should_delete and _load_credentials.""" + +import os +import sys +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +# Ensure repo root is importable +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from weekly_cleanup import AGE_THRESHOLD_DAYS, _load_credentials, _should_delete + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _item(score: int, age_days: int) -> SimpleNamespace: + """Build a fake PRAW item with the given score and age.""" + now_utc = datetime.now(timezone.utc) + created_utc = now_utc.timestamp() - age_days * 86400 + return SimpleNamespace(score=score, created_utc=created_utc) + + +# ── _should_delete ──────────────────────────────────────────────────────────── + +class TestShouldDelete: + def test_score_zero_is_deleted(self): + assert _should_delete(_item(score=0, age_days=0)) is True + + def test_score_negative_is_deleted(self): + assert _should_delete(_item(score=-5, age_days=0)) is True + + def test_score_one_old_enough_is_deleted(self): + assert _should_delete(_item(score=1, age_days=AGE_THRESHOLD_DAYS + 1)) is True + + def test_score_one_too_new_is_kept(self): + assert _should_delete(_item(score=1, age_days=AGE_THRESHOLD_DAYS - 1)) is False + + def test_score_one_exactly_threshold_is_kept(self): + # boundary: age == threshold (not strictly greater) → keep + assert _should_delete(_item(score=1, age_days=AGE_THRESHOLD_DAYS)) is False + + def test_score_two_is_always_kept(self): + assert _should_delete(_item(score=2, age_days=365)) is False + + def test_high_score_old_is_kept(self): + assert _should_delete(_item(score=100, age_days=1000)) is False + + +# ── _load_credentials ───────────────────────────────────────────────────────── + +class TestLoadCredentials: + _ENV_VARS = { + "REDDIT_CLIENT_ID": "id123", + "REDDIT_CLIENT_SECRET": "secret456", + "REDDIT_USERNAME": "testuser", + "REDDIT_PASSWORD": "hunter2", + } + + def test_reads_from_env_vars(self): + with patch.dict(os.environ, self._ENV_VARS, clear=False): + result = _load_credentials() + assert result == ("id123", "secret456", "testuser", "hunter2") + + def test_reads_from_credentials_file(self, tmp_path, monkeypatch): + cred_file = tmp_path / "Credentials.txt" + cred_file.write_text("fileid\nfilesecret\nfileuser\nfilepass\n", encoding="utf-8") + + # Clear env vars so file fallback is triggered + monkeypatch.delenv("REDDIT_CLIENT_ID", raising=False) + monkeypatch.delenv("REDDIT_CLIENT_SECRET", raising=False) + monkeypatch.delenv("REDDIT_USERNAME", raising=False) + monkeypatch.delenv("REDDIT_PASSWORD", raising=False) + + with patch("weekly_cleanup.os.path.join", return_value=str(cred_file)), \ + patch("weekly_cleanup.os.path.exists", return_value=True): + result = _load_credentials() + + assert result == ("fileid", "filesecret", "fileuser", "filepass") + + def test_raises_when_nothing_configured(self, monkeypatch): + monkeypatch.delenv("REDDIT_CLIENT_ID", raising=False) + monkeypatch.delenv("REDDIT_CLIENT_SECRET", raising=False) + monkeypatch.delenv("REDDIT_USERNAME", raising=False) + monkeypatch.delenv("REDDIT_PASSWORD", raising=False) + + with patch("weekly_cleanup.os.path.exists", return_value=False): + with pytest.raises(RuntimeError, match="Reddit credentials not found"): + _load_credentials() + + def test_partial_env_vars_falls_through_to_file(self, tmp_path, monkeypatch): + """Only some env vars set → should not use env, must fall back to file.""" + monkeypatch.setenv("REDDIT_CLIENT_ID", "partial") + monkeypatch.delenv("REDDIT_CLIENT_SECRET", raising=False) + monkeypatch.delenv("REDDIT_USERNAME", raising=False) + monkeypatch.delenv("REDDIT_PASSWORD", raising=False) + + cred_file = tmp_path / "Credentials.txt" + cred_file.write_text("a\nb\nc\nd\n", encoding="utf-8") + + with patch("weekly_cleanup.os.path.join", return_value=str(cred_file)), \ + patch("weekly_cleanup.os.path.exists", return_value=True): + result = _load_credentials() + + assert result == ("a", "b", "c", "d")