"""人物管理 API 测试(api-spec §6)。 注意:所有需要用户认证的端点使用 ``auth_headers`` 注入 Bearer token。 """ from collections.abc import AsyncGenerator import pytest from httpx import ASGITransport, AsyncClient from app.main import app @pytest.fixture async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]: transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test", headers=auth_headers, ) as http_client: yield http_client @pytest.mark.asyncio async def test_list_returns_seeded_default(client: AsyncClient) -> None: resp = await client.get("/api/v1/characters") assert resp.status_code == 200 body = resp.json() assert body["total"] >= 1 item = body["items"][0] assert item["id"].startswith("character_") assert item["status"] == "active" assert item["personality_version"] == 0 # 旧人格版本表退役,版本语义由魂籍承载 assert item["memory_count"] == 0 @pytest.mark.asyncio async def test_create_character_defaults_to_wuji(client: AsyncClient) -> None: resp = await client.post("/api/v1/characters", json={"name": "新人物"}) assert resp.status_code == 200 body = resp.json() assert body["status"] == "wuji" assert body["name"] == "新人物" assert body["owner_scope"] is not None assert body["is_system_template"] is False assert body["model_policy"]["default_chat_model_hint"] listed = await client.get("/api/v1/characters") assert listed.json()["total"] >= 2 @pytest.mark.asyncio async def test_create_character_rejects_empty_name(client: AsyncClient) -> None: resp = await client.post("/api/v1/characters", json={"name": ""}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_list_filter_by_status(client: AsyncClient) -> None: await client.post("/api/v1/characters", json={"name": "空白人物"}) resp = await client.get("/api/v1/characters?status=wuji") assert resp.status_code == 200 body = resp.json() assert body["total"] == 1 assert body["items"][0]["status"] == "wuji" @pytest.mark.asyncio async def test_get_character_and_not_found(client: AsyncClient) -> None: active = await client.get("/api/v1/characters/active") character_id = active.json()["id"] resp = await client.get(f"/api/v1/characters/{character_id}") assert resp.status_code == 200 assert resp.json()["id"] == character_id missing = await client.get("/api/v1/characters/character_does_not_exist") assert missing.status_code == 404 @pytest.mark.asyncio async def test_update_profile_and_model_policy(client: AsyncClient) -> None: character_id = (await client.get("/api/v1/characters/active")).json()["id"] profile = await client.patch( f"/api/v1/characters/{character_id}/profile", json={"display_name": "昵称", "background": "背景故事"}, ) assert profile.status_code == 200 assert profile.json()["display_name"] == "昵称" assert profile.json()["profile"]["background"] == "背景故事" policy = await client.patch( f"/api/v1/characters/{character_id}/model-policy", json={"privacy_level": "sensitive", "default_chat_model_hint": "custom-chat"}, ) assert policy.status_code == 200 body = policy.json() assert body["privacy_level"] == "sensitive" assert body["model_policy"]["privacy_level"] == "sensitive" assert body["model_policy"]["default_chat_model_hint"] == "custom-chat" @pytest.mark.asyncio async def test_activate_switches_active_character(client: AsyncClient) -> None: char_a = (await client.get("/api/v1/characters/active")).json()["id"] char_b = (await client.post("/api/v1/characters", json={"name": "B"})).json()["id"] resp = await client.post(f"/api/v1/characters/{char_b}/activate") assert resp.status_code == 200 assert resp.json() == {"active_character_id": char_b, "status": "active"} # A 被切到 paused(A1 互斥) a_after = await client.get(f"/api/v1/characters/{char_a}") assert a_after.json()["status"] == "paused" # 当前激活指向 B assert (await client.get("/api/v1/characters/active")).json()["id"] == char_b @pytest.mark.asyncio async def test_pause_resume_archive(client: AsyncClient) -> None: char_b = (await client.post("/api/v1/characters", json={"name": "B"})).json()["id"] await client.post(f"/api/v1/characters/{char_b}/activate") paused = await client.post(f"/api/v1/characters/{char_b}/pause") assert paused.status_code == 200 assert paused.json()["status"] == "paused" # 暂停当前唯一激活人物后无 active no_active = await client.get("/api/v1/characters/active") assert no_active.status_code == 404 resumed = await client.post(f"/api/v1/characters/{char_b}/resume") assert resumed.status_code == 200 assert resumed.json()["status"] == "active" archived = await client.post(f"/api/v1/characters/{char_b}/archive") assert archived.status_code == 200 assert archived.json()["status"] == "archived" # 归档人物不可直接激活 again = await client.post(f"/api/v1/characters/{char_b}/activate") assert again.status_code == 409 @pytest.mark.asyncio async def test_pause_rejects_non_active(client: AsyncClient) -> None: char_b = (await client.post("/api/v1/characters", json={"name": "B"})).json()["id"] # wuji 状态不可暂停 resp = await client.post(f"/api/v1/characters/{char_b}/pause") assert resp.status_code == 409