merge: T6 知识库实体与图谱(T-P2-01/05)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# pytest prepend 导入模式按 basename 注册模块:unit 与 integration 下的
|
||||
# 同名测试文件(如 test_kb_entity.py,任务契约指定两端同名)会撞模块名。
|
||||
# 加包标记后模块名唯一化为 tests.integration.<name>,与 tests/__init__.py 一致。
|
||||
@@ -0,0 +1,406 @@
|
||||
"""kb 实体底座集成用例(真实 PostgreSQL,T-P2-01/05).
|
||||
|
||||
覆盖:实体生命周期(draft→reviewing→published、改 published 被拒、
|
||||
draft 改 version+1)、关系写入约束(自环/重复/端点缺失)、递归 CTE
|
||||
1~3 跳可解释路径与防环、列表过滤分页、自建色板读写。
|
||||
实体/关系录入没有 HTTP 路由(运营后台属 T-P2-09),这些路径直接驱动
|
||||
service;读路径走 HTTP,顺带验证路由装配与统一信封。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _dispose_engine_after_test() -> AsyncIterator[None]:
|
||||
"""用例结束即释放全局引擎。
|
||||
|
||||
引擎是进程级懒加载单例,而 pytest-asyncio 每个用例一个新 event loop:
|
||||
走 HTTP 的用例靠 client 的 lifespan teardown 顺手 dispose;本文件的
|
||||
service 直连用例没有这层兜底,不主动释放会把绑定旧 loop 的连接池
|
||||
泄漏给后续用例(表现为 Event loop is closed / sqlalchemy 报错)。
|
||||
"""
|
||||
yield
|
||||
from packages.shared.db import dispose_engine
|
||||
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> Any:
|
||||
import httpx
|
||||
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
# kb 链路不碰 LLM,无需替换 provider;lifespan 照常起网关与连接池
|
||||
app = create_app(get_settings())
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth(client: Any) -> dict[str, Any]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/dev-token", json={"device_key": f"t6-{uuid.uuid4().hex[:8]}"}
|
||||
)
|
||||
assert resp.json()["code"] == 0
|
||||
data = resp.json()["data"]
|
||||
return {
|
||||
"headers": {"Authorization": f"Bearer {data['access_token']}"},
|
||||
"user_id": data["user_id"],
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def service_scope() -> AsyncIterator[Any]:
|
||||
"""直连 service 的事务作用域:成功 commit、异常 rollback。"""
|
||||
from packages.kb.service import KbService
|
||||
from packages.shared.db import get_session_factory
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
try:
|
||||
yield KbService(session)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def _mk_entity(
|
||||
kb: Any, *, entity_type: str, name: str, attrs: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
from packages.kb.schemas import EntityCreate
|
||||
|
||||
return await kb.create_entity(
|
||||
payload=EntityCreate(entity_type=entity_type, name=name, attrs=attrs or {}), # type: ignore[arg-type]
|
||||
updated_by="it",
|
||||
)
|
||||
|
||||
|
||||
async def test_entity_lifecycle_publish_freezes_and_version_bumps(
|
||||
client: Any, auth: dict[str, Any]
|
||||
) -> None:
|
||||
headers = auth["headers"]
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
# 新建 → draft / version=1
|
||||
created = await client.post(
|
||||
"/api/v1/kb/items",
|
||||
json={"entity_type": "item", "name": f"白衬衫-{suffix}", "attrs": {"sleeve": "long"}},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 200
|
||||
body = created.json()
|
||||
assert body["code"] == 0
|
||||
entity = body["data"]
|
||||
entity_id = entity["entity_id"]
|
||||
assert entity["status"] == "draft"
|
||||
assert entity["version"] == 1
|
||||
assert entity["attrs"] == {"sleeve": "long"}
|
||||
|
||||
# 详情可读
|
||||
detail = (await client.get(f"/api/v1/kb/items/{entity_id}", headers=headers)).json()
|
||||
assert detail["code"] == 0
|
||||
assert detail["data"]["name"] == f"白衬衫-{suffix}"
|
||||
|
||||
# 非法跃迁:draft 不能直接 published
|
||||
shortcut = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"status": "published"}, headers=headers
|
||||
)
|
||||
assert shortcut.status_code == 409
|
||||
assert shortcut.json()["code"] == 40900
|
||||
|
||||
# draft → reviewing(纯跃迁,version 不变)→ published
|
||||
reviewing = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"status": "reviewing"}, headers=headers
|
||||
)
|
||||
assert reviewing.json()["data"]["status"] == "reviewing"
|
||||
assert reviewing.json()["data"]["version"] == 1
|
||||
published = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"status": "published"}, headers=headers
|
||||
)
|
||||
assert published.json()["data"]["status"] == "published"
|
||||
|
||||
# 改 published 被拒(内容与回 draft 都不行)
|
||||
frozen = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"attrs": {"sleeve": "short"}}, headers=headers
|
||||
)
|
||||
assert frozen.status_code == 409
|
||||
assert frozen.json()["code"] == 40900
|
||||
backdoor = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"status": "draft"}, headers=headers
|
||||
)
|
||||
assert backdoor.status_code == 409
|
||||
assert backdoor.json()["code"] == 40900
|
||||
|
||||
# published → archived 收尾合法;archived 终态
|
||||
archived = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"status": "archived"}, headers=headers
|
||||
)
|
||||
assert archived.json()["data"]["status"] == "archived"
|
||||
resurrect = await client.patch(
|
||||
f"/api/v1/kb/items/{entity_id}", json={"status": "reviewing"}, headers=headers
|
||||
)
|
||||
assert resurrect.status_code == 409
|
||||
|
||||
# draft 实体改内容 → version+1;同名同类目拒绝
|
||||
draft2 = await client.post(
|
||||
"/api/v1/kb/items",
|
||||
json={"entity_type": "item", "name": f"牛仔裤-{suffix}"},
|
||||
headers=headers,
|
||||
)
|
||||
draft2_id = draft2.json()["data"]["entity_id"]
|
||||
edited = await client.patch(
|
||||
f"/api/v1/kb/items/{draft2_id}",
|
||||
json={"attrs": {"fit": "slim"}, "source": "搭配手册"},
|
||||
headers=headers,
|
||||
)
|
||||
assert edited.json()["data"]["version"] == 2
|
||||
assert edited.json()["data"]["attrs"] == {"fit": "slim"}
|
||||
duplicate = await client.post(
|
||||
"/api/v1/kb/items",
|
||||
json={"entity_type": "item", "name": f"牛仔裤-{suffix}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert duplicate.status_code == 400
|
||||
assert duplicate.json()["code"] == 40000
|
||||
|
||||
# 非法 entity_type / 超界 max_hops 由入口校验拦成 422
|
||||
bad_type = await client.post(
|
||||
"/api/v1/kb/items", json={"entity_type": "user", "name": "x"}, headers=headers
|
||||
)
|
||||
assert bad_type.status_code == 422
|
||||
assert bad_type.json()["code"] == 40001
|
||||
bad_hops = await client.get(f"/api/v1/kb/items/{draft2_id}/graph?max_hops=4", headers=headers)
|
||||
assert bad_hops.status_code == 422
|
||||
|
||||
|
||||
async def test_relation_rules_self_loop_duplicate_and_missing() -> None:
|
||||
from packages.kb.schemas import RelationCreate
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
async with service_scope() as kb:
|
||||
a = await _mk_entity(kb, entity_type="item", name=f"selfloop-{suffix}")
|
||||
b = await _mk_entity(kb, entity_type="style", name=f"duppair-{suffix}")
|
||||
|
||||
# 自环拒绝
|
||||
with pytest.raises(AppError) as loop_err:
|
||||
await kb.add_relation(
|
||||
payload=RelationCreate(
|
||||
from_entity_id=a.entity_id, to_entity_id=a.entity_id, rel_type="suits"
|
||||
)
|
||||
)
|
||||
assert loop_err.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
# 端点缺失 → 40400
|
||||
with pytest.raises(AppError) as missing_err:
|
||||
await kb.add_relation(
|
||||
payload=RelationCreate(from_entity_id=a.entity_id, to_entity_id=1, rel_type="suits")
|
||||
)
|
||||
assert missing_err.value.code == ErrorCode.NOT_FOUND
|
||||
|
||||
# 三元组重复拒绝
|
||||
await kb.add_relation(
|
||||
payload=RelationCreate(
|
||||
from_entity_id=a.entity_id, to_entity_id=b.entity_id, rel_type="suits"
|
||||
)
|
||||
)
|
||||
with pytest.raises(AppError) as dup_err:
|
||||
await kb.add_relation(
|
||||
payload=RelationCreate(
|
||||
from_entity_id=a.entity_id, to_entity_id=b.entity_id, rel_type="suits"
|
||||
)
|
||||
)
|
||||
assert dup_err.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
|
||||
async def test_graph_two_hops_explainable_paths_and_cycle_guard(
|
||||
client: Any, auth: dict[str, Any]
|
||||
) -> None:
|
||||
headers = auth["headers"]
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
async with service_scope() as kb:
|
||||
from packages.kb.schemas import RelationCreate
|
||||
|
||||
shirt = await _mk_entity(kb, entity_type="item", name=f"衬衫-{suffix}")
|
||||
commut = await _mk_entity(kb, entity_type="style", name=f"通勤风-{suffix}")
|
||||
interview = await _mk_entity(kb, entity_type="occasion", name=f"面试-{suffix}")
|
||||
for from_e, to_e, rel in (
|
||||
(shirt, commut, "suits"),
|
||||
(commut, interview, "fits"),
|
||||
(interview, shirt, "matches"), # 成环:面试 → 衬衫 → 通勤风 → …
|
||||
):
|
||||
await kb.add_relation(
|
||||
payload=RelationCreate(
|
||||
from_entity_id=from_e.entity_id, to_entity_id=to_e.entity_id, rel_type=rel
|
||||
)
|
||||
)
|
||||
|
||||
graph = (
|
||||
await client.get(f"/api/v1/kb/items/{shirt.entity_id}/graph?max_hops=2", headers=headers)
|
||||
).json()
|
||||
assert graph["code"] == 0
|
||||
data = graph["data"]
|
||||
assert data["from_entity_id"] == str(shirt.entity_id)
|
||||
assert data["max_hops"] == 2
|
||||
|
||||
def _signature(path: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
return (
|
||||
tuple(node["entity_id"] for node in path["nodes"]),
|
||||
tuple(hop["rel_type"] for hop in path["hops"]),
|
||||
)
|
||||
|
||||
signatures = {_signature(p) for p in data["paths"]}
|
||||
assert (
|
||||
(str(shirt.entity_id), str(commut.entity_id)),
|
||||
("suits",),
|
||||
) in signatures, "1 跳路径应包含每跳 rel_type"
|
||||
assert (
|
||||
(str(shirt.entity_id), str(commut.entity_id), str(interview.entity_id)),
|
||||
("suits", "fits"),
|
||||
) in signatures, "2 跳路径应可解释:节点序列 + 逐跳 rel_type"
|
||||
# 节点摘要可读(不是光秃秃的 id)
|
||||
first = next(p for p in data["paths"] if len(p["hops"]) == 1)
|
||||
assert first["nodes"][0]["name"] == f"衬衫-{suffix}"
|
||||
assert first["nodes"][0]["entity_type"] == "item"
|
||||
|
||||
# 防环:3 跳也不会绕回起点或无限展开
|
||||
deep = (
|
||||
await client.get(f"/api/v1/kb/items/{shirt.entity_id}/graph?max_hops=3", headers=headers)
|
||||
).json()["data"]
|
||||
for path in deep["paths"]:
|
||||
ids = [node["entity_id"] for node in path["nodes"]]
|
||||
assert len(ids) == len(set(ids)), "路径内不得重复出现同一实体"
|
||||
assert ids[0] == str(shirt.entity_id)
|
||||
# 环上只有 2 条不回头路径:衬衫→通勤风、衬衫→通勤风→面试(→衬衫 被防环剪掉)
|
||||
assert len(deep["paths"]) == 2
|
||||
|
||||
# 起点 404 语义
|
||||
missing = await client.get("/api/v1/kb/items/1/graph", headers=headers)
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["code"] == 40400
|
||||
|
||||
|
||||
async def test_list_items_filter_and_pagination(client: Any, auth: dict[str, Any]) -> None:
|
||||
headers = auth["headers"]
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
for i in range(3):
|
||||
resp = await client.post(
|
||||
"/api/v1/kb/items",
|
||||
json={"entity_type": "style", "name": f"pag-{suffix}-{i}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.json()["code"] == 0
|
||||
await client.post(
|
||||
"/api/v1/kb/items",
|
||||
json={"entity_type": "material", "name": f"pag-{suffix}-denim"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# entity_type 过滤 + 名称模糊(把别的历史数据隔离掉)
|
||||
base = "/api/v1/kb/items"
|
||||
first = (
|
||||
await client.get(
|
||||
f"{base}?entity_type=style&name=pag-{suffix}&limit=2&offset=0", headers=headers
|
||||
)
|
||||
).json()["data"]
|
||||
assert first["total"] == 3
|
||||
assert len(first["items"]) == 2
|
||||
second = (
|
||||
await client.get(
|
||||
f"{base}?entity_type=style&name=pag-{suffix}&limit=2&offset=2", headers=headers
|
||||
)
|
||||
).json()["data"]
|
||||
assert len(second["items"]) == 1
|
||||
ids_first = {item["entity_id"] for item in first["items"]}
|
||||
ids_second = {item["entity_id"] for item in second["items"]}
|
||||
assert ids_first | ids_second and not ids_first & ids_second
|
||||
|
||||
# status 过滤:归档一条后,draft 视图少一行
|
||||
archived_id = first["items"][0]["entity_id"]
|
||||
await client.patch(
|
||||
f"/api/v1/kb/items/{archived_id}", json={"status": "archived"}, headers=headers
|
||||
)
|
||||
drafts = (
|
||||
await client.get(
|
||||
f"{base}?entity_type=style&name=pag-{suffix}&status=draft", headers=headers
|
||||
)
|
||||
).json()["data"]
|
||||
assert drafts["total"] == 2
|
||||
|
||||
# 非法 status 枚举 → 422
|
||||
bad_status = await client.get(f"{base}?status=frozen", headers=headers)
|
||||
assert bad_status.status_code == 422
|
||||
|
||||
|
||||
async def test_color_palette_roundtrip() -> None:
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
# season_code 是 varchar(16):uuid 后缀只能取 8 位防超长
|
||||
season = f"sw{uuid.uuid4().hex[:8]}"
|
||||
async with service_scope() as kb:
|
||||
color = await kb.add_color(
|
||||
season_code=season,
|
||||
role="base",
|
||||
lab_l=82.35,
|
||||
lab_a=1.2,
|
||||
lab_b=9.87,
|
||||
hex_code="#F2D8C2",
|
||||
name_cn="杏色",
|
||||
)
|
||||
assert color.role == "base"
|
||||
assert color.hex == "#F2D8C2"
|
||||
assert color.lab_l == pytest.approx(82.35)
|
||||
|
||||
with pytest.raises(AppError) as hex_err:
|
||||
await kb.add_color(
|
||||
season_code=season,
|
||||
role="accent",
|
||||
lab_l=50,
|
||||
lab_a=10,
|
||||
lab_b=-10,
|
||||
hex_code="F2D8C2", # 缺 #
|
||||
name_cn="坏色",
|
||||
)
|
||||
assert hex_err.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
with pytest.raises(AppError) as role_err:
|
||||
await kb.add_color(
|
||||
season_code=season,
|
||||
role="primary", # 不在 base/accent/avoid
|
||||
lab_l=50,
|
||||
lab_a=10,
|
||||
lab_b=-10,
|
||||
hex_code="#112233",
|
||||
name_cn="坏角色",
|
||||
)
|
||||
assert role_err.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
rows = await kb.list_colors(season_code=season)
|
||||
assert [row.hex for row in rows] == ["#F2D8C2"]
|
||||
Reference in New Issue
Block a user