Files
life-echo/api/tests/test_avatar_preset_http.py
Kevin eabda2c6a9 chore: resolve WIP after merging internal/development
- .gitignore: keep api/uploads ignore and copyright_source_listing.pdf path

- auth: keep COS avatar upload URL; delete prior COS object when applying preset

- i18n: regenerate resources.ts (includes profile tapAwayToClose)

- Avatar/COS tests and personal-info remain from prior local work

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:34:50 +08:00

243 lines
8.2 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
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 = 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_cos_secret_id", "sid"),
("tencent_cos_secret_key", "sk"),
("tencent_cos_bucket", bucket),
("tencent_cos_region", region),
("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_update_avatar(u: 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 = 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)
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
assert r.json()["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"
mock_storage.get_url.assert_called_once()
assert mock_storage.get_url.call_args[0][0] == f"avatars/{uid}.jpg"