2026-09-28 08:09:09 +02:00
|
|
|
"""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
|
2026-09-30 21:35:26 +02:00
|
|
|
(or instead of the 204 that means "draw initials").
|
2026-09-28 08:09:09 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-30 21:35:26 +02:00
|
|
|
async def test_cached_miss_is_a_204_again(
|
2026-09-28 08:09:09 +02:00
|
|
|
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"})
|
2026-09-30 21:35:26 +02:00
|
|
|
# 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"]
|
2026-09-28 08:09:09 +02:00
|
|
|
assert calls == 1
|