"""角色头像上传 API 测试。 验证头像文件的上传、读取、替换、类型校验、大小限制与清理。 """ from collections.abc import AsyncGenerator import pytest from httpx import ASGITransport, AsyncClient from app.main import app _AVATAR_PATH = "/api/v1/characters" @pytest.fixture async def admin_client( admin_headers: dict[str, str], ) -> AsyncGenerator[AsyncClient, None]: transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test", headers=admin_headers, ) as http_client: yield http_client @pytest.fixture async def active_char_id(admin_client: AsyncClient) -> str: """获取当前激活角色 ID。""" resp = await admin_client.get(f"{_AVATAR_PATH}/active") assert resp.status_code == 200, resp.text return resp.json()["id"] @pytest.mark.asyncio async def test_upload_and_get_avatar( admin_client: AsyncClient, active_char_id: str, ) -> None: """上传头像后可以 GET 获取到图片文件。""" png_bytes = _make_test_png() upload = await admin_client.post( f"{_AVATAR_PATH}/{active_char_id}/avatar", files={"file": ("test.png", png_bytes, "image/png")}, ) assert upload.status_code == 200, upload.text body = upload.json() assert "avatar_url" in body assert active_char_id in body["avatar_url"] # GET 获取图片 get_resp = await admin_client.get( f"{_AVATAR_PATH}/{active_char_id}/avatar", ) assert get_resp.status_code == 200 assert get_resp.headers["content-type"] == "image/png" assert len(await get_resp.aread()) > 0 @pytest.mark.asyncio async def test_avatar_replace( admin_client: AsyncClient, active_char_id: str, ) -> None: """上传新头像会替换旧头像(同角色只保留一张)。""" # 上传第一张 first = _make_test_png(width=10, height=10) r1 = await admin_client.post( f"{_AVATAR_PATH}/{active_char_id}/avatar", files={"file": ("first.png", first, "image/png")}, ) assert r1.status_code == 200 # 上传第二张 second = _make_test_png(width=20, height=20) r2 = await admin_client.post( f"{_AVATAR_PATH}/{active_char_id}/avatar", files={"file": ("second.png", second, "image/png")}, ) assert r2.status_code == 200 # 读取头像文件大小应与第二张匹配 get_resp = await admin_client.get( f"{_AVATAR_PATH}/{active_char_id}/avatar", ) assert get_resp.status_code == 200 body = await get_resp.aread() assert len(body) == len(second) @pytest.mark.asyncio async def test_avatar_rejects_invalid_type( admin_client: AsyncClient, active_char_id: str, ) -> None: """不支持的文件类型应返回 4xx。""" resp = await admin_client.post( f"{_AVATAR_PATH}/{active_char_id}/avatar", files={"file": ("test.svg", b"", "image/svg+xml")}, ) assert resp.status_code in (400, 422) @pytest.mark.asyncio async def test_avatar_requires_auth(active_char_id: str) -> None: """未认证用户不能上传头像。""" transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test", ) as client: resp = await client.post( f"{_AVATAR_PATH}/{active_char_id}/avatar", files={"file": ("test.png", _make_test_png(), "image/png")}, ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_avatar_not_found(admin_client: AsyncClient) -> None: """不存在的角色上传头像应 404。""" resp = await admin_client.post( f"{_AVATAR_PATH}/character_nonexistent_id_00000/avatar", files={"file": ("test.png", _make_test_png(), "image/png")}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_get_avatar_no_image( admin_client: AsyncClient, active_char_id: str, ) -> None: """无头像时 GET 应 404。""" from app.services.avatar import delete_avatar await delete_avatar(active_char_id) resp = await admin_client.get( f"{_AVATAR_PATH}/{active_char_id}/avatar", ) assert resp.status_code == 404 # ── 辅助:生成最小 PNG ── def _make_test_png(width: int = 1, height: int = 1) -> bytes: """生成一个最小的有效 PNG 文件。""" import struct import zlib sig = b"\x89PNG\r\n\x1a\n" ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) ihdr_crc = zlib.crc32(b"IHDR" + ihdr_data) ihdr = b"\x00\x00\x00\x0dIHDR" + ihdr_data + struct.pack(">I", ihdr_crc) raw = b"\x00" + b"\xff\x00\x00" * width raw *= height compressed = zlib.compress(raw) idat_crc = zlib.crc32(b"IDAT" + compressed) idat = struct.pack(">I", len(compressed)) + b"IDAT" + compressed + struct.pack(">I", idat_crc) iend_crc = zlib.crc32(b"IEND") iend = b"\x00\x00\x00\x00IEND" + struct.pack(">I", iend_crc) return sig + ihdr + idat + iend