Avatars: a cached avatar or miss failed on every later request
The shared Redis client decodes replies as UTF-8 text, but the avatar cache holds raw image bytes and a b"none" miss marker: an image could not be read back at all, and a miss came back as str and crashed on .partition(b"\n"). Only a sender's first request worked; after that, a 500. The cache now goes through a binary-safe client, get_redis_bytes(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""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 404 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_404_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"})
|
||||
assert resp.status_code == 404
|
||||
assert calls == 1
|
||||
Reference in New Issue
Block a user