From 576ea68bb2a2e53a1559422b8c7d640f2a1b49fa Mon Sep 17 00:00:00 2001 From: ALeonard9 Date: Wed, 5 Aug 2026 00:46:12 -0500 Subject: [PATCH] fix: prevent stale tracker response caches --- app/run.py | 18 +++ tests/integration/router_delete_cache_test.py | 112 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/integration/router_delete_cache_test.py diff --git a/app/run.py b/app/run.py index 6b966f0..966520d 100644 --- a/app/run.py +++ b/app/run.py @@ -48,6 +48,18 @@ settings = get_settings() +def _is_mutable_user_read(method: str, path: str) -> bool: + """Return whether a response reflects tracker or viewer-specific state.""" + if method != 'GET': + return False + return ( + path.startswith('/v1/users/me/') + or path.startswith('/v1/public/') + or path == '/v1/search' + or path.endswith('/search') + ) + + # Create FastAPI app app = FastAPI( title='druthers.io API ' + settings.env, @@ -128,6 +140,12 @@ async def log_request_latency(request, call_next): ) # Lets the browser's network panel attribute the time without a log dive. response.headers['Server-Timing'] = f"app;dur={elapsed_ms:.1f}" + # Tracker mutations make public profiles, rankings, and catalog-search + # badges stale immediately. These reads are cheap live SQL lookups and + # viewer-specific, so do not let a browser, CDN, or framework data cache + # retain them under a URL-only key (#297). + if _is_mutable_user_read(request.method, request.url.path): + response.headers['Cache-Control'] = 'private, no-store' return response diff --git a/tests/integration/router_delete_cache_test.py b/tests/integration/router_delete_cache_test.py new file mode 100644 index 0000000..196c57c --- /dev/null +++ b/tests/integration/router_delete_cache_test.py @@ -0,0 +1,112 @@ +# pylint: disable=missing-function-docstring +"""Deletion consistency for every public shelf and mutable read (#297).""" + +import pytest +from fastapi.testclient import TestClient + +DOMAINS = ( + ( + 'movies', + '/v1/movies', + {'title': 'Heat', 'imdb': 'tt0113277'}, + 'visibility_movies', + ), + ( + 'tv-shows', + '/v1/tv-shows', + {'title': 'Severance', 'tvmaze': 44932}, + 'visibility_tv', + ), + ( + 'books', + '/v1/books', + {'title': 'Piranesi', 'isbn': '9781635575637'}, + 'visibility_books', + ), + ( + 'games', + '/v1/games', + {'title': 'Hades', 'igdb': 113112}, + 'visibility_games', + ), +) + + +def _auth(token: str) -> dict: + return {'Authorization': f'Bearer {token}'} + + +@pytest.mark.parametrize( + ('tracker_path', 'catalog_path', 'catalog_payload', 'visibility_field'), DOMAINS +) +def test_delete_is_immediately_visible_everywhere( + test_client: TestClient, + tracker_path: str, + catalog_path: str, + catalog_payload: dict, + visibility_field: str, +): + """A deletion cannot survive in public, rankings, or mutable-read caches.""" + user_headers = _auth(test_client.first_user.token) + item_id = test_client.post( + catalog_path, + headers=_auth(test_client.admin_user.token), + json=catalog_payload, + ).json()['id'] + assert ( + test_client.post( + f'/v1/users/me/{tracker_path}/{item_id}', + headers=user_headers, + json={'on_rankings': True}, + ).status_code + == 201 + ) + test_client.put( + '/v1/users/me/visibility', + headers=user_headers, + json={ + 'handle': 'avery', + 'visibility_profile': 'public', + visibility_field: 'public', + }, + ) + + public_before = test_client.get('/v1/public/avery') + assert public_before.json()['total_ranked'] == 1 + assert public_before.headers['cache-control'] == 'private, no-store' + + rankings_before = test_client.get( + f'/v1/users/me/{tracker_path}?on_rankings=true', headers=user_headers + ) + assert len(rankings_before.json()) == 1 + assert rankings_before.headers['cache-control'] == 'private, no-store' + + assert ( + test_client.delete( + f'/v1/users/me/{tracker_path}/{item_id}', headers=user_headers + ).status_code + == 204 + ) + + public_after = test_client.get('/v1/public/avery') + assert public_after.json()['total_ranked'] == 0 + assert public_after.json()['shelves'][0]['items'] == [] + assert public_after.headers['cache-control'] == 'private, no-store' + + rankings_after = test_client.get( + f'/v1/users/me/{tracker_path}?on_rankings=true', headers=user_headers + ) + assert rankings_after.json() == [] + assert rankings_after.headers['cache-control'] == 'private, no-store' + + +def test_search_responses_are_not_cacheable(test_client: TestClient, monkeypatch): + empty_results = {'movies': [], 'tv_shows': [], 'books': [], 'games': []} + monkeypatch.setattr( + 'app.router.v1.router_search._fan_out', lambda _q: empty_results + ) + response = test_client.get( + '/v1/search?q=anything', headers=_auth(test_client.first_user.token) + ) + assert response.status_code == 200 + assert response.headers['cache-control'] == 'private, no-store'