Files
wecom_it_smart_desk/backend/tests/test_meetingroom.py
T

874 lines
34 KiB
Python
Raw Normal View History

# =============================================================================
# IT智能服务台 — 会议室预定功能测试
# =============================================================================
# 测试覆盖:
# MeetingroomService (mock WecomService + MockRedis):
# 1. get_room_list — 缓存命中/未命中
# 2. get_room_status — 预定状态查询
# 3. get_current_status — 实时状态(free/busy/starting_soon
# 4. book_room — 预定成功后缓存清除
# 5. book_room — 时间冲突异常
# 6. cancel_booking — 取消成功后缓存清除
# 7. invalidate_room_cache — 缓存清除
# 8. get_booking_detail — 预定详情缓存
#
# API端点 (mock _get_meetingroom_service):
# 9. GET /itportal/meetingroom/list
# 10. GET /itportal/meetingroom/{id}/booking
# 11. GET /itportal/meetingroom/{id}/status
# 12. POST /itportal/meetingroom/book
# 13. DELETE /itportal/meetingroom/booking/{id}
# 14. GET /itportal/meetingroom/terminal/{sn}/binding
#
# 终端绑定CRUD (db_session + mock auth):
# 15. POST /itportal/admin/terminal-bindings — 新增
# 16. GET /itportal/admin/terminal-bindings — 列表
# 17. PUT /itportal/admin/terminal-bindings/{id} — 更新
# 18. DELETE /itportal/admin/terminal-bindings/{id} — 删除
#
# 模型:
# 19. TerminalRoomBinding — 字段定义
# 20. MeetingroomBookingSnapshot — 字段定义
# =============================================================================
import json
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from app.services.meetingroom_service import MeetingroomService
from app.models.terminal_room_binding import TerminalRoomBinding
from app.models.meetingroom_booking_snapshot import MeetingroomBookingSnapshot
from app.schemas.meetingroom import (
BookRequest,
TerminalBindingCreate,
TerminalBindingUpdate,
)
from app.dependencies import UserInfo
# =============================================================================
# 测试数据工厂
# =============================================================================
def make_room(id: int = 1, name: str = "测试会议室") -> dict:
"""构造企微API返回的会议室数据"""
return {
"meetingroom_id": id,
"name": name,
"capacity": 10,
"location": "18F东区",
"equipment": [1, 2],
"need_approval": 0,
}
def make_booking(
booking_id: str = "bk001",
subject: str = "项目周会",
start_offset_min: int = -30,
duration_min: int = 60,
status: int = 0,
) -> dict:
"""构造企微API返回的预定记录(start_offset_min 相对当前时间的偏移分钟数)"""
now = datetime.now()
start = now + timedelta(minutes=start_offset_min)
end = start + timedelta(minutes=duration_min)
return {
"booking_id": booking_id,
"subject": subject,
"booker": "zhangsan",
"booker_name": "张三",
"start_time": int(start.timestamp()),
"end_time": int(end.timestamp()),
"status": status,
}
# =============================================================================
# MockRedis — 复用 conftest 中的 MockRedis 实现
# =============================================================================
class MockRedisHelper:
"""轻量级 MockRedis,用于 service 层测试(独立于 conftest"""
def __init__(self):
self._data: dict = {}
async def get(self, key: str):
v = self._data.get(key)
if v is not None:
return v.encode("utf-8") if isinstance(v, str) else v
return None
async def setex(self, name: str, time: int, value: str):
self._data[name] = value
async def set(self, name: str, value: str, **kwargs):
self._data[name] = value
async def delete(self, *names):
count = 0
for n in names:
if n in self._data:
del self._data[n]
count += 1
return count
async def exists(self, *keys):
return sum(1 for k in keys if k in self._data)
async def expire(self, name: str, time: int):
return name in self._data
async def close(self):
pass
# =============================================================================
# MeetingroomService 单元测试
# =============================================================================
class TestMeetingroomService:
"""MeetingroomService 业务逻辑测试"""
@pytest.fixture
def mock_wecom(self):
"""创建 mock WecomService"""
wecom = AsyncMock()
wecom.get_meetingroom_list.return_value = [make_room(1, "A会议室"), make_room(2, "B会议室")]
wecom.get_booking_info.return_value = []
wecom.book_meetingroom.return_value = {"booking_id": "bk_new_001"}
wecom.cancel_booking.return_value = {"errcode": 0}
wecom.get_booking_detail.return_value = {
"booking_id": "bk001",
"subject": "详情测试",
"booker": "zhangsan",
}
return wecom
@pytest.fixture
def mock_redis(self):
return MockRedisHelper()
@pytest.fixture
def service(self, mock_wecom, mock_redis):
return MeetingroomService(wecom_service=mock_wecom, redis_client=mock_redis)
# --------------------------------------------------------------------------
# get_room_list
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_room_list_with_cache(self, service, mock_wecom, mock_redis):
"""缓存命中时不调用企微API"""
# 第一次调用 — 应调用API并缓存
result1 = await service.get_room_list()
assert len(result1) == 2
assert mock_wecom.get_meetingroom_list.call_count == 1
# 第二次调用 — 应命中缓存,不调用API
result2 = await service.get_room_list()
assert len(result2) == 2
assert mock_wecom.get_meetingroom_list.call_count == 1 # 仍是1次
@pytest.mark.asyncio
async def test_get_room_list_no_redis(self, mock_wecom):
"""无Redis时直接调用API"""
service = MeetingroomService(wecom_service=mock_wecom, redis_client=None)
result = await service.get_room_list()
assert len(result) == 2
assert mock_wecom.get_meetingroom_list.call_count == 1
# --------------------------------------------------------------------------
# get_room_status
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_room_status(self, service, mock_wecom):
"""验证预定状态查询"""
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "会议A", start_offset_min=60),
make_booking("bk002", "会议B", start_offset_min=120),
]
result = await service.get_room_status(meetingroom_id=1, date="2026-07-15")
assert len(result) == 2
assert result[0]["booking_id"] == "bk001"
assert mock_wecom.get_booking_info.call_count == 1
# 验证传给企微API的时间范围参数
call_args = mock_wecom.get_booking_info.call_args
assert call_args[0][0] == 1 # meetingroom_id
assert "2026-07-15T00:00:00" in call_args[0][1] # start_time
assert "2026-07-15T23:59:59" in call_args[0][2] # end_time
@pytest.mark.asyncio
async def test_get_room_status_default_date(self, service, mock_wecom):
"""未传date时默认今天"""
await service.get_room_status(meetingroom_id=1)
today = datetime.now().strftime("%Y-%m-%d")
call_args = mock_wecom.get_booking_info.call_args
assert today in call_args[0][1]
# --------------------------------------------------------------------------
# get_current_status
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_current_status_free(self, service, mock_wecom):
"""当前空闲状态判断"""
# 预定在未来2小时,不在15分钟内 → free
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "未来会议", start_offset_min=120, duration_min=60),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "free"
assert result["current_meeting"] is None
assert result["next_meeting"] is not None
assert result["minutes_to_next"] is not None
assert result["minutes_to_next"] > 15
@pytest.mark.asyncio
async def test_get_current_status_busy(self, service, mock_wecom):
"""当前使用中状态判断"""
# 预定在当前时间段内 → busy
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "进行中会议", start_offset_min=-10, duration_min=30),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "busy"
assert result["current_meeting"] is not None
assert result["current_meeting"]["subject"] == "进行中会议"
@pytest.mark.asyncio
async def test_get_current_status_starting_soon(self, service, mock_wecom):
"""即将开始状态判断(15分钟内)"""
# 预定在5分钟后开始 → starting_soon
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "即将开始", start_offset_min=5, duration_min=30),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "starting_soon"
assert result["next_meeting"] is not None
assert result["minutes_to_next"] <= 15
@pytest.mark.asyncio
async def test_get_current_status_no_bookings(self, service, mock_wecom):
"""无预定时返回free"""
mock_wecom.get_booking_info.return_value = []
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "free"
assert result["current_meeting"] is None
assert result["next_meeting"] is None
assert result["minutes_to_next"] is None
@pytest.mark.asyncio
async def test_get_current_status_skip_cancelled(self, service, mock_wecom):
"""跳过已取消的预定"""
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "已取消", start_offset_min=-10, duration_min=30, status=1),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "free"
# --------------------------------------------------------------------------
# book_room
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_book_room_success(self, service, mock_wecom, mock_redis):
"""预定成功后缓存被清除"""
# 先写入缓存
cache_key = service.CACHE_KEY_BOOKING_INFO.format(room_id=1, date=datetime.now().strftime("%Y-%m-%d"))
await mock_redis.setex(cache_key, 30, json.dumps([{"old": True}]))
# 执行预定
result = await service.book_room(
meetingroom_id=1,
subject="测试预定",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
assert result["booking_id"] == "bk_new_001"
assert mock_wecom.book_meetingroom.call_count == 1
# 验证缓存被清除
cached = await mock_redis.get(cache_key)
assert cached is None
@pytest.mark.asyncio
async def test_book_room_conflict(self, service, mock_wecom):
"""时间冲突处理 — 企微API抛异常时透传"""
mock_wecom.book_meetingroom.side_effect = Exception("时间冲突: 该时段已被预定")
with pytest.raises(Exception, match="时间冲突"):
await service.book_room(
meetingroom_id=1,
subject="冲突预定",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
# --------------------------------------------------------------------------
# cancel_booking
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_booking_success(self, service, mock_wecom, mock_redis):
"""取消成功后缓存被清除"""
# 先写入状态缓存
status_key = service.CACHE_KEY_STATUS.format(room_id=1)
await mock_redis.setex(status_key, 10, json.dumps({"status": "busy"}))
# 执行取消
result = await service.cancel_booking(booking_id="bk001", meetingroom_id=1)
assert result["errcode"] == 0
assert mock_wecom.cancel_booking.call_count == 1
# 验证缓存被清除
cached = await mock_redis.get(status_key)
assert cached is None
# --------------------------------------------------------------------------
# invalidate_room_cache
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invalidate_room_cache(self, service, mock_redis):
"""缓存清除"""
today = datetime.now().strftime("%Y-%m-%d")
booking_key = service.CACHE_KEY_BOOKING_INFO.format(room_id=1, date=today)
status_key = service.CACHE_KEY_STATUS.format(room_id=1)
# 写入缓存
await mock_redis.setex(booking_key, 30, json.dumps([{"test": True}]))
await mock_redis.setex(status_key, 10, json.dumps({"status": "busy"}))
# 清除缓存
await service.invalidate_room_cache(meetingroom_id=1)
# 验证两个缓存key都被删除
assert await mock_redis.get(booking_key) is None
assert await mock_redis.get(status_key) is None
@pytest.mark.asyncio
async def test_invalidate_room_cache_no_redis(self, mock_wecom):
"""无Redis时invalidate不报错"""
service = MeetingroomService(wecom_service=mock_wecom, redis_client=None)
await service.invalidate_room_cache(meetingroom_id=1) # 不应抛异常
# --------------------------------------------------------------------------
# get_booking_detail
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_booking_detail_with_cache(self, service, mock_wecom, mock_redis):
"""预定详情缓存"""
result1 = await service.get_booking_detail(meetingroom_id=1, booking_id="bk001")
assert result1["booking_id"] == "bk001"
assert mock_wecom.get_booking_detail.call_count == 1
# 第二次应命中缓存
result2 = await service.get_booking_detail(meetingroom_id=1, booking_id="bk001")
assert result2["booking_id"] == "bk001"
assert mock_wecom.get_booking_detail.call_count == 1 # 仍1次
# --------------------------------------------------------------------------
# _parse_booking_time
# --------------------------------------------------------------------------
def test_parse_booking_time_timestamp(self):
"""解析时间戳格式"""
ts = int(datetime(2026, 7, 15, 10, 0, 0).timestamp())
result = MeetingroomService._parse_booking_time(ts)
assert result is not None
assert result.year == 2026
assert result.month == 7
assert result.day == 15
def test_parse_booking_time_iso(self):
"""解析ISO字符串格式"""
result = MeetingroomService._parse_booking_time("2026-07-15T10:00:00")
assert result is not None
assert result.year == 2026
def test_parse_booking_time_none(self):
"""None输入返回None"""
assert MeetingroomService._parse_booking_time(None) is None
def test_parse_booking_time_invalid(self):
"""无效输入返回None"""
assert MeetingroomService._parse_booking_time("invalid") is None
# =============================================================================
# API 端点测试 — 会议室预定
# =============================================================================
class TestMeetingroomAPI:
"""会议室预定API端点测试"""
@pytest.fixture
def mock_service(self):
"""创建 mock MeetingroomService"""
svc = AsyncMock(spec=MeetingroomService)
svc.get_room_list.return_value = [make_room(1, "A会议室"), make_room(2, "B会议室")]
svc.get_room_status.return_value = [
make_booking("bk001", "会议A", start_offset_min=60),
]
svc.get_current_status.return_value = {
"status": "free",
"current_meeting": None,
"next_meeting": None,
"minutes_to_next": None,
"bookings": [],
}
svc.book_room.return_value = {"booking_id": "bk_new_001"}
svc.cancel_booking.return_value = {"errcode": 0}
svc.get_booking_detail.return_value = {
"booking_id": "bk001",
"subject": "详情",
"booker": "zhangsan",
"booker_name": "张三",
"attendees": ["lisi"],
"start_time": "2026-07-15T10:00:00",
"end_time": "2026-07-15T11:00:00",
}
svc._format_booking = MeetingroomService._format_booking
return svc
@pytest_asyncio.fixture
async def meetingroom_client(self, mock_service, db_session):
"""创建测试客户端,mock掉 _get_meetingroom_service。
使用独立 FastAPI app(只挂载 meetingroom 路由),
避免全量 create_app() 触发 itsm_service.py 中 httpx.Timeout 兼容性问题。
"""
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI
from app.database import get_db
from app.utils.response import AppException, app_exception_handler
app = FastAPI()
# 注册全局异常处理器(让 AppException 返回 JSON 而非 500
app.add_exception_handler(AppException, app_exception_handler)
# 只挂载会议室预定路由
from app.api.meetingroom import router as meetingroom_router
app.include_router(meetingroom_router)
async def _override_db():
yield db_session
app.dependency_overrides[get_db] = _override_db
with patch("app.api.meetingroom._get_meetingroom_service", return_value=mock_service):
with patch("app.api.meetingroom.WecomService", return_value=AsyncMock()):
with patch("app.services.wecom_service.WecomService", return_value=AsyncMock()):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_get_meetingroom_list(self, meetingroom_client, mock_service):
"""GET /itportal/meetingroom/list"""
resp = await meetingroom_client.get("/itportal/meetingroom/list")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert len(data["data"]["rooms"]) == 2
assert data["data"]["rooms"][0]["name"] == "A会议室"
mock_service.get_room_list.assert_called_once()
@pytest.mark.asyncio
async def test_get_booking_info(self, meetingroom_client, mock_service):
"""GET /itportal/meetingroom/{id}/booking"""
resp = await meetingroom_client.get("/itportal/meetingroom/1/booking")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert len(data["data"]["bookings"]) == 1
@pytest.mark.asyncio
async def test_get_current_status(self, meetingroom_client, mock_service):
"""GET /itportal/meetingroom/{id}/status"""
resp = await meetingroom_client.get("/itportal/meetingroom/1/status")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "free"
@pytest.mark.asyncio
async def test_get_terminal_binding_not_found(self, meetingroom_client):
"""GET /itportal/meetingroom/terminal/{sn}/binding — 未绑定时返回null"""
resp = await meetingroom_client.get("/itportal/meetingroom/terminal/UNKNOWN_SN/binding")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"] is None
# =============================================================================
# API 端点测试 — 终端绑定CRUD
# =============================================================================
class TestTerminalBindingCRUD:
"""终端绑定管理CRUD测试"""
@pytest_asyncio.fixture
async def admin_client(self, db_session):
"""创建带admin权限的测试客户端。
使用独立 FastAPI app(只挂载 terminal_binding 路由),
避免全量 create_app() 触发 itsm_service.py 中 httpx.Timeout 兼容性问题。
"""
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI
from app.database import get_db
from app.dependencies import get_current_user
from app.utils.response import AppException, app_exception_handler
app = FastAPI()
# 注册全局异常处理器(让 AppException 返回 JSON 而非 500
app.add_exception_handler(AppException, app_exception_handler)
from app.api.admin.terminal_binding import router as terminal_binding_router
app.include_router(terminal_binding_router)
async def _override_db():
yield db_session
admin_user = UserInfo(
employee_id="admin001",
name="管理员",
department="IT部",
avatar="",
roles=["admin"],
current_role="admin",
login_source="test",
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_current_user] = lambda: admin_user
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_create_binding(self, admin_client, db_session):
"""POST /itportal/admin/terminal-bindings — 新增绑定"""
resp = await admin_client.post("/itportal/admin/terminal-bindings", json={
"terminal_sn": "SN_TEST_001",
"terminal_name": "18F东区大屏",
"meetingroom_id": 1,
"meetingroom_name": "18F东区会议室",
"location": "18F东区",
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "id" in data["data"]
binding_id = data["data"]["id"]
# 验证数据已写入
from sqlalchemy import select
result = await db_session.execute(
select(TerminalRoomBinding).where(TerminalRoomBinding.id == binding_id)
)
binding = result.scalar_one_or_none()
assert binding is not None
assert binding.terminal_sn == "SN_TEST_001"
assert binding.meetingroom_id == 1
assert binding.is_active is True
@pytest.mark.asyncio
async def test_create_binding_duplicate(self, admin_client, db_session):
"""重复绑定同一terminal_sn应报错"""
# 先创建一条
db_session.add(TerminalRoomBinding(
terminal_sn="SN_DUP_001",
terminal_name="终端1",
meetingroom_id=1,
meetingroom_name="会议室A",
location="18F",
is_active=True,
))
await db_session.flush()
# 再创建同SN的
resp = await admin_client.post("/itportal/admin/terminal-bindings", json={
"terminal_sn": "SN_DUP_001",
"terminal_name": "终端2",
"meetingroom_id": 2,
"meetingroom_name": "会议室B",
"location": "19F",
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] != 0 # 应返回错误
@pytest.mark.asyncio
async def test_list_bindings(self, admin_client, db_session):
"""GET /itportal/admin/terminal-bindings — 列表查询"""
# 插入测试数据
for i in range(3):
db_session.add(TerminalRoomBinding(
terminal_sn=f"SN_LIST_{i:03d}",
terminal_name=f"终端{i}",
meetingroom_id=i + 1,
meetingroom_name=f"会议室{i+1}",
location=f"{i+10}F",
is_active=True,
))
await db_session.flush()
resp = await admin_client.get("/itportal/admin/terminal-bindings")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["total"] >= 3
assert len(data["data"]["list"]) >= 3
@pytest.mark.asyncio
async def test_list_bindings_with_keyword(self, admin_client, db_session):
"""GET /itportal/admin/terminal-bindings?keyword=xxx — 关键词搜索"""
db_session.add(TerminalRoomBinding(
terminal_sn="SN_KW_001",
terminal_name="大屏A",
meetingroom_id=1,
meetingroom_name="会议室A",
location="18F",
is_active=True,
))
db_session.add(TerminalRoomBinding(
terminal_sn="SN_KW_002",
terminal_name="大屏B",
meetingroom_id=2,
meetingroom_name="会议室B",
location="19F",
is_active=True,
))
await db_session.flush()
resp = await admin_client.get("/itportal/admin/terminal-bindings?keyword=大屏A")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
# 至少包含"大屏A"的记录
names = [item["terminal_name"] for item in data["data"]["list"]]
assert "大屏A" in names
@pytest.mark.asyncio
async def test_update_binding(self, admin_client, db_session):
"""PUT /itportal/admin/terminal-bindings/{id} — 更新绑定"""
binding = TerminalRoomBinding(
terminal_sn="SN_UPD_001",
terminal_name="旧名称",
meetingroom_id=1,
meetingroom_name="旧会议室",
location="18F",
is_active=True,
)
db_session.add(binding)
await db_session.flush()
resp = await admin_client.put(f"/itportal/admin/terminal-bindings/{binding.id}", json={
"terminal_name": "新名称",
"meetingroom_name": "新会议室",
"is_active": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
# 验证更新生效(flush 确保修改写入 session,再检查属性)
await db_session.flush()
assert binding.terminal_name == "新名称"
assert binding.meetingroom_name == "新会议室"
assert binding.is_active is False
@pytest.mark.asyncio
async def test_update_binding_not_found(self, admin_client):
"""更新不存在的绑定应报错"""
resp = await admin_client.put("/itportal/admin/terminal-bindings/99999", json={
"terminal_name": "测试",
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] != 0
@pytest.mark.asyncio
async def test_delete_binding(self, admin_client, db_session):
"""DELETE /itportal/admin/terminal-bindings/{id} — 删除绑定"""
binding = TerminalRoomBinding(
terminal_sn="SN_DEL_001",
terminal_name="待删除",
meetingroom_id=1,
meetingroom_name="会议室",
location="18F",
is_active=True,
)
db_session.add(binding)
await db_session.flush()
binding_id = binding.id
resp = await admin_client.delete(f"/itportal/admin/terminal-bindings/{binding_id}")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
@pytest.mark.asyncio
async def test_delete_binding_not_found(self, admin_client):
"""删除不存在的绑定应报错"""
resp = await admin_client.delete("/itportal/admin/terminal-bindings/99999")
assert resp.status_code == 200
data = resp.json()
assert data["code"] != 0
# =============================================================================
# 模型测试
# =============================================================================
class TestModels:
"""模型字段定义测试"""
def test_terminal_room_binding_model(self):
"""TerminalRoomBinding 模型字段定义正确"""
# 验证表名
assert TerminalRoomBinding.__tablename__ == "terminal_room_binding"
# 验证字段存在
columns = {c.name for c in TerminalRoomBinding.__table__.columns}
expected = {
"id", "terminal_sn", "terminal_name", "meetingroom_id",
"meetingroom_name", "location", "is_active",
"created_at", "updated_at",
}
assert expected.issubset(columns), f"缺失字段: {expected - columns}"
# 验证 terminal_sn 唯一索引
sn_col = TerminalRoomBinding.__table__.columns["terminal_sn"]
assert sn_col.unique is True
# 验证 meetingroom_id 有索引(非唯一)
room_col = TerminalRoomBinding.__table__.columns["meetingroom_id"]
assert room_col.index is True
# 验证默认值
assert TerminalRoomBinding.__table__.columns["is_active"].default.arg is True
def test_meetingroom_booking_snapshot_model(self):
"""MeetingroomBookingSnapshot 模型字段定义正确"""
# 验证表名
assert MeetingroomBookingSnapshot.__tablename__ == "meetingroom_booking_snapshot"
# 验证字段存在
columns = {c.name for c in MeetingroomBookingSnapshot.__table__.columns}
expected = {
"id", "meetingroom_id", "booking_id", "subject", "booker",
"start_time", "end_time", "status", "snapshot_date", "created_at",
}
assert expected.issubset(columns), f"缺失字段: {expected - columns}"
# 验证索引字段
assert MeetingroomBookingSnapshot.__table__.columns["meetingroom_id"].index is True
assert MeetingroomBookingSnapshot.__table__.columns["booking_id"].index is True
assert MeetingroomBookingSnapshot.__table__.columns["snapshot_date"].index is True
# 验证默认值
assert MeetingroomBookingSnapshot.__table__.columns["status"].default.arg == 0
@pytest.mark.asyncio
async def test_terminal_room_binding_repr(self):
"""TerminalRoomBinding __repr__ 方法"""
binding = TerminalRoomBinding(
terminal_sn="SN_REPR_001",
terminal_name="测试终端",
meetingroom_id=1,
meetingroom_name="测试会议室",
)
repr_str = repr(binding)
assert "SN_REPR_001" in repr_str
assert "测试会议室" in repr_str
@pytest.mark.asyncio
async def test_meetingroom_booking_snapshot_repr(self):
"""MeetingroomBookingSnapshot __repr__ 方法"""
from datetime import date
snapshot = MeetingroomBookingSnapshot(
meetingroom_id=1,
booking_id="bk_repr_001",
subject="测试快照",
start_time=datetime(2026, 7, 15, 10, 0, 0),
end_time=datetime(2026, 7, 15, 11, 0, 0),
snapshot_date=date(2026, 7, 15),
)
repr_str = repr(snapshot)
assert "bk_repr_001" in repr_str
assert "测试快照" in repr_str
# =============================================================================
# Pydantic Schema 测试
# =============================================================================
class TestSchemas:
"""Pydantic 请求/响应模型测试"""
def test_book_request_valid(self):
"""BookRequest 验证合法请求"""
req = BookRequest(
meetingroom_id=1,
subject="测试预定",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
assert req.meetingroom_id == 1
assert req.subject == "测试预定"
def test_book_request_empty_subject(self):
"""BookRequest 空主题应验证失败"""
with pytest.raises(Exception):
BookRequest(
meetingroom_id=1,
subject="",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
def test_terminal_binding_create_valid(self):
"""TerminalBindingCreate 验证"""
req = TerminalBindingCreate(
terminal_sn="SN001",
terminal_name="终端1",
meetingroom_id=1,
meetingroom_name="会议室1",
location="18F",
)
assert req.terminal_sn == "SN001"
def test_terminal_binding_create_empty_sn(self):
"""空terminal_sn应验证失败"""
with pytest.raises(Exception):
TerminalBindingCreate(
terminal_sn="",
meetingroom_id=1,
)
def test_terminal_binding_update_partial(self):
"""TerminalBindingUpdate 部分更新"""
req = TerminalBindingUpdate(terminal_name="新名称")
assert req.terminal_name == "新名称"
assert req.meetingroom_id is None
assert req.is_active is None