"""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