| | """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" |
| |
|