Files
meida_backend/tests/integration/test_admin_console.py
T
xsl 3961b6fbfa feat(admin): minimal console for KB content operations
apps/admin 独立应用:X-Admin-Token 鉴权(空=全部路由不注册=404)、实体/规则
维护与状态机流转、发布编排(publish/archive → 规则缓存 invalidate +
retrieval invalidate_cache 双失效)、素材登记与审核(tryon_ready 门禁透传)、
缺口看板(T-P2-09,FR-ADM-01~05)。V1 共享令牌鉴权,无角色无审计字段,
操作留证靠 structlog request_id。

测试:单测 tests/unit/test_admin_app.py(10 例:空令牌 404、错/缺/非 ASCII
令牌 40100、正确令牌 200 假服务、发布编排接线、幂等重放无副作用);
集成 tests/integration/test_admin_console.py(5 例:顾问全流程不碰客户端
API、双失效副作用可观察、非法跃迁 40900、素材审核门禁、缺口看板排序、
令牌 401)。483 单测 + 43 集成全绿;ruff/mypy/lint-imports 全绿。

偏差说明:
1. packages/retrieval.repo.list_gaps 有缺陷:对 select(KbGap) 的结果调
   .mappings(),行键是实体名而非列名,GapSummary(row) 取 row.gap_id 必然
   AttributeError(该路径无既有测试覆盖,接真库即暴露)。packages/** 本
   任务禁改,故缺口查询在 apps/admin/routers/gaps.py 以等价 SQL 自实现
   (hit_count 降序、同频次按 last_seen_at 降序、limit/offset 语义一致);
   建议合并阶段修复上游后切回 retrieval.list_gaps。
2. 实体 transition 的 target 收全量四态(任务文档示例仅列 reviewing/
   published/archived):按任务测试要求 published→draft 必须返回 40900
   (状态机语义冲突)而非 schema 422;draft 作为目标由服务层跃迁矩阵恒拒。
3. 规则路由以 rule_key 为路径参数(任务文档写 /rules/{id},但 RuleService
   全程以 rule_key 寻址,无按数字 id 的接口,以代码为准)。
4. 发布编排触发面含 archived(撤稿):依据 retrieval.invalidate_cache 的
   自身契约「发布/归档知识后调用」,比文档字面的「发布成功后」多了撤稿
   方向;reviewing 等其余状态不触发。
5. updated_by/author 记 "admin-console":V1 共享令牌无操作者身份,字段
   留渠道来源;运营账号体系属 V2(见包 docstring 的升级路径)。
2026-08-14 21:19:31 +08:00

335 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""极简运营后台集成用例(真实 PostgreSQL,T-P2-09 / FR-ADM-01~05)。
核心断言是「顾问全流程不碰客户端 API」:建实体 → 评审 → 发布 → 建规则 →
发布,全部只走 ``/admin/api/v1``;发布编排的副作用(规则缓存失效 +
检索缓存失效版本号递增)直接可观察。其余覆盖:非法跃迁 40900、素材登记
审核约束、缺口看板排序、令牌校验。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import pytest
ADMIN_TOKEN = "it-admin-token-t12"
AUTH = {"X-Admin-Token": ADMIN_TOKEN}
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]:
"""用例结束即释放全局引擎(理由同 test_kb_entity.py:每用例独立事件循环)。"""
yield
from packages.shared.db import dispose_engine
await dispose_engine()
@pytest.fixture
async def client() -> Any:
import httpx
from apps.admin.app import create_admin_app
from packages.shared.config import Settings
# 显式给非空令牌(.env 默认为空 = 未部署语义,那一条在单测覆盖)
app = create_admin_app(Settings(admin_token=ADMIN_TOKEN))
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
@asynccontextmanager
async def _db() -> AsyncIterator[Any]:
"""直连 session(只读断言与 kb_gap 预插数据),复用全局懒加载工厂。"""
from packages.shared.db import get_session_factory
async with get_session_factory()() as session:
yield session
def _rule_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "body_shape",
"priority": 90,
"confidence": 0.9,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["T12垫肩"]}}],
"explanation": "T12 后台发布编排集成规则",
},
}
async def _create_published_entity(client: Any, suffix: str) -> str:
"""经后台 HTTP 走完整评审线:draft → reviewing → published。"""
created = await client.post(
"/admin/api/v1/kb/items",
json={"entity_type": "item", "name": f"风衣-{suffix}", "attrs": {"length": "long"}},
headers=AUTH,
)
assert created.status_code == 200
entity_id = created.json()["data"]["entity_id"]
reviewing = await client.post(
f"/admin/api/v1/kb/items/{entity_id}/transition", json={"target": "reviewing"}, headers=AUTH
)
assert reviewing.status_code == 200
published = await client.post(
f"/admin/api/v1/kb/items/{entity_id}/transition", json={"target": "published"}, headers=AUTH
)
assert published.status_code == 200
return entity_id
async def test_consultant_full_flow_publishes_without_engineering(client: Any) -> None:
"""顾问全流程只打后台 API;发布编排的两个失效副作用都可观察。"""
from packages.retrieval import repo as retrieval_repo
from packages.rules import service as rules_service
from packages.shared.models import KbEntity
suffix = uuid.uuid4().hex[:8]
rule_key = f"T12-IT-{suffix}"
# -- 建实体(draft 起步,操作者记为 admin-console)-----------------------
created = await client.post(
"/admin/api/v1/kb/items",
json={
"entity_type": "item",
"name": f"西装外套-{suffix}",
"attrs": {"lapel": "peak"},
"source": "搭配手册",
},
headers=AUTH,
)
assert created.status_code == 200
entity = created.json()["data"]
entity_id = entity["entity_id"]
assert entity["status"] == "draft"
assert entity["version"] == 1
assert entity["updated_by"] == "admin-console"
# -- reviewing:不是知识可见性变更,不触发失效编排 -----------------------
v0 = retrieval_repo.INVALIDATION_VERSION
reviewing = await client.post(
f"/admin/api/v1/kb/items/{entity_id}/transition",
json={"target": "reviewing"},
headers=AUTH,
)
assert reviewing.status_code == 200
assert reviewing.json()["data"]["status"] == "reviewing"
assert v0 == retrieval_repo.INVALIDATION_VERSION
# -- published:双失效(规则缓存 + 检索缓存)的副作用可观察 --------------
v1 = retrieval_repo.INVALIDATION_VERSION
published = await client.post(
f"/admin/api/v1/kb/items/{entity_id}/transition",
json={"target": "published"},
headers=AUTH,
)
assert published.status_code == 200
assert published.json()["data"]["status"] == "published"
assert v1 + 1 == retrieval_repo.INVALIDATION_VERSION
# 「检索新内容可见」的简化断言:直接再查库
async with _db() as session:
row = await session.get(KbEntity, int(entity_id))
assert row is not None
assert row.status == "published"
# -- 建规则(draft)→ 发布 → PublishedRules 缓存能载入 -------------------
rule_created = await client.post(
"/admin/api/v1/rules", json=_rule_payload(rule_key), headers=AUTH
)
assert rule_created.status_code == 200
rule_body = rule_created.json()["data"]
assert rule_body["status"] == "draft"
assert rule_body["author"] == "admin-console"
# 预热进程内缓存:发布前不可见
async with _db() as session:
pre = await rules_service._published_rules.load(session)
assert rule_key not in {r.rule_key for r in pre}
v2 = retrieval_repo.INVALIDATION_VERSION
rule_published = await client.post(
f"/admin/api/v1/rules/{rule_key}/transition",
json={"target": "published"},
headers=AUTH,
)
assert rule_published.status_code == 200
assert rule_published.json()["data"]["published_at"] is not None
assert v2 + 1 == retrieval_repo.INVALIDATION_VERSION
# 发布 + 失效后重载:新规则可被 PublishedRules 载入
async with _db() as session:
post = await rules_service._published_rules.load(session)
assert rule_key in {r.rule_key for r in post}
# -- 撤稿规则(下线同样触发失效编排),给环境留干净状态 -------------------
v3 = retrieval_repo.INVALIDATION_VERSION
archived = await client.post(
f"/admin/api/v1/rules/{rule_key}/transition",
json={"target": "archived"},
headers=AUTH,
)
assert archived.status_code == 200
assert archived.json()["data"]["status"] == "archived"
assert v3 + 1 == retrieval_repo.INVALIDATION_VERSION
async with _db() as session:
after = await rules_service._published_rules.load(session)
assert rule_key not in {r.rule_key for r in after}
async def test_illegal_transitions_surface_as_40900(client: Any) -> None:
suffix = uuid.uuid4().hex[:8]
entity_id = await _create_published_entity(client, suffix)
# published → draft:状态机语义冲突 40900(而不是 schema 422)
backward = await client.post(
f"/admin/api/v1/kb/items/{entity_id}/transition",
json={"target": "draft"},
headers=AUTH,
)
assert backward.status_code == 409
assert backward.json()["code"] == 40900
# 规则侧:published 再发布(自跃迁)同样 40900
rule_key = f"T12-SM-{suffix}"
created = await client.post("/admin/api/v1/rules", json=_rule_payload(rule_key), headers=AUTH)
assert created.status_code == 200
published = await client.post(
f"/admin/api/v1/rules/{rule_key}/transition",
json={"target": "published"},
headers=AUTH,
)
assert published.status_code == 200
republish = await client.post(
f"/admin/api/v1/rules/{rule_key}/transition",
json={"target": "published"},
headers=AUTH,
)
assert republish.status_code == 409
assert republish.json()["code"] == 40900
# 收尾下线,不留 published 规则影响其他用例
cleanup = await client.post(
f"/admin/api/v1/rules/{rule_key}/transition",
json={"target": "archived"},
headers=AUTH,
)
assert cleanup.status_code == 200
async def test_asset_register_review_and_tryon_ready_gate(client: Any) -> None:
suffix = uuid.uuid4().hex[:8]
entity_id = await _create_published_entity(client, suffix)
# 登记时即使请求体给 tryon_ready=true,服务端也强制 False(审核是唯一开关)
registered = await client.post(
"/admin/api/v1/assets",
json={
"entity_id": entity_id,
"asset_type": "display",
"cos_key": f"kb/it/t12/{suffix}/coat.jpg",
"spec_check": {"bg": "white", "size": "1024x1536"},
"tryon_ready": True,
"license": "CC-BY-4.0",
},
headers=AUTH,
)
assert registered.status_code == 200
asset = registered.json()["data"]
asset_id = asset["asset_id"]
assert asset["tryon_ready"] is False
assert asset["review_status"] == "pending"
# 审核不通过却想打开 tryon_ready → 服务层约束透传成 400
rejected = await client.post(
f"/admin/api/v1/assets/{asset_id}/review",
json={"review_status": "rejected", "tryon_ready": True},
headers=AUTH,
)
assert rejected.status_code == 400
assert rejected.json()["code"] == 40000
# 审核通过 + tryon_ready 一起打开
approved = await client.post(
f"/admin/api/v1/assets/{asset_id}/review",
json={"review_status": "approved", "tryon_ready": True},
headers=AUTH,
)
assert approved.status_code == 200
assert approved.json()["data"]["tryon_ready"] is True
assert approved.json()["data"]["review_status"] == "approved"
# 列表过滤:tryon_ready=true 命中,false 视图为空
ready = await client.get(
f"/admin/api/v1/assets?entity_id={entity_id}&tryon_ready=true", headers=AUTH
)
assert ready.status_code == 200
ready_items = ready.json()["data"]["items"]
assert [item["asset_id"] for item in ready_items] == [asset_id]
not_ready = await client.get(
f"/admin/api/v1/assets?entity_id={entity_id}&tryon_ready=false", headers=AUTH
)
assert not_ready.json()["data"]["total"] == 0
async def test_gap_dashboard_orders_by_hit_count_desc(client: Any) -> None:
from packages.domain.ids import next_id
from packages.shared.models import KbGap
suffix = uuid.uuid4().hex[:8]
low, high = f"T12-缺口低频-{suffix}", f"T12-缺口高频-{suffix}"
async with _db() as session:
session.add(KbGap(gap_id=next_id(), query_text=low, filter_ctx={}, hit_count=2))
session.add(
KbGap(
gap_id=next_id(),
query_text=high,
filter_ctx={"body_shape": "A型"},
hit_count=51,
)
)
await session.commit()
resp = await client.get("/admin/api/v1/kb/gaps?limit=200", headers=AUTH)
assert resp.status_code == 200
items = resp.json()["data"]["items"]
texts = [item["query_text"] for item in items]
assert low in texts and high in texts
# hit_count 降序:高频缺口的位次必须靠前(历史数据可以穿插在中间)
assert texts.index(high) < texts.index(low)
high_row = next(item for item in items if item["query_text"] == high)
assert high_row["hit_count"] == 51
assert high_row["filter_ctx"] == {"body_shape": "A型"}
assert high_row["gap_id"].isdigit() # 雪花 ID 以字符串出现
async def test_missing_or_wrong_token_is_401(client: Any) -> None:
for headers in (None, {"X-Admin-Token": "wrong"}):
resp = await client.get("/admin/api/v1/rules", headers=headers)
assert resp.status_code == 401
assert resp.json()["code"] == 40100