Files
bambuddy/backend/tests/unit/test_static_assets.py
T
maziggy 89a9a81195 Compress and cache the web interface's asset files (#3175)
/assets was served uncompressed and without Cache-Control, so phones
downloaded the full 11 MB bundle and revalidated it on every load. Text
assets are now gzip-compressed once per file and kept in memory, and
files with a content hash in their name are marked immutable. The
pdf.js runtime data has no hash and is revalidated instead.

-----

Load pages and languages when they are needed (#3175)

Every page and all 15 languages were one 11 MB script. Pages are now
separate files loaded on first open, and only English is bundled; the
language in use loads before the first render. A tab left on an older
build reloads once when a file is gone, after the server answers
again, so a kiosk is never left on the browser's connection error page.
The service worker serves hashed files from its cache and drops the
previous build's files.
2026-10-05 14:59:11 +02:00

208 lines
8.1 KiB
Python

"""/assets serving: gzip and long-lived caching for the frontend build (#3175)."""
from __future__ import annotations
import gzip
import pytest
from httpx import ASGITransport, AsyncClient
from starlette.applications import Starlette
from starlette.routing import Mount
from backend.app.core import static_assets
from backend.app.core.static_assets import (
IMMUTABLE_CACHE_CONTROL,
REVALIDATE_CACHE_CONTROL,
AssetStaticFiles,
accepts_gzip,
)
BUNDLE = b"export const answer = 42;\n" * 400 # ~10 KB, compresses well
BUNDLE_NAME = "index-CdoB5JwO.js"
@pytest.fixture
def assets_dir(tmp_path):
root = tmp_path / "assets"
(root / "pdfjs" / "cmaps").mkdir(parents=True)
(root / BUNDLE_NAME).write_bytes(BUNDLE)
(root / "index-Dk3mQx9a.css").write_bytes(b"body { color: red; }\n" * 200)
(root / "tiny-AbCdEfGh.js").write_bytes(b"export {};\n")
(root / "logo-AbCdEfGh.png").write_bytes(b"\x89PNG" + b"\x00" * 4000)
(root / "pdfjs" / "cmaps" / "Adobe-Japan1-UCS2.bcmap").write_bytes(b"\x00" * 4000)
(root / "pdfjs" / "wasm.json").write_bytes(b'{"a": 1}' * 400)
static_assets._gzip_cache.clear()
yield root
static_assets._gzip_cache.clear()
@pytest.fixture
async def client(assets_dir):
app = Starlette(routes=[Mount("/assets", app=AssetStaticFiles(directory=assets_dir))])
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def _get(client: AsyncClient, path: str, **headers: str):
"""GET without httpx decoding the body, so the raw bytes can be checked."""
request = client.build_request("GET", path, headers=headers)
response = await client.send(request, stream=True)
raw = b"".join([chunk async for chunk in response.aiter_raw()])
await response.aclose()
return response, raw
class TestCompression:
@pytest.mark.asyncio
async def test_bundle_is_gzipped_when_accepted(self, client):
response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip, deflate, br"})
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert response.headers["content-type"].startswith("text/javascript")
assert int(response.headers["content-length"]) == len(raw) < len(BUNDLE)
assert gzip.decompress(raw) == BUNDLE
assert "accept-ranges" not in response.headers
@pytest.mark.asyncio
async def test_identity_when_gzip_not_accepted(self, client):
response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"})
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert response.headers["vary"] == "Accept-Encoding"
assert raw == BUNDLE
@pytest.mark.asyncio
async def test_gzip_q0_is_a_refusal(self, client):
response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip;q=0, *"})
assert "content-encoding" not in response.headers
assert raw == BUNDLE
@pytest.mark.asyncio
async def test_variants_carry_different_etags(self, client):
plain, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"})
zipped, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
assert plain.headers["etag"] != zipped.headers["etag"]
@pytest.mark.asyncio
async def test_gzip_etag_revalidates_to_304(self, client):
first, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
again, raw = await _get(
client,
f"/assets/{BUNDLE_NAME}",
**{"Accept-Encoding": "gzip", "If-None-Match": first.headers["etag"]},
)
assert again.status_code == 304
assert raw == b""
assert again.headers["etag"] == first.headers["etag"]
assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
@pytest.mark.asyncio
async def test_range_request_is_served_uncompressed(self, client):
response, raw = await _get(
client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip", "Range": "bytes=0-9"}
)
assert response.status_code == 206
assert "content-encoding" not in response.headers
assert raw == BUNDLE[:10]
@pytest.mark.asyncio
async def test_head_is_not_compressed(self, client):
response = await client.head(f"/assets/{BUNDLE_NAME}", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
@pytest.mark.asyncio
@pytest.mark.parametrize("name", ["tiny-AbCdEfGh.js", "logo-AbCdEfGh.png", "pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap"])
async def test_small_or_binary_files_are_not_compressed(self, client, name):
response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert "content-encoding" not in response.headers
@pytest.mark.asyncio
async def test_compressed_body_is_reused(self, client, monkeypatch):
calls = []
real = static_assets._gzip_file
monkeypatch.setattr(static_assets, "_gzip_file", lambda path: calls.append(path) or real(path))
for _ in range(3):
response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
assert gzip.decompress(raw) == BUNDLE
assert len(calls) == 1
@pytest.mark.asyncio
async def test_rebuilt_file_is_compressed_again(self, client, assets_dir):
await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
(assets_dir / BUNDLE_NAME).write_bytes(b"export const answer = 43;\n" * 500)
_, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
assert gzip.decompress(raw) == b"export const answer = 43;\n" * 500
class TestCacheControl:
@pytest.mark.asyncio
@pytest.mark.parametrize("name", [BUNDLE_NAME, "index-Dk3mQx9a.css", "logo-AbCdEfGh.png"])
async def test_hashed_files_are_immutable(self, client, name):
response, _ = await _get(client, f"/assets/{name}")
assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
@pytest.mark.asyncio
@pytest.mark.parametrize("name", ["pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap", "pdfjs/wasm.json"])
async def test_pdfjs_runtime_files_are_revalidated(self, client, name):
response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert response.headers["cache-control"] == REVALIDATE_CACHE_CONTROL
@pytest.mark.asyncio
async def test_identity_304_keeps_cache_control(self, client):
first, _ = await _get(client, f"/assets/{BUNDLE_NAME}")
again, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"If-None-Match": first.headers["etag"]})
assert again.status_code == 304
assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
@pytest.mark.asyncio
async def test_missing_old_chunk_is_404_not_html(self, client):
"""A tab still running the previous build asks for chunks the new build
deleted. It must get a 404 so the frontend can reload, never a page."""
response, _ = await _get(client, "/assets/ProfilesPage-OldHash1.js", **{"Accept-Encoding": "gzip"})
assert response.status_code == 404
assert "cache-control" not in response.headers
@pytest.mark.parametrize(
("header", "expected"),
[
(None, False),
("", False),
("gzip", True),
("GZIP", True),
("gzip, deflate, br, zstd", True),
("br", False),
("identity", False),
("gzip;q=0", False),
("gzip; q=0.5", True),
("*", True),
("*;q=0", False),
("gzip;q=0, *", False),
("br, *;q=0.1", True),
("gzip;q=abc", False),
],
)
def test_accepts_gzip(header, expected):
assert accepts_gzip(header) is expected