Files
life-echo/api/tests/test_avatar_preset_http.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

250 lines
8.5 KiB
Python

"""预设头像 HTTP 契约。"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from PIL import Image
from app.core.dependencies import get_current_user
from app.features.auth.deps import get_auth_service
from app.features.auth.router import router as auth_router
from app.features.auth.service import AuthService
from app.features.user.models import User
from tests.conftest import install_test_error_handlers
def _mock_current_user() -> User:
u = MagicMock(spec=User)
u.id = str(uuid.uuid4())
u.phone = "13800000000"
u.email = None
u.nickname = "测试用户"
u.avatar_url = None
u.subscription_type = "free"
u.created_at = datetime.now(timezone.utc)
u.language_preference = "zh"
return u
@pytest.fixture
def preset_auth_app() -> FastAPI:
app = install_test_error_handlers(FastAPI())
app.include_router(auth_router)
fixed_user = _mock_current_user()
async def _fake_update_avatar(uid: str, url: str):
fixed_user.avatar_url = url
return fixed_user
mock_service = MagicMock(spec=AuthService)
mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar)
app.dependency_overrides[get_auth_service] = lambda: mock_service
app.dependency_overrides[get_current_user] = lambda: fixed_user
app.state._mock_auth_service = mock_service
app.state._fixed_user = fixed_user
return app
@pytest.mark.asyncio
async def test_list_avatar_presets(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets")
assert r.status_code == 200
items = r.json()
assert len(items) == 8
assert items[0]["id"] == "01"
assert items[0]["url"] == "/api/auth/avatar-presets/01.png"
@pytest.mark.asyncio
async def test_get_avatar_preset_ok(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets/01.png")
assert r.status_code == 200
assert r.headers.get("content-type", "").startswith("image/png")
@pytest.mark.asyncio
async def test_get_avatar_preset_unknown(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets/99.png")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets/../secrets.env")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.put(
"/api/auth/me/avatar/preset",
json={"preset_id": "02"},
headers={"Authorization": "Bearer x"},
)
assert r.status_code == 200
body = r.json()
url = body["avatar_url"]
assert url.startswith("/api/auth/avatar-presets/02.png")
assert "?v=" in url
svc: MagicMock = preset_auth_app.state._mock_auth_service
stored = svc.update_avatar_url.await_args[0][1]
assert stored.startswith("/api/auth/avatar-presets/02.png")
assert "?v=" in stored
@pytest.mark.asyncio
async def test_set_avatar_preset_invalid(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.put(
"/api/auth/me/avatar/preset",
json={"preset_id": "99"},
headers={"Authorization": "Bearer x"},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_get_uploaded_avatar_rejects_traversal() -> None:
app = FastAPI()
app.include_router(auth_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatars/../../../etc/passwd")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_get_uploaded_avatar_ok_with_safe_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import app.features.auth.router as auth_router_mod
avatar_dir = tmp_path / "avatars"
avatar_dir.mkdir()
(avatar_dir / "abc-def-123.jpg").write_bytes(b"x")
monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir)
app = FastAPI()
app.include_router(auth_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatars/abc-def-123.jpg")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_get_uploaded_avatar_ok_when_stem_has_underscore(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import app.features.auth.router as auth_router_mod
avatar_dir = tmp_path / "avatars"
avatar_dir.mkdir()
(avatar_dir / "user_abc_01.jpg").write_bytes(b"x")
monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir)
app = FastAPI()
app.include_router(auth_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatars/user_abc_01.jpg")
assert r.status_code == 200
def _minimal_jpeg_bytes() -> bytes:
img = Image.new("RGB", (2, 2), color=(120, 80, 200))
buf = BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
@pytest.mark.asyncio
async def test_upload_avatar_cos_calls_storage_and_presigns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
import app.core.dependencies as deps
from app.core.config import settings
from app.core.dependencies import get_current_user
from app.features.auth.router import router as auth_router
bucket, region = "life-test-bucket", "ap-shanghai"
uid = str(uuid.uuid4())
public = f"https://{bucket}.cos.{region}.myqcloud.com/avatars/{uid}.jpg"
for attr, val in (
("tencent_secret_id", "sid"),
("tencent_secret_key", "sk"),
("tencent_cos_bucket", bucket),
("tencent_cos_base_url", f"https://{bucket}.cos.{region}.myqcloud.com"),
):
monkeypatch.setattr(settings, attr, val, raising=False)
mock_storage = MagicMock()
mock_storage.upload = MagicMock(return_value=public)
mock_storage.get_url = MagicMock(return_value="https://example.com/signed-avatar")
monkeypatch.setattr(deps, "get_object_storage", lambda: mock_storage)
fixed_user = _mock_current_user()
fixed_user.id = uid
fixed_user.avatar_url = None
async def _fake_upload_avatar(
user_id: str,
file_content: bytes,
content_type: str,
*,
old_avatar_url: str | None,
):
_ = (user_id, file_content, content_type, old_avatar_url)
url = mock_storage.upload(f"avatars/{uid}.jpg", b"jpeg", "image/jpeg")
fixed_user.avatar_url = url
return fixed_user
mock_service = MagicMock(spec=AuthService)
mock_service.upload_avatar = AsyncMock(side_effect=_fake_upload_avatar)
app = install_test_error_handlers(FastAPI())
app.include_router(auth_router)
app.dependency_overrides[get_auth_service] = lambda: mock_service
app.dependency_overrides[get_current_user] = lambda: fixed_user
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.post(
"/api/auth/me/avatar",
files={"file": ("a.jpg", BytesIO(_minimal_jpeg_bytes()), "image/jpeg")},
headers={"Authorization": "Bearer x"},
)
assert r.status_code == 200
body = r.json()
assert body["avatar_url"] == "https://example.com/signed-avatar"
mock_storage.upload.assert_called_once()
assert mock_storage.upload.call_args[0][0] == f"avatars/{uid}.jpg"