Files
group_fqcd_jr/tests/integration/test_config_release_mysql.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

154 lines
7.4 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.
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.configuration import ConfigRelease
from app.model.platform import DomainEventOutbox
from app.service.config_release_service import ConfigReleaseError, ConfigReleaseService
@pytest.mark.integration
@pytest.mark.asyncio
async def test_config_release_lifecycle_is_versioned_and_audited() -> None:
now = datetime.now(UTC).replace(tzinfo=None)
release_no = f"it-{uuid4().hex}"
peer_release_no = f"it-peer-{uuid4().hex}"
release_id = 0
peer_release_id = 0
rollback_id = 0
# 本测试会激活临时版本,把平台当前生效的版本顶成 superseded。先记下它是谁,
# 清理时恢复——否则平台会静默停在"零个 active 版本"(见 finally 的说明)。
async with SessionFactory() as session:
original_active_id = await session.scalar(
select(ConfigRelease.id).where(ConfigRelease.status == "active")
)
try:
async with SessionFactory() as session:
release = ConfigRelease(
release_no=release_no,
title="integration",
change_summary="integration",
status="draft",
created_by=101,
created_at=now,
updated_at=now,
)
session.add(release)
await session.flush()
release_id = release.id
service = ConfigReleaseService(session)
# 草稿不能直接审核:审核节点不可跳过。
with pytest.raises(ConfigReleaseError, match="not pending review"):
await service.approve(release_id, 101)
await service.submit_for_review(release_id, 101)
# 自审允许:创建人 101 审核自己提交的版本不报错,并如实写入 reviewer_id
# (chk_config_release_separation 已由 20260910 / 20260911 两个
# drop_review_separation 迁移撤下,均幂等)。
self_reviewed = await service.approve(release_id, 101)
assert self_reviewed.reviewer_id == 101
assert self_reviewed.reviewed_at is not None
await service.activate(release_id, 202)
# 他人复核:`reviewer_id` 如实写入审核人,覆盖 approve 的另一条分支。
peer = ConfigRelease(
release_no=peer_release_no,
title="integration-peer",
change_summary="integration-peer",
status="draft",
created_by=101,
created_at=now,
updated_at=now,
)
session.add(peer)
await session.flush()
peer_release_id = peer.id
await service.submit_for_review(peer_release_id, 101)
assert (await service.approve(peer_release_id, 202)).reviewer_id == 202
with pytest.raises(ConfigReleaseError, match="not pending review"):
await service.approve(peer_release_id, 202)
await session.commit()
async with SessionFactory() as session:
service = ConfigReleaseService(session)
rollback = await service.rollback(release_id, 101)
rollback_id = rollback.id
await session.commit()
async with SessionFactory() as session:
release = await session.get(ConfigRelease, release_id)
rollback = await session.get(ConfigRelease, rollback_id)
events = list(
await session.scalars(
select(DomainEventOutbox).where(
DomainEventOutbox.event_type == "config.cache_invalidate_requested",
DomainEventOutbox.aggregate_id.in_([str(release_id), str(rollback_id)]),
)
)
)
audits = list(
await session.scalars(
select(InteractionAudit).where(
InteractionAudit.action_type.in_(
[
"config.submit_review",
"config.approve",
"config.activate",
"config.rollback",
]
),
InteractionAudit.detail["release_id"].in_([release_id, rollback_id]),
)
)
)
assert release is not None and release.status == "superseded"
assert rollback is not None and rollback.status == "active"
assert rollback.rollback_of_release_id == release_id
assert len(events) == 2
assert len(audits) == 4
finally:
async with SessionFactory() as session:
await session.execute(
delete(InteractionAudit).where(
InteractionAudit.detail["release_id"].in_(
[release_id, peer_release_id, rollback_id]
)
)
)
await session.execute(
delete(DomainEventOutbox).where(
DomainEventOutbox.aggregate_id.in_(
[str(release_id), str(peer_release_id), str(rollback_id)]
),
# 必须限定 event_type:`aggregate_id` 是字符串列,`release_id` 是自增小整数,
# 与其它域用小整数做 `aggregate_id` 的记录(例如知识库的
# `knowledge.vector_sync_requested`,aggregate_id = fin_knowledge_meta.id)
# 数值上会碰撞。只按 aggregate_id 删会**误删其它域的 outbox 行**,
# 表现为那些知识行永远拿不到向量同步事件、静默不被索引。
DomainEventOutbox.event_type == "config.cache_invalidate_requested",
)
)
await session.execute(
delete(ConfigRelease).where(ConfigRelease.release_no.like("it-%"))
)
# 回滚记录的 release_no 是 rollback-*,不在 it-* 前缀内。不删会留下一个
# 没有配置项的 active 版本,污染后续脚本读取的"当前生效版本"。
await session.execute(
delete(ConfigRelease).where(ConfigRelease.rollback_of_release_id == release_id)
)
# 删除临时版本只是清理的一半。本测试期间的激活已经把**平台原有的** active 版本
# 顶成了 superseded(activate 顶替当前 active),只删自己的版本会让平台静默停在
# "零个 active 版本":工具白名单随之变成空集,所有 Agent 工具按 fail-closed 被拒,
# 而这种故障没有任何报错,只在真正调用工具时才暴露(曾实际发生过)。因此删完后
# 必须把原先生效的版本恢复为 active——此时临时版本已删除,不会撞唯一键
# uk_config_release_active_one。
if original_active_id is not None:
original = await session.get(ConfigRelease, original_active_id)
if original is not None and original.status == "superseded":
original.status = "active"
original.updated_at = datetime.now(UTC).replace(tzinfo=None)
await session.commit()