"""规则引擎集成用例(真实 PostgreSQL):published 载入、draft 不载入、硬约束拦截。 覆盖任务契约:插 3 条规则(含 1 条 is_hard)→ service 载入 → 求值命中与拦截 正确;draft 规则不被载入。规则键带随机后缀 + 前缀清理,可与任意环境的存量 数据共存。 """ from __future__ import annotations import os import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any import pytest 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", ), ] _PREFIX = "R-IT7-" def _key(suffix: str) -> str: return f"{_PREFIX}{suffix}-{uuid.uuid4().hex[:6]}" @asynccontextmanager async def _session() -> AsyncIterator[Any]: """每个用例独立 engine:pytest-asyncio 每个用例一个事件循环, 共享全局连接池会把上一个 loop 的连接带进下一个用例(Future attached to a different loop)。用完 dispose,进程内全局池保持零污染。 """ from sqlalchemy.ext.asyncio import async_sessionmaker from packages.shared.config import get_settings from packages.shared.db import create_engine engine = create_engine(get_settings()) factory = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False) try: async with factory() as session: try: yield session await session.commit() except Exception: await session.rollback() raise finally: await engine.dispose() async def _cleanup() -> None: from sqlalchemy import delete from packages.rules import service as rules_service from packages.shared.models import KbRule rules_service.invalidate_published_cache() async with _session() as session: await session.execute(delete(KbRule).where(KbRule.rule_key.like(f"{_PREFIX}%"))) def _body_payload(rule_key: str) -> dict[str, Any]: return { "rule_key": rule_key, "category": "body_shape", "priority": 90, "confidence": 0.95, "conditions": {"body_shape": "A型", "features": {"include": ["溜肩"]}}, "effects": { "recommend": [{"slot": "top", "attrs": {"shoulder": ["IT7垫肩"]}}], "avoid": [{"slot": "top", "attrs": {"shoulder": ["IT7落肩"]}}], "explanation": "IT7 体型规则", }, } def _hard_payload(rule_key: str) -> dict[str, Any]: return { "rule_key": rule_key, "category": "compliance", "priority": 100, "confidence": 1.0, "is_hard": True, "conditions": {"occasion": {"include": ["IT7晚宴"]}}, "effects": { "avoid": [{"slot": "accessory", "attrs": {"material": ["IT7真兽皮"]}}], "explanation": "IT7 硬约束", }, } def _draft_payload(rule_key: str) -> dict[str, Any]: return { "rule_key": rule_key, "category": "body_shape", "priority": 90, "conditions": {"body_shape": "A型"}, "effects": { "recommend": [{"slot": "dress", "attrs": {"silhouette": ["IT7草稿独有"]}}], "explanation": "IT7 未发布规则", }, } async def test_published_loaded_draft_not_loaded_and_hard_blocks() -> None: from packages.rules import dsl from packages.rules import service as rules_service await _cleanup() body_key, hard_key, draft_key = _key("BODY"), _key("HARD"), _key("DRAFT") try: async with _session() as session: svc = rules_service.RuleService(session) await svc.create(_body_payload(body_key), author="it7") await svc.create(_hard_payload(hard_key), author="it7") await svc.create(_draft_payload(draft_key), author="it7") # 保持 draft await svc.publish(body_key) await svc.publish(hard_key) ctx = dsl.RuleContext(body_shape="A型", features=("溜肩",), occasion="IT7晚宴") # 求值:published 载入(两条命中),draft 不载入 async with _session() as session: svc = rules_service.RuleService(session) verdict = await svc.evaluate(ctx) hit_keys = [h.rule_key for h in verdict.rule_hits] assert body_key in hit_keys assert hard_key in hit_keys assert draft_key not in hit_keys # 硬约束:is_hard 的 avoid 命中 → hard_blocks 带出可过滤的约束内容 assert verdict.blocked is True block_keys = [b.rule_key for b in verdict.hard_blocks] assert hard_key in block_keys hard_block = next(b for b in verdict.hard_blocks if b.rule_key == hard_key) assert hard_block.avoid[0].slot == "accessory" assert hard_block.avoid[0].attrs["material"] == ("IT7真兽皮",) # 命中的推荐来自 body 规则;draft 的效果从未出现 rec_values = { value for item in verdict.recommend for values in item.attrs.values() for value in values } assert "IT7垫肩" in rec_values assert "IT7草稿独有" not in rec_values # 下线 body 规则 → 缓存失效 → 再求值不再命中(保留 hard 规则互不干扰) async with _session() as session: svc = rules_service.RuleService(session) await svc.archive(body_key) async with _session() as session: svc = rules_service.RuleService(session) verdict_after = await svc.evaluate(ctx) after_keys = [h.rule_key for h in verdict_after.rule_hits] assert body_key not in after_keys assert hard_key in after_keys assert draft_key not in after_keys finally: await _cleanup() async def test_create_rejects_invalid_dsl() -> None: from packages.shared.errors import AppError, ErrorCode await _cleanup() try: async with _session() as session: from packages.rules.service import RuleService svc = RuleService(session) # 未识别的键(conclusions 是架构旧列名)必须被拒绝而不是静默忽略 with pytest.raises(AppError) as excinfo: await svc.create({**_body_payload(_key("BAD")), "conclusions": ["垫肩"]}) assert excinfo.value.code == ErrorCode.VALIDATION_FAILED finally: await _cleanup() async def test_state_machine_guards_on_real_rows() -> None: from packages.shared.errors import AppError, ErrorCode await _cleanup() sm_key = _key("SM") other_key = _key("SM2") try: async with _session() as session: from packages.rules.service import RuleService svc = RuleService(session) row = await svc.create(_body_payload(sm_key), author="it7") assert row.status == "draft" assert row.version == 1 published = await svc.publish(sm_key) assert published.status == "published" assert published.published_at is not None # published 不能再发布、不能直接改内容(要改必须先下线) with pytest.raises(AppError) as republish: await svc.publish(sm_key) assert republish.value.code == ErrorCode.STATE_CONFLICT with pytest.raises(AppError) as edit: await svc.update(sm_key, _body_payload(sm_key)) assert edit.value.code == ErrorCode.STATE_CONFLICT # draft 可以修改且 version+1;rule_key 不可改 draft_row = await svc.create(_body_payload(other_key), author="it7") # identity map:update 返回的是同一 ORM 对象,先快照修改前的版本号 base_version = draft_row.version updated = await svc.update( other_key, { **_body_payload(other_key), "priority": 85, "effects": { "recommend": [{"slot": "top", "attrs": {"shoulder": ["IT7改"]}}], "explanation": "IT7 修改后", }, }, ) assert updated.version == base_version + 1 assert updated.priority == 85 with pytest.raises(AppError) as rename: await svc.update(other_key, _body_payload(_key("OTHER"))) assert rename.value.code == ErrorCode.BAD_REQUEST # archived 是终态 await svc.archive(sm_key) with pytest.raises(AppError) as rearchive: await svc.archive(sm_key) assert rearchive.value.code == ErrorCode.STATE_CONFLICT finally: await _cleanup()