import hashlib import inspect import json import unittest from datetime import datetime from types import SimpleNamespace from typing import Any, get_type_hints from unittest.mock import AsyncMock, MagicMock, patch from fastapi import FastAPI, Request from fastapi.routing import APIRoute from sqlalchemy.exc import IntegrityError, MultipleResultsFound from sqlalchemy.ext.asyncio import AsyncSession from common.aspect.interface_auth import CheckUserInterfaceAuth from common.aspect.pre_auth import PreAuth from common.context import RequestContext from common.vo import ( CrudResponseModel, DataResponseModel, PageModel, PageResponseModel, ResponseBaseModel, ) from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.service.log_service import LogQueueService from module_admin.service.login_service import LoginService from module_business.controller import inbound_controller as inbound_controller_module from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizInboundItem, BizInboundOrder, BizPalletEvent, BizPalletInventory, ) from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, InboundCreateModel, InboundModel, InboundPageQueryModel, InboundScanQueryModel, InboundUpdateModel, PalletScanResponseModel, ) from module_business.service import inbound_service as inbound_service_module from module_business.service.access_service import AccessService from module_business.service.inbound_service import InboundService def integrity_error() -> IntegrityError: return IntegrityError('statement', {}, RuntimeError('constraint')) def make_user(user_id: int = 7) -> CurrentUserModel: return CurrentUserModel( permissions=[], roles=[], user=UserInfoModel(userId=user_id, userName='admin' if user_id == 1 else f'user-{user_id}'), ) def make_pallet(pallet_id: int, *, asset_status: str = '0') -> BizPallet: return BizPallet( pallet_id=pallet_id, pallet_code=f'P-{pallet_id:03d}', qr_token=f'qr-{pallet_id}', capacity_packages=20, asset_status=asset_status, ) def make_inventory( pallet_id: int, *, flow_state: str = 'UNSTOCKED', warehouse_id: int | None = None, site_id: int | None = None, current_line_id: int | None = None, active_filling_id: int | None = None, remaining_packages: int = 0, version: int = 3, ) -> BizPalletInventory: return BizPalletInventory( pallet_id=pallet_id, flow_state=flow_state, warehouse_id=warehouse_id, site_id=site_id, current_line_id=current_line_id, active_filling_id=active_filling_id, remaining_packages=remaining_packages, version=version, ) def make_inbound( inbound_id: int = 11, *, warehouse_id: int = 7, pallet_ids: tuple[int, ...] = (5, 2), status: str = 'DRAFT', idempotency_key: str | None = None, request_hash: str | None = None, ) -> BizInboundOrder: inbound = BizInboundOrder( inbound_id=inbound_id, inbound_no=f'IN-{inbound_id}', warehouse_id=warehouse_id, status=status, confirm_idempotency_key=idempotency_key, confirm_request_hash=request_hash, ) inbound.items = [ BizInboundItem( inbound_item_id=index, inbound_id=inbound_id, pallet_id=pallet_id, ) for index, pallet_id in enumerate(pallet_ids, start=1) ] return inbound class TestInboundPublicContract(unittest.TestCase): def test_public_business_methods_require_explicit_test_scope(self) -> None: for method_name in ( 'get_inbound_list_services', 'inbound_detail_services', 'get_warehouse_options_services', 'scan_inbound_pallet_services', 'add_inbound_services', 'edit_inbound_services', 'delete_inbound_services', 'confirm_inbound_services', ): with self.subTest(method=method_name): self.assertIn('test_scope', inspect.signature(getattr(InboundService, method_name)).parameters) def test_service_and_controller_are_available(self) -> None: self.assertIsNotNone(InboundService) self.assertIsNotNone(inbound_controller_module.inbound_controller) def test_confirm_signature_and_types_are_exact(self) -> None: signature = inspect.signature(InboundService.confirm_inbound_services) self.assertEqual( ('query_db', 'inbound_id', 'page_object', 'current_user', 'test_scope'), tuple(signature.parameters), ) hints = get_type_hints(InboundService.confirm_inbound_services) self.assertIs(AsyncSession, hints['query_db']) self.assertIs(int, hints['inbound_id']) self.assertIs(ConfirmCommandModel, hints['page_object']) self.assertIs(CurrentUserModel, hints['current_user']) self.assertIs(CrudResponseModel, hints['return']) def test_confirm_hash_is_compact_sorted_sha256_and_excludes_key(self) -> None: expected_payload = json.dumps( {'inbound_id': 11}, sort_keys=True, separators=(',', ':'), ).encode() expected = hashlib.sha256(expected_payload).hexdigest() self.assertEqual(expected, InboundService._build_confirm_request_hash(11)) self.assertNotEqual(expected, InboundService._build_confirm_request_hash(12)) class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() self.fixed_time = datetime(2026, 7, 26, 10, 30) self.scope_patcher = patch.object( AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2, 5, 7, 8, 11]), ) self.default_scope = self.scope_patcher.start() self.addCleanup(self.scope_patcher.stop) async def test_add_validates_and_locks_pallets_before_writing_draft(self) -> None: page = InboundCreateModel(warehouseId=7, palletIds=[5, 2]) inventories = [make_inventory(2), make_inventory(5)] pallets = [make_pallet(2), make_pallet(5)] actions: list[str] = [] captured: dict[str, Any] = {} async def access(*_args: Any, **_kwargs: Any) -> None: actions.append('access') async def lock_inventories(*_args: Any) -> list[BizPalletInventory]: actions.append('inventory-lock') return inventories async def lock_pallets(*_args: Any) -> list[BizPallet]: actions.append('pallet-lock') return pallets async def add_inbound(_db: AsyncSession, inbound: BizInboundOrder) -> BizInboundOrder: actions.append('add-order') inbound.inbound_id = 11 captured['order'] = inbound return inbound async def replace_items( _db: AsyncSession, inbound_id: int, items: list[BizInboundItem], ) -> list[BizInboundItem]: actions.append('replace-items') captured['inbound_id'] = inbound_id captured['items'] = items return items async def commit() -> None: actions.append('commit') self.db.commit.side_effect = commit with ( patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)), patch.object( InventoryDao, 'get_inventories_for_update', new=AsyncMock(side_effect=lock_inventories), ) as inventory_lock, patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)) as pallet_lock, patch.object(OperationDao, 'add_inbound', new=AsyncMock(side_effect=add_inbound)), patch.object(OperationDao, 'replace_inbound_items', new=AsyncMock(side_effect=replace_items)), patch.object(inbound_service_module.secrets, 'token_hex', return_value='fixed-random') as token_hex, patch.object(inbound_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = self.fixed_time result = await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual( [ 'access', 'inventory-lock', 'pallet-lock', 'access', 'add-order', 'replace-items', 'commit', ], actions, ) inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) token_hex.assert_called_once_with(16) order = captured['order'] self.assertEqual('IN-fixed-random', order.inbound_no) self.assertLessEqual(len(order.inbound_no), 64) self.assertEqual('DRAFT', order.status) self.assertEqual('user-7', order.create_by) self.assertEqual('user-7', order.update_by) self.assertEqual(self.fixed_time, order.create_time) self.assertEqual(self.fixed_time, order.update_time) self.assertEqual(11, captured['inbound_id']) self.assertEqual([2, 5], [item.pallet_id for item in captured['items']]) self.assertTrue(all(item.create_by == 'user-7' for item in captured['items'])) self.assertTrue(all(item.create_time == self.fixed_time for item in captured['items'])) self.assertTrue(result.is_success) self.db.rollback.assert_not_awaited() async def test_add_rejects_invalid_pallet_before_any_order_or_event_write(self) -> None: page = InboundCreateModel(warehouseId=7, palletIds=[2, 5]) invalid_inventory = make_inventory(5, warehouse_id=7) add_order = AsyncMock() replace_items = AsyncMock() add_event = AsyncMock() with ( patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object( InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[make_inventory(2), invalid_inventory]), ), patch.object( InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]), ), patch.object(OperationDao, 'add_inbound', new=add_order), patch.object(OperationDao, 'replace_inbound_items', new=replace_items), patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException), ): await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) add_order.assert_not_awaited() replace_items.assert_not_awaited() add_event.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_add_requires_active_authorized_warehouse(self) -> None: page = InboundCreateModel(warehouseId=7, palletIds=[2]) lock = AsyncMock() cases = ( (None, ServiceException), (SimpleNamespace(warehouse_id=7, status='1'), ServiceException), ) for warehouse, exception_type in cases: with self.subTest(warehouse=warehouse): self.db.reset_mock() lock.reset_mock() with ( patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=warehouse)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(InventoryDao, 'get_inventories_for_update', new=lock), self.assertRaises(exception_type), ): await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) lock.assert_not_awaited() self.db.rollback.assert_awaited_once() self.db.reset_mock() lock.reset_mock() with ( patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object( AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库')), ), patch.object(InventoryDao, 'get_inventories_for_update', new=lock), self.assertRaises(PermissionException), ): await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_add_checks_warehouse_access_before_revealing_existence(self) -> None: page = InboundCreateModel(warehouseId=404, palletIds=[2]) get_warehouse = AsyncMock(return_value=None) access = AsyncMock( side_effect=PermissionException(data='', message='无权访问该仓库'), ) pallet_lock = AsyncMock() with ( patch.object(MasterDao, 'get_warehouse', new=get_warehouse), patch.object(AccessService, 'assert_warehouse_access', new=access), patch.object(InventoryDao, 'get_inventories_for_update', new=pallet_lock), self.assertRaises(Exception) as raised, ): await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该仓库', raised.exception.message) access.assert_awaited_once_with(self.db, self.user, 404) get_warehouse.assert_not_awaited() pallet_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_add_rechecks_revoked_mapping_after_pallet_locks_before_writing(self) -> None: page = InboundCreateModel(warehouseId=7, palletIds=[2]) async def warehouse_access(*_args: Any, **kwargs: Any) -> None: if kwargs.get('for_update'): raise PermissionException(data='', message='无权访问该仓库') add_order = AsyncMock(return_value=make_inbound(pallet_ids=(2,))) replace_items = AsyncMock() warehouse_mapping = AsyncMock(side_effect=warehouse_access) warehouse_read = AsyncMock( return_value=SimpleNamespace(warehouse_id=7, status='0'), ) with ( patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), patch.object(MasterDao, 'get_warehouse', new=warehouse_read), patch.object( InboundService, '_lock_and_validate_pallets', new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])), ), patch.object(OperationDao, 'add_inbound', new=add_order), patch.object(OperationDao, 'replace_inbound_items', new=replace_items), self.assertRaises(PermissionException) as raised, ): await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该仓库', raised.exception.message) self.assertEqual(2, warehouse_read.await_count) warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True) self.assertEqual(2, warehouse_mapping.await_count) warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True) add_order.assert_not_awaited() replace_items.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_edit_scopes_original_and_checks_target_access_then_replaces_items(self) -> None: page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2]) inbound = make_inbound(warehouse_id=7, pallet_ids=(3,)) lock_order = AsyncMock(return_value=inbound) access = AsyncMock() update = AsyncMock() replace = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), patch.object(AccessService, 'assert_warehouse_access', new=access), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=8, status='0')), ), patch.object( InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[make_inventory(2), make_inventory(5)]), ) as inventory_lock, patch.object( InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]), ) as pallet_lock, patch.object(OperationDao, 'update_inbound', new=update), patch.object(OperationDao, 'replace_inbound_items', new=replace), patch.object(inbound_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = self.fixed_time result = await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.default_scope.assert_awaited_once_with(self.db, 7) lock_order.assert_awaited_once_with( self.db, 11, TestBatchScope.formal(), warehouse_ids=[2, 5, 7, 8, 11], ) self.assertEqual( [ unittest.mock.call(self.db, self.user, 8), unittest.mock.call(self.db, self.user, 7, for_update=True), unittest.mock.call(self.db, self.user, 8, for_update=True), ], access.await_args_list, ) inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) self.assertEqual(8, inbound.warehouse_id) self.assertEqual('user-7', inbound.update_by) self.assertEqual(self.fixed_time, inbound.update_time) update.assert_awaited_once_with(self.db, inbound) replace.assert_awaited_once() self.assertEqual([2, 5], [item.pallet_id for item in replace.await_args.args[2]]) self.db.commit.assert_awaited_once() self.assertTrue(result.is_success) async def test_edit_hides_document_when_current_mapping_is_revoked_after_pallet_locks(self) -> None: current_warehouse_id = 7 page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2]) inbound = make_inbound(warehouse_id=current_warehouse_id, pallet_ids=(2,)) async def warehouse_access( _db: AsyncSession, _user: CurrentUserModel, warehouse_id: int, **kwargs: Any, ) -> None: if warehouse_id == current_warehouse_id and kwargs.get('for_update'): raise PermissionException(data='', message='无权访问该仓库') update = AsyncMock() replace = AsyncMock() warehouse_mapping = AsyncMock(side_effect=warehouse_access) with ( patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)), patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), patch.object( MasterDao, 'get_warehouse', new=AsyncMock( side_effect=lambda _db, warehouse_id, _scope, **_kwargs: SimpleNamespace( warehouse_id=warehouse_id, status='0', ) ), ), patch.object( InboundService, '_lock_and_validate_pallets', new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])), ), patch.object(OperationDao, 'update_inbound', new=update), patch.object(OperationDao, 'replace_inbound_items', new=replace), self.assertRaises(PermissionException) as raised, ): await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) warehouse_mapping.assert_any_await(self.db, self.user, 7, for_update=True) warehouse_mapping.assert_any_await(self.db, self.user, 8, for_update=True) update.assert_not_awaited() replace.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_edit_rejects_confirmed_document_without_pallet_or_order_write(self) -> None: page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2]) update = AsyncMock() lock_pallets = AsyncMock() with ( patch.object( OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=make_inbound(status='CONFIRMED')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(MasterDao, 'get_warehouse', new=AsyncMock()), patch.object(InventoryDao, 'get_inventories_for_update', new=lock_pallets), patch.object(OperationDao, 'update_inbound', new=update), self.assertRaises(ServiceException), ): await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) lock_pallets.assert_not_awaited() update.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_edit_scopes_parent_lock_and_hides_unmatched_document(self) -> None: page = InboundUpdateModel(inboundId=404, warehouseId=8, palletIds=[2]) lock_order = AsyncMock(return_value=None) get_warehouse = AsyncMock() inventory_lock = AsyncMock() with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope, patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), patch.object(MasterDao, 'get_warehouse', new=get_warehouse), patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), self.assertRaises(Exception) as raised, ): await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该入库单', raised.exception.message) scope.assert_awaited_once_with(self.db, 7) lock_order.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7]) get_warehouse.assert_not_awaited() inventory_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_admin_missing_edit_keeps_precise_not_found(self) -> None: page = InboundUpdateModel(inboundId=404, warehouseId=8, palletIds=[2]) scope = AsyncMock() lock_order = AsyncMock(return_value=None) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), self.assertRaises(ServiceException) as raised, ): await InboundService.edit_inbound_services(self.db, page, make_user(1), TestBatchScope.formal()) self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() lock_order.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None) self.db.rollback.assert_awaited_once() async def test_batch_delete_locks_and_preflights_all_before_ordered_delete(self) -> None: lock = AsyncMock() delete = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_for_update', new=lock), patch.object(OperationDao, 'delete_inbound', new=delete), self.assertRaises(ServiceWarning), ): await InboundService.delete_inbound_services(self.db, [5, 2], self.user, TestBatchScope.formal()) lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() async def test_batch_delete_rechecks_all_current_warehouses_before_first_delete(self) -> None: lock = AsyncMock() delete = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_for_update', new=lock), patch.object(OperationDao, 'delete_inbound', new=delete), self.assertRaises(ServiceWarning), ): await InboundService.delete_inbound_services(self.db, [12, 11], self.user, TestBatchScope.formal()) lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() async def test_batch_delete_is_atomic_when_later_document_is_confirmed(self) -> None: lock = AsyncMock() delete = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_for_update', new=lock), patch.object(OperationDao, 'delete_inbound', new=delete), self.assertRaises(ServiceWarning), ): await InboundService.delete_inbound_services(self.db, [5, 2], self.user, TestBatchScope.formal()) lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() async def test_delete_scopes_parent_lock_and_hides_unmatched_document(self) -> None: lock_order = AsyncMock(return_value=None) delete = AsyncMock() with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope, patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), patch.object(OperationDao, 'delete_inbound', new=delete), self.assertRaises(ServiceWarning), ): await InboundService.delete_inbound_services(self.db, [404], self.user, TestBatchScope.formal()) scope.assert_not_awaited() lock_order.assert_not_awaited() delete.assert_not_awaited() async def test_admin_missing_delete_keeps_precise_not_found(self) -> None: scope = AsyncMock() lock_order = AsyncMock(return_value=None) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), self.assertRaises(ServiceWarning), ): await InboundService.delete_inbound_services(self.db, [404], make_user(1), TestBatchScope.formal()) scope.assert_not_awaited() lock_order.assert_not_awaited() async def test_all_draft_mutations_convert_integrity_errors(self) -> None: add_page = InboundCreateModel(warehouseId=7, palletIds=[2]) with ( patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object( InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[make_inventory(2)]), ), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), patch.object(OperationDao, 'add_inbound', new=AsyncMock(side_effect=integrity_error())), self.assertRaises(ServiceException), ): await InboundService.add_inbound_services(self.db, add_page, self.user, TestBatchScope.formal()) self.db.rollback.assert_awaited_once() self.db.reset_mock() with ( patch.object( OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=make_inbound(pallet_ids=(2,))), ), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())), self.assertRaises(ServiceWarning), ): await InboundService.delete_inbound_services(self.db, [11], self.user, TestBatchScope.formal()) class TestInboundReadService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() async def test_list_passes_normal_user_scope_into_paginated_dao(self) -> None: query = InboundPageQueryModel(pageNum=2, pageSize=5) page = PageModel[Any]( rows=[make_inbound()], pageNum=2, pageSize=5, total=1, hasNext=False, ) get_list = AsyncMock(return_value=page) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope, patch.object(OperationDao, 'get_inbound_list', new=get_list), ): actual = await InboundService.get_inbound_list_services( self.db, query, self.user, TestBatchScope.formal(), is_page=True ) scope.assert_awaited_once_with(self.db, 7) get_list.assert_awaited_once_with( self.db, query, warehouse_ids=[3, 7], scope=TestBatchScope.formal(), is_page=True, ) self.assertIsInstance(actual, PageModel) self.assertIsInstance(actual.rows[0], InboundModel) async def test_admin_list_uses_none_scope_and_does_not_query_assignments(self) -> None: query = InboundPageQueryModel() page = PageModel[Any](rows=[], pageNum=1, pageSize=10, total=0, hasNext=False) scope = AsyncMock() get_list = AsyncMock(return_value=page) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_list', new=get_list), ): await InboundService.get_inbound_list_services( self.db, query, make_user(1), TestBatchScope.formal(), is_page=True ) scope.assert_not_awaited() get_list.assert_awaited_once_with( self.db, query, warehouse_ids=None, scope=TestBatchScope.formal(), is_page=True, ) async def test_explicit_unauthorized_warehouse_is_403_before_query(self) -> None: query = InboundPageQueryModel(warehouseId=9) get_list = AsyncMock() with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[3, 7])), patch.object(OperationDao, 'get_inbound_list', new=get_list), self.assertRaises(PermissionException), ): await InboundService.get_inbound_list_services( self.db, query, self.user, TestBatchScope.formal(), is_page=True ) get_list.assert_not_awaited() async def test_user_without_scope_gets_empty_paginated_result_without_total_leak(self) -> None: query = InboundPageQueryModel(pageNum=4, pageSize=6) empty_page = PageModel[Any](rows=[], pageNum=4, pageSize=6, total=0, hasNext=False) get_list = AsyncMock(return_value=empty_page) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])), patch.object(OperationDao, 'get_inbound_list', new=get_list), ): actual = await InboundService.get_inbound_list_services( self.db, query, self.user, TestBatchScope.formal(), is_page=True ) get_list.assert_awaited_once_with( self.db, query, warehouse_ids=[], scope=TestBatchScope.formal(), is_page=True, ) self.assertEqual([], actual.rows) self.assertEqual(0, actual.total) self.assertFalse(actual.has_next) async def test_detail_pushes_warehouse_scope_into_parent_query(self) -> None: inbound = make_inbound() get_detail = AsyncMock(return_value=inbound) access = AsyncMock() with ( patch.object( AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7]), ) as scope, patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(AccessService, 'assert_warehouse_access', new=access), ): actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal()) scope.assert_awaited_once_with(self.db, 7) get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[3, 7]) access.assert_not_awaited() self.assertIsInstance(actual, InboundModel) async def test_normal_user_detail_hides_missing_or_external_document(self) -> None: get_detail = AsyncMock(return_value=None) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), self.assertRaises(Exception) as raised, ): await InboundService.inbound_detail_services(self.db, 404, self.user, TestBatchScope.formal()) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该入库单', raised.exception.message) get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7]) async def test_admin_missing_detail_keeps_precise_not_found(self) -> None: scope = AsyncMock() get_detail = AsyncMock(return_value=None) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), self.assertRaises(ServiceException) as raised, ): await InboundService.inbound_detail_services(self.db, 404, make_user(1), TestBatchScope.formal()) self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None) async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None: warehouses = [ SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0'), SimpleNamespace(warehouse_id=3, warehouse_code='W3', warehouse_name='三仓', status='1'), SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0'), ] with ( patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)) as list_warehouse, patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2, 3])), ): actual = await InboundService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal()) query = list_warehouse.await_args.args[1] self.assertEqual('0', query.status) self.assertEqual(TestBatchScope.formal(), list_warehouse.await_args.args[2]) self.assertIs(False, list_warehouse.await_args.args[3]) self.assertEqual([BusinessOptionModel(id=2, code='W2', name='二仓')], actual) self.assertEqual({'id', 'code', 'name'}, set(BusinessOptionModel.model_fields)) async def test_admin_warehouse_options_include_all_active_records(self) -> None: warehouses = [ SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0'), SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0'), ] scope = AsyncMock() with ( patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)), patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), ): actual = await InboundService.get_warehouse_options_services(self.db, make_user(1), TestBatchScope.formal()) scope.assert_not_awaited() self.assertEqual([2, 5], [option.id for option in actual]) async def test_warehouse_options_accept_real_page_util_camel_case_rows(self) -> None: warehouses = [ { 'warehouseId': 2, 'warehouseCode': 'W2', 'warehouseName': '二仓', 'status': '0', } ] with ( patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)), patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2])), ): actual = await InboundService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal()) self.assertEqual([BusinessOptionModel(id=2, code='W2', name='二仓')], actual) class TestInboundScanService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() self.query = InboundScanQueryModel(warehouseId=7) async def test_scan_returns_only_unstocked_inbound_action_without_lock_or_write(self) -> None: pallet = make_pallet(2) inventory = make_inventory(2) with ( patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access, patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)) as get_pallet, patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)) as get_inventory, patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as locking_read, patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event, ): actual = await InboundService.scan_inbound_pallet_services( self.db, 'persisted-token', self.query, self.user, TestBatchScope.formal(), ) access.assert_awaited_once_with(self.db, self.user, 7) get_pallet.assert_awaited_once_with(self.db, 'persisted-token', TestBatchScope.formal()) get_inventory.assert_awaited_once_with(self.db, 2, TestBatchScope.formal()) locking_read.assert_not_awaited() add_event.assert_not_awaited() self.db.commit.assert_not_awaited() self.assertIsInstance(actual, PalletScanResponseModel) self.assertEqual(PalletFlowState.UNSTOCKED, actual.flow_state) self.assertEqual(['inbound'], actual.allowed_actions) self.assertIsNone(actual.strain_id) self.assertIsNone(actual.batch_no) async def test_scan_rejects_every_ineligible_asset_or_inventory_state(self) -> None: cases = ( ('disabled-asset', make_pallet(2, asset_status='1'), make_inventory(2)), ('wrong-state', make_pallet(2), make_inventory(2, flow_state='EMPTY_IN_WAREHOUSE')), ('warehouse-set', make_pallet(2), make_inventory(2, warehouse_id=7)), ('site-set', make_pallet(2), make_inventory(2, site_id=8)), ('line-set', make_pallet(2), make_inventory(2, current_line_id=9)), ('filling-set', make_pallet(2), make_inventory(2, active_filling_id=10)), ('remaining', make_pallet(2), make_inventory(2, remaining_packages=1)), ) for label, pallet, inventory in cases: with ( self.subTest(label=label), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)), self.assertRaises(ServiceException), ): await InboundService.scan_inbound_pallet_services( self.db, 'persisted-token', self.query, self.user, TestBatchScope.formal(), ) async def test_scan_rejects_unauthorized_disabled_or_missing_target_warehouse_first(self) -> None: get_pallet = AsyncMock() with ( patch.object( AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库')), ), patch.object(MasterDao, 'get_warehouse', new=AsyncMock()), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=get_pallet), self.assertRaises(PermissionException), ): await InboundService.scan_inbound_pallet_services( self.db, 'token', self.query, self.user, TestBatchScope.formal() ) get_pallet.assert_not_awaited() for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1')): with self.subTest(warehouse=warehouse): get_pallet.reset_mock() with ( patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=warehouse)), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=get_pallet), self.assertRaises(ServiceException), ): await InboundService.scan_inbound_pallet_services( self.db, 'token', self.query, self.user, TestBatchScope.formal() ) get_pallet.assert_not_awaited() class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() self.command = ConfirmCommandModel(idempotencyKey='confirm-key') self.fixed_time = datetime(2026, 7, 26, 14, 20) self.request_hash = InboundService._build_confirm_request_hash(11) self.scope_patcher = patch.object( AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7]), ) self.default_scope = self.scope_patcher.start() self.addCleanup(self.scope_patcher.stop) async def test_confirm_scopes_initial_parent_and_hides_unmatched_document(self) -> None: get_detail = AsyncMock(return_value=None) get_by_key = AsyncMock() lock_order = AsyncMock() inventory_lock = AsyncMock() with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope, patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=get_by_key), patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), self.assertRaises(Exception) as raised, ): await InboundService.confirm_inbound_services( self.db, 404, self.command, self.user, TestBatchScope.formal() ) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该入库单', raised.exception.message) scope.assert_awaited_once_with(self.db, 7) get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7]) get_by_key.assert_not_awaited() lock_order.assert_not_awaited() inventory_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_admin_missing_confirm_keeps_precise_not_found(self) -> None: scope = AsyncMock() get_detail = AsyncMock(return_value=None) get_by_key = AsyncMock() lock_order = AsyncMock() with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=get_by_key), patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), self.assertRaises(ServiceException) as raised, ): await InboundService.confirm_inbound_services( self.db, 404, self.command, make_user(1), TestBatchScope.formal() ) self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None) get_by_key.assert_not_awaited() lock_order.assert_not_awaited() self.db.rollback.assert_awaited_once() async def _run_successful_confirm(self, *, commit_side_effect: Exception | None = None) -> dict[str, Any]: inbound = make_inbound() inventories = [make_inventory(2, version=4), make_inventory(5, version=8)] pallets = [make_pallet(2), make_pallet(5)] actions: list[str] = [] events: list[BizPalletEvent] = [] async def detail(*_args: Any, **_kwargs: Any) -> BizInboundOrder: actions.append('detail') return inbound async def access(*_args: Any, **_kwargs: Any) -> None: actions.append('access') async def by_key(*_args: Any, **_kwargs: Any) -> None: actions.append('idempotency-read') async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder: actions.append('order-lock') return inbound async def lock_inventories(*_args: Any) -> list[BizPalletInventory]: actions.append('inventory-lock') return inventories async def lock_pallets(*_args: Any) -> list[BizPallet]: actions.append('pallet-lock') return pallets async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace: actions.append('warehouse-lock') return SimpleNamespace(warehouse_id=7, status='0') def transition(current: PalletFlowState, target: PalletFlowState) -> None: actions.append(f'transition-{current.value}-{target.value}') async def update(*_args: Any) -> BizInboundOrder: actions.append('update-order') return inbound async def add_event( _db: AsyncSession, inventory: BizPalletInventory, event: BizPalletEvent, _scope: TestBatchScope, ) -> BizPalletEvent: actions.append(f'event-{inventory.pallet_id}') events.append(event) event.event_id = 100 + inventory.pallet_id inventory.last_event_id = event.event_id return event async def commit() -> None: actions.append('commit') if commit_side_effect is not None: raise commit_side_effect self.db.commit.side_effect = commit patches = ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(side_effect=detail)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)), patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(side_effect=by_key), ), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock_order)), patch.object( InventoryDao, 'get_inventories_for_update', new=AsyncMock(side_effect=lock_inventories), ), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)), patch.object(PalletStateMachine, 'ensure_transition', side_effect=transition), patch.object(OperationDao, 'update_inbound', new=AsyncMock(side_effect=update)), patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), patch.object(inbound_service_module, 'datetime'), patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=lock_warehouse)), ) with ( patches[0] as detail_read, patches[1] as access_probe, patches[2] as key_read, patches[3] as order_lock, patches[4] as inventory_lock, patches[5] as pallet_lock, patches[6] as ensure_transition, patches[7], patches[8], patches[9] as service_datetime, patches[10] as warehouse_lock, ): service_datetime.now.return_value = self.fixed_time result = await InboundService.confirm_inbound_services( self.db, 11, self.command, self.user, TestBatchScope.formal() ) return { 'result': result, 'inbound': inbound, 'inventories': inventories, 'events': events, 'actions': actions, 'detail_read': detail_read, 'key_read': key_read, 'access_probe': access_probe, 'order_lock': order_lock, 'inventory_lock': inventory_lock, 'pallet_lock': pallet_lock, 'warehouse_lock': warehouse_lock, 'ensure_transition': ensure_transition, } async def test_confirm_uses_complete_lock_order_state_machine_version_and_events(self) -> None: captured = await self._run_successful_confirm() self.assertEqual( [ 'detail', 'idempotency-read', 'order-lock', 'inventory-lock', 'pallet-lock', 'warehouse-lock', 'access', 'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE', 'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE', 'update-order', 'event-2', 'event-5', 'commit', ], captured['actions'], ) self.default_scope.assert_awaited_once_with(self.db, 7) captured['detail_read'].assert_awaited_once_with( self.db, 11, TestBatchScope.formal(), warehouse_ids=[7], ) captured['key_read'].assert_awaited_once_with( self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7], ) captured['order_lock'].assert_awaited_once_with( self.db, 11, TestBatchScope.formal(), warehouse_ids=[7], ) captured['warehouse_lock'].assert_awaited_once_with( self.db, 7, TestBatchScope.formal(), for_update=True, ) captured['access_probe'].assert_awaited_once_with(self.db, self.user, 7, for_update=True) captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) captured['ensure_transition'].assert_has_calls( [ unittest.mock.call(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE), unittest.mock.call(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE), ] ) inbound = captured['inbound'] self.assertEqual('CONFIRMED', inbound.status) self.assertEqual('confirm-key', inbound.confirm_idempotency_key) self.assertEqual(self.request_hash, inbound.confirm_request_hash) self.assertEqual(7, inbound.confirmed_by) self.assertEqual(self.fixed_time, inbound.confirmed_at) self.assertEqual('user-7', inbound.update_by) self.assertEqual([5, 9], [inventory.version for inventory in captured['inventories']]) for inventory in captured['inventories']: self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state) self.assertEqual(7, inventory.warehouse_id) self.assertIsNone(inventory.site_id) self.assertIsNone(inventory.current_line_id) self.assertIsNone(inventory.active_filling_id) self.assertEqual(0, inventory.remaining_packages) self.assertEqual(self.fixed_time, inventory.updated_at) self.assertEqual([2, 5], [event.pallet_id for event in captured['events']]) for event in captured['events']: self.assertEqual(PalletEventType.INBOUND_CONFIRMED.value, event.event_type) self.assertEqual('INBOUND', event.source_type) self.assertEqual(11, event.source_id) self.assertEqual('UNSTOCKED', event.before_state) self.assertEqual('EMPTY_IN_WAREHOUSE', event.after_state) self.assertIsNone(event.before_warehouse_id) self.assertIsNone(event.before_site_id) self.assertIsNone(event.before_line_id) self.assertEqual(7, event.after_warehouse_id) self.assertIsNone(event.after_site_id) self.assertIsNone(event.after_line_id) self.assertEqual(0, event.before_remaining_packages) self.assertEqual(0, event.after_remaining_packages) self.assertEqual(7, event.operator_id) self.assertEqual(self.fixed_time, event.occurred_at) self.assertEqual('confirm-key', event.idempotency_key) self.assertEqual(self.request_hash, event.request_hash) self.db.commit.assert_awaited_once() self.db.rollback.assert_not_awaited() self.assertTrue(captured['result'].is_success) async def test_confirm_hides_document_when_mapping_is_revoked_after_pallet_locks(self) -> None: inbound = make_inbound(pallet_ids=(2,)) inventory = make_inventory(2) async def warehouse_access(*_args: Any, **kwargs: Any) -> None: if kwargs.get('for_update'): raise PermissionException(data='', message='无权访问该仓库') update = AsyncMock() add_event = AsyncMock() warehouse_mapping = AsyncMock(side_effect=warehouse_access) with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None), ), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)), patch.object( InboundService, '_lock_and_validate_pallets', new=AsyncMock(return_value=([inventory], [make_pallet(2)])), ), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ) as warehouse_lock, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), patch.object(OperationDao, 'update_inbound', new=update), patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(PermissionException) as raised, ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True) self.assertEqual('DRAFT', inbound.status) self.assertEqual('UNSTOCKED', inventory.flow_state) update.assert_not_awaited() add_event.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_confirm_rejects_incomplete_locked_sets_before_any_mutation(self) -> None: inbound = make_inbound() inventories = [make_inventory(2)] pallets = [make_pallet(2), make_pallet(5)] update = AsyncMock() add_event = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=pallets)) as pallet_lock, patch.object(OperationDao, 'update_inbound', new=update), patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException), ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) self.assertEqual('DRAFT', inbound.status) self.assertEqual('UNSTOCKED', inventories[0].flow_state) update.assert_not_awaited() add_event.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_confirm_state_machine_failure_rolls_back_before_writes(self) -> None: inbound = make_inbound(pallet_ids=(2,)) inventory = make_inventory(2) update = AsyncMock() add_event = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(PalletStateMachine, 'ensure_transition', side_effect=ValueError('invalid')), patch.object(OperationDao, 'update_inbound', new=update), patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaisesRegex(ValueError, 'invalid'), ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('DRAFT', inbound.status) self.assertEqual('UNSTOCKED', inventory.flow_state) update.assert_not_awaited() add_event.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_same_key_retry_rechecks_locked_document_and_ends_read_transaction(self) -> None: confirmed = make_inbound( status='CONFIRMED', idempotency_key='confirm-key', request_hash=self.request_hash, ) inventory_lock = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=confirmed)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access, patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=confirmed), ), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)) as lock, patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ) as warehouse_lock, ): result = await InboundService.confirm_inbound_services( self.db, 11, self.command, self.user, TestBatchScope.formal() ) lock.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7]) warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) self.assertEqual( [ unittest.mock.call(self.db, self.user, 7), unittest.mock.call(self.db, self.user, 7, for_update=True), ], access.await_args_list, ) inventory_lock.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() self.assertTrue(result.is_success) async def test_same_key_retry_uses_exact_warehouse_when_user_can_access_duplicate_keys(self) -> None: target = make_inbound( status='CONFIRMED', idempotency_key='confirm-key', request_hash=self.request_hash, ) other_domain = make_inbound( inbound_id=12, warehouse_id=8, status='CONFIRMED', idempotency_key='confirm-key', request_hash=InboundService._build_confirm_request_hash(12), ) async def read_key( _db: AsyncSession, _key: str, _scope: TestBatchScope, *, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: matches = [ inbound for inbound in (target, other_domain) if warehouse_ids is None or inbound.warehouse_id in warehouse_ids ] if len(matches) > 1: raise MultipleResultsFound('广域幂等键查询返回多条入库单') return matches[0] if matches else None get_detail = AsyncMock(return_value=target) key_read = AsyncMock(side_effect=read_key) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=target)), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), ): result = await InboundService.confirm_inbound_services( self.db, 11, self.command, self.user, TestBatchScope.formal(), ) self.assertTrue(result.is_success) get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7, 8]) key_read.assert_awaited_once_with( self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7], ) self.db.rollback.assert_awaited_once() async def test_same_key_retry_hides_document_when_current_mapping_is_revoked(self) -> None: confirmed = make_inbound( status='CONFIRMED', idempotency_key='confirm-key', request_hash=self.request_hash, ) async def warehouse_access(*_args: Any, **kwargs: Any) -> None: if kwargs.get('for_update'): raise PermissionException(data='', message='无权访问该仓库') inventory_lock = AsyncMock() warehouse_mapping = AsyncMock(side_effect=warehouse_access) with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=confirmed)), patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=confirmed), ), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)), patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1')), ) as warehouse_lock, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), self.assertRaises(PermissionException) as raised, ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True) inventory_lock.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_same_key_for_different_inbound_is_rejected_without_lock_or_write(self) -> None: target = make_inbound() existing = make_inbound( inbound_id=12, warehouse_id=8, status='CONFIRMED', idempotency_key='confirm-key', request_hash=InboundService._build_confirm_request_hash(12), ) lock = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=target)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access, patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=existing), ), patch.object(OperationDao, 'get_inbound_for_update', new=lock), self.assertRaises(ServiceException), ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 8) lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_concurrent_status_change_is_rechecked_after_parent_lock(self) -> None: initial = make_inbound() locked = make_inbound( status='CONFIRMED', idempotency_key='other-key', request_hash=self.request_hash, ) inventory_lock = AsyncMock() with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=initial)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=locked)), patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), self.assertRaises(ServiceException), ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) inventory_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_confirm_uses_locked_current_items_instead_of_initial_snapshot(self) -> None: initial = make_inbound(pallet_ids=(2,)) locked = make_inbound(pallet_ids=(5,)) inventory = make_inventory(5) get_detail = AsyncMock(return_value=initial) inventory_lock = AsyncMock(return_value=[inventory]) with ( patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=locked)), patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(5)])), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), ): result = await InboundService.confirm_inbound_services( self.db, 11, self.command, self.user, TestBatchScope.formal() ) get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7]) inventory_lock.assert_awaited_once_with(self.db, [5], TestBatchScope.formal()) self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state) self.assertTrue(result.is_success) async def test_integrity_conflict_recovers_only_exact_confirmed_result_and_ends_read_transaction(self) -> None: inbound = make_inbound(pallet_ids=(2,)) inventory = make_inventory(2) recovered = make_inbound( pallet_ids=(2,), status='CONFIRMED', idempotency_key='confirm-key', request_hash=self.request_hash, ) key_reads = AsyncMock(side_effect=[None, recovered]) self.db.commit.side_effect = integrity_error() parent_lock = AsyncMock(side_effect=[inbound, recovered]) with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access, patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_reads), patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), ): result = await InboundService.confirm_inbound_services( self.db, 11, self.command, self.user, TestBatchScope.formal() ) self.assertEqual(2, parent_lock.await_count) self.assertEqual( [ unittest.mock.call(self.db, self.user, 7, for_update=True), unittest.mock.call(self.db, self.user, 7, for_update=True), ], access.await_args_list, ) self.assertEqual(2, key_reads.await_count) key_reads.assert_has_awaits( [ unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), ] ) self.assertEqual(2, self.db.rollback.await_count) self.assertTrue(result.is_success) async def test_integrity_recovery_uses_exact_warehouse_with_duplicate_visible_keys(self) -> None: initial = make_inbound(status='DRAFT') recovered = make_inbound( status='CONFIRMED', idempotency_key='confirm-key', request_hash=self.request_hash, ) other_domain = make_inbound( inbound_id=12, warehouse_id=8, status='CONFIRMED', idempotency_key='confirm-key', request_hash=InboundService._build_confirm_request_hash(12), ) read_count = 0 async def read_key( _db: AsyncSession, _key: str, _scope: TestBatchScope, *, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: nonlocal read_count read_count += 1 if read_count == 1: return None matches = [ inbound for inbound in (recovered, other_domain) if warehouse_ids is None or inbound.warehouse_id in warehouse_ids ] if len(matches) > 1: raise MultipleResultsFound('广域幂等键恢复查询返回多条入库单') return matches[0] if matches else None warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]]) key_read = AsyncMock(side_effect=read_key) parent_lock = AsyncMock(side_effect=[integrity_error(), recovered]) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope), patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=initial)), patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read), patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), ): result = await InboundService.confirm_inbound_services( self.db, 11, self.command, self.user, TestBatchScope.formal(), ) self.assertTrue(result.is_success) self.assertEqual(2, warehouse_scope.await_count) self.assertEqual(2, parent_lock.await_count) self.assertEqual( [ unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), ], key_read.await_args_list, ) self.assertEqual(2, self.db.rollback.await_count) self.db.commit.assert_not_awaited() async def test_integrity_recovery_locks_parent_and_rechecks_revoked_current_mapping(self) -> None: inbound = make_inbound(pallet_ids=(2,)) recovered = make_inbound( pallet_ids=(2,), status='CONFIRMED', idempotency_key='confirm-key', request_hash=self.request_hash, ) locked_access_count = 0 recovery_access_number = 2 async def warehouse_access(*_args: Any, **kwargs: Any) -> None: nonlocal locked_access_count if not kwargs.get('for_update'): return locked_access_count += 1 if locked_access_count == recovery_access_number: raise PermissionException(data='', message='无权访问该仓库') self.db.commit.side_effect = integrity_error() parent_lock = AsyncMock(side_effect=[inbound, recovered]) warehouse_mapping = AsyncMock(side_effect=warehouse_access) with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(side_effect=[None, recovered]), ), patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock), patch.object( InboundService, '_lock_and_validate_pallets', new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])), ), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ) as warehouse_lock, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), self.assertRaises(PermissionException) as raised, ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) self.assertEqual(2, parent_lock.await_count) parent_lock.assert_awaited_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7]) self.assertEqual(2, warehouse_lock.await_count) self.assertEqual(2, locked_access_count) self.assertEqual(2, self.db.rollback.await_count) async def test_integrity_conflict_with_mismatched_recovery_is_stable_failure(self) -> None: inbound = make_inbound(pallet_ids=(2,)) inventory = make_inventory(2) recovered = make_inbound( pallet_ids=(2,), status='CONFIRMED', idempotency_key='confirm-key', request_hash='different-hash', ) self.db.commit.side_effect = integrity_error() with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object( OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(side_effect=[None, recovered]), ), patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), patch.object( MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), self.assertRaises(ServiceException), ): await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual(2, self.db.rollback.await_count) class TestInboundLogPermission(unittest.IsolatedAsyncioTestCase): async def test_real_inbound_log_wrapper_preserves_permission_code(self) -> None: async def receive() -> dict[str, Any]: return { 'type': 'http.request', 'body': b'{"warehouseId":404,"palletIds":[2]}', 'more_body': False, } request = Request( { 'type': 'http', 'method': 'POST', 'scheme': 'http', 'path': '/business/inbound', 'raw_path': b'/business/inbound', 'root_path': '', 'query_string': b'', 'headers': [ (b'content-type', b'application/json'), (b'user-agent', b'unittest'), ], 'client': ('127.0.0.1', 12345), 'server': ('testserver', 80), }, receive=receive, ) user = make_user() db = MagicMock(spec=AsyncSession) service = AsyncMock( side_effect=PermissionException(data='', message='无权访问该仓库'), ) enqueue_log = AsyncMock() user_token = RequestContext.set_current_user(user) try: with ( patch.object(InboundService, 'add_inbound_services', new=service), patch( 'common.annotation.log_annotation.Log._get_oper_location', new=AsyncMock(return_value='内网IP'), ), patch.object(LogQueueService, 'enqueue_operation_log', new=enqueue_log), patch('common.annotation.log_annotation.logger.exception') as exception_log, ): response = await inbound_controller_module.add_business_inbound( request=request, add_inbound=InboundCreateModel(warehouseId=404, palletIds=[2]), query_db=db, current_user=user, test_scope=TestBatchScope.formal(), ) finally: RequestContext.reset_current_user(user_token) payload = json.loads(response.body) self.assertEqual(403, payload['code']) self.assertEqual('无权访问该仓库', payload['msg']) self.assertFalse(payload['success']) service.assert_awaited_once() exception_log.assert_not_called() enqueue_log.assert_awaited_once() class TestInboundController(unittest.TestCase): @staticmethod def _routes() -> dict[tuple[str, str], APIRoute]: return { (route.path, next(iter(route.methods))): route for route in inbound_controller_module.inbound_controller.routes if isinstance(route, APIRoute) } def test_openapi_has_exact_methods_query_aliases_and_response_models(self) -> None: app = FastAPI() app.include_router(inbound_controller_module.inbound_controller) schema = app.openapi() expected = { ('/business/inbound/list', 'get'), ('/business/inbound/warehouse-options', 'get'), ('/business/inbound/scan/{qr_token}', 'get'), ('/business/inbound/{inbound_id}', 'get'), ('/business/inbound', 'post'), ('/business/inbound', 'put'), ('/business/inbound/{inbound_ids}', 'delete'), ('/business/inbound/{inbound_id}/confirm', 'put'), } self.assertEqual(expected, {(path, method) for path, methods in schema['paths'].items() for method in methods}) scan_parameters = schema['paths']['/business/inbound/scan/{qr_token}']['get']['parameters'] self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters}) routes = self._routes() self.assertEqual(PageResponseModel[InboundModel], routes[('/business/inbound/list', 'GET')].response_model) self.assertEqual( DataResponseModel[list[BusinessOptionModel]], routes[('/business/inbound/warehouse-options', 'GET')].response_model, ) self.assertEqual( DataResponseModel[PalletScanResponseModel], routes[('/business/inbound/scan/{qr_token}', 'GET')].response_model, ) self.assertEqual( DataResponseModel[InboundModel], routes[('/business/inbound/{inbound_id}', 'GET')].response_model, ) for key in ( ('/business/inbound', 'POST'), ('/business/inbound', 'PUT'), ('/business/inbound/{inbound_ids}', 'DELETE'), ('/business/inbound/{inbound_id}/confirm', 'PUT'), ): self.assertEqual(ResponseBaseModel, routes[key].response_model) def test_static_routes_are_declared_before_dynamic_detail(self) -> None: paths = [route.path for route in inbound_controller_module.inbound_controller.routes] detail_index = paths.index('/business/inbound/{inbound_id}') self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index) self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index) def test_each_route_has_exact_permission_and_six_unique_permission_values(self) -> None: expected = { ('/business/inbound/list', 'GET'): 'business:inbound:list', ('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query', ('/business/inbound', 'POST'): 'business:inbound:add', ('/business/inbound', 'PUT'): 'business:inbound:edit', ('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:remove', ('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm', } actual = {} for key, route in self._routes().items(): permissions = [ dependency.dependency for dependency in route.dependencies if isinstance(dependency.dependency, CheckUserInterfaceAuth) ] self.assertEqual(1, len(permissions)) self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies)) actual[key] = permissions[0].perm self.assertEqual(expected, actual) self.assertEqual( { 'business:inbound:list', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', 'business:inbound:remove', 'business:inbound:confirm', }, set(actual.values()), ) def test_all_data_scoped_reads_and_writes_inject_current_user_and_writes_have_log(self) -> None: routes = self._routes() for key, route in routes.items(): self.assertTrue( any( dependency.name == 'current_user' and dependency.call == LoginService.get_current_user for dependency in route.dependant.dependencies ), key, ) if key[1] in {'POST', 'PUT', 'DELETE'}: self.assertTrue(hasattr(route.endpoint, '__wrapped__'), key) def test_controller_does_not_add_scan_or_export_permission(self) -> None: source = inspect.getsource(inbound_controller_module) self.assertNotIn('business:inbound:scan', source) self.assertNotIn('business:inbound:export', source) if __name__ == '__main__': unittest.main()