86 lines
2.8 KiB
Python
86 lines
2.8 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 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
|