"""极简运营后台集成用例(真实 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