Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -v
Empty file added tests/__init__.py
Empty file.
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pytest
pytest-mock
flask
praw
47 changes: 47 additions & 0 deletions tests/test_drive_upload.py
Original file line number Diff line number Diff line change
@@ -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
138 changes: 138 additions & 0 deletions tests/test_web_app.py
Original file line number Diff line number Diff line change
@@ -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()
107 changes: 107 additions & 0 deletions tests/test_weekly_cleanup.py
Original file line number Diff line number Diff line change
@@ -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")
Loading