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 mysql 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=mysql.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()