merge: T12 极简运营后台(T-P2-09,FR-ADM-01~05)

This commit is contained in:
xsl
2026-08-14 21:20:06 +08:00
12 changed files with 1374 additions and 1 deletions
+334
View File
@@ -0,0 +1,334 @@
"""极简运营后台集成用例(真实 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
+393
View File
@@ -0,0 +1,393 @@
"""admin 应用装配层单测(不连库,FR-ADM 鉴权语义,T-P2-09)。
覆盖三件事:
1. ``ADMIN_TOKEN=""`` 时全部业务路由不注册(404 = 等价未部署);
2. 令牌缺失 / 错误 / 非 ASCII → 40100(比较必须走 bytes:str 版
``compare_digest`` 遇非 ASCII 会 TypeError 变 500);
3. 正确令牌 → 200:服务依赖用假实现替换(单测不碰 DB),顺带验证
发布编排(publish → 规则缓存 + 检索缓存双失效)在路由层的接线。
真实的 40900 / 缓存副作用语义由 ``tests/integration/test_admin_console.py``
用真库验证。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI
from apps.admin.app import ADMIN_API_PREFIX, create_admin_app
from apps.admin.deps import (
kb_asset_service_dep,
kb_service_dep,
rule_service_dep,
session_dep,
)
from apps.admin.publishing import invalidate_downstream_caches
from packages.kb.schemas import EntityUpdate
from packages.shared.config import Settings
TOKEN = "unit-admin-token"
P = ADMIN_API_PREFIX
#: 全部业务路由的代表性样本——空令牌时一个都不该存在。
_ALL_ROUTES: tuple[tuple[str, str], ...] = (
("GET", f"{P}/kb/items"),
("POST", f"{P}/kb/items"),
("GET", f"{P}/kb/items/1"),
("PATCH", f"{P}/kb/items/1"),
("POST", f"{P}/kb/items/1/transition"),
("GET", f"{P}/kb/items/1/graph"),
("GET", f"{P}/kb/gaps"),
("GET", f"{P}/rules"),
("POST", f"{P}/rules"),
("PATCH", f"{P}/rules/demo-rule"),
("POST", f"{P}/rules/demo-rule/transition"),
("GET", f"{P}/assets"),
("POST", f"{P}/assets"),
("POST", f"{P}/assets/1/review"),
)
def _build_app(token: str) -> FastAPI:
return create_admin_app(Settings(admin_token=token))
@asynccontextmanager
async def _client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
"""不进 lifespan:单测不建 DB engine,只测装配与鉴权层。"""
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
yield http
# --- 假实现:够走通序列化即可,不模拟 ORM 行为 --------------------------------
def _fake_entity(status: str) -> SimpleNamespace:
now = datetime.now(UTC)
return SimpleNamespace(
entity_id=123456789012345678,
entity_type="item",
name="白衬衫",
attrs={"sleeve": "long"},
status=status,
version=1,
source=None,
license=None,
updated_by=None,
created_at=now,
updated_at=now,
)
def _fake_rule(status: str) -> SimpleNamespace:
now = datetime.now(UTC)
return SimpleNamespace(
rule_id=987654321098765432,
rule_key="demo-rule",
category="body_shape",
version=1,
status=status,
priority=90,
conditions={"body_shape": "A型"},
effects={"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"]}}]},
is_hard=False,
confidence=0.9,
evidence=None,
author=None,
reviewer=None,
published_at=None,
created_at=now,
)
class _ListKbService:
async def search_entities(self, **_: object) -> tuple[list[object], int]:
return [], 0
class _ListRuleService:
async def list(self, status: str | None = None) -> list[object]:
return []
class _ListAssetService:
async def list_assets(self, **_: object) -> tuple[list[object], int]:
return [], 0
class _TransitionKbService:
"""记录 update 调用的假实体服务(transition 路由专用)。"""
def __init__(self, entity: SimpleNamespace) -> None:
self._entity = entity
self.update_calls: list[dict[str, object]] = []
async def get_entity(self, *, entity_id: int) -> SimpleNamespace:
return self._entity
async def update_entity(
self,
*,
entity_id: int,
payload: EntityUpdate,
updated_by: str | None = None,
) -> SimpleNamespace:
self.update_calls.append({"entity_id": entity_id, "target": payload.status})
self._entity.status = payload.status
return self._entity
class _TransitionRuleService:
def __init__(self, row: SimpleNamespace) -> None:
self._row = row
self.calls: list[str] = []
async def publish(self, rule_key: str) -> SimpleNamespace:
self.calls.append(f"publish:{rule_key}")
self._row.status = "published"
return self._row
async def archive(self, rule_key: str) -> SimpleNamespace:
self.calls.append(f"archive:{rule_key}")
self._row.status = "archived"
return self._row
class _FakeScalars:
def all(self) -> list[object]:
return []
class _FakeExecResult:
def scalars(self) -> _FakeScalars:
return _FakeScalars()
class _FakeSession:
async def execute(self, stmt: object) -> _FakeExecResult:
return _FakeExecResult()
async def _fake_session_dep() -> AsyncIterator[_FakeSession]:
yield _FakeSession()
# --- 1. 空令牌 = 未部署 --------------------------------------------------------
async def test_empty_token_registers_no_routes_at_all() -> None:
app = create_admin_app(Settings(admin_token=""))
async with _client(app) as client:
for method, path in _ALL_ROUTES:
resp = await client.request(
method, path, json={}, headers={"X-Admin-Token": "anything"}
)
assert resp.status_code == 404, (method, path)
# 404 也保持统一信封(经 StarletteHTTPException 处理器转 40400)
assert resp.json()["code"] == 40400
# openapi / docs 一并关闭,扫描器探不到任何面
assert (await client.get(f"{P}/openapi.json")).status_code == 404
assert (await client.get(f"{P}/docs")).status_code == 404
# --- 2. 令牌校验 --------------------------------------------------------------
async def test_missing_or_wrong_token_is_40100() -> None:
app = _build_app(TOKEN)
async with _client(app) as client:
cases: list[dict[str, str] | None] = [
None, # 无头
{"X-Admin-Token": "wrong-token"},
{"X-Admin-Token": ""}, # 空头与缺失同罪
{"X-Admin-Token": TOKEN + "x"}, # 前缀对也不行(compare_digest 全长比较)
]
for headers in cases:
resp = await client.get(f"{P}/rules", headers=headers)
assert resp.status_code == 401
assert resp.json()["code"] == 40100
async def test_non_ascii_token_does_not_blow_up_into_500() -> None:
"""str 版 compare_digest 遇非 ASCII 会 TypeError;实现必须走 bytes 比较。
httpx 不许 str 头带非 ASCII,但线上恶意客户端可以发任意字节——这里用
bytes 头值模拟(starlette 会以 latin-1 解出非 ASCII str)。
"""
app = _build_app(TOKEN)
async with _client(app) as client:
resp = await client.get(f"{P}/rules", headers={"X-Admin-Token": "非ASCII令牌".encode()})
assert resp.status_code == 401
assert resp.json()["code"] == 40100
# --- 3. 正确令牌 → 200(假服务) ----------------------------------------------
async def test_valid_token_reaches_all_read_handlers() -> None:
app = _build_app(TOKEN)
app.dependency_overrides[kb_service_dep] = lambda: _ListKbService()
app.dependency_overrides[rule_service_dep] = lambda: _ListRuleService()
app.dependency_overrides[kb_asset_service_dep] = lambda: _ListAssetService()
app.dependency_overrides[session_dep] = _fake_session_dep
headers = {"X-Admin-Token": TOKEN}
async with _client(app) as client:
for path in (f"{P}/kb/items", f"{P}/rules", f"{P}/assets", f"{P}/kb/gaps"):
resp = await client.get(path, headers=headers)
assert resp.status_code == 200, path
body = resp.json()
assert body["code"] == 0
assert body["data"]["items"] == []
assert body["request_id"] # 中间件注入,V1 操作留证的依托
# --- 4. 发布编排接线 ----------------------------------------------------------
def test_invalidate_downstream_caches_calls_both_domains(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from apps.admin import publishing
calls: list[str] = []
monkeypatch.setattr(
publishing.rules_service, "invalidate_published_cache", lambda: calls.append("rules")
)
monkeypatch.setattr(
publishing.retrieval_service, "invalidate_cache", lambda: calls.append("retrieval")
)
invalidate_downstream_caches(domain="kb_entity", key="1", target="published")
assert calls == ["rules", "retrieval"]
async def test_entity_publish_transition_triggers_double_invalidation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _build_app(TOKEN)
svc = _TransitionKbService(_fake_entity(status="reviewing"))
app.dependency_overrides[kb_service_dep] = lambda: svc
triggered: list[dict[str, str]] = []
monkeypatch.setattr(
"apps.admin.routers.kb.invalidate_downstream_caches",
lambda **kw: triggered.append(kw),
)
async with _client(app) as client:
resp = await client.post(
f"{P}/kb/items/1/transition",
json={"target": "published"},
headers={"X-Admin-Token": TOKEN},
)
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "published"
assert svc.update_calls == [{"entity_id": 1, "target": "published"}]
assert triggered == [
{"domain": "kb_entity", "key": "123456789012345678", "target": "published"}
]
async def test_entity_transition_replay_is_noop_without_invalidation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""与当前状态相同的重放幂等成功,但不触发缓存重建(重试不该有副作用)。"""
app = _build_app(TOKEN)
svc = _TransitionKbService(_fake_entity(status="published"))
app.dependency_overrides[kb_service_dep] = lambda: svc
triggered: list[dict[str, str]] = []
monkeypatch.setattr(
"apps.admin.routers.kb.invalidate_downstream_caches",
lambda **kw: triggered.append(kw),
)
async with _client(app) as client:
resp = await client.post(
f"{P}/kb/items/1/transition",
json={"target": "published"},
headers={"X-Admin-Token": TOKEN},
)
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "published"
assert svc.update_calls == []
assert triggered == []
async def test_entity_transition_accepts_draft_target_at_schema_level(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""target 收全量四态:draft 能通过 body 校验到达处理器(证明不是 422 拦截)。
这里用「draft→draft 的幂等重放分支」验证到达;真实的 published→draft
40900 语义由集成测试对真状态机验证。
"""
app = _build_app(TOKEN)
svc = _TransitionKbService(_fake_entity(status="draft"))
app.dependency_overrides[kb_service_dep] = lambda: svc
triggered: list[dict[str, str]] = []
monkeypatch.setattr(
"apps.admin.routers.kb.invalidate_downstream_caches",
lambda **kw: triggered.append(kw),
)
async with _client(app) as client:
resp = await client.post(
f"{P}/kb/items/1/transition",
json={"target": "draft"},
headers={"X-Admin-Token": TOKEN},
)
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "draft"
assert svc.update_calls == []
async def test_rule_transition_publish_and_archive_both_invalidate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _build_app(TOKEN)
svc = _TransitionRuleService(_fake_rule(status="draft"))
app.dependency_overrides[rule_service_dep] = lambda: svc
triggered: list[dict[str, str]] = []
monkeypatch.setattr(
"apps.admin.routers.rules.invalidate_downstream_caches",
lambda **kw: triggered.append(kw),
)
headers = {"X-Admin-Token": TOKEN}
async with _client(app) as client:
published = await client.post(
f"{P}/rules/demo-rule/transition", json={"target": "published"}, headers=headers
)
assert published.status_code == 200
assert published.json()["data"]["status"] == "published"
archived = await client.post(
f"{P}/rules/demo-rule/transition", json={"target": "archived"}, headers=headers
)
assert archived.status_code == 200
assert archived.json()["data"]["status"] == "archived"
# 规则状态机没有「回 draft」动作,schema 层直接收窄成 422
bad = await client.post(
f"{P}/rules/demo-rule/transition", json={"target": "draft"}, headers=headers
)
assert bad.status_code == 422
assert bad.json()["code"] == 40001
assert svc.calls == ["publish:demo-rule", "archive:demo-rule"]
assert [t["target"] for t in triggered] == ["published", "archived"]
async def test_entity_transition_rejects_unknown_target() -> None:
"""未知目标值(不在四态里)在 schema 层拦成 422。"""
app = _build_app(TOKEN)
async with _client(app) as client:
resp = await client.post(
f"{P}/kb/items/1/transition",
json={"target": "frozen"},
headers={"X-Admin-Token": TOKEN},
)
assert resp.status_code == 422
assert resp.json()["code"] == 40001