662 lines
28 KiB
Python
662 lines
28 KiB
Python
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()
|