feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复

== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
@@ -0,0 +1,248 @@
# =============================================================================
# 复杂场景重构 — 超时清理定时任务单元测试
# =============================================================================
# 测试范围:timeout_cleaner.py
# - run_once 24h 超时自动关闭
# - run_once 无超时会话时不操作
# - run_once Redis 恢复点清理
# - run_once 推送 WS 超时关闭事件
#
# 技术说明:TimeoutCleaner.run_once() 内部使用 db_factory 创建独立 DB 会话并
# 调用 commit()。测试中通过 MockDbFactory 复用 db_sessionSAVEPOINT),
# 并将 commit 替换为 flush,避免干扰 conftest 的事务回滚机制。
# =============================================================================
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from app.constants import (
PAUSE_TIMEOUT_HOURS,
REDIS_KEY_PAUSED_SESSIONS,
REDIS_KEY_RESUME_POINT,
)
from app.models.automation import AutoSession
from app.services.automation.timeout_cleaner import TimeoutCleaner
class MockDbFactory:
"""模拟 DB 会话工厂,复用 db_session 的 SAVEPOINT 事务。
run_once() 内部调用 await db.commit(),在 SAVEPOINT 环境下会导致
事务提前提交。此处将 commit 替换为 flush,确保数据写入 SAVEPOINT
但不提交外层事务,测试结束后由 conftest 回滚清理。
"""
def __init__(self, db_session):
self._db = db_session
def __call__(self):
return self
async def __aenter__(self):
self._db.commit = AsyncMock(side_effect=self._db.flush)
return self._db
async def __aexit__(self, *args):
pass
class TestTimeoutCleaner:
"""超时清理器测试用例"""
@pytest.mark.asyncio
async def test_timeout_cleaner(self, db_session, mock_redis):
"""测试 24h 超时自动关闭"""
# Arrange — 创建一个 25 小时前暂停的会话
session = AutoSession(
employee_id="emp_001",
title="超时任务",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
)
db_session.add(session)
await db_session.flush()
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 1
await db_session.refresh(session)
assert session.status == "closed"
assert session.closed_by == "system(timeout)"
@pytest.mark.asyncio
async def test_timeout_cleaner_no_expired(self, db_session, mock_redis):
"""测试无超时会话时不操作"""
# Arrange — 创建一个刚暂停的会话(未超时)
session = AutoSession(
employee_id="emp_001",
title="近期任务",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
db_session.add(session)
await db_session.flush()
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 0
await db_session.refresh(session)
assert session.status == "paused"
@pytest.mark.asyncio
async def test_timeout_cleaner_redis_cleanup(self, db_session, mock_redis):
"""测试超时关闭时清理 Redis 恢复点"""
# Arrange
session = AutoSession(
employee_id="emp_redis_test",
title="Redis清理测试",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
)
db_session.add(session)
await db_session.flush()
session_id = session.id
employee_id = session.employee_id
# 预置 Redis 恢复点和暂停集合
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
await mock_redis.setex(resume_key, 90000, '{"title":"test"}')
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(employee_id=employee_id)
await mock_redis.sadd(paused_key, session_id)
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
await cleaner.run_once()
# Assert — Redis 恢复点已删除
raw = await mock_redis.get(resume_key)
assert raw is None
# Assert — 暂停集合中已移除
members = await mock_redis.smembers(paused_key)
assert session_id not in members
@pytest.mark.asyncio
async def test_timeout_cleaner_multiple_sessions(self, db_session, mock_redis):
"""测试多个超时会话一次性关闭"""
# Arrange
sessions = []
for i in range(3):
s = AutoSession(
employee_id=f"emp_{i}",
title=f"超时任务{i}",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=26),
)
db_session.add(s)
sessions.append(s)
await db_session.flush()
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 3
for s in sessions:
await db_session.refresh(s)
assert s.status == "closed"
assert s.closed_by == "system(timeout)"
@pytest.mark.asyncio
async def test_timeout_cleaner_mixed_sessions(self, db_session, mock_redis):
"""测试混合场景 — 只关闭超时的,不关闭未超时的"""
# Arrange
expired = AutoSession(
employee_id="emp_001",
title="超时",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
)
recent = AutoSession(
employee_id="emp_002",
title="近期",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(minutes=30),
)
db_session.add_all([expired, recent])
await db_session.flush()
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 1
await db_session.refresh(expired)
assert expired.status == "closed"
await db_session.refresh(recent)
assert recent.status == "paused"
@pytest.mark.asyncio
async def test_timeout_cleaner_without_redis(self, db_session):
"""测试无 Redis 时超时关闭仍成功"""
# Arrange
session = AutoSession(
employee_id="emp_001",
title="无Redis超时",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
)
db_session.add(session)
await db_session.flush()
# Act — redis=None
cleaner = TimeoutCleaner(MockDbFactory(db_session), redis=None)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 1
await db_session.refresh(session)
assert session.status == "closed"
@pytest.mark.asyncio
async def test_timeout_cleaner_boundary_just_expired(self, db_session, mock_redis):
"""测试边界 — 刚好超过 24 小时的会话被关闭"""
# Arrange — 24小时 + 1分钟前
session = AutoSession(
employee_id="emp_001",
title="边界测试",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=24, minutes=1),
)
db_session.add(session)
await db_session.flush()
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 1
@pytest.mark.asyncio
async def test_timeout_cleaner_boundary_just_under(self, db_session, mock_redis):
"""测试边界 — 差 1 分钟到 24 小时的会话不被关闭"""
# Arrange — 23小时59分钟前
session = AutoSession(
employee_id="emp_001",
title="未超时边界",
status="paused",
paused_at=datetime.now(timezone.utc) - timedelta(hours=23, minutes=59),
)
db_session.add(session)
await db_session.flush()
# Act
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
closed_count = await cleaner.run_once()
# Assert
assert closed_count == 0