初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
Whitespace-only changes.
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user