# -*- coding: utf-8 -*- """Bug 修复回归测试 — 知识迭代模块 (Bug #8 / #7 / #6) Bug #8: 新增 POST /admin/knowledge-iteration/suggestions 端点(手动创建知识建议) Bug #7: Neo4j create_relation 中 CREATE → MERGE(关系创建幂等化) Bug #6: 新增 expire_pending_suggestions 定时任务(72 小时过期 pending 建议) 测试依赖: conftest.py 提供的 client / db_session / mock_redis fixtures """ import uuid from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import AsyncClient from sqlalchemy import select, text as sa_text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.models.knowledge_suggestion import KnowledgeSuggestion from app.models.neo4j_schema import IssueNode, ActionNode, RelationEdge # ============================================================================ # 辅助函数 — 管理员/普通用户登录(复用 test_tier1_api.py 模式) # ============================================================================ async def _login_admin(client: AsyncClient, db_session: AsyncSession) -> str: """创建 admin 角色用户并返回 Bearer token。""" from app.models.role import Role from app.models.user_role import UserRole admin_id = f"test_admin_{uuid.uuid4().hex[:8]}" # 1. 确保 admin 角色存在 stmt = select(Role).where(Role.name == "admin") result = await db_session.execute(stmt) admin_role = result.scalars().first() if not admin_role: admin_role = Role( name="admin", display_name="管理员", description="系统管理员", permissions=[], ) db_session.add(admin_role) await db_session.flush() # 2. 创建 UserRole 关联 ur_stmt = select(UserRole).where( UserRole.employee_id == admin_id, UserRole.role_id == admin_role.id, ) ur_result = await db_session.execute(ur_stmt) if not ur_result.scalars().first(): db_session.add(UserRole( employee_id=admin_id, role_id=admin_role.id, source="manual", assigned_by="test_fixture", )) await db_session.flush() # 3. 登录 resp = await client.post("/agents/login", json={ "user_id": admin_id, "name": "测试管理员", }) data = resp.json() return data["data"]["token"] async def _login_any_user(client: AsyncClient, db_session: AsyncSession) -> str: """创建普通用户并返回 Bearer token(用于权限测试)。""" from app.models.role import Role from app.models.user_role import UserRole user_id = f"test_user_{uuid.uuid4().hex[:8]}" # 确保 user 角色存在 stmt = select(Role).where(Role.name == "user") result = await db_session.execute(stmt) user_role = result.scalars().first() if not user_role: user_role = Role( name="user", display_name="普通用户", description="普通员工", permissions=[], ) db_session.add(user_role) await db_session.flush() # 创建 UserRole 关联(不含 admin 角色) ur_stmt = select(UserRole).where( UserRole.employee_id == user_id, UserRole.role_id == user_role.id, ) ur_result = await db_session.execute(ur_stmt) if not ur_result.scalars().first(): db_session.add(UserRole( employee_id=user_id, role_id=user_role.id, source="manual", assigned_by="test_fixture", )) await db_session.flush() # 登录 resp = await client.post("/agents/login", json={ "user_id": user_id, "name": "测试用户", }) data = resp.json() return data["data"]["token"] # ============================================================================ # Section A — Bug #8: POST /admin/knowledge-iteration/suggestions # ============================================================================ class TestCreateSuggestion: """测试手动创建知识建议端点(Bug #8 修复)。""" @pytest.mark.asyncio async def test_create_suggestion_success(self, client: AsyncClient, db_session: AsyncSession): """正常创建建议,验证返回 code=0、status=pending、数据字段正确。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": "VPN 连接失败怎么办", "content": "1. 检查网络 2. 重启 VPN 客户端 3. 联系 IT 支持", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert "创建成功" in data["message"] suggestion = data["data"] assert suggestion["status"] == "pending" assert suggestion["title"] == "VPN 连接失败怎么办" assert suggestion["content"] == "1. 检查网络 2. 重启 VPN 客户端 3. 联系 IT 支持" assert suggestion["source_type"] == "manual" assert suggestion["suggestion_type"] == "new_faq" assert suggestion["id"] # UUID 非空 assert suggestion["graph_sync_status"] == "pending" @pytest.mark.asyncio async def test_create_suggestion_missing_title(self, client: AsyncClient, db_session: AsyncSession): """缺少 title 字段 — Pydantic 校验拒绝(422)。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "content": "测试内容", "source_type": "manual", # 缺少 title } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) # Pydantic 对缺失的必填字段返回 422 assert resp.status_code == 422 @pytest.mark.asyncio async def test_create_suggestion_missing_content(self, client: AsyncClient, db_session: AsyncSession): """缺少 content 字段 — Pydantic 校验拒绝(422)。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": "测试标题", "source_type": "manual", # 缺少 content } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_create_suggestion_missing_source_type(self, client: AsyncClient, db_session: AsyncSession): """缺少 source_type 字段 — Pydantic 校验拒绝(422)。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": "测试标题", "content": "测试内容", # 缺少 source_type } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_create_suggestion_empty_title(self, client: AsyncClient, db_session: AsyncSession): """title 为空字符串 — 业务校验返回 code=400。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": "", "content": "测试内容", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 400 assert "标题" in data["message"] @pytest.mark.asyncio async def test_create_suggestion_empty_content(self, client: AsyncClient, db_session: AsyncSession): """content 为空字符串 — 业务校验返回 code=400。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": "测试标题", "content": "", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 400 assert "内容" in data["message"] @pytest.mark.asyncio async def test_create_suggestion_whitespace_title(self, client: AsyncClient, db_session: AsyncSession): """title 为纯空格 — strip 后为空,业务校验返回 code=400。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": " ", "content": "测试内容", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 400 @pytest.mark.asyncio async def test_create_suggestion_with_optional_fields(self, client: AsyncClient, db_session: AsyncSession): """带可选字段(audience、issue、action、relation_type)创建成功。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "new_faq", "title": "带图字段的知识建议", "content": "包含 audience、issue、action、relation_type 等可选字段", "source_type": "manual", "category": "网络", "tags": ["VPN", "网络"], "confidence": 0.88, "audience": "employee_quick_reply", "issue": "VPN 连接问题", "action": "重启 VPN 客户端", "relation_type": "LEADS_TO", "parent_issue": "网络故障", "graph_meta": {"source": "test"}, } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 suggestion = data["data"] assert suggestion["status"] == "pending" assert suggestion["audience"] == "employee_quick_reply" assert suggestion["issue"] == "VPN 连接问题" assert suggestion["action"] == "重启 VPN 客户端" assert suggestion["relation_type"] == "LEADS_TO" assert suggestion["parent_issue"] == "网络故障" assert suggestion["confidence"] == 0.88 assert suggestion["category"] == "网络" assert "VPN" in suggestion["tags"] @pytest.mark.asyncio async def test_create_suggestion_requires_admin(self, client: AsyncClient, db_session: AsyncSession): """非管理员请求被拒(403)。""" token = await _login_any_user(client, db_session) body = { "suggestion_type": "new_faq", "title": "权限测试", "content": "测试内容", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_create_suggestion_no_auth(self, client: AsyncClient): """未携带 Token — 401/403。""" body = { "suggestion_type": "new_faq", "title": "无认证测试", "content": "测试内容", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, ) assert resp.status_code in (401, 403) @pytest.mark.asyncio async def test_create_suggestion_default_category(self, client: AsyncClient, db_session: AsyncSession): """不传 category 时使用默认值 "其他"。""" token = await _login_admin(client, db_session) body = { "suggestion_type": "update", "title": "默认分类测试", "content": "不传 category 字段", "source_type": "manual", } resp = await client.post( "/admin/knowledge-iteration/suggestions", json=body, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["category"] == "其他" # ============================================================================ # Section B — Bug #7: Neo4j create_relation MERGE 幂等性 # ============================================================================ class TestCreateRelationMerge: """测试 create_relation 使用 MERGE 替代 CREATE(Bug #7 修复)。""" @pytest.mark.asyncio async def test_create_relation_uses_merge(self): """验证 create_relation 生成的 Cypher 语句包含 MERGE 而非 CREATE。""" from app.services.neo4j_client import Neo4jClient # 用 __new__ 绕过 __init__,避免连接真实 Neo4j client = Neo4jClient.__new__(Neo4jClient) client._driver = None # Mock execute_write_query 捕获 Cypher 语句 captured_cyphers = [] async def _capture_write(cypher, params=None): captured_cyphers.append(cypher) return [{"created": 1}] client.execute_write_query = _capture_write rel = RelationEdge( from_uuid="uuid-from-001", to_uuid="uuid-to-001", type="LEADS_TO", order=1, weight=0.9, ) await client.create_relation("uuid-from-001", "uuid-to-001", rel) assert len(captured_cyphers) == 1 cypher = captured_cyphers[0] # 核心断言:Cypher 必须使用 MERGE 而非 CREATE assert "MERGE" in cypher, f"Cypher 应包含 MERGE,实际: {cypher}" assert "CREATE" not in cypher.replace("CREATE", "__X__", 0) or "MERGE" in cypher, \ f"Cypher 不应使用 CREATE 创建关系,实际: {cypher}" # 更精确的检查:不应有独立的 CREATE 关系语句 # (CREATE CONSTRAINT 等不含在此 Cypher 中,所以直接检查) assert "MERGE (from_node)-[" in cypher, f"应使用 MERGE 创建关系边,实际: {cypher}" @pytest.mark.asyncio async def test_create_relation_idempotent(self): """调用 create_relation 两次相同参数,验证 Cypher 语句一致(MERGE 幂等)。 MERGE 语义:相同参数重复执行不会创建重复边。 本测试通过验证两次调用生成相同的 MERGE Cypher 来确认幂等性。 """ from app.services.neo4j_client import Neo4jClient client = Neo4jClient.__new__(Neo4jClient) client._driver = None captured_cyphers = [] call_count = {"value": 0} async def _capture_write(cypher, params=None): captured_cyphers.append((cypher, params)) call_count["value"] += 1 # MERGE 幂等:两次调用都返回成功 return [{"created": 1}] client.execute_write_query = _capture_write rel = RelationEdge( from_uuid="uuid-from-002", to_uuid="uuid-to-002", type="RELATES_TO", order=2, weight=0.5, ) # 第一次调用 result1 = await client.create_relation("uuid-from-002", "uuid-to-002", rel) # 第二次调用(相同参数) result2 = await client.create_relation("uuid-from-002", "uuid-to-002", rel) assert result1 is True assert result2 is True # 两次调用的 Cypher 应完全相同(MERGE 幂等语义) assert len(captured_cyphers) == 2 cypher1, params1 = captured_cyphers[0] cypher2, params2 = captured_cyphers[1] assert cypher1 == cypher2, "两次调用的 Cypher 语句应相同" assert params1 == params2, "两次调用的参数应相同" # 确认使用 MERGE assert "MERGE" in cypher1 @pytest.mark.asyncio async def test_create_relation_different_types(self): """验证不同关系类型生成的 Cypher 都使用 MERGE。""" from app.services.neo4j_client import Neo4jClient client = Neo4jClient.__new__(Neo4jClient) client._driver = None captured_cyphers = [] async def _capture_write(cypher, params=None): captured_cyphers.append(cypher) return [{"created": 1}] client.execute_write_query = _capture_write for rel_type in ["LEADS_TO", "RELATES_TO", "CAN_JUMP_TO"]: rel = RelationEdge( from_uuid=f"from-{rel_type}", to_uuid=f"to-{rel_type}", type=rel_type, order=1, weight=1.0, ) await client.create_relation(f"from-{rel_type}", f"to-{rel_type}", rel) assert len(captured_cyphers) == 3 for cypher in captured_cyphers: assert "MERGE" in cypher, f"所有关系类型都应使用 MERGE,实际: {cypher}" # ============================================================================ # Section C — Bug #6: expire_pending_suggestions 定时任务 # ============================================================================ def _sqlite_compatible_text(sql_string): """将 PostgreSQL 方言 SQL 翻译为 SQLite 兼容 SQL。 expire_pending_suggestions() 内部使用 NOW() 和 INTERVAL '72 hours', 这些是 PostgreSQL 专属语法。测试使用 SQLite 内存数据库, 需要翻译为 datetime('now') 和 datetime('now', '-72 hours')。 """ sqlite_sql = ( sql_string .replace("NOW() - INTERVAL '72 hours'", "datetime('now', '-72 hours')") .replace("NOW()", "datetime('now')") ) return sa_text(sqlite_sql) def _utcnow(): """返回 UTC naive datetime,与 SQLite datetime('now') 时区一致。 _utcnow() 返回本地时间(如 UTC+8),而 SQLite datetime('now') 返回 UTC。 若用本地时间设置 created_at,WHERE created_at < datetime('now', '-72 hours') 会因时区偏移导致匹配失败。测试中统一使用 UTC 时间避免此问题。 """ return datetime.now(timezone.utc).replace(tzinfo=None) class TestExpirePendingSuggestions: """测试 expire_pending_suggestions 定时任务(Bug #6 修复)。""" @pytest.mark.asyncio async def test_expire_pending_suggestions(self, db_session: AsyncSession): """超过 72 小时的 pending 建议被标记为 expired。""" from app.main import expire_pending_suggestions # 创建一条 created_at 超过 72 小时的 pending 建议 # 注意: 使用 UTC 时间(_utcnow)而非 datetime.now(),因为 SQLite 的 # datetime('now') 返回 UTC,而 datetime.now() 返回本地时间(UTC+8), # 时区不一致会导致 WHERE 条件匹配失败 old_time = _utcnow() - timedelta(hours=73) suggestion = KnowledgeSuggestion( suggestion_type="new_faq", status="pending", title="过期测试建议", content="这条建议创建超过 72 小时,应被过期", category="网络", tags=["测试"], source_type="conversation", source_data=["conv-old-001"], reason="过期测试", confidence=0.8, audience="employee_quick_reply", created_at=old_time, ) db_session.add(suggestion) await db_session.commit() # 构建与 db_session 同引擎的 session factory(StaticPool 共享连接) session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) # Patch _get_session_factory 返回测试 session factory # Patch text 为 SQLite 兼容版本 with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() # 刷新 session 缓存并查询 suggestion_id = suggestion.id db_session.expire_all() stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None assert updated.status == "expired", \ f"超时 pending 建议应变为 expired,实际: {updated.status}" @pytest.mark.asyncio async def test_expire_does_not_affect_recent(self, db_session: AsyncSession): """新创建的 pending 建议(created_at 为当前时间)不受影响。""" from app.main import expire_pending_suggestions # 创建一条新的 pending 建议(created_at 为当前时间) suggestion = KnowledgeSuggestion( suggestion_type="new_faq", status="pending", title="新建待审建议", content="这条建议刚创建,不应被过期", category="软件", tags=["测试"], source_type="manual", source_data=None, reason="近期测试", confidence=0.75, audience="employee_quick_reply", created_at=_utcnow(), ) db_session.add(suggestion) await db_session.commit() session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() suggestion_id = suggestion.id db_session.expire_all() stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None assert updated.status == "pending", \ f"近期 pending 建议不应被过期,实际: {updated.status}" @pytest.mark.asyncio async def test_expire_does_not_affect_queued(self, db_session: AsyncSession): """queued 状态的建议(即使超过 72 小时)不被过期。""" from app.main import expire_pending_suggestions old_time = _utcnow() - timedelta(hours=73) suggestion = KnowledgeSuggestion( suggestion_type="update", status="queued", title="队列中的旧建议", content="这条建议在队列中,即使超过 72 小时也不应被过期", category="账号", tags=["队列", "测试"], source_type="conversation", source_data=["conv-queued-old"], reason="队列过期测试", confidence=0.6, audience="engineer_workguide", created_at=old_time, ) db_session.add(suggestion) await db_session.commit() session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() suggestion_id = suggestion.id db_session.expire_all() stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None assert updated.status == "queued", \ f"queued 建议不应被过期,实际: {updated.status}" @pytest.mark.asyncio async def test_expire_does_not_affect_approved(self, db_session: AsyncSession): """approved 状态的建议(超过 72 小时)不被过期。""" from app.main import expire_pending_suggestions old_time = _utcnow() - timedelta(hours=73) suggestion = KnowledgeSuggestion( suggestion_type="new_faq", status="approved", title="已通过的旧建议", content="这条建议已通过审核,不应被过期", category="安全", tags=["审批", "测试"], source_type="annotation", source_data=["annot-old-001"], reason="审批过期测试", confidence=0.9, audience="employee_quick_reply", created_at=old_time, ) db_session.add(suggestion) await db_session.commit() session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() suggestion_id = suggestion.id db_session.expire_all() stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None assert updated.status == "approved", \ f"approved 建议不应被过期,实际: {updated.status}" @pytest.mark.asyncio async def test_expire_does_not_affect_rejected(self, db_session: AsyncSession): """rejected 状态的建议(超过 72 小时)不被过期。""" from app.main import expire_pending_suggestions old_time = _utcnow() - timedelta(hours=73) suggestion = KnowledgeSuggestion( suggestion_type="outdated", status="rejected", title="已拒绝的旧建议", content="这条建议已被拒绝,不应被过期", category="硬件", tags=["拒绝", "测试"], source_type="ai_uncertain", source_data=None, reason="拒绝过期测试", confidence=0.3, audience="engineer_workguide", reject_reason="内容不准确", created_at=old_time, ) db_session.add(suggestion) await db_session.commit() session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() suggestion_id = suggestion.id db_session.expire_all() stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None assert updated.status == "rejected", \ f"rejected 建议不应被过期,实际: {updated.status}" @pytest.mark.asyncio async def test_expire_multiple_pending_only(self, db_session: AsyncSession): """混合状态下只有超时 pending 被过期,其他状态不受影响。""" from app.main import expire_pending_suggestions old_time = _utcnow() - timedelta(hours=80) # 创建多条不同状态的建议,created_at 均超过 72 小时 suggestions = [] for status in ["pending", "queued", "approved", "rejected", "pending"]: s = KnowledgeSuggestion( suggestion_type="new_faq", status=status, title=f"混合测试-{status}", content=f"状态 {status} 的旧建议", category="网络", tags=["混合测试"], source_type="conversation", source_data=[f"conv-mix-{status}"], reason="混合状态测试", confidence=0.7, audience="employee_quick_reply", created_at=old_time, ) suggestions.append(s) db_session.add(s) await db_session.commit() session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() # 在 expire_all 之前捕获 id 和原始 status,避免 expire 后触发同步懒加载 captured = [(s.id, s.status) for s in suggestions] db_session.expire_all() # 验证:只有 pending → expired,其他状态不变 for sid, orig_status in captured: stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == sid) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None if orig_status == "pending": assert updated.status == "expired", \ f"pending 应变为 expired,实际: {updated.status}" else: assert updated.status == orig_status, \ f"{orig_status} 状态不应改变,实际: {updated.status}" @pytest.mark.asyncio async def test_expire_boundary_72_hours(self, db_session: AsyncSession): """边界测试:created_at 恰好 71 小时(未超 72 小时)的 pending 不被过期。""" from app.main import expire_pending_suggestions # 71 小时前 — 未达到 72 小时阈值 boundary_time = _utcnow() - timedelta(hours=71) suggestion = KnowledgeSuggestion( suggestion_type="new_faq", status="pending", title="边界测试建议", content="created_at 71 小时前,不应被过期", category="软件", tags=["边界"], source_type="manual", source_data=None, reason="边界测试", confidence=0.8, audience="employee_quick_reply", created_at=boundary_time, ) db_session.add(suggestion) await db_session.commit() session_factory = async_sessionmaker( db_session.bind, class_=AsyncSession, expire_on_commit=False, ) with patch("app.database._get_session_factory", return_value=session_factory): with patch("app.main.text", side_effect=_sqlite_compatible_text): await expire_pending_suggestions() suggestion_id = suggestion.id db_session.expire_all() stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db_session.execute(stmt) updated = result.scalar_one_or_none() assert updated is not None # 71 小时 < 72 小时,不应被过期 assert updated.status == "pending", \ f"71 小时的 pending 不应被过期,实际: {updated.status}"