Files
meida_backend/tests/unit/test_admin_app.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

394 lines
14 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.
"""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