Files
lukasandClaude Opus 5.5 8dc60dbf5c Avatars: a sender without one logged a console error; phone list rows spaced 20px
A missing avatar was a 404, which the browser reports as an error for every
such sender on every page. It is now an empty 204: the <img> still fails to
decode it and the initials show through, but nothing is logged, and the miss
carries the same one-day browser cache as a hit so it stops being re-fetched.

On a phone the mail list's rows sit 20px apart instead of 7px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:35:26 +02:00

89 lines
3.0 KiB
Python

"""Sender avatar cache (SPEC §9.1): a cached hit or miss must be served again.
The shared Redis client decodes replies as text, so the cache of raw image
bytes broke on the second request for any sender — a 500 instead of the image
(or instead of the 204 that means "draw initials").
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import avatar
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
# A PNG signature: not valid UTF-8, exactly what a text-decoding client chokes on.
PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\xff\xfe"
@pytest_asyncio.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
user = User(handle=f"av{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
async with httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token},
) as c:
yield c
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_cached_avatar_is_served_again(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = 0
async def fake_resolve(email: str, md5: str) -> tuple[str, bytes] | None:
nonlocal calls
calls += 1
return "image/png", PNG
monkeypatch.setattr(avatar, "_resolve", fake_resolve)
for _ in range(2):
resp = await client.get("/api/avatar", params={"email": "alice@example.com"})
assert resp.status_code == 200
assert resp.content == PNG
assert resp.headers["content-type"] == "image/png"
assert calls == 1 # the second came from the cache
async def test_cached_miss_is_a_204_again(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = 0
async def fake_resolve(email: str, md5: str) -> tuple[str, bytes] | None:
nonlocal calls
calls += 1
return None
monkeypatch.setattr(avatar, "_resolve", fake_resolve)
for _ in range(2):
resp = await client.get("/api/avatar", params={"email": "nobody@example.com"})
# 204, not 404: a miss is the normal case, and a 404 put a console
# error in the browser for every sender without an avatar.
assert resp.status_code == 204
assert "max-age" in resp.headers["cache-control"]
assert calls == 1