|
| 1 | +"""Tests for the /trending/{period}.json FastAPI endpoint (internal API).""" |
| 2 | + |
| 3 | +import os |
| 4 | +from unittest.mock import patch |
| 5 | + |
| 6 | +import pytest |
| 7 | + |
| 8 | +MOCK_WORKS = [ |
| 9 | + {"key": "/works/OL1W", "title": "Popular Book 1", "author_name": ["Author A"]}, |
| 10 | + {"key": "/works/OL2W", "title": "Popular Book 2", "author_name": ["Author B"]}, |
| 11 | +] |
| 12 | + |
| 13 | + |
| 14 | +@pytest.fixture |
| 15 | +def mock_get_trending_books(): |
| 16 | + with patch("openlibrary.fastapi.internal.api.get_trending_books", return_value=MOCK_WORKS) as mock: |
| 17 | + yield mock |
| 18 | + |
| 19 | + |
| 20 | +class TestTrendingBooksEndpoint: |
| 21 | + """Tests for GET /trending/{period}.json.""" |
| 22 | + |
| 23 | + @pytest.mark.parametrize( |
| 24 | + ("period", "expected_days"), |
| 25 | + [ |
| 26 | + ("daily", 1), |
| 27 | + ("weekly", 7), |
| 28 | + ("monthly", 30), |
| 29 | + ("yearly", 365), |
| 30 | + ("forever", None), |
| 31 | + ("now", 0), |
| 32 | + ], |
| 33 | + ) |
| 34 | + def test_valid_periods(self, fastapi_client, mock_get_trending_books, period, expected_days): |
| 35 | + response = fastapi_client.get(f"/trending/{period}.json") |
| 36 | + response.raise_for_status() |
| 37 | + data = response.json() |
| 38 | + assert data["query"] == f"/trending/{period}" |
| 39 | + # When expected_days is None (forever), 'days' shouldn't be in the response because of response_model_exclude_none=True |
| 40 | + if expected_days is None: |
| 41 | + assert "days" not in data |
| 42 | + else: |
| 43 | + assert data["days"] == expected_days |
| 44 | + assert isinstance(data["works"], list) |
| 45 | + |
| 46 | + def test_empty_works_list(self, fastapi_client, mock_get_trending_books): |
| 47 | + mock_get_trending_books.return_value = [] |
| 48 | + response = fastapi_client.get("/trending/now.json") |
| 49 | + response.raise_for_status() |
| 50 | + assert response.json()["works"] == [] |
| 51 | + |
| 52 | + @pytest.mark.parametrize( |
| 53 | + ("query_string", "expected_kwargs"), |
| 54 | + [ |
| 55 | + ("hours=12", {"since_hours": 12}), |
| 56 | + ("minimum=3", {"minimum": 3}), |
| 57 | + ("fields=key,title", {"fields": ["key", "title"]}), |
| 58 | + ("sort_by_count=false", {"sort_by_count": False}), |
| 59 | + ("page=2&limit=10", {"page": 2, "limit": 10}), |
| 60 | + ("fields=key,%20title", {"fields": ["key", "title"]}), |
| 61 | + ("", {"sort_by_count": True, "fields": None}), |
| 62 | + ( |
| 63 | + "page=3&limit=50&hours=6&sort_by_count=false&minimum=10&fields=key,title", |
| 64 | + {"page": 3, "limit": 50, "since_hours": 6, "sort_by_count": False, "minimum": 10, "fields": ["key", "title"]}, |
| 65 | + ), |
| 66 | + ( |
| 67 | + "fields=key,title,subtitle,author_name,author_key,cover_i,cover_edition_key", |
| 68 | + {"fields": ["key", "title", "subtitle", "author_name", "author_key", "cover_i", "cover_edition_key"]}, |
| 69 | + ), |
| 70 | + ( |
| 71 | + "fields=key,title,ia,ia_collection", |
| 72 | + {"fields": ["key", "title", "ia", "ia_collection"]}, |
| 73 | + ), |
| 74 | + ], |
| 75 | + ) |
| 76 | + def test_query_params_forwarded(self, fastapi_client, mock_get_trending_books, query_string, expected_kwargs): |
| 77 | + response = fastapi_client.get(f"/trending/daily.json?{query_string}") |
| 78 | + response.raise_for_status() |
| 79 | + for k, v in expected_kwargs.items(): |
| 80 | + assert mock_get_trending_books.call_args.kwargs[k] == v |
| 81 | + |
| 82 | + @pytest.mark.parametrize( |
| 83 | + ("url", "description"), |
| 84 | + [ |
| 85 | + ("/trending/badperiod.json", "invalid period"), |
| 86 | + ("/trending/daily.json?sort_by_count=maybe", "invalid boolean"), |
| 87 | + ("/trending/daily.json?page=0", "page zero"), |
| 88 | + ("/trending/daily.json?limit=1001", "limit exceeds max"), |
| 89 | + ], |
| 90 | + ) |
| 91 | + def test_validation_errors(self, fastapi_client, mock_get_trending_books, url, description): |
| 92 | + assert fastapi_client.get(url).status_code == 422 |
| 93 | + mock_get_trending_books.assert_not_called() |
| 94 | + |
| 95 | + @pytest.mark.parametrize( |
| 96 | + ("hours_param", "expected_hours"), |
| 97 | + [("6", 6), (None, 0)], |
| 98 | + ) |
| 99 | + def test_hours_in_response(self, fastapi_client, mock_get_trending_books, hours_param, expected_hours): |
| 100 | + url = "/trending/daily.json" |
| 101 | + if hours_param: |
| 102 | + url += f"?hours={hours_param}" |
| 103 | + response = fastapi_client.get(url) |
| 104 | + response.raise_for_status() |
| 105 | + assert response.json()["hours"] == expected_hours |
| 106 | + |
| 107 | + def test_limit_defaults_to_100(self, fastapi_client, mock_get_trending_books): |
| 108 | + response = fastapi_client.get("/trending/daily.json") |
| 109 | + response.raise_for_status() |
| 110 | + assert mock_get_trending_books.call_args.kwargs["limit"] == 100 |
| 111 | + |
| 112 | + def test_trending_period_literal_matches_since_days(self): |
| 113 | + from typing import get_args |
| 114 | + |
| 115 | + from openlibrary.fastapi.internal.api import TrendingPeriod |
| 116 | + from openlibrary.views.loanstats import SINCE_DAYS |
| 117 | + |
| 118 | + literal_keys = set(get_args(TrendingPeriod)) |
| 119 | + since_days_keys = set(SINCE_DAYS.keys()) |
| 120 | + |
| 121 | + assert literal_keys == since_days_keys, ( |
| 122 | + "TrendingPeriod Literal must stay in sync with views.loanstats.SINCE_DAYS keys. " |
| 123 | + f"Missing in Literal: {since_days_keys - literal_keys}. " |
| 124 | + f"Extra in Literal: {literal_keys - since_days_keys}." |
| 125 | + ) |
| 126 | + |
| 127 | + def test_works_content_in_response(self, fastapi_client, mock_get_trending_books): |
| 128 | + response = fastapi_client.get("/trending/daily.json") |
| 129 | + response.raise_for_status() |
| 130 | + works = response.json()["works"] |
| 131 | + assert len(works) == 2 |
| 132 | + assert works[0]["key"] == "/works/OL1W" |
| 133 | + assert works[1]["key"] == "/works/OL2W" |
| 134 | + |
| 135 | + |
| 136 | +@pytest.mark.skipif( |
| 137 | + os.getenv("LOCAL_DEV") is None, |
| 138 | + reason="Trending endpoint is excluded from OpenAPI schema outside LOCAL_DEV (include_in_schema=False)", |
| 139 | +) |
| 140 | +class TestOpenAPIDocumentation: |
| 141 | + """Verify the trending endpoint is correctly described in the OpenAPI schema.""" |
| 142 | + |
| 143 | + def test_openapi_contains_trending_endpoint(self, fastapi_client): |
| 144 | + response = fastapi_client.get("/openapi.json") |
| 145 | + assert response.status_code == 200 |
| 146 | + paths = response.json()["paths"] |
| 147 | + assert "/trending/{period}.json" in paths |
| 148 | + |
| 149 | + def test_openapi_trending_params_have_descriptions(self, fastapi_client): |
| 150 | + response = fastapi_client.get("/openapi.json") |
| 151 | + assert response.status_code == 200 |
| 152 | + params = response.json()["paths"]["/trending/{period}.json"]["get"]["parameters"] |
| 153 | + by_name = {p["name"]: p for p in params} |
| 154 | + assert by_name["period"]["description"] |
| 155 | + assert by_name["hours"]["description"] |
0 commit comments