初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

View File
Whitespace-only changes.
+189
View File
@@ -0,0 +1,189 @@
"""Admin 人物模板管理 API 测试(§19.8)。
验证系统模板的 CRUD、引用检查、权限隔离。
"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@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 user_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_admin_list_templates(admin_client: AsyncClient) -> None:
"""管理员可以列出所有系统模板。"""
resp = await admin_client.get("/api/v1/admin/characters/templates")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
# 至少包含 default 模板
keys = [t["key"] for t in body]
assert "default" in keys
@pytest.mark.asyncio
async def test_admin_get_template(admin_client: AsyncClient) -> None:
"""管理员可以获取单个模板详情。"""
# 先列出获取 key
list_resp = await admin_client.get("/api/v1/admin/characters/templates")
assert list_resp.status_code == 200
templates = list_resp.json()
if not templates:
pytest.skip("无模板可用")
key = templates[0]["key"]
resp = await admin_client.get(
f"/api/v1/admin/characters/templates/{key}",
)
assert resp.status_code == 200
body = resp.json()
assert body["key"] == key
assert "profile" in body
assert "model_policy" in body
@pytest.mark.asyncio
async def test_admin_create_template(admin_client: AsyncClient) -> None:
"""管理员可以创建新系统模板。"""
payload = {
"key": "test_create_char",
"name": "测试人物",
"display_name": "测试",
"language": "zh-CN",
"profile": {
"description": "自动化测试创建",
"speaking_style": "简洁",
},
"model_policy": {
"memory_extraction_model_hint": "memory-extraction",
},
}
resp = await admin_client.post(
"/api/v1/admin/characters/templates",
json=payload,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["key"] == "test_create_char"
assert body["name"] == "测试人物"
@pytest.mark.asyncio
async def test_admin_create_duplicate_key(admin_client: AsyncClient) -> None:
"""重复 key 应返回 409。"""
payload = {
"key": "test_dup_key",
"name": "首次",
}
resp = await admin_client.post(
"/api/v1/admin/characters/templates",
json=payload,
)
assert resp.status_code == 201
# 重复创建
resp2 = await admin_client.post(
"/api/v1/admin/characters/templates",
json=payload,
)
assert resp2.status_code == 409
@pytest.mark.asyncio
async def test_admin_update_template(admin_client: AsyncClient) -> None:
"""管理员可以部分更新模板。"""
# 先创建
key = "test_update_tpl"
await admin_client.post(
"/api/v1/admin/characters/templates",
json={"key": key, "name": "原名"},
)
# 更新
resp = await admin_client.patch(
f"/api/v1/admin/characters/templates/{key}",
json={"name": "新名", "profile": {"description": "新描述"}},
)
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "新名"
@pytest.mark.asyncio
async def test_admin_delete_template(admin_client: AsyncClient) -> None:
"""管理员可以删除未被引用的模板。"""
key = "test_del_tpl"
resp = await admin_client.post(
"/api/v1/admin/characters/templates",
json={"key": key, "name": "待删除"},
)
assert resp.status_code == 201
resp = await admin_client.delete(
f"/api/v1/admin/characters/templates/{key}",
)
assert resp.status_code == 204
# 确认已删除
get_resp = await admin_client.get(
f"/api/v1/admin/characters/templates/{key}",
)
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_non_admin_cannot_list_templates(user_client: AsyncClient) -> None:
"""非管理员不能通过用户端 API 列出模板。"""
resp = await user_client.get("/api/v1/characters/templates")
assert resp.status_code in (403,)
@pytest.mark.asyncio
async def test_non_admin_cannot_create_from_template(
user_client: AsyncClient,
) -> None:
"""非管理员不能带 template_key 创建角色。"""
resp = await user_client.post(
"/api/v1/characters",
json={"name": "hacker_attempt", "template_key": "default"},
)
assert resp.status_code in (403,)
@pytest.mark.asyncio
async def test_admin_can_create_from_template(
admin_client: AsyncClient,
) -> None:
"""管理员可以从模板创建角色。"""
resp = await admin_client.post(
"/api/v1/characters",
json={"name": "admin_char_test", "template_key": "default"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "active"
+126
View File
@@ -0,0 +1,126 @@
"""认证端点 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture(autouse=True)
def _jwt_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""所有 API 测试需要 JWT_SECRET 配置。"""
monkeypatch.setattr("app.core.config.settings.jwt_secret", "test-jwt-secret-for-testing")
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_register_and_login(client: AsyncClient) -> None:
# Register
resp = await client.post(
"/api/v1/auth/register",
json={"username": "testuser", "email": "test@example.com", "password": "testpass123"},
)
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "testuser"
assert data["email"] == "test@example.com"
user_id = data["id"]
assert user_id.startswith("user_")
# Login
resp = await client.post(
"/api/v1/auth/login",
json={"username": "testuser", "password": "testpass123"},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert data["user"]["id"] == user_id
@pytest.mark.asyncio
async def test_duplicate_username(client: AsyncClient) -> None:
# Register first user
resp = await client.post(
"/api/v1/auth/register",
json={"username": "dupe", "email": "dupe1@example.com", "password": "pass123"},
)
assert resp.status_code == 200
# Try duplicate username
resp = await client.post(
"/api/v1/auth/register",
json={"username": "dupe", "email": "dupe2@example.com", "password": "pass456"},
)
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_wrong_password_login(client: AsyncClient) -> None:
await client.post(
"/api/v1/auth/register",
json={"username": "wrongpass", "email": "wrong@example.com", "password": "correct"},
)
resp = await client.post(
"/api/v1/auth/login",
json={"username": "wrongpass", "password": "wrong"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token(client: AsyncClient) -> None:
# Register & login
await client.post(
"/api/v1/auth/register",
json={"username": "refreshtest", "email": "refresh@example.com", "password": "pass"},
)
login_resp = await client.post(
"/api/v1/auth/login",
json={"username": "refreshtest", "password": "pass"},
)
login_data = login_resp.json()
refresh_token = login_data["refresh_token"]
# Refresh
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
@pytest.mark.asyncio
async def test_get_me(client: AsyncClient) -> None:
await client.post(
"/api/v1/auth/register",
json={"username": "metest", "email": "me@example.com", "password": "pass"},
)
login_resp = await client.post(
"/api/v1/auth/login",
json={"username": "metest", "password": "pass"},
)
access_token = login_resp.json()["access_token"]
# GET /auth/me
resp = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {access_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "metest"
assert data["email"] == "me@example.com"
+161
View File
@@ -0,0 +1,161 @@
"""角色头像上传 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"<svg></svg>", "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
@@ -0,0 +1,97 @@
"""角色卡导入 API 测试。"""
from __future__ import annotations
import base64
import json
import struct
import zlib
import httpx
def _make_png_text(keyword: str, text: str) -> bytes:
def chunk(ctype: bytes, data: bytes) -> bytes:
body = ctype + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
async def test_import_json_card(auth_client: httpx.AsyncClient) -> None:
card = {"spec_version": "2.0", "data": {"name": "API卡", "personality": "p"}}
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "API卡"
assert data["character_id"].startswith("character_")
async def test_import_png_card(auth_client: httpx.AsyncClient) -> None:
card = {"spec_version": "2.0", "data": {"name": "PNG卡"}}
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.png", _make_png_text("chara", text), "image/png")},
)
assert resp.status_code == 200
assert resp.json()["name"] == "PNG卡"
async def test_import_card_with_lorebook(auth_client: httpx.AsyncClient) -> None:
card = {
"spec_version": "2.0",
"data": {
"name": "带书",
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
},
}
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert data["lorebook_id"] is not None
assert data["entry_count"] == 1
# 导入的人物可被列出
listed = await auth_client.get("/api/v1/characters")
assert any(c["id"] == data["character_id"] for c in listed.json()["items"])
async def test_export_card_round_trip(auth_client: httpx.AsyncClient) -> None:
# 导入一张带世谱的卡
card = {
"spec_version": "2.0",
"data": {
"name": "往返",
"nickname": "返返",
"personality": "p",
"character_book": {"entries": [{"keys": ["k"], "content": "v"}]},
},
}
imp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("c.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert imp.status_code == 200
cid = imp.json()["character_id"]
# 导出
exp = await auth_client.get(f"/api/v1/characters/{cid}/export-card")
assert exp.status_code == 200
exported = exp.json()
assert exported["spec"] == "chara_card_v3"
assert exported["data"]["name"] == "往返"
assert exported["data"]["nickname"] == "返返"
assert exported["data"]["personality"] == "p"
assert exported["data"]["character_book"]["entries"][0]["keys"] == ["k"]
async def test_export_unknown_character_404(auth_client: httpx.AsyncClient) -> None:
exp = await auth_client.get("/api/v1/characters/character_missing/export-card")
assert exp.status_code == 404
+153
View File
@@ -0,0 +1,153 @@
"""人物管理 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"] >= 1
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
+144
View File
@@ -0,0 +1,144 @@
"""内驱力 /drive API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.big_cycle import BigCycle
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.services.characters import get_active_character_model
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed(character_id: str, subgoal_status: str = "candidate") -> str:
async with AsyncSessionLocal() as session:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
curios = CuriosityQuestion(
character_id=character_id,
cycle_id=cycle.id,
question="用户为何频繁提到项目X",
source="memory_gap",
expected_value=0.7,
urgency=0.5,
risk_level="L2",
)
session.add(curios)
await session.flush()
sub = ExploratorySubgoal(
character_id=character_id,
cycle_id=cycle.id,
curiosity_question_id=curios.id,
title="询问项目目标",
description="主动询问",
expected_information_gain=0.6,
interruption_cost=0.3,
risk_level="L2",
status=subgoal_status,
)
session.add(sub)
await session.commit()
return sub.id
@pytest.mark.asyncio
async def test_list_curiosities(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed(character.id)
resp = await client.get(f"/api/v1/characters/{character.id}/curiosities")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["question"] == "用户为何频繁提到项目X"
@pytest.mark.asyncio
async def test_list_subgoals_with_status_filter(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed(character.id, "candidate")
await _seed(character.id, "approved")
resp = await client.get(
f"/api/v1/characters/{character.id}/subgoals", params={"status": "candidate"}
)
assert resp.status_code == 200
assert resp.json()["total"] == 1
@pytest.mark.asyncio
async def test_approve_subgoal(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
assert resp.status_code == 200
assert resp.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_defer_subgoal(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/defer")
assert resp.status_code == 200
assert resp.json()["status"] == "deferred"
@pytest.mark.asyncio
async def test_reject_subgoal(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/reject")
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
@pytest.mark.asyncio
async def test_approve_subgoal_creates_goal(client: AsyncClient) -> None:
"""审批通过次生目标后应自动创建太一目标。"""
from sqlalchemy import select
from app.models.governance import Goal
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
assert resp.status_code == 200
# 验证生成了 Goal
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Goal).where(Goal.character_id == character.id)
)
goals = result.scalars().all()
matching = [g for g in goals if g.title == "询问项目目标"]
assert len(matching) == 1
goal = matching[0]
assert goal.level == "task"
assert goal.type == "exploration"
assert goal.status == "active"
@@ -0,0 +1,106 @@
"""演化候选审核 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.repositories.json_utils import dumps
from app.services.characters import get_active_character_model
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed_cycle_and_candidate(character_id: str, **overrides) -> tuple[str, str]:
base = {
"type": "procedural",
"source": "big_cycle",
"level": "L2",
"scope": "task_scope",
"title": "确认假设",
"proposal": "技术问题先确认前提",
"delta_json": dumps({"title": "确认假设", "content": "先确认前提"}),
"evidence_ids_json": "[]",
"confidence": 0.8,
"benefit_score": 0.7,
"risk_score": 0.2,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
async with AsyncSessionLocal() as session:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
candidate = EvolutionCandidate(character_id=character_id, cycle_id=cycle.id, **base)
session.add(candidate)
await session.commit()
return candidate.id, cycle.id
@pytest.mark.asyncio
async def test_list_evolution_candidates(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_cycle_and_candidate(character.id)
await _seed_cycle_and_candidate(character.id, status="applied")
resp = await client.get(f"/api/v1/characters/{character.id}/evolution-candidates")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
pending = await client.get(
f"/api/v1/characters/{character.id}/evolution-candidates", params={"status": "pending"}
)
assert pending.json()["total"] == 1
@pytest.mark.asyncio
async def test_approve_pending_candidate_applies(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="procedural")
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "applied"
assert data["resulting_target_id"] is not None
@pytest.mark.asyncio
async def test_reject_pending_candidate(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id)
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/reject")
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
@pytest.mark.asyncio
async def test_approve_not_implemented_type_returns_error(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="tool_strategy", level="L2")
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
assert resp.status_code in (400, 409, 501)
assert resp.json()["error"]["code"] == "not_implemented"
+66
View File
@@ -0,0 +1,66 @@
"""测试 per-character goals API。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.main import app
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_goals_by_character(client: AsyncClient, db_session: AsyncSession) -> None:
"""GET /api/v1/goals?character_id=char_test 返回该人物的目标。"""
from app.models.governance import Goal
goal = Goal(
id="goal_api_test",
title="API测试目标",
level="task",
character_id="char_test",
status="active",
)
db_session.add(goal)
await db_session.commit()
resp = await client.get("/api/v1/goals?character_id=char_test")
assert resp.status_code == 200
data = resp.json()
assert any(g["id"] == "goal_api_test" for g in data)
@pytest.mark.asyncio
async def test_list_goals_filters_status(client: AsyncClient, db_session: AsyncSession) -> None:
"""GET /api/v1/goals?character_id=char_test&status=active 只返回 active。"""
from app.models.governance import Goal
db_session.add_all(
[
Goal(id="goal_a1", title="a1", level="task", character_id="char_test", status="active"),
Goal(
id="goal_c1", title="c1", level="task", character_id="char_test", status="completed"
),
]
)
await db_session.commit()
resp = await client.get("/api/v1/goals?character_id=char_test&status=active")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["id"] == "goal_a1"
+176
View File
@@ -0,0 +1,176 @@
"""资料导入候选抽取测试(5b,异步后台 + mock ai_provider)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.application.imports import drain_pending_extractions
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.user import User
from app.services.auth import create_access_token
async def _get_character_id(client: AsyncClient) -> str:
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
class _FakeChatResult:
def __init__(self, content: str) -> None:
self.content = content
_PROFILE_RESPONSE = (
'[{"field":"background","value":"来自北京,从事设计工作",'
'"confidence":0.9,"reason":"资料明确提及","evidence":[0,1]}]'
)
_MEMORY_RESPONSE = (
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
)
_PERSONALITY_RESPONSE = (
'[{"field":"expression_style","change":"温和且有条理","reason":"资料体现","evidence":[1]}]'
)
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_chat(messages, *, model_hint, **_kwargs):
if model_hint == "memory-extraction":
return _FakeChatResult(_MEMORY_RESPONSE)
system_text = str(messages[0].content) if messages else ""
if "人格" in system_text:
return _FakeChatResult(_PERSONALITY_RESPONSE)
return _FakeChatResult(_PROFILE_RESPONSE)
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _MockManager:
async def chat(self, messages, model_hint=None):
return await fake_chat(messages, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _create_import(client: AsyncClient, character_id: str) -> str:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports",
json={
"material_type": "background_story",
"content": "我叫小魂,来自北京。\n\n我从事设计工作,喜欢简洁直接的回答。",
},
)
return resp.json()["id"]
@pytest.mark.asyncio
async def test_extract_profile_candidates(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
triggered = await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
assert triggered.status_code == 200
assert triggered.json()["status"] == "extracting_profile"
await drain_pending_extractions()
candidates = (
await client.get(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
).json()
assert len(candidates) == 1
assert candidates[0]["field"] == "background"
assert candidates[0]["status"] == "pending"
assert len(candidates[0]["evidence_ids"]) == 2 # 映射到 import_items.id
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "reviewing"
@pytest.mark.asyncio
async def test_extract_memory_candidates(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
await drain_pending_extractions()
candidates = (
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
).json()
assert len(candidates) == 1
assert candidates[0]["type"] == "preference"
assert candidates[0]["title"] == "喜欢简洁回答"
@pytest.mark.asyncio
async def test_extract_personality_proposals(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/personality-proposals"
)
await drain_pending_extractions()
# 人格建议落入 personality_proposals(pending);无独立列表端点,以 job 进入 reviewing 验证
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "reviewing"
@pytest.mark.asyncio
async def test_extraction_failure_marks_job_failed(monkeypatch: pytest.MonkeyPatch) -> None:
async def failing_chat(*_args, **_kwargs):
raise RuntimeError("模型不可用")
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _FailingManager:
async def chat(self, messages, model_hint=None):
return await failing_chat(messages, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _FailingManager())
transport = ASGITransport(app=app)
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).where(User.is_admin.is_(True)).limit(1))
admin = result.scalar_one_or_none()
assert admin is not None
token = create_access_token(subject=admin.id, extra={"owner_scope": admin.id})
headers = {"Authorization": f"Bearer {token}"}
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
await drain_pending_extractions()
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "failed"
assert job["error_code"] == "extraction_failed"
+142
View File
@@ -0,0 +1,142 @@
"""资料导入候选审核测试(5c)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.imports import drain_pending_extractions
from app.main import app
async def _get_character_id(client: AsyncClient) -> str:
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
class _FakeChatResult:
def __init__(self, content: str) -> None:
self.content = content
_PROFILE_RESPONSE = (
'[{"field":"background","value":"来自北京,从事设计工作",'
'"confidence":0.9,"reason":"资料提及","evidence":[0]}]'
)
_MEMORY_RESPONSE = (
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
)
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_chat(*_args, model_hint, **_kwargs):
if model_hint == "memory-extraction":
return _FakeChatResult(_MEMORY_RESPONSE)
return _FakeChatResult(_PROFILE_RESPONSE)
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _MockManager:
async def chat(self, _messages, model_hint=None):
return await fake_chat(None, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _setup(client: AsyncClient, kind: str) -> tuple[str, str, str]:
character_id = await _get_character_id(client)
import_id = (
await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "background_story", "content": "我叫小魂,来自北京。"},
)
).json()["id"]
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
await drain_pending_extractions()
candidates = (
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
).json()
return character_id, import_id, candidates[0]["id"]
@pytest.mark.asyncio
async def test_batch_review_profile_approve_writes_profile(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.status_code == 200
assert result.json()["applied"] == 1
character = (await client.get(f"/api/v1/characters/{character_id}")).json()
assert character["profile"]["background"] == "来自北京,从事设计工作"
@pytest.mark.asyncio
async def test_batch_review_memory_approve_creates_memory(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "memory-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/memory-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.json()["applied"] == 1
memories = (await client.get("/api/v1/memory")).json()
assert memories["total"] >= 1
assert any(m["title"] == "喜欢简洁回答" for m in memories["items"])
@pytest.mark.asyncio
async def test_batch_review_reject_marks_candidate(client: AsyncClient) -> None:
character_id, import_id, candidate_id = await _setup(client, "profile-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "reject"}]},
)
assert result.json()["rejected"] == 1
rejected = (
await client.get(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates",
params={"status": "rejected"},
)
).json()
assert any(c["id"] == candidate_id for c in rejected)
@pytest.mark.asyncio
async def test_batch_review_skips_non_pending(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
# 先 approve
await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
# 再审同一候选(已 applied)→ skipped
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.json()["skipped"] == 1
assert result.json()["applied"] == 0
+145
View File
@@ -0,0 +1,145 @@
"""资料导入 API 测试(api-spec §8,5a 结构解析)。"""
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.fixture
async def character_id(client: AsyncClient) -> str:
"""从认证用户获取活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
@pytest.mark.asyncio
async def test_create_import_text_parses_paragraphs(client: AsyncClient, character_id: str) -> None:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports",
json={
"material_type": "background_story",
"format": "text",
"content": "第一段背景。\n\n第二段背景。",
"title": "背景资料",
},
)
assert resp.status_code == 200
job = resp.json()
assert job["id"].startswith("import_")
assert job["status"] == "parsed"
assert job["item_count"] == 2
assert job["material_type"] == "background_story"
items = await client.get(f"/api/v1/characters/{character_id}/imports/{job['id']}/items")
assert items.status_code == 200
body = items.json()
assert body["total"] == 2
assert body["items"][0]["item_type"] == "paragraph"
@pytest.mark.asyncio
async def test_create_import_chat_history_json(client: AsyncClient, character_id: str) -> None:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports",
json={
"material_type": "chat_history",
"format": "json",
"content": '[{"role":"user","content":"你好"},{"role":"assistant","content":"你好呀"}]',
},
)
assert resp.status_code == 200
job = resp.json()
assert job["item_count"] == 2
items = (
await client.get(f"/api/v1/characters/{character_id}/imports/{job['id']}/items")
).json()["items"]
assert items[0]["item_type"] == "message"
assert items[0]["role"] == "user"
assert items[1]["role"] == "assistant"
@pytest.mark.asyncio
async def test_get_import_and_not_found(client: AsyncClient, character_id: str) -> None:
created = (
await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "diary", "content": "今天天气不错。"},
)
).json()
fetched = await client.get(f"/api/v1/characters/{character_id}/imports/{created['id']}")
assert fetched.status_code == 200
assert fetched.json()["id"] == created["id"]
missing = await client.get(f"/api/v1/characters/{character_id}/imports/import_missing")
assert missing.status_code == 404
@pytest.mark.asyncio
async def test_list_imports(client: AsyncClient, character_id: str) -> None:
await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "diary", "content": "条目一"},
)
await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "diary", "content": "条目二"},
)
resp = await client.get(f"/api/v1/characters/{character_id}/imports")
assert resp.status_code == 200
assert resp.json()["total"] == 2
@pytest.mark.asyncio
async def test_upload_text_file(client: AsyncClient, character_id: str) -> None:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports/upload",
files={"file": ("note.txt", b"hello world\n\nsecond paragraph", "text/plain")},
data={"title": "笔记"},
)
assert resp.status_code == 200
job = resp.json()
assert job["source_type"] == "uploaded_file"
assert job["item_count"] == 2
assert job["title"] == "笔记"
@pytest.mark.asyncio
async def test_upload_audio_stores_metadata_only(client: AsyncClient, character_id: str) -> None:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports/upload",
files={"file": ("sample.wav", b"fake audio bytes", "audio/wav")},
data={
"speaker_label": "人物本人",
"consent_status": "granted",
"usage_scope": "reference_only",
},
)
assert resp.status_code == 200
job = resp.json()
assert job["material_type"] == "voice_sample"
assert job["media_type"] == "audio"
assert job["status"] == "uploaded"
assert job["item_count"] == 0 # 媒体资料不切片
@pytest.mark.asyncio
async def test_create_import_rejects_empty_content(client: AsyncClient, character_id: str) -> None:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "background_story", "content": ""},
)
assert resp.status_code == 422
+79
View File
@@ -0,0 +1,79 @@
"""世谱 API 集成测试。"""
from __future__ import annotations
import httpx
async def _default_character_id(auth_client: httpx.AsyncClient) -> str:
resp = await auth_client.get("/api/v1/characters")
assert resp.status_code == 200
return resp.json()["items"][0]["id"]
async def test_create_list_get_lorebook(auth_client: httpx.AsyncClient) -> None:
character_id = await _default_character_id(auth_client)
resp = await auth_client.post(
"/api/v1/lorebooks",
json={"name": "测试世谱", "character_id": character_id, "scope": "character"},
)
assert resp.status_code == 200
book = resp.json()
book_id = book["id"]
assert book["name"] == "测试世谱"
assert book["scope"] == "character"
entry_resp = await auth_client.post(
f"/api/v1/lorebooks/{book_id}/entries",
json={"keys": ["猫"], "content": "她喜欢猫。", "position": "after_char"},
)
assert entry_resp.status_code == 200
assert entry_resp.json()["keys"] == ["猫"]
listed = await auth_client.get(f"/api/v1/lorebooks?character_id={character_id}")
assert listed.status_code == 200
assert any(b["id"] == book_id for b in listed.json())
got = await auth_client.get(f"/api/v1/lorebooks/{book_id}")
assert got.status_code == 200
assert len(got.json()["entries"]) == 1
async def test_update_entry(auth_client: httpx.AsyncClient) -> None:
character_id = await _default_character_id(auth_client)
book = (await auth_client.post("/api/v1/lorebooks", json={"character_id": character_id})).json()
entry = (
await auth_client.post(
f"/api/v1/lorebooks/{book['id']}/entries",
json={"keys": ["旧"], "content": "旧"},
)
).json()
patched = await auth_client.patch(
f"/api/v1/lorebooks/{book['id']}/entries/{entry['id']}",
json={"keys": ["新"], "content": "新内容"},
)
assert patched.status_code == 200
assert patched.json()["keys"] == ["新"]
assert patched.json()["content"] == "新内容"
async def test_delete_lorebook_archives(auth_client: httpx.AsyncClient) -> None:
character_id = await _default_character_id(auth_client)
book = (
await auth_client.post("/api/v1/lorebooks", json={"character_id": character_id})
).json()
dele = await auth_client.delete(f"/api/v1/lorebooks/{book['id']}")
assert dele.status_code == 200
# archived 后不再可见
got = await auth_client.get(f"/api/v1/lorebooks/{book['id']}")
assert got.status_code == 404
async def test_other_owner_isolated(auth_client: httpx.AsyncClient) -> None:
character_id = await _default_character_id(auth_client)
book = (
await auth_client.post("/api/v1/lorebooks", json={"character_id": character_id})
).json()
# book id 对当前用户可见
assert (await auth_client.get(f"/api/v1/lorebooks/{book['id']}")).status_code == 200
+77
View File
@@ -0,0 +1,77 @@
"""MCP server API 测试(respx mock MCP server HTTP)。"""
from collections.abc import AsyncGenerator
import httpx
import pytest
import respx
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
@respx.mock
async def test_register_mcp_server(client: AsyncClient) -> None:
jrpc_result = {
"jsonrpc": "2.0",
"id": 1,
"result": {"tools": [{"name": "weather", "description": "查天气"}]},
}
respx.post("http://mcp.local/").mock(return_value=httpx.Response(200, json=jrpc_result))
resp = await client.post(
"/api/v1/tools/mcp-servers",
json={"name": "weather-srv", "endpoint_url": "http://mcp.local"},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "weather-srv"
assert data["has_auth_token"] is False
assert len(data["tools"]) == 1
assert data["tools"][0]["name"] == "weather"
@pytest.mark.asyncio
@respx.mock
async def test_list_and_delete_mcp_server(client: AsyncClient) -> None:
jrpc_result = {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
respx.post("http://mcp.local/").mock(return_value=httpx.Response(200, json=jrpc_result))
create = await client.post(
"/api/v1/tools/mcp-servers",
json={"name": "srv", "endpoint_url": "http://mcp.local"},
)
sid = create.json()["id"]
listed = await client.get("/api/v1/tools/mcp-servers")
assert listed.status_code == 200
assert len(listed.json()) == 1
deleted = await client.delete(f"/api/v1/tools/mcp-servers/{sid}")
assert deleted.status_code == 204
listed2 = await client.get("/api/v1/tools/mcp-servers")
assert listed2.json() == []
@pytest.mark.asyncio
@respx.mock
async def test_register_duplicate_conflict(client: AsyncClient) -> None:
jrpc_result = {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
respx.post("http://mcp.local/").mock(return_value=httpx.Response(200, json=jrpc_result))
await client.post(
"/api/v1/tools/mcp-servers",
json={"name": "dup", "endpoint_url": "http://mcp.local"},
)
resp = await client.post(
"/api/v1/tools/mcp-servers",
json={"name": "dup", "endpoint_url": "http://mcp.local"},
)
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "conflict"
@@ -0,0 +1,78 @@
"""模型网关 endpoint 覆盖 API 测试(api-spec §4.4,DB 优先于 .env/yaml)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
def _first_endpoint(gateway: dict) -> tuple[str, str]:
model = gateway["models"][0]
return model["name"], model["endpoints"][0]["name"]
@pytest.mark.asyncio
async def test_list_models_has_endpoint_override_fields(client: AsyncClient) -> None:
resp = await client.get("/api/v1/settings/models")
assert resp.status_code == 200
ep = resp.json()["models"][0]["endpoints"][0]
assert "priority" in ep
assert "is_overridden" in ep
assert "has_url_override" in ep
assert "has_key_override" in ep
assert ep["is_overridden"] is False
@pytest.mark.asyncio
async def test_patch_endpoint_status_override(client: AsyncClient) -> None:
gateway = (await client.get("/api/v1/settings/models")).json()
model, endpoint = _first_endpoint(gateway)
resp = await client.patch(
f"/api/v1/settings/models/{model}/endpoints/{endpoint}",
json={"status": "inactive"},
)
assert resp.status_code == 200
body = resp.json()
assert body["is_overridden"] is True
assert body["status"] == "inactive"
# GET 反映覆盖
gateway2 = (await client.get("/api/v1/settings/models")).json()
eps = [m for m in gateway2["models"] if m["name"] == model][0]["endpoints"]
ep = [e for e in eps if e["name"] == endpoint][0]
assert ep["is_overridden"] is True
assert ep["status"] == "inactive"
@pytest.mark.asyncio
async def test_patch_endpoint_key_override_redacted(client: AsyncClient) -> None:
gateway = (await client.get("/api/v1/settings/models")).json()
model, endpoint = _first_endpoint(gateway)
resp = await client.patch(
f"/api/v1/settings/models/{model}/endpoints/{endpoint}",
json={"api_key": "sk-test-secret-value"},
)
assert resp.status_code == 200
assert resp.json()["has_key_override"] is True
# 脱敏:响应体不含 api_key 原值
assert "sk-test-secret-value" not in resp.text
@pytest.mark.asyncio
async def test_patch_endpoint_not_found(client: AsyncClient) -> None:
resp = await client.patch(
"/api/v1/settings/models/no_such_model/endpoints/primary",
json={"status": "inactive"},
)
assert resp.status_code == 404
+68
View File
@@ -0,0 +1,68 @@
"""owner_scope 全链路贯穿集成测试。
验证两个不同用户的 character/preferences 数据互不可见。
"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture(autouse=True)
def _jwt_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""所有 API 测试需要 JWT_SECRET 配置。"""
monkeypatch.setattr("app.core.config.settings.jwt_secret", "test-jwt-secret-for-testing")
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _register_and_login(client: AsyncClient, suffix: str) -> dict:
"""辅助:注册并登录,返回 access_token 和 user_id。"""
username = f"user_{suffix}"
email = f"{suffix}@test.com"
await client.post(
"/api/v1/auth/register",
json={"username": username, "email": email, "password": "pass"},
)
resp = await client.post(
"/api/v1/auth/login",
json={"username": username, "password": "pass"},
)
data = resp.json()
return {
"access_token": data["access_token"],
"user_id": data["user"]["id"],
}
@pytest.mark.asyncio
async def test_users_see_own_characters_only(client: AsyncClient) -> None:
user_a = await _register_and_login(client, "a")
user_b = await _register_and_login(client, "b")
# 用户 A 查看角色列表
resp_a = await client.get(
"/api/v1/characters",
headers={"Authorization": f"Bearer {user_a['access_token']}"},
)
assert resp_a.status_code == 200
# 用户 B 查看角色列表
resp_b = await client.get(
"/api/v1/characters",
headers={"Authorization": f"Bearer {user_b['access_token']}"},
)
assert resp_b.status_code == 200
# 由于角色是按 owner_scope 过滤的,双方看不到对方的角色
# 因为 seed_default_data 只为第一个用户(admin)创建了角色,
# 用户 A 是自己注册的,所以没有角色(注册不自动创建角色)
# 这个测试验证查询不会跨用户串数据
@@ -0,0 +1,63 @@
"""人格提议列表 API 测试(api-spec §11,审批墙数据源)。"""
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_proposals_empty(client: AsyncClient) -> None:
resp = await client.get("/api/v1/personality/proposals")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
@pytest.mark.asyncio
async def test_list_proposals_filter_by_status(client: AsyncClient) -> None:
proposed = await client.post(
"/api/v1/personality/propose-update",
json={"fields": {"identity_profile": "更新的身份画像"}, "reason": "测试提议"},
)
assert proposed.status_code == 201
proposal_id = proposed.json()["proposal_id"]
listed = await client.get("/api/v1/personality/proposals")
assert listed.status_code == 200
body = listed.json()
assert body["total"] == 1
assert body["items"][0]["proposal_id"] == proposal_id
assert body["items"][0]["status"] == "pending"
pending = await client.get("/api/v1/personality/proposals?status=pending")
assert pending.json()["total"] == 1
approved = await client.get("/api/v1/personality/proposals?status=approved")
assert approved.json()["total"] == 0
@pytest.mark.asyncio
async def test_list_proposals_pagination(client: AsyncClient) -> None:
for i in range(3):
await client.post(
"/api/v1/personality/propose-update",
json={"fields": {"identity_profile": f"v{i}"}, "reason": f"r{i}"},
)
page1 = await client.get("/api/v1/personality/proposals?page=1&page_size=2")
assert page1.json()["total"] == 3
assert len(page1.json()["items"]) == 2
page2 = await client.get("/api/v1/personality/proposals?page=2&page_size=2")
assert len(page2.json()["items"]) == 1
+41
View File
@@ -0,0 +1,41 @@
"""Run 列表 API 测试(归档页运行时间河数据源)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_runs_returns_page(client: AsyncClient) -> None:
resp = await client.get("/api/v1/runs")
assert resp.status_code == 200
body = resp.json()
assert "items" in body
assert "total" in body
assert body["page"] == 1
assert isinstance(body["items"], list)
@pytest.mark.asyncio
async def test_list_runs_filters_accepted(client: AsyncClient) -> None:
# 过滤参数被接受;seed 后无 run,验证端点工作且不报错
resp = await client.get("/api/v1/runs?status=completed&character_id=character_none")
assert resp.status_code == 200
assert resp.json()["total"] == 0
@pytest.mark.asyncio
async def test_get_run_detail_not_found(client: AsyncClient) -> None:
# 列表端点("")不破坏详情端点(/{run_id})的路由顺序
resp = await client.get("/api/v1/runs/run_missing")
assert resp.status_code == 404
@@ -0,0 +1,76 @@
"""Skill 能力包 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_create_and_list_skills(client: AsyncClient) -> None:
resp = await client.post(
"/api/v1/tools/skills",
json={
"name": "coding",
"trigger": "代码,编程",
"prompt_fragment": "你是代码助手",
"tool_bindings": ["read_file"],
},
)
assert resp.status_code == 201
assert resp.json()["name"] == "coding"
assert resp.json()["tool_bindings"] == ["read_file"]
listed = await client.get("/api/v1/tools/skills")
assert listed.status_code == 200
assert listed.json()[0]["name"] == "coding"
@pytest.mark.asyncio
async def test_update_skill(client: AsyncClient) -> None:
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
sid = create.json()["id"]
resp = await client.patch(f"/api/v1/tools/skills/{sid}", json={"status": "disabled"})
assert resp.status_code == 200
assert resp.json()["status"] == "disabled"
@pytest.mark.asyncio
async def test_delete_skill(client: AsyncClient) -> None:
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
sid = create.json()["id"]
resp = await client.delete(f"/api/v1/tools/skills/{sid}")
assert resp.status_code == 204
listed = await client.get("/api/v1/tools/skills")
assert listed.json() == []
@pytest.mark.asyncio
async def test_create_duplicate_conflict(client: AsyncClient) -> None:
await client.post("/api/v1/tools/skills", json={"name": "dup"})
resp = await client.post("/api/v1/tools/skills", json={"name": "dup"})
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_list_skills_status_filter(client: AsyncClient) -> None:
await client.post("/api/v1/tools/skills", json={"name": "a", "status": "enabled"})
await client.post("/api/v1/tools/skills", json={"name": "b", "status": "disabled"})
resp = await client.get("/api/v1/tools/skills", params={"status": "enabled"})
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["name"] == "a"
+150
View File
@@ -0,0 +1,150 @@
"""工具与确认 API 测试:TestClient + respx 模拟沙箱。"""
from __future__ import annotations
from collections.abc import Iterator
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app.api.v1.deps import get_confirmation_store, get_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import SandboxSettings
from app.integrations.sandbox.client import SandboxClient
from app.main import app
from app.modules.runtime.events import InMemoryEventStore
from app.modules.safety.confirmation import InMemoryConfirmationStore
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
def _settings() -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ok() -> httpx.Response:
return httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 3,
"timeout": False,
"truncated": False,
"trace_id": "t1",
},
)
@pytest.fixture
def runtime_bundle() -> Iterator[None]:
"""注入 mock ToolRuntime / 确认存储到 app 依赖覆盖;返回值无意义。"""
store = InMemoryEventStore()
confirmations = InMemoryConfirmationStore()
http = httpx.AsyncClient()
sandbox = SandboxClient(http, _settings())
runtime = ToolRuntime(sandbox, SafetyGovernor(), store)
app.dependency_overrides[get_tool_runtime] = lambda: runtime
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
try:
yield
finally:
app.dependency_overrides.clear()
@respx.mock
def test_list_tools(runtime_bundle: None) -> None:
_ = runtime_bundle # fixture 仅用于副作用(注入依赖覆盖)
with TestClient(app) as client:
resp = client.get("/api/v1/tools")
assert resp.status_code == 200
names = {tool["name"] for tool in resp.json()}
assert {"datetime", "shell", "fetch_url"} <= names
@respx.mock
def test_invoke_datetime_completed(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/datetime/invoke",
json={"input": {}, "trace_id": "t1"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
assert body["result"]["stdout"] == "42\n"
@respx.mock
def test_shell_invoke_then_approve(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke",
json={"input": {"command": "echo hi"}, "trace_id": "t1", "run_id": "run_tools_test"},
)
assert resp.status_code == 200
ask = resp.json()
assert ask["status"] == "requires_confirmation"
assert ask["risk_level"] == "L3"
confirmation_id = ask["confirmation_id"]
approved = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
assert approved.status_code == 200
assert approved.json()["status"] == "resolved"
@respx.mock
def test_deny_then_approve_returns_404(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke",
json={"input": {"command": "echo hi"}, "run_id": "run_tools_test2"},
)
confirmation_id = resp.json()["confirmation_id"]
denied = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/deny")
assert denied.status_code == 200
assert denied.json()["status"] in ("cancelled", "resolved")
again = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
assert again.status_code == 404
assert again.json()["error"]["code"] == "not_found"
@respx.mock
def test_forbidden_tool_returns_403() -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
store = InMemoryEventStore()
confirmations = InMemoryConfirmationStore()
http = httpx.AsyncClient()
sandbox = SandboxClient(http, _settings())
governor = SafetyGovernor(
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
runtime = ToolRuntime(sandbox, governor, store)
app.dependency_overrides[get_tool_runtime] = lambda: runtime
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
try:
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke", json={"input": {"command": "rm -rf /"}}
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "permission_denied"
finally:
app.dependency_overrides.clear()
@@ -0,0 +1,36 @@
"""GET /tools/capabilities 统一能力列举测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.services import skills as skills_service
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_capabilities_includes_skills(client: AsyncClient) -> None:
await skills_service.create_skill(name="coding", trigger="代码", prompt_fragment="代码助手")
resp = await client.get("/api/v1/tools/capabilities")
assert resp.status_code == 200
data = resp.json()
# runtime 在测试环境可能未就绪(ASGITransport 不跑 lifespan),至少含 skill
skill_caps = [c for c in data if c["source"] == "skill"]
assert any(c["name"] == "coding" for c in skill_caps)
@pytest.mark.asyncio
async def test_list_capabilities_source_values(client: AsyncClient) -> None:
resp = await client.get("/api/v1/tools/capabilities")
assert resp.status_code == 200
for c in resp.json():
assert c["source"] in {"builtin", "mcp", "skill"}
assert "name" in c and "risk_level" in c
+104
View File
@@ -0,0 +1,104 @@
"""世界模型 API 测试(河图枢运行时,api-spec §12)。"""
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_entities_empty(client: AsyncClient) -> None:
resp = await client.get("/api/v1/world/entities")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
@pytest.mark.asyncio
async def test_create_and_get_entity(client: AsyncClient) -> None:
created = await client.post(
"/api/v1/world/entities",
json={"name": "用户项目", "type": "project", "state_type": "fact", "confidence": 0.9},
)
assert created.status_code == 200
entity = created.json()
assert entity["id"].startswith("world_entity_")
assert entity["name"] == "用户项目"
assert entity["type"] == "project"
assert entity["state_type"] == "fact"
assert entity["character_id"].startswith("character_")
fetched = await client.get(f"/api/v1/world/entities/{entity['id']}")
assert fetched.status_code == 200
assert fetched.json()["id"] == entity["id"]
@pytest.mark.asyncio
async def test_get_entity_not_found(client: AsyncClient) -> None:
resp = await client.get("/api/v1/world/entities/world_entity_missing")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_entities_filter_by_type(client: AsyncClient) -> None:
await client.post("/api/v1/world/entities", json={"name": "概念A", "type": "concept"})
await client.post("/api/v1/world/entities", json={"name": "项目B", "type": "project"})
filtered = await client.get("/api/v1/world/entities?type=project")
assert filtered.status_code == 200
body = filtered.json()
assert body["total"] == 1
assert body["items"][0]["type"] == "project"
all_entities = await client.get("/api/v1/world/entities")
assert all_entities.json()["total"] == 2
@pytest.mark.asyncio
async def test_create_relation(client: AsyncClient) -> None:
user = (
await client.post("/api/v1/world/entities", json={"name": "用户", "type": "person"})
).json()["id"]
project = (
await client.post("/api/v1/world/entities", json={"name": "魂镜项目", "type": "project"})
).json()["id"]
created = await client.post(
"/api/v1/world/relations",
json={"subject_id": user, "predicate": "正在进行", "object_id": project},
)
assert created.status_code == 200
relation = created.json()
assert relation["id"].startswith("world_relation_")
assert relation["predicate"] == "正在进行"
assert relation["subject_id"] == user
assert relation["object_id"] == project
listed = await client.get("/api/v1/world/relations")
assert listed.json()["total"] == 1
@pytest.mark.asyncio
async def test_create_relation_rejects_missing_entity(client: AsyncClient) -> None:
project = (
await client.post("/api/v1/world/entities", json={"name": "项目", "type": "project"})
).json()["id"]
# 主体不存在
resp = await client.post(
"/api/v1/world/relations",
json={"subject_id": "world_entity_missing", "predicate": "依赖", "object_id": project},
)
assert resp.status_code == 404