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
18 changes: 18 additions & 0 deletions app/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down
112 changes: 112 additions & 0 deletions tests/integration/router_delete_cache_test.py
Original file line number Diff line number Diff line change
@@ -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'