功能:增加测试批次生命周期管理

This commit is contained in:
焦龙言
2026-09-22 16:19:32 +08:00
parent c82f5e42dd
commit af5428d0e3
4 changed files with 1095 additions and 2 deletions
@@ -1,9 +1,52 @@
from sqlalchemy import select from datetime import datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import TestBatchStatus from module_business.domain.enums import TestBatchStatus
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
BizPallet,
BizProductionLine,
BizStrain,
BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundItem,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
from module_business.entity.do.test_batch_do import BizTestBatch, BizTestBatchUser from module_business.entity.do.test_batch_do import BizTestBatch, BizTestBatchUser
_BATCH_OWNED_MODELS: tuple[type[Any], ...] = (
BizStrain,
BizCustomer,
BizCustomerSite,
BizProductionLine,
BizWarehouse,
BizPallet,
BizInboundOrder,
BizInboundItem,
BizFillingRecord,
BizShipmentOrder,
BizShipmentItem,
BizUsageRecord,
BizReturnOrder,
BizReturnItem,
BizPalletInventory,
BizPalletEvent,
)
class TestBatchDao: class TestBatchDao:
"""测试批次访问数据库操作层。""" """测试批次访问数据库操作层。"""
@@ -45,3 +88,69 @@ class TestBatchDao:
statement = statement.join(BizTestBatchUser).where(BizTestBatchUser.user_id == user_id) statement = statement.join(BizTestBatchUser).where(BizTestBatchUser.user_id == user_id)
result = await db.execute(statement.order_by(BizTestBatch.batch_id.desc())) result = await db.execute(statement.order_by(BizTestBatch.batch_id.desc()))
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod
async def list_batches(cls, db: AsyncSession) -> list[BizTestBatch]:
result = await db.execute(select(BizTestBatch).order_by(BizTestBatch.batch_id.desc()))
return list(result.scalars().all())
@classmethod
async def add_batch(cls, db: AsyncSession, batch: BizTestBatch) -> None:
db.add(batch)
@classmethod
async def list_batch_user_ids(cls, db: AsyncSession, batch_id: int) -> list[int]:
result = await db.execute(
select(BizTestBatchUser.user_id)
.where(BizTestBatchUser.batch_id == batch_id)
.order_by(BizTestBatchUser.user_id)
)
return list(result.scalars().all())
@classmethod
async def list_active_user_ids(cls, db: AsyncSession, user_ids: list[int]) -> list[int]:
if not user_ids:
return []
result = await db.execute(
select(SysUser.user_id)
.where(
SysUser.user_id.in_(user_ids),
SysUser.status == '0',
SysUser.del_flag == '0',
)
.order_by(SysUser.user_id)
)
return list(result.scalars().all())
@classmethod
async def replace_batch_users(
cls,
db: AsyncSession,
batch_id: int,
user_ids: list[int],
create_by: str,
create_time: datetime,
) -> None:
await db.execute(delete(BizTestBatchUser).where(BizTestBatchUser.batch_id == batch_id))
if user_ids:
db.add_all(
[
BizTestBatchUser(
batch_id=batch_id,
user_id=user_id,
create_by=create_by,
create_time=create_time,
)
for user_id in user_ids
]
)
@classmethod
async def count_owned_rows(cls, db: AsyncSession, batch_id: int) -> dict[str, int]:
counts: dict[str, int] = {}
for model in _BATCH_OWNED_MODELS:
result = await db.execute(
select(func.count()).select_from(model).where(model.test_batch_id == batch_id)
)
counts[model.__tablename__] = result.scalar() or 0
return counts
@@ -7,6 +7,7 @@ from pydantic.alias_generators import to_camel
from module_business.domain.enums import TestBatchStatus from module_business.domain.enums import TestBatchStatus
PositiveId = Annotated[int, Field(gt=0)] PositiveId = Annotated[int, Field(gt=0)]
NonNegativeCount = Annotated[int, Field(ge=0)]
class TestBatchApiModel(BaseModel): class TestBatchApiModel(BaseModel):
@@ -45,7 +46,7 @@ class TestBatchModel(TestBatchApiModel):
closed_at: datetime | None = None closed_at: datetime | None = None
purged_by: int | None = None purged_by: int | None = None
purged_at: datetime | None = None purged_at: datetime | None = None
purge_summary: dict[str, int] | None = None purge_summary: dict[str, NonNegativeCount] | None = None
class TestBatchOptionModel(TestBatchApiModel): class TestBatchOptionModel(TestBatchApiModel):
@@ -0,0 +1,322 @@
import json
import secrets
from datetime import datetime
from typing import Any
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.test_batch_dao import TestBatchDao
from module_business.domain.enums import TestBatchStatus
from module_business.entity.do.test_batch_do import BizTestBatch
from module_business.entity.vo.test_batch_vo import (
BatchImpactModel,
TestBatchCreateModel,
TestBatchDetailModel,
TestBatchEditModel,
TestBatchModel,
TestBatchOptionModel,
TestBatchUserAssignModel,
)
from module_business.service.test_batch_access_service import TestBatchAccessService
_OWNED_TABLE_LABELS: tuple[tuple[str, str], ...] = (
('biz_strain', '菌种'),
('biz_customer', '客户'),
('biz_customer_site', '客户现场'),
('biz_production_line', '生产线'),
('biz_warehouse', '仓库'),
('biz_pallet', '托盘'),
('biz_inbound_order', '入库单'),
('biz_inbound_item', '入库明细'),
('biz_filling_record', '装填记录'),
('biz_shipment_order', '发货单'),
('biz_shipment_item', '发货明细'),
('biz_usage_record', '使用记录'),
('biz_return_order', '退回单'),
('biz_return_item', '退回明细'),
('biz_pallet_inventory', '托盘库存'),
('biz_pallet_event', '托盘事件'),
)
class TestBatchService:
"""测试数据批次生命周期与用户授权服务。"""
@staticmethod
def _assert_admin(current_user: CurrentUserModel) -> None:
TestBatchAccessService.assert_super_admin(current_user)
@staticmethod
def _user_identity(current_user: CurrentUserModel) -> tuple[int, str]:
user = current_user.user
if user is None or user.user_id is None or user.user_name is None:
raise PermissionException('', '当前用户信息不可用')
return user.user_id, user.user_name
@staticmethod
def _parse_purge_summary(raw_summary: Any) -> dict[str, int] | None:
if raw_summary is None:
return None
try:
parsed = json.loads(raw_summary) if isinstance(raw_summary, str) else raw_summary
except (TypeError, ValueError):
raise ServiceException(message='测试批次清理摘要格式无效') from None
if not isinstance(parsed, dict) or any(
not isinstance(key, str)
or isinstance(value, bool)
or not isinstance(value, int)
or value < 0
for key, value in parsed.items()
):
raise ServiceException(message='测试批次清理摘要格式无效')
return parsed
@classmethod
def _to_batch_model(cls, batch: BizTestBatch | Any) -> TestBatchModel:
values = {
field_name: getattr(batch, field_name, None)
for field_name in TestBatchModel.model_fields
}
values['purge_summary'] = cls._parse_purge_summary(getattr(batch, 'purge_summary', None))
return TestBatchModel.model_validate(values)
@staticmethod
def _clear_preview(batch: BizTestBatch | Any) -> None:
batch.preview_action = None
batch.preview_token_hash = None
batch.preview_scope_hash = None
batch.preview_expires_at = None
@staticmethod
def _touch(batch: BizTestBatch | Any, user_name: str, now: datetime) -> None:
batch.update_by = user_name
batch.update_time = now
@staticmethod
def _raise_for_non_active(batch: BizTestBatch | Any) -> None:
if batch.status == TestBatchStatus.CLOSED.value:
raise ServiceWarning(message='测试批次已关闭')
if batch.status == TestBatchStatus.PURGED.value:
raise ServiceWarning(message='测试批次已清理')
if batch.status != TestBatchStatus.ACTIVE.value:
raise ServiceWarning(message='测试批次状态异常')
@classmethod
async def list_available_batches(
cls,
db: AsyncSession,
current_user: CurrentUserModel,
) -> list[TestBatchOptionModel]:
user_id, _ = cls._user_identity(current_user)
is_admin = current_user.user is not None and current_user.user.admin is True
batches = await TestBatchDao.list_available_batches(db, user_id, is_admin)
return [TestBatchOptionModel.model_validate(batch) for batch in batches]
@classmethod
async def list_batches(
cls,
db: AsyncSession,
current_user: CurrentUserModel,
) -> list[TestBatchModel]:
cls._assert_admin(current_user)
batches = await TestBatchDao.list_batches(db)
return [cls._to_batch_model(batch) for batch in batches]
@classmethod
async def get_batch_detail(
cls,
db: AsyncSession,
batch_id: int,
current_user: CurrentUserModel,
) -> TestBatchDetailModel:
cls._assert_admin(current_user)
batch = await TestBatchDao.get_batch(db, batch_id)
if batch is None:
raise ServiceWarning(message='测试批次不存在')
user_ids = await TestBatchDao.list_batch_user_ids(db, batch_id)
counts = await TestBatchDao.count_owned_rows(db, batch_id)
data_summary = [
BatchImpactModel(
table_name=table_name,
label=label,
count=counts.get(table_name, 0),
keys=[],
)
for table_name, label in _OWNED_TABLE_LABELS
]
batch_model = cls._to_batch_model(batch)
return TestBatchDetailModel(
**batch_model.model_dump(),
user_ids=user_ids,
data_summary=data_summary,
)
@classmethod
async def create_batch(
cls,
db: AsyncSession,
command: TestBatchCreateModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
cls._assert_admin(current_user)
_, user_name = cls._user_identity(current_user)
batch_name = command.batch_name.strip()
if not batch_name:
raise ServiceWarning(message='测试批次名称不能为空')
now = datetime.now()
batch = BizTestBatch(
batch_code=f'TB-{now:%Y%m%d%H%M%S}-{secrets.token_hex(4).upper()}',
batch_name=batch_name,
status=TestBatchStatus.ACTIVE.value,
create_by=user_name,
create_time=now,
update_by=user_name,
update_time=now,
remark=command.remark,
)
try:
await TestBatchDao.add_batch(db, batch)
await db.commit()
return CrudResponseModel(is_success=True, message='测试批次创建成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='测试批次创建失败,批次编码冲突') from None
except Exception:
await db.rollback()
raise
@classmethod
async def edit_batch(
cls,
db: AsyncSession,
batch_id: int,
command: TestBatchEditModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
cls._assert_admin(current_user)
_, user_name = cls._user_identity(current_user)
try:
batch = await TestBatchDao.get_batch_for_update(db, batch_id)
if batch is None:
raise ServiceWarning(message='测试批次不存在')
if batch.status == TestBatchStatus.PURGED.value:
raise ServiceWarning(message='测试批次已清理,不能修改')
if batch.status not in (TestBatchStatus.ACTIVE.value, TestBatchStatus.CLOSED.value):
raise ServiceWarning(message='测试批次状态异常')
now = datetime.now()
batch.remark = command.remark
cls._touch(batch, user_name, now)
await db.commit()
return CrudResponseModel(is_success=True, message='测试批次修改成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='测试批次修改失败,数据状态已变化') from None
except Exception:
await db.rollback()
raise
@classmethod
async def replace_users(
cls,
db: AsyncSession,
batch_id: int,
command: TestBatchUserAssignModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
cls._assert_admin(current_user)
_, user_name = cls._user_identity(current_user)
try:
batch = await TestBatchDao.get_batch_for_update(db, batch_id)
if batch is None:
raise ServiceWarning(message='测试批次不存在')
cls._raise_for_non_active(batch)
user_ids = list(dict.fromkeys(command.user_ids))
if user_ids:
active_user_ids = await TestBatchDao.list_active_user_ids(db, user_ids)
if set(active_user_ids) != set(user_ids):
raise ServiceWarning(message='存在不存在、停用或已删除的用户')
now = datetime.now()
await TestBatchDao.replace_batch_users(db, batch_id, user_ids, user_name, now)
cls._touch(batch, user_name, now)
await db.commit()
return CrudResponseModel(is_success=True, message='测试批次用户授权成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='测试批次用户授权失败,关联数据无效') from None
except Exception:
await db.rollback()
raise
@classmethod
async def close_batch(
cls,
db: AsyncSession,
batch_id: int,
current_user: CurrentUserModel,
) -> CrudResponseModel:
cls._assert_admin(current_user)
user_id, user_name = cls._user_identity(current_user)
try:
batch = await TestBatchDao.get_batch_for_update(db, batch_id)
if batch is None:
raise ServiceWarning(message='测试批次不存在')
cls._raise_for_non_active(batch)
now = datetime.now()
batch.status = TestBatchStatus.CLOSED.value
batch.closed_by = user_id
batch.closed_at = now
cls._clear_preview(batch)
cls._touch(batch, user_name, now)
await db.commit()
return CrudResponseModel(is_success=True, message='测试批次关闭成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='测试批次关闭失败,数据状态已变化') from None
except Exception:
await db.rollback()
raise
@classmethod
async def reopen_batch(
cls,
db: AsyncSession,
batch_id: int,
current_user: CurrentUserModel,
) -> CrudResponseModel:
cls._assert_admin(current_user)
_, user_name = cls._user_identity(current_user)
try:
batch = await TestBatchDao.get_batch_for_update(db, batch_id)
if batch is None:
raise ServiceWarning(message='测试批次不存在')
if batch.status == TestBatchStatus.PURGED.value:
raise ServiceWarning(message='测试批次已清理,不能重新开启')
if batch.status == TestBatchStatus.ACTIVE.value:
raise ServiceWarning(message='测试批次已处于启用状态')
if batch.status != TestBatchStatus.CLOSED.value:
raise ServiceWarning(message='测试批次状态异常')
now = datetime.now()
batch.status = TestBatchStatus.ACTIVE.value
batch.closed_by = None
batch.closed_at = None
cls._clear_preview(batch)
cls._touch(batch, user_name, now)
await db.commit()
return CrudResponseModel(is_success=True, message='测试批次重新开启成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='测试批次重新开启失败,数据状态已变化') from None
except Exception:
await db.rollback()
raise
@@ -0,0 +1,661 @@
import inspect
import json
import unittest
from datetime import datetime
from types import SimpleNamespace
from typing import get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.test_batch_dao import TestBatchDao
from module_business.entity.do.test_batch_do import BizTestBatch, BizTestBatchUser
from module_business.entity.vo.test_batch_vo import (
TestBatchCreateModel,
TestBatchDetailModel,
TestBatchEditModel,
TestBatchModel,
TestBatchOptionModel,
TestBatchUserAssignModel,
)
from module_business.service.test_batch_service import TestBatchService
from tests.module_business.test_batch_fixtures import make_admin, make_current_user
OWNED_TABLES = (
('biz_strain', '菌种'),
('biz_customer', '客户'),
('biz_customer_site', '客户现场'),
('biz_production_line', '生产线'),
('biz_warehouse', '仓库'),
('biz_pallet', '托盘'),
('biz_inbound_order', '入库单'),
('biz_inbound_item', '入库明细'),
('biz_filling_record', '装填记录'),
('biz_shipment_order', '发货单'),
('biz_shipment_item', '发货明细'),
('biz_usage_record', '使用记录'),
('biz_return_order', '退回单'),
('biz_return_item', '退回明细'),
('biz_pallet_inventory', '托盘库存'),
('biz_pallet_event', '托盘事件'),
)
def make_batch(
*,
batch_id: int = 3,
status: str = 'ACTIVE',
purge_summary: str | None = None,
) -> SimpleNamespace:
created_at = datetime(2026, 9, 22, 9, 0, 0)
return SimpleNamespace(
batch_id=batch_id,
batch_code=f'TB-{batch_id}',
batch_name=f'测试批次-{batch_id}',
status=status,
remark='回归测试',
create_by='admin',
create_time=created_at,
update_by='admin',
update_time=created_at,
closed_by=None,
closed_at=None,
purged_by=None,
purged_at=None,
purge_summary=purge_summary,
preview_action='ADOPT',
preview_token_hash='SENSITIVE_TOKEN_HASH',
preview_scope_hash='SENSITIVE_SCOPE_HASH',
preview_expires_at=datetime(2026, 9, 22, 10, 0, 0),
)
def compile_sql(statement: object) -> str:
return ' '.join(
str(statement.compile(dialect=postgresql.dialect(), compile_kwargs={'literal_binds': True})).split()
)
class TestBatchServiceSignatureTest(unittest.TestCase):
def test_eight_public_service_signatures_are_stable(self) -> None:
expected = {
'list_available_batches': (
('db', 'current_user'),
{'db': AsyncSession, 'current_user': CurrentUserModel, 'return': list[TestBatchOptionModel]},
),
'list_batches': (
('db', 'current_user'),
{'db': AsyncSession, 'current_user': CurrentUserModel, 'return': list[TestBatchModel]},
),
'get_batch_detail': (
('db', 'batch_id', 'current_user'),
{
'db': AsyncSession,
'batch_id': int,
'current_user': CurrentUserModel,
'return': TestBatchDetailModel,
},
),
'create_batch': (
('db', 'command', 'current_user'),
{
'db': AsyncSession,
'command': TestBatchCreateModel,
'current_user': CurrentUserModel,
'return': CrudResponseModel,
},
),
'edit_batch': (
('db', 'batch_id', 'command', 'current_user'),
{
'db': AsyncSession,
'batch_id': int,
'command': TestBatchEditModel,
'current_user': CurrentUserModel,
'return': CrudResponseModel,
},
),
'replace_users': (
('db', 'batch_id', 'command', 'current_user'),
{
'db': AsyncSession,
'batch_id': int,
'command': TestBatchUserAssignModel,
'current_user': CurrentUserModel,
'return': CrudResponseModel,
},
),
'close_batch': (
('db', 'batch_id', 'current_user'),
{
'db': AsyncSession,
'batch_id': int,
'current_user': CurrentUserModel,
'return': CrudResponseModel,
},
),
'reopen_batch': (
('db', 'batch_id', 'current_user'),
{
'db': AsyncSession,
'batch_id': int,
'current_user': CurrentUserModel,
'return': CrudResponseModel,
},
),
}
self.assertEqual(set(expected), {name for name in expected if hasattr(TestBatchService, name)})
for name, (parameters, hints) in expected.items():
method = getattr(TestBatchService, name)
with self.subTest(method=name):
self.assertTrue(inspect.iscoroutinefunction(method))
self.assertEqual(parameters, tuple(inspect.signature(method).parameters))
self.assertEqual(hints, get_type_hints(method))
class TestBatchServiceReadTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.db = AsyncMock(spec=AsyncSession)
async def test_regular_user_options_only_include_own_active_batches(self) -> None:
batches = [make_batch()]
with patch.object(TestBatchDao, 'list_available_batches', AsyncMock(return_value=batches)) as query:
result = await TestBatchService.list_available_batches(self.db, make_current_user(8))
query.assert_awaited_once_with(self.db, 8, False)
self.assertEqual([3], [item.batch_id for item in result])
self.assertTrue(all(isinstance(item, TestBatchOptionModel) for item in result))
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_admin_options_request_all_active_batches(self) -> None:
with patch.object(TestBatchDao, 'list_available_batches', AsyncMock(return_value=[])) as query:
result = await TestBatchService.list_available_batches(self.db, make_admin())
self.assertEqual([], result)
query.assert_awaited_once_with(self.db, 1, True)
async def test_options_reject_missing_user_context_without_querying(self) -> None:
current_user = CurrentUserModel(permissions=[], roles=[], user=None)
query = AsyncMock()
with patch.object(TestBatchDao, 'list_available_batches', query), self.assertRaises(PermissionException):
await TestBatchService.list_available_batches(self.db, current_user)
query.assert_not_awaited()
async def test_management_list_requires_hard_admin_flag(self) -> None:
query = AsyncMock()
with (
patch.object(TestBatchDao, 'list_batches', query),
self.assertRaises(PermissionException) as raised,
):
await TestBatchService.list_batches(
self.db,
make_current_user(2, ['system:testbatch:list']),
)
self.assertIn('仅超级管理员', raised.exception.message)
query.assert_not_awaited()
async def test_list_parses_purge_summary_and_excludes_preview_secrets(self) -> None:
batch = make_batch(status='PURGED', purge_summary='{"biz_pallet": 2, "biz_usage_record": 1}')
with patch.object(TestBatchDao, 'list_batches', AsyncMock(return_value=[batch])):
result = await TestBatchService.list_batches(self.db, make_admin())
self.assertEqual({'biz_pallet': 2, 'biz_usage_record': 1}, result[0].purge_summary)
serialized = json.dumps(result[0].model_dump(mode='json', by_alias=True), ensure_ascii=False)
self.assertNotIn('SENSITIVE_TOKEN_HASH', serialized)
self.assertNotIn('SENSITIVE_SCOPE_HASH', serialized)
self.assertNotIn('previewToken', serialized)
async def test_invalid_purge_summary_is_rejected_without_echoing_content(self) -> None:
sensitive_value = 'RAW_PREVIEW_TOKEN_MUST_NOT_LEAK'
batch = make_batch(status='PURGED', purge_summary=f'{{"token": "{sensitive_value}"}}')
with (
patch.object(TestBatchDao, 'list_batches', AsyncMock(return_value=[batch])),
self.assertRaises(ServiceException) as raised,
):
await TestBatchService.list_batches(self.db, make_admin())
self.assertEqual('测试批次清理摘要格式无效', raised.exception.message)
self.assertNotIn(sensitive_value, raised.exception.message)
async def test_missing_batch_detail_stops_before_related_queries(self) -> None:
users = AsyncMock()
counts = AsyncMock()
with (
patch.object(TestBatchDao, 'get_batch', AsyncMock(return_value=None)),
patch.object(TestBatchDao, 'list_batch_user_ids', users),
patch.object(TestBatchDao, 'count_owned_rows', counts),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.get_batch_detail(self.db, 404, make_admin())
self.assertIn('不存在', raised.exception.message)
users.assert_not_awaited()
counts.assert_not_awaited()
async def test_detail_returns_all_sixteen_classifications_and_user_ids(self) -> None:
counts = {table_name: index for index, (table_name, _) in enumerate(OWNED_TABLES)}
with (
patch.object(TestBatchDao, 'get_batch', AsyncMock(return_value=make_batch())),
patch.object(TestBatchDao, 'list_batch_user_ids', AsyncMock(return_value=[7, 9])),
patch.object(TestBatchDao, 'count_owned_rows', AsyncMock(return_value=counts)),
):
result = await TestBatchService.get_batch_detail(self.db, 3, make_admin())
self.assertEqual([7, 9], result.user_ids)
self.assertEqual(
[(table_name, label, index, []) for index, (table_name, label) in enumerate(OWNED_TABLES)],
[(item.table_name, item.label, item.count, item.keys) for item in result.data_summary],
)
serialized = json.dumps(result.model_dump(mode='json', by_alias=True), ensure_ascii=False)
self.assertNotIn('SENSITIVE_TOKEN_HASH', serialized)
class TestBatchServiceWriteTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.db = AsyncMock(spec=AsyncSession)
self.admin = make_admin()
async def test_non_admin_cannot_create_even_with_permission_character(self) -> None:
add_batch = AsyncMock()
command = TestBatchCreateModel(batchName='测试批次')
with patch.object(TestBatchDao, 'add_batch', add_batch), self.assertRaises(PermissionException):
await TestBatchService.create_batch(
self.db,
command,
make_current_user(2, ['system:testbatch:add']),
)
add_batch.assert_not_awaited()
self.db.commit.assert_not_awaited()
async def test_create_trims_name_generates_code_and_commits(self) -> None:
command = TestBatchCreateModel(batchName=' 回归批次 ', remark='用于回归')
with (
patch('module_business.service.test_batch_service.secrets.token_hex', return_value='a1b2c3d4'),
patch.object(TestBatchDao, 'add_batch', AsyncMock()) as add_batch,
):
result = await TestBatchService.create_batch(self.db, command, self.admin)
created = add_batch.await_args.args[1]
self.assertIsInstance(created, BizTestBatch)
self.assertEqual('回归批次', created.batch_name)
self.assertRegex(created.batch_code, r'^TB-\d{14}-A1B2C3D4$')
self.assertEqual('ACTIVE', created.status)
self.assertEqual('admin', created.create_by)
self.assertEqual(CrudResponseModel(is_success=True, message='测试批次创建成功'), result)
self.db.commit.assert_awaited_once_with()
self.db.rollback.assert_not_awaited()
async def test_blank_trimmed_name_is_rejected_before_write(self) -> None:
add_batch = AsyncMock()
with (
patch.object(TestBatchDao, 'add_batch', add_batch),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.create_batch(
self.db,
TestBatchCreateModel(batchName=' '),
self.admin,
)
self.assertIn('名称不能为空', raised.exception.message)
add_batch.assert_not_awaited()
async def test_create_integrity_error_rolls_back_and_maps_message(self) -> None:
error = IntegrityError('insert', {}, RuntimeError('duplicate'))
with (
patch.object(TestBatchDao, 'add_batch', AsyncMock(side_effect=error)),
self.assertRaises(ServiceException) as raised,
):
await TestBatchService.create_batch(
self.db,
TestBatchCreateModel(batchName='测试批次'),
self.admin,
)
self.assertIn('批次编码冲突', raised.exception.message)
self.db.rollback.assert_awaited_once_with()
self.db.commit.assert_not_awaited()
async def test_unexpected_create_failure_rolls_back_and_propagates(self) -> None:
with (
patch.object(TestBatchDao, 'add_batch', AsyncMock(side_effect=RuntimeError('database unavailable'))),
self.assertRaisesRegex(RuntimeError, 'database unavailable'),
):
await TestBatchService.create_batch(
self.db,
TestBatchCreateModel(batchName='测试批次'),
self.admin,
)
self.db.rollback.assert_awaited_once_with()
async def test_edit_missing_batch_rolls_back(self) -> None:
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=None)),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.edit_batch(
self.db,
3,
TestBatchEditModel(remark='备注'),
self.admin,
)
self.assertIn('不存在', raised.exception.message)
self.db.rollback.assert_awaited_once_with()
self.db.commit.assert_not_awaited()
async def test_edit_purged_batch_is_rejected(self) -> None:
batch = make_batch(status='PURGED')
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.edit_batch(
self.db,
3,
TestBatchEditModel(remark='备注'),
self.admin,
)
self.assertIn('已清理', raised.exception.message)
self.db.rollback.assert_awaited_once_with()
async def test_edit_active_batch_updates_only_remark_and_audit_fields(self) -> None:
batch = make_batch()
original_name = batch.batch_name
with patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)):
result = await TestBatchService.edit_batch(
self.db,
3,
TestBatchEditModel(remark='新备注'),
self.admin,
)
self.assertEqual('新备注', batch.remark)
self.assertEqual(original_name, batch.batch_name)
self.assertEqual('admin', batch.update_by)
self.assertEqual('测试批次修改成功', result.message)
self.db.commit.assert_awaited_once_with()
async def test_replace_users_locks_first_deduplicates_and_commits(self) -> None:
events: list[str] = []
batch = make_batch()
async def lock(*_: object) -> SimpleNamespace:
events.append('lock')
return batch
async def validate(*_: object) -> list[int]:
events.append('validate')
return [2, 3]
async def replace(*_: object) -> None:
events.append('replace')
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(side_effect=lock)) as lock_batch,
patch.object(TestBatchDao, 'list_active_user_ids', AsyncMock(side_effect=validate)) as active_users,
patch.object(TestBatchDao, 'replace_batch_users', AsyncMock(side_effect=replace)) as replace_users,
):
result = await TestBatchService.replace_users(
self.db,
3,
TestBatchUserAssignModel(userIds=[3, 2, 3]),
self.admin,
)
self.assertEqual(['lock', 'validate', 'replace'], events)
lock_batch.assert_awaited_once_with(self.db, 3)
active_users.assert_awaited_once_with(self.db, [3, 2])
replace_users.assert_awaited_once()
self.assertEqual((self.db, 3, [3, 2], 'admin'), replace_users.await_args.args[:4])
self.assertEqual('测试批次用户授权成功', result.message)
self.db.commit.assert_awaited_once_with()
async def test_replace_users_accepts_empty_assignment_without_user_query(self) -> None:
active_users = AsyncMock()
replace_users = AsyncMock()
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=make_batch())),
patch.object(TestBatchDao, 'list_active_user_ids', active_users),
patch.object(TestBatchDao, 'replace_batch_users', replace_users),
):
await TestBatchService.replace_users(
self.db,
3,
TestBatchUserAssignModel(userIds=[]),
self.admin,
)
active_users.assert_not_awaited()
self.assertEqual((self.db, 3, [], 'admin'), replace_users.await_args.args[:4])
self.db.commit.assert_awaited_once_with()
async def test_replace_users_rejects_missing_disabled_or_deleted_user(self) -> None:
replace_users = AsyncMock()
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=make_batch())),
patch.object(TestBatchDao, 'list_active_user_ids', AsyncMock(return_value=[2])),
patch.object(TestBatchDao, 'replace_batch_users', replace_users),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.replace_users(
self.db,
3,
TestBatchUserAssignModel(userIds=[2, 9]),
self.admin,
)
self.assertIn('不存在、停用或已删除', raised.exception.message)
replace_users.assert_not_awaited()
self.db.rollback.assert_awaited_once_with()
async def test_replace_users_rejects_non_active_batch_after_lock(self) -> None:
for status, message in (('CLOSED', '已关闭'), ('PURGED', '已清理'), ('UNKNOWN', '状态异常')):
with self.subTest(status=status):
self.db.reset_mock()
active_users = AsyncMock()
replace_users = AsyncMock()
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=make_batch(status=status))),
patch.object(TestBatchDao, 'list_active_user_ids', active_users),
patch.object(TestBatchDao, 'replace_batch_users', replace_users),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.replace_users(
self.db,
3,
TestBatchUserAssignModel(userIds=[2]),
self.admin,
)
self.assertIn(message, raised.exception.message)
active_users.assert_not_awaited()
replace_users.assert_not_awaited()
self.db.rollback.assert_awaited_once_with()
async def test_replace_users_integrity_failure_rolls_back(self) -> None:
error = IntegrityError('insert', {}, RuntimeError('foreign key'))
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=make_batch())),
patch.object(TestBatchDao, 'list_active_user_ids', AsyncMock(return_value=[2])),
patch.object(TestBatchDao, 'replace_batch_users', AsyncMock(side_effect=error)),
self.assertRaises(ServiceException) as raised,
):
await TestBatchService.replace_users(
self.db,
3,
TestBatchUserAssignModel(userIds=[2]),
self.admin,
)
self.assertIn('授权失败', raised.exception.message)
self.db.rollback.assert_awaited_once_with()
self.db.commit.assert_not_awaited()
async def test_only_active_batch_can_close_and_preview_is_invalidated(self) -> None:
batch = make_batch()
with patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)) as lock_batch:
result = await TestBatchService.close_batch(self.db, 3, self.admin)
lock_batch.assert_awaited_once_with(self.db, 3)
self.assertEqual('CLOSED', batch.status)
self.assertEqual(1, batch.closed_by)
self.assertIsInstance(batch.closed_at, datetime)
self.assertIsNone(batch.preview_action)
self.assertIsNone(batch.preview_token_hash)
self.assertIsNone(batch.preview_scope_hash)
self.assertIsNone(batch.preview_expires_at)
self.assertEqual('测试批次关闭成功', result.message)
self.db.commit.assert_awaited_once_with()
self.db.rollback.assert_not_awaited()
async def test_close_missing_or_illegal_state_rolls_back(self) -> None:
cases = (
(None, '不存在'),
(make_batch(status='CLOSED'), '已关闭'),
(make_batch(status='PURGED'), '已清理'),
(make_batch(status='UNKNOWN'), '状态异常'),
)
for batch, message in cases:
with self.subTest(message=message):
self.db.reset_mock()
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.close_batch(self.db, 3, self.admin)
self.assertIn(message, raised.exception.message)
self.db.rollback.assert_awaited_once_with()
self.db.commit.assert_not_awaited()
async def test_closed_batch_can_reopen_and_close_metadata_is_cleared(self) -> None:
batch = make_batch(status='CLOSED')
batch.closed_by = 1
batch.closed_at = datetime(2026, 9, 22, 11, 0, 0)
with patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)) as lock_batch:
result = await TestBatchService.reopen_batch(self.db, 3, self.admin)
lock_batch.assert_awaited_once_with(self.db, 3)
self.assertEqual('ACTIVE', batch.status)
self.assertIsNone(batch.closed_by)
self.assertIsNone(batch.closed_at)
self.assertIsNone(batch.preview_action)
self.assertIsNone(batch.preview_token_hash)
self.assertIsNone(batch.preview_scope_hash)
self.assertIsNone(batch.preview_expires_at)
self.assertEqual('测试批次重新开启成功', result.message)
self.db.commit.assert_awaited_once_with()
async def test_reopen_missing_or_illegal_state_rolls_back(self) -> None:
cases = (
(None, '不存在'),
(make_batch(status='ACTIVE'), '已处于启用状态'),
(make_batch(status='PURGED'), '已清理'),
(make_batch(status='UNKNOWN'), '状态异常'),
)
for batch, message in cases:
with self.subTest(message=message):
self.db.reset_mock()
with (
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)),
self.assertRaises(ServiceWarning) as raised,
):
await TestBatchService.reopen_batch(self.db, 3, self.admin)
self.assertIn(message, raised.exception.message)
self.db.rollback.assert_awaited_once_with()
self.db.commit.assert_not_awaited()
class TestBatchDaoLifecycleTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.db = AsyncMock(spec=AsyncSession)
async def test_add_batch_only_adds_without_committing(self) -> None:
batch = BizTestBatch(batch_code='TB-1', batch_name='测试', status='ACTIVE')
await TestBatchDao.add_batch(self.db, batch)
self.db.add.assert_called_once_with(batch)
self.db.commit.assert_not_awaited()
async def test_list_batches_orders_newest_first_without_status_filter(self) -> None:
expected = [make_batch(batch_id=4), make_batch(batch_id=3, status='CLOSED')]
result_proxy = MagicMock()
result_proxy.scalars.return_value.all.return_value = expected
self.db.execute.return_value = result_proxy
result = await TestBatchDao.list_batches(self.db)
self.assertEqual(expected, result)
sql = compile_sql(self.db.execute.await_args.args[0])
self.assertIn('ORDER BY biz_test_batch.batch_id DESC', sql)
self.assertNotIn('WHERE', sql)
self.db.commit.assert_not_awaited()
async def test_active_user_lookup_filters_disabled_and_deleted_users(self) -> None:
result_proxy = MagicMock()
result_proxy.scalars.return_value.all.return_value = [2, 3]
self.db.execute.return_value = result_proxy
result = await TestBatchDao.list_active_user_ids(self.db, [2, 3, 9])
self.assertEqual([2, 3], result)
sql = compile_sql(self.db.execute.await_args.args[0])
self.assertIn('sys_user.user_id IN (2, 3, 9)', sql)
self.assertIn("sys_user.status = '0'", sql)
self.assertIn("sys_user.del_flag = '0'", sql)
async def test_empty_active_user_lookup_skips_database(self) -> None:
self.assertEqual([], await TestBatchDao.list_active_user_ids(self.db, []))
self.db.execute.assert_not_awaited()
async def test_replace_batch_users_deletes_then_adds_without_commit(self) -> None:
created_at = datetime(2026, 9, 22, 12, 0, 0)
await TestBatchDao.replace_batch_users(self.db, 3, [8, 9], 'admin', created_at)
delete_sql = compile_sql(self.db.execute.await_args.args[0])
self.assertIn('DELETE FROM biz_test_batch_user', delete_sql)
self.assertIn('biz_test_batch_user.batch_id = 3', delete_sql)
added = self.db.add_all.call_args.args[0]
self.assertEqual(2, len(added))
self.assertTrue(all(isinstance(item, BizTestBatchUser) for item in added))
self.assertEqual([(3, 8, 'admin'), (3, 9, 'admin')], [(item.batch_id, item.user_id, item.create_by) for item in added])
self.assertTrue(all(item.create_time == created_at for item in added))
self.db.commit.assert_not_awaited()
async def test_count_owned_rows_queries_exactly_sixteen_scoped_tables(self) -> None:
results = []
for index in range(len(OWNED_TABLES)):
result_proxy = MagicMock()
result_proxy.scalar.return_value = index
results.append(result_proxy)
self.db.execute.side_effect = results
counts = await TestBatchDao.count_owned_rows(self.db, 7)
self.assertEqual({name: index for index, (name, _) in enumerate(OWNED_TABLES)}, counts)
self.assertEqual(16, self.db.execute.await_count)
statements = [compile_sql(call.args[0]) for call in self.db.execute.await_args_list]
for statement, (table_name, _) in zip(statements, OWNED_TABLES, strict=True):
self.assertIn(f'FROM {table_name}', statement)
self.assertIn(f'{table_name}.test_batch_id = 7', statement)
self.db.commit.assert_not_awaited()
if __name__ == '__main__':
unittest.main()