|
|
"""Tests for server cache control middleware""" |
|
|
|
|
|
import pytest |
|
|
from aiohttp import web |
|
|
from aiohttp.test_utils import make_mocked_request |
|
|
from typing import Dict, Any |
|
|
|
|
|
from middleware.cache_middleware import cache_control, ONE_HOUR, ONE_DAY, IMG_EXTENSIONS |
|
|
|
|
|
pytestmark = pytest.mark.asyncio |
|
|
|
|
|
|
|
|
CACHE_SCENARIOS = [ |
|
|
|
|
|
{ |
|
|
"name": "image_200_status", |
|
|
"path": "/test.jpg", |
|
|
"status": 200, |
|
|
"expected_cache": f"public, max-age={ONE_DAY}", |
|
|
"should_have_header": True, |
|
|
}, |
|
|
{ |
|
|
"name": "image_404_status", |
|
|
"path": "/missing.jpg", |
|
|
"status": 404, |
|
|
"expected_cache": f"public, max-age={ONE_HOUR}", |
|
|
"should_have_header": True, |
|
|
}, |
|
|
|
|
|
{ |
|
|
"name": "js_no_cache", |
|
|
"path": "/script.js", |
|
|
"status": 200, |
|
|
"expected_cache": "no-cache", |
|
|
"should_have_header": True, |
|
|
}, |
|
|
{ |
|
|
"name": "css_no_cache", |
|
|
"path": "/styles.css", |
|
|
"status": 200, |
|
|
"expected_cache": "no-cache", |
|
|
"should_have_header": True, |
|
|
}, |
|
|
{ |
|
|
"name": "index_json_no_cache", |
|
|
"path": "/api/index.json", |
|
|
"status": 200, |
|
|
"expected_cache": "no-cache", |
|
|
"should_have_header": True, |
|
|
}, |
|
|
|
|
|
{ |
|
|
"name": "html_no_header", |
|
|
"path": "/index.html", |
|
|
"status": 200, |
|
|
"expected_cache": None, |
|
|
"should_have_header": False, |
|
|
}, |
|
|
{ |
|
|
"name": "txt_no_header", |
|
|
"path": "/data.txt", |
|
|
"status": 200, |
|
|
"expected_cache": None, |
|
|
"should_have_header": False, |
|
|
}, |
|
|
{ |
|
|
"name": "api_endpoint_no_header", |
|
|
"path": "/api/endpoint", |
|
|
"status": 200, |
|
|
"expected_cache": None, |
|
|
"should_have_header": False, |
|
|
}, |
|
|
{ |
|
|
"name": "pdf_no_header", |
|
|
"path": "/file.pdf", |
|
|
"status": 200, |
|
|
"expected_cache": None, |
|
|
"should_have_header": False, |
|
|
}, |
|
|
] |
|
|
|
|
|
|
|
|
IMAGE_STATUS_SCENARIOS = [ |
|
|
|
|
|
{"status": 200, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
{"status": 201, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
{"status": 202, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
{"status": 204, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
{"status": 206, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
|
|
|
{"status": 301, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
{"status": 308, "expected": f"public, max-age={ONE_DAY}"}, |
|
|
|
|
|
{"status": 302, "expected": "no-cache"}, |
|
|
{"status": 303, "expected": "no-cache"}, |
|
|
{"status": 307, "expected": "no-cache"}, |
|
|
|
|
|
{"status": 404, "expected": f"public, max-age={ONE_HOUR}"}, |
|
|
] |
|
|
|
|
|
|
|
|
CASE_SENSITIVITY_PATHS = ["/image.JPG", "/photo.PNG", "/pic.JpEg"] |
|
|
|
|
|
|
|
|
EDGE_CASE_PATHS = [ |
|
|
{ |
|
|
"name": "query_strings_ignored", |
|
|
"path": "/image.jpg?v=123&size=large", |
|
|
"expected": f"public, max-age={ONE_DAY}", |
|
|
}, |
|
|
{ |
|
|
"name": "multiple_dots_in_path", |
|
|
"path": "/image.min.jpg", |
|
|
"expected": f"public, max-age={ONE_DAY}", |
|
|
}, |
|
|
{ |
|
|
"name": "nested_paths_with_images", |
|
|
"path": "/static/images/photo.jpg", |
|
|
"expected": f"public, max-age={ONE_DAY}", |
|
|
}, |
|
|
] |
|
|
|
|
|
|
|
|
class TestCacheControl: |
|
|
"""Test cache control middleware functionality""" |
|
|
|
|
|
@pytest.fixture |
|
|
def status_handler_factory(self): |
|
|
"""Create a factory for handlers that return specific status codes""" |
|
|
|
|
|
def factory(status: int, headers: Dict[str, str] = None): |
|
|
async def handler(request): |
|
|
return web.Response(status=status, headers=headers or {}) |
|
|
|
|
|
return handler |
|
|
|
|
|
return factory |
|
|
|
|
|
@pytest.fixture |
|
|
def mock_handler(self, status_handler_factory): |
|
|
"""Create a mock handler that returns a response with 200 status""" |
|
|
return status_handler_factory(200) |
|
|
|
|
|
@pytest.fixture |
|
|
def handler_with_existing_cache(self, status_handler_factory): |
|
|
"""Create a handler that returns response with existing Cache-Control header""" |
|
|
return status_handler_factory(200, {"Cache-Control": "max-age=3600"}) |
|
|
|
|
|
async def assert_cache_header( |
|
|
self, |
|
|
response: web.Response, |
|
|
expected_cache: str = None, |
|
|
should_have_header: bool = True, |
|
|
): |
|
|
"""Helper to assert cache control headers""" |
|
|
if should_have_header: |
|
|
assert "Cache-Control" in response.headers |
|
|
if expected_cache: |
|
|
assert response.headers["Cache-Control"] == expected_cache |
|
|
else: |
|
|
assert "Cache-Control" not in response.headers |
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("scenario", CACHE_SCENARIOS, ids=lambda x: x["name"]) |
|
|
async def test_cache_control_scenarios( |
|
|
self, scenario: Dict[str, Any], status_handler_factory |
|
|
): |
|
|
"""Test various cache control scenarios""" |
|
|
handler = status_handler_factory(scenario["status"]) |
|
|
request = make_mocked_request("GET", scenario["path"]) |
|
|
response = await cache_control(request, handler) |
|
|
|
|
|
assert response.status == scenario["status"] |
|
|
await self.assert_cache_header( |
|
|
response, scenario["expected_cache"], scenario["should_have_header"] |
|
|
) |
|
|
|
|
|
@pytest.mark.parametrize("ext", IMG_EXTENSIONS) |
|
|
async def test_all_image_extensions(self, ext: str, mock_handler): |
|
|
"""Test all defined image extensions are handled correctly""" |
|
|
request = make_mocked_request("GET", f"/image{ext}") |
|
|
response = await cache_control(request, mock_handler) |
|
|
|
|
|
assert response.status == 200 |
|
|
assert "Cache-Control" in response.headers |
|
|
assert response.headers["Cache-Control"] == f"public, max-age={ONE_DAY}" |
|
|
|
|
|
@pytest.mark.parametrize( |
|
|
"status_scenario", IMAGE_STATUS_SCENARIOS, ids=lambda x: f"status_{x['status']}" |
|
|
) |
|
|
async def test_image_status_codes( |
|
|
self, status_scenario: Dict[str, Any], status_handler_factory |
|
|
): |
|
|
"""Test different status codes for image requests""" |
|
|
handler = status_handler_factory(status_scenario["status"]) |
|
|
request = make_mocked_request("GET", "/image.jpg") |
|
|
response = await cache_control(request, handler) |
|
|
|
|
|
assert response.status == status_scenario["status"] |
|
|
assert "Cache-Control" in response.headers |
|
|
assert response.headers["Cache-Control"] == status_scenario["expected"] |
|
|
|
|
|
@pytest.mark.parametrize("path", CASE_SENSITIVITY_PATHS) |
|
|
async def test_case_insensitive_image_extension(self, path: str, mock_handler): |
|
|
"""Test that image extensions are matched case-insensitively""" |
|
|
request = make_mocked_request("GET", path) |
|
|
response = await cache_control(request, mock_handler) |
|
|
|
|
|
assert "Cache-Control" in response.headers |
|
|
assert response.headers["Cache-Control"] == f"public, max-age={ONE_DAY}" |
|
|
|
|
|
@pytest.mark.parametrize("edge_case", EDGE_CASE_PATHS, ids=lambda x: x["name"]) |
|
|
async def test_edge_cases(self, edge_case: Dict[str, str], mock_handler): |
|
|
"""Test edge cases like query strings, nested paths, etc.""" |
|
|
request = make_mocked_request("GET", edge_case["path"]) |
|
|
response = await cache_control(request, mock_handler) |
|
|
|
|
|
assert "Cache-Control" in response.headers |
|
|
assert response.headers["Cache-Control"] == edge_case["expected"] |
|
|
|
|
|
|
|
|
async def test_js_preserves_existing_headers(self, handler_with_existing_cache): |
|
|
"""Test that .js files preserve existing Cache-Control headers""" |
|
|
request = make_mocked_request("GET", "/script.js") |
|
|
response = await cache_control(request, handler_with_existing_cache) |
|
|
|
|
|
|
|
|
assert response.headers["Cache-Control"] == "max-age=3600" |
|
|
|
|
|
async def test_css_preserves_existing_headers(self, handler_with_existing_cache): |
|
|
"""Test that .css files preserve existing Cache-Control headers""" |
|
|
request = make_mocked_request("GET", "/styles.css") |
|
|
response = await cache_control(request, handler_with_existing_cache) |
|
|
|
|
|
|
|
|
assert response.headers["Cache-Control"] == "max-age=3600" |
|
|
|
|
|
async def test_image_preserves_existing_headers(self, status_handler_factory): |
|
|
"""Test that image cache headers preserve existing Cache-Control""" |
|
|
handler = status_handler_factory(200, {"Cache-Control": "private, no-cache"}) |
|
|
request = make_mocked_request("GET", "/image.jpg") |
|
|
response = await cache_control(request, handler) |
|
|
|
|
|
|
|
|
assert response.headers["Cache-Control"] == "private, no-cache" |
|
|
|
|
|
async def test_304_not_modified_inherits_cache(self, status_handler_factory): |
|
|
"""Test that 304 Not Modified doesn't set cache headers for images""" |
|
|
handler = status_handler_factory(304, {"Cache-Control": "max-age=7200"}) |
|
|
request = make_mocked_request("GET", "/not-modified.jpg") |
|
|
response = await cache_control(request, handler) |
|
|
|
|
|
assert response.status == 304 |
|
|
|
|
|
assert response.headers["Cache-Control"] == "max-age=7200" |
|
|
|