Files
simple-nanoshare/tests/test_serve_file_cache.py
daniel156161 3db374b1ab
Build and Push Docker Container / build-and-push (push) Successful in 2m37s
feat(files): cache served files and add HTML text viewer
File cache:
- Add my_modules/filecache with metadata, blob, validator and store layers
- Cache convex.get_file via AsyncCache, TTL clamped to the file expiry
- Skip caching for missing, incomplete or already expired records
- Add a process-local, size-bounded LRU for immutable storage blobs
- Build the cache stack from FILE_CACHE_* env vars in build_file_cache
- Attach the cache to the app in my_modules/app/setup.py
- Expose app-bound helpers in filecache/access.py for routes

Serving:
- Send strong ETag, Last-Modified and Cache-Control on served files
- Answer If-None-Match and If-Modified-Since with 304 from metadata only
- Avoid pulling the blob from Convex on a revalidation hit
- Add a short Cloudflare-CDN-Cache-Control edge window for deleted files
- Record cache_status in the wide event on a 304
- Invalidate cached metadata after file update and delete in both routes

Text viewer:
- Add my_modules/textview to render text/plain and text/markdown as HTML
- Linkify http(s) URLs and trim trailing sentence punctuation
- Resolve links pointing at this instance to the referenced file
- Inline image previews, mark missing and expired targets
- Cap resolution at MAX_LOOKUPS backend lookups per document
- Serve the viewer only for real browser navigations, never for curl or CLI
- Keep raw bytes for ?raw=1, downloads and Accept headers without text/html
- Add templates/side/views/webpage/files/text_view.htm with a raw link

Other:
- Add tests for cache layers, serving, text rendering and the route
- Bump multidict to 7.2.1 in uv.lock
- Update the my_helpers submodule pointer
2026-10-25 18:54:46 +01:00

288 lines
9.6 KiB
Python

import asyncio
import importlib.util
import sys
import time
import types
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from quart import Blueprint, Quart
from my_modules.AsyncCache import AsyncCache
from my_modules.filecache.store import build_file_cache
ROOT = Path(__file__).resolve().parents[1]
IMAGE = b'\x89PNG\r\n\x1a\n' + b'pixels' * 100
def base_file(**overrides):
data = {
'file_name': 'barend.png',
'content_type': 'image/png',
'db_image_url': 'http://convex.internal/api/storage/abc',
'uploaded_at': 1700000000000,
'expires_at': '',
'user_id': 'owner',
}
data.update(overrides)
return data
def load_share_module(monkeypatch):
stubs = {
'my_modules.decoratory.header': {'login_required': lambda fn: fn},
'my_modules.functions': {'get_ip': lambda: '192.0.2.1'},
'my_modules.app.setup': {
'LIMITER': types.SimpleNamespace(limit=lambda *args, **kwargs: lambda fn: fn),
},
'my_modules.app.logger': {'logger': None},
'my_modules.expiry': {'parse_expires': lambda value: value},
'quart_common.web.wide_event': {'add_wide_event_context': lambda **kwargs: None},
}
for name, values in stubs.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
path = ROOT / 'routes/side/main.py'
spec = importlib.util.spec_from_file_location('serve_file_cache_under_test', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_app(monkeypatch, file_data=None, with_cache=True):
module = load_share_module(monkeypatch)
app = Quart(
'serve_file_cache_test',
template_folder=str(ROOT / 'templates/side'),
static_folder=str(ROOT / 'templates/static'),
)
app.secret_key = 'test-secret'
record = base_file() if file_data is None else file_data
async def get_file(file_id):
return record if file_id == 'abc' else None
async def get_from_storage(storage_id):
return BytesIO(IMAGE)
app.convex = types.SimpleNamespace(
get_file=AsyncMock(side_effect=get_file),
get_from_storage=AsyncMock(side_effect=get_from_storage),
add_file_access=AsyncMock(return_value=None),
)
if with_cache:
app.file_cache = build_file_cache(AsyncCache(backend='memory'))
app.register_blueprint(module.side_main_bp)
register_template_endpoints(app)
return app
def register_template_endpoints(app):
"""Minimal endpoints so base.htm and navigation.htm can resolve url_for."""
for name, rule, endpoint in (
('basic', '/favicon.ico', 'favicon'),
('auth_login', '/login', 'login'),
('link_composer', '/links', 'link_composer_page'),
):
blueprint = Blueprint(name, __name__)
blueprint.add_url_rule(rule, endpoint, lambda: '')
app.register_blueprint(blueprint)
def get(app, path='/-abc', headers=None):
async def run():
return await app.test_client().get(path, headers=headers or {})
return asyncio.run(run())
# --- conditional requests -----------------------------------------------------
def test_first_response_carries_validators(monkeypatch):
app = build_app(monkeypatch)
response = get(app)
assert response.status_code == 200
assert response.headers['ETag'].startswith('"')
assert response.headers['Cache-Control'] == 'public, max-age=86400'
assert 'Last-Modified' in response.headers
# --- Cloudflare edge TTL ------------------------------------------------------
def test_edge_header_is_shorter_than_the_browser_header(monkeypatch):
app = build_app(monkeypatch)
response = get(app)
assert response.headers['Cache-Control'] == 'public, max-age=86400'
assert response.headers['Cloudflare-CDN-Cache-Control'] == 'public, max-age=1800'
def test_plain_cdn_header_is_not_sent(monkeypatch):
# Only the Cloudflare-specific header, so it is not proxied to the browser.
app = build_app(monkeypatch)
assert 'CDN-Cache-Control' not in get(app).headers
def test_edge_header_is_on_the_304_too(monkeypatch):
app = build_app(monkeypatch)
etag = get(app).headers['ETag']
response = get(app, headers={'If-None-Match': etag})
assert response.status_code == 304
assert response.headers['Cloudflare-CDN-Cache-Control'] == 'public, max-age=1800'
def test_edge_ttl_is_configurable(monkeypatch):
monkeypatch.setenv('FILE_CACHE_EDGE_MAX_AGE', '42')
app = build_app(monkeypatch)
assert get(app).headers['Cloudflare-CDN-Cache-Control'] == 'public, max-age=42'
def test_edge_caching_can_be_switched_off(monkeypatch):
monkeypatch.setenv('FILE_CACHE_EDGE_MAX_AGE', '0')
app = build_app(monkeypatch)
response = get(app)
assert response.headers['Cloudflare-CDN-Cache-Control'] == 'no-store'
# The browser must keep caching even when the edge does not.
assert response.headers['Cache-Control'] == 'public, max-age=86400'
def test_expired_file_is_never_edge_cached(monkeypatch):
app = build_app(monkeypatch, file_data={'expired': True, 'user_id': 'owner'})
response = get(app)
assert response.status_code == 410
assert response.headers['Cache-Control'] == 'no-store'
def test_text_view_is_never_edge_cached(monkeypatch):
app = build_app(monkeypatch, file_data=base_file(
file_name='note.txt', content_type='text/plain',
))
response = get(app, headers={'Accept': 'text/html,application/xhtml+xml'})
assert response.headers['Cache-Control'] == 'private, no-store'
def test_revalidation_returns_304_without_touching_storage(monkeypatch):
app = build_app(monkeypatch)
etag = get(app).headers['ETag']
app.convex.get_from_storage.reset_mock()
response = get(app, headers={'If-None-Match': etag})
assert response.status_code == 304
assert response.headers['ETag'] == etag
app.convex.get_from_storage.assert_not_awaited()
def test_revalidation_by_date_returns_304(monkeypatch):
app = build_app(monkeypatch)
last_modified = get(app).headers['Last-Modified']
app.convex.get_from_storage.reset_mock()
response = get(app, headers={'If-Modified-Since': last_modified})
assert response.status_code == 304
app.convex.get_from_storage.assert_not_awaited()
def test_stale_etag_still_gets_the_body(monkeypatch):
app = build_app(monkeypatch)
response = get(app, headers={'If-None-Match': '"stale-value"'})
assert response.status_code == 200
assert asyncio.run(response.get_data()) == IMAGE
def test_download_variant_has_its_own_etag(monkeypatch):
app = build_app(monkeypatch)
inline = get(app).headers['ETag']
attachment = get(app, '/-abc?download=1').headers['ETag']
assert inline != attachment
def test_download_etag_does_not_satisfy_the_inline_request(monkeypatch):
app = build_app(monkeypatch)
attachment = get(app, '/-abc?download=1').headers['ETag']
assert get(app, '/-abc', {'If-None-Match': attachment}).status_code == 200
# --- cache reuse --------------------------------------------------------------
def test_cold_clients_reuse_metadata_and_blob(monkeypatch):
app = build_app(monkeypatch)
for _ in range(5):
assert get(app).status_code == 200
assert app.convex.get_file.await_count == 1
assert app.convex.get_from_storage.await_count == 1
def test_without_a_cache_every_request_hits_convex(monkeypatch):
app = build_app(monkeypatch, with_cache=False)
for _ in range(3):
assert get(app).status_code == 200
assert app.convex.get_file.await_count == 3
assert app.convex.get_from_storage.await_count == 3
def test_cached_body_is_identical_on_repeat(monkeypatch):
app = build_app(monkeypatch)
first = asyncio.run(get(app).get_data())
second = asyncio.run(get(app).get_data())
assert first == second == IMAGE
def test_oversized_blobs_are_not_cached(monkeypatch):
monkeypatch.setenv('FILE_CACHE_BLOB_ITEM_BYTES', '4')
app = build_app(monkeypatch)
get(app)
get(app)
assert app.convex.get_from_storage.await_count == 2
# Metadata is small, so it stays cached even when the blob does not.
assert app.convex.get_file.await_count == 1
# --- expiry and invalidation --------------------------------------------------
def test_expired_file_is_not_served_from_cache(monkeypatch):
app = build_app(monkeypatch, file_data={'expired': True, 'user_id': 'owner'})
for _ in range(3):
assert get(app).status_code == 410
# The expired marker must never be cached.
assert app.convex.get_file.await_count == 3
def test_cache_control_is_clamped_for_a_file_that_expires_soon(monkeypatch):
soon = (time.time() + 120) * 1000
app = build_app(monkeypatch, file_data=base_file(expires_at=soon))
value = get(app).headers['Cache-Control']
assert 100 <= int(value.rsplit('=', 1)[1]) <= 120
def test_invalidation_makes_the_next_request_refetch_metadata(monkeypatch):
app = build_app(monkeypatch)
get(app)
async def invalidate():
async with app.app_context():
from my_modules.filecache.access import drop_file_cache
await drop_file_cache('abc')
asyncio.run(invalidate())
get(app)
assert app.convex.get_file.await_count == 2
def test_missing_file_is_never_cached(monkeypatch):
app = build_app(monkeypatch)
for _ in range(3):
assert get(app, '/-unknown').status_code == 404
assert app.convex.get_file.await_count == 3
# --- access logging -----------------------------------------------------------
@pytest.mark.parametrize('headers,expected_status', [
({}, 200),
({'If-None-Match': 'PLACEHOLDER'}, 304),
])
def test_access_is_logged_for_both_fresh_and_revalidated_requests(monkeypatch, headers, expected_status):
app = build_app(monkeypatch)
if 'If-None-Match' in headers:
headers['If-None-Match'] = get(app).headers['ETag']
app.convex.add_file_access.reset_mock()
response = get(app, headers=headers)
assert response.status_code == expected_status
app.convex.add_file_access.assert_awaited_once()