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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dist/
.eggs/

# Testing and coverage
tests/
.coverage
.coverage.*
htmlcov/
Expand Down
33 changes: 29 additions & 4 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
name: Build and Push Docker image
name: Test, Build, and Push Docker image

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version

- name: Install dependencies
run: uv sync --frozen

- name: Run tests
run: uv run pytest

build-push:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
contents: read
packages: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ The API provides interactive documentation at:
- `n` (integer, default `10`) - Number of unread items to return
- `category` (string) - FreshRSS category label to scope unread items, e.g. `/freshrss/unread?category=Tech`

## Testing

Run the test suite with:

```bash
uv sync
uv run pytest
```

GitHub Actions runs these tests for pull requests and before publishing the Docker image from `main`.

Example of services.yaml:

```
Expand Down
23 changes: 17 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ def get_greader_token():
"Email": FRESHRSS_USERNAME,
"Passwd": FRESHRSS_PASSWORD,
}
res = requests.post(login_url, data=payload, timeout=10)
try:
res = requests.post(login_url, data=payload, timeout=10)
except requests.RequestException as exc:
logging.warning("FreshRSS login request failed: %s", exc)
raise HTTPException(status_code=502, detail="FreshRSS login request failed") from exc
if res.status_code != 200:
logging.warning("FreshRSS login failed (status %d): %s", res.status_code, res.text)
raise HTTPException(status_code=502, detail=f"FreshRSS login failed with status {res.status_code}")
Expand Down Expand Up @@ -77,12 +81,17 @@ def freshrss_unread(
"output": "json",
"n": n,
}
stream_id = f"user/-/label/{category}" if category else "user/-/state/com.google/reading-list"
category_label = category if isinstance(category, str) and category else None
stream_id = f"user/-/label/{category_label}" if category_label else "user/-/state/com.google/reading-list"
# Using the same host as before but with the right endpoint
url = f"{FRESHRSS_HOST}/api/greader.php/reader/api/0/stream/contents/{stream_id}"
r = requests.get(url, headers=headers, params=params, timeout=10)
r.raise_for_status()
raw = r.json()
try:
r = requests.get(url, headers=headers, params=params, timeout=10)
r.raise_for_status()
raw = r.json()
except requests.RequestException as exc:
logging.warning("FreshRSS unread request failed: %s", exc)
raise HTTPException(status_code=502, detail="FreshRSS unread request failed") from exc
items = []

now = datetime.now(timezone.utc)
Expand All @@ -93,12 +102,14 @@ def freshrss_unread(
continue
published_dt = datetime.fromtimestamp(published_ts, timezone.utc)
published_str = humanize.naturaltime(now - published_dt)
alternates = entry.get("alternate") or []
item_url = alternates[0].get("href", "") if alternates else ""
items.append(
{
"title": entry.get("title"),
"feed": entry.get("origin", {}).get("title"),
"published": entry.get("published"),
"url": entry.get("alternate", [{}])[0].get("href", ""),
"url": item_url,
"display": f"{entry.get('title')} • {published_str}",
}
)
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ dependencies = [
]

[dependency-groups]
dev = []
dev = [
"pytest>=8.0.0",
]
206 changes: 206 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import importlib
import sys
from pathlib import Path

import pytest
from fastapi import HTTPException
import requests

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))


REQUIRED_ENV = {
"FRESHRSS_HOST": "https://freshrss.example.test",
"FRESHRSS_USER": "reader",
"FRESHRSS_PASS": "secret",
}


class FakeResponse:
def __init__(self, status_code=200, text="", payload=None, raise_error=None):
self.status_code = status_code
self.text = text
self._payload = payload if payload is not None else {}
self._raise_error = raise_error

def json(self):
return self._payload

def raise_for_status(self):
if self._raise_error:
raise self._raise_error


def import_app(monkeypatch, env=None):
for name in REQUIRED_ENV:
monkeypatch.delenv(name, raising=False)
for name, value in (env or REQUIRED_ENV).items():
monkeypatch.setenv(name, value)
sys.modules.pop("main", None)
return importlib.import_module("main")


def test_import_requires_freshrss_environment(monkeypatch):
for name in REQUIRED_ENV:
monkeypatch.delenv(name, raising=False)
sys.modules.pop("main", None)

with pytest.raises(RuntimeError) as excinfo:
importlib.import_module("main")

message = str(excinfo.value)
assert "FRESHRSS_HOST" in message
assert "FRESHRSS_USER" in message
assert "FRESHRSS_PASS" in message


def test_health_endpoint_returns_ok(monkeypatch):
main = import_app(monkeypatch)

assert main.health() == {"status": "ok"}
assert any(route.path == "/health" for route in main.app.routes)


def test_get_greader_token_logs_in_once_and_caches_token(monkeypatch):
main = import_app(monkeypatch)
calls = []

def fake_post(url, data, timeout):
calls.append({"url": url, "data": data, "timeout": timeout})
return FakeResponse(text="SID=ignored\nAuth= cached-token \n")

monkeypatch.setattr(main.requests, "post", fake_post)

assert main.get_greader_token() == "cached-token"
assert main.get_greader_token() == "cached-token"
assert calls == [
{
"url": "https://freshrss.example.test/api/greader.php/accounts/ClientLogin",
"data": {"Email": "reader", "Passwd": "secret"},
"timeout": 10,
}
]


def test_get_greader_token_rejects_failed_login(monkeypatch):
main = import_app(monkeypatch)
monkeypatch.setattr(main.requests, "post", lambda *args, **kwargs: FakeResponse(status_code=403, text="nope"))

with pytest.raises(HTTPException) as excinfo:
main.get_greader_token()

assert excinfo.value.status_code == 502
assert "FreshRSS login failed with status 403" == excinfo.value.detail


def test_get_greader_token_rejects_missing_auth_line(monkeypatch):
main = import_app(monkeypatch)
monkeypatch.setattr(main.requests, "post", lambda *args, **kwargs: FakeResponse(text="SID=only"))

with pytest.raises(HTTPException) as excinfo:
main.get_greader_token()

assert excinfo.value.status_code == 502
assert excinfo.value.detail == "Auth token not found in FreshRSS response"


def test_get_greader_token_wraps_request_failures(monkeypatch):
main = import_app(monkeypatch)

def fake_post(*args, **kwargs):
raise requests.Timeout("slow upstream")

monkeypatch.setattr(main.requests, "post", fake_post)

with pytest.raises(HTTPException) as excinfo:
main.get_greader_token()

assert excinfo.value.status_code == 502
assert excinfo.value.detail == "FreshRSS login request failed"


def test_freshrss_unread_fetches_reading_list_and_shapes_items(monkeypatch):
main = import_app(monkeypatch)
monkeypatch.setattr(main, "get_greader_token", lambda: "token-123")
captured = {}

def fake_get(url, headers, params, timeout):
captured.update({"url": url, "headers": headers, "params": params, "timeout": timeout})
return FakeResponse(
payload={
"items": [
{
"title": "Release shipped",
"origin": {"title": "GitHub Releases"},
"published": 1700000000,
"alternate": [{"href": "https://example.test/release"}],
},
{
"title": "Missing timestamp is ignored",
"origin": {"title": "Bad Feed"},
},
]
}
)

monkeypatch.setattr(main.requests, "get", fake_get)

result = main.freshrss_unread(n=5)

assert captured == {
"url": "https://freshrss.example.test/api/greader.php/reader/api/0/stream/contents/user/-/state/com.google/reading-list",
"headers": {"Authorization": "GoogleLogin auth=token-123"},
"params": {"xt": "user/-/state/com.google/read", "output": "json", "n": 5},
"timeout": 10,
}
assert len(result) == 1
assert result[0]["title"] == "Release shipped"
assert result[0]["feed"] == "GitHub Releases"
assert result[0]["published"] == 1700000000
assert result[0]["url"] == "https://example.test/release"
assert result[0]["display"].startswith("Release shipped • ")


def test_freshrss_unread_scopes_to_category_and_handles_missing_url(monkeypatch):
main = import_app(monkeypatch)
monkeypatch.setattr(main, "get_greader_token", lambda: "token-123")
captured = {}

def fake_get(url, headers, params, timeout):
captured.update({"url": url, "params": params})
return FakeResponse(
payload={
"items": [
{
"title": "Category item",
"origin": {},
"published": 1700000000,
"alternate": [],
}
]
}
)

monkeypatch.setattr(main.requests, "get", fake_get)

result = main.freshrss_unread(n=3, category="Tech")

assert captured["url"].endswith("/stream/contents/user/-/label/Tech")
assert captured["params"]["n"] == 3
assert result[0]["feed"] is None
assert result[0]["url"] == ""


def test_freshrss_unread_wraps_upstream_http_errors(monkeypatch):
main = import_app(monkeypatch)
monkeypatch.setattr(main, "get_greader_token", lambda: "token-123")
upstream_error = requests.HTTPError("500 Server Error")
monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(raise_error=upstream_error))

with pytest.raises(HTTPException) as excinfo:
main.freshrss_unread()

assert excinfo.value.status_code == 502
assert excinfo.value.detail == "FreshRSS unread request failed"
Loading
Loading