merge: T12 极简运营后台(T-P2-09,FR-ADM-01~05)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user