1399 lines
60 KiB
Python
1399 lines
60 KiB
Python
import inspect
|
|
import secrets
|
|
import unittest
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from unittest.mock import call as mock_call
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.routing import APIRoute
|
|
from pydantic import ValidationError
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
|
from common.aspect.pre_auth import PreAuth
|
|
from common.vo import CrudResponseModel, DataResponseModel, PageModel, PageResponseModel, ResponseBaseModel
|
|
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
|
|
from module_admin.entity.do.user_do import SysUser
|
|
from module_admin.service.login_service import LoginService
|
|
from module_business.controller import pallet_controller as pallet_controller_module
|
|
from module_business.controller import site_controller as site_controller_module
|
|
from module_business.controller import warehouse_controller as warehouse_controller_module
|
|
from module_business.dao.access_dao import AccessDao
|
|
from module_business.dao.master_dao import MasterDao
|
|
from module_business.dependency.test_batch_dependency import TestBatchScopeResolver
|
|
from module_business.domain.test_batch_scope import TestBatchScope
|
|
from module_business.entity.do.master_do import (
|
|
BizCustomerSite,
|
|
BizPallet,
|
|
BizUserSite,
|
|
BizUserWarehouse,
|
|
BizWarehouse,
|
|
)
|
|
from module_business.entity.do.operation_do import BizPalletInventory
|
|
from module_business.entity.vo.master_vo import (
|
|
BusinessUserOptionModel,
|
|
DeletePalletModel,
|
|
PalletCreateModel,
|
|
PalletEditModel,
|
|
PalletModel,
|
|
PalletPageQueryModel,
|
|
UserAssignModel,
|
|
)
|
|
from module_business.service import pallet_service as pallet_service_module
|
|
from module_business.service.pallet_service import PalletService
|
|
from module_business.service.test_batch_access_service import TestBatchAccessService
|
|
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
|
|
from utils.page_util import PageUtil
|
|
|
|
|
|
def compile_sql(statement: Any) -> str:
|
|
return ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
|
|
|
|
|
|
def integrity_error() -> IntegrityError:
|
|
return IntegrityError('statement', {}, RuntimeError('constraint'))
|
|
|
|
|
|
def make_pallet_record(
|
|
pallet_id: int,
|
|
capacity_packages: int = 20,
|
|
test_batch_id: int | None = None,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
pallet_id=pallet_id,
|
|
test_batch_id=test_batch_id,
|
|
pallet_code=f'P-{pallet_id:03d}',
|
|
qr_token=f'fixed-token-{pallet_id}',
|
|
capacity_packages=capacity_packages,
|
|
asset_status='0',
|
|
remark=None,
|
|
)
|
|
|
|
|
|
class TestPalletServiceContract(unittest.TestCase):
|
|
def test_service_is_available(self) -> None:
|
|
self.assertIsNotNone(PalletService)
|
|
|
|
|
|
class TestPalletVoContract(unittest.TestCase):
|
|
def test_business_user_option_only_exposes_safe_fields(self) -> None:
|
|
source = SimpleNamespace(
|
|
user_id=7,
|
|
user_name='operator',
|
|
nick_name=None,
|
|
assigned=True,
|
|
password='secret',
|
|
email='operator@example.com',
|
|
phonenumber='13800000000',
|
|
token='token',
|
|
)
|
|
|
|
option = BusinessUserOptionModel.model_validate(source)
|
|
|
|
self.assertEqual(
|
|
{'userId': 7, 'userName': 'operator', 'nickName': None, 'assigned': True},
|
|
option.model_dump(by_alias=True),
|
|
)
|
|
self.assertTrue(
|
|
{'password', 'email', 'phonenumber', 'phone', 'token'}.isdisjoint(BusinessUserOptionModel.model_fields)
|
|
)
|
|
|
|
def test_business_user_option_validates_identity_fields(self) -> None:
|
|
invalid_values = (
|
|
{'userId': 0, 'userName': 'operator', 'assigned': False},
|
|
{'userId': 1, 'userName': '', 'assigned': False},
|
|
{'userId': 1, 'userName': ' ', 'assigned': False},
|
|
)
|
|
for value in invalid_values:
|
|
with self.subTest(value=value), self.assertRaises(ValidationError):
|
|
BusinessUserOptionModel.model_validate(value)
|
|
|
|
def test_pallet_edit_rejects_code_and_qr_token(self) -> None:
|
|
base = {'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'}
|
|
for identity_field in ({'palletCode': 'P-001'}, {'qrToken': 'fixed-token'}):
|
|
with self.subTest(identity_field=identity_field), self.assertRaises(ValidationError):
|
|
PalletEditModel.model_validate({**base, **identity_field})
|
|
|
|
|
|
class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
|
|
async def test_list_pallet_filters_and_orders_by_pallet_id(self) -> None:
|
|
query = PalletPageQueryModel(palletCode='P-00', assetStatus='0')
|
|
expected = object()
|
|
|
|
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=expected)) as paginate:
|
|
actual = await MasterDao.list_pallet(
|
|
self.db,
|
|
query,
|
|
TestBatchScope.formal(),
|
|
is_page=True,
|
|
)
|
|
|
|
self.assertIs(expected, actual)
|
|
statement = paginate.await_args.args[1]
|
|
sql = compile_sql(statement)
|
|
self.assertIn('FROM biz_pallet', sql)
|
|
self.assertIn("biz_pallet.pallet_code LIKE '%P-00%'", sql)
|
|
self.assertIn("biz_pallet.asset_status = '0'", sql)
|
|
self.assertIn('ORDER BY biz_pallet.pallet_id', sql)
|
|
|
|
async def test_add_pallet_and_inventory_only_flush_current_transaction(self) -> None:
|
|
pallet = BizPallet(
|
|
pallet_code='P-001',
|
|
qr_token='fixed-token',
|
|
capacity_packages=20,
|
|
asset_status='0',
|
|
)
|
|
inventory = BizPalletInventory(
|
|
pallet_id=3,
|
|
flow_state='UNSTOCKED',
|
|
remaining_packages=0,
|
|
version=0,
|
|
)
|
|
|
|
actual = await MasterDao.add_pallet(self.db, pallet)
|
|
await MasterDao.add_pallet_inventory(self.db, inventory)
|
|
|
|
self.assertIs(pallet, actual)
|
|
self.assertEqual([pallet, inventory], [call.args[0] for call in self.db.add.call_args_list])
|
|
self.assertEqual(2, self.db.flush.await_count)
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_not_awaited()
|
|
|
|
async def test_pallet_code_lookup_keeps_global_uniqueness(self) -> None:
|
|
query_result = MagicMock()
|
|
query_result.scalar_one_or_none.return_value = None
|
|
self.db.execute = AsyncMock(return_value=query_result)
|
|
|
|
await MasterDao.find_pallet_by_code(self.db, 'P-001')
|
|
|
|
statement = self.db.execute.await_args.args[0]
|
|
sql = compile_sql(statement)
|
|
self.assertIn("biz_pallet.pallet_code = 'P-001'", sql)
|
|
self.assertNotIn('test_batch_id', compile_sql(statement.whereclause))
|
|
|
|
async def test_update_pallet_sql_never_contains_identity_fields(self) -> None:
|
|
edit = PalletEditModel(palletId=3, capacityPackages=18, assetStatus='1', remark='停用')
|
|
updated_at = datetime(2026, 7, 26, 10, 30)
|
|
|
|
await MasterDao.update_pallet(
|
|
self.db,
|
|
edit,
|
|
TestBatchScope.formal(),
|
|
update_by='admin',
|
|
update_time=updated_at,
|
|
)
|
|
|
|
statement = self.db.execute.await_args.args[0]
|
|
sql = compile_sql(statement)
|
|
self.assertIn('UPDATE biz_pallet', sql)
|
|
self.assertIn('capacity_packages=18', sql)
|
|
self.assertIn("asset_status='1'", sql)
|
|
self.assertIn("remark='停用'", sql)
|
|
self.assertIn("update_by='admin'", sql)
|
|
self.assertIn('WHERE biz_pallet.pallet_id = 3', sql)
|
|
self.assertNotIn('pallet_code=', sql)
|
|
self.assertNotIn('qr_token=', sql)
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_not_awaited()
|
|
|
|
async def test_inventory_write_read_uses_row_lock(self) -> None:
|
|
result = MagicMock()
|
|
inventory = SimpleNamespace(pallet_id=9)
|
|
result.scalar_one_or_none.return_value = inventory
|
|
self.db.execute.return_value = result
|
|
|
|
actual = await MasterDao.get_pallet_inventory(
|
|
self.db,
|
|
pallet_id=9,
|
|
scope=TestBatchScope.formal(),
|
|
for_update=True,
|
|
)
|
|
|
|
self.assertIs(inventory, actual)
|
|
statement = self.db.execute.await_args.args[0]
|
|
self.assertIn('biz_pallet_inventory.pallet_id = 9', compile_sql(statement))
|
|
self.assertIn('FOR UPDATE', compile_sql(statement))
|
|
|
|
async def test_pallet_write_read_uses_row_lock(self) -> None:
|
|
result = MagicMock()
|
|
pallet = make_pallet_record(9)
|
|
result.scalar_one_or_none.return_value = pallet
|
|
self.db.execute.return_value = result
|
|
|
|
actual = await MasterDao.get_pallet(
|
|
self.db,
|
|
pallet_id=9,
|
|
scope=TestBatchScope.formal(),
|
|
for_update=True,
|
|
)
|
|
|
|
self.assertIs(pallet, actual)
|
|
statement = self.db.execute.await_args.args[0]
|
|
self.assertIn('biz_pallet.pallet_id = 9', compile_sql(statement))
|
|
self.assertIn('FOR UPDATE', compile_sql(statement))
|
|
|
|
async def test_assignment_resource_write_reads_use_row_lock(self) -> None:
|
|
cases = (
|
|
(MasterDao.get_warehouse, BizWarehouse, 'warehouse_id', 7),
|
|
(MasterDao.get_site, BizCustomerSite, 'site_id', 9),
|
|
)
|
|
for method, model, id_field, resource_id in cases:
|
|
with self.subTest(resource=model.__tablename__):
|
|
self.db.reset_mock()
|
|
query_result = MagicMock()
|
|
record = SimpleNamespace()
|
|
query_result.scalar_one_or_none.return_value = record
|
|
self.db.execute.return_value = query_result
|
|
|
|
actual = await method(
|
|
self.db,
|
|
resource_id,
|
|
TestBatchScope.formal(),
|
|
for_update=True,
|
|
)
|
|
|
|
self.assertIs(record, actual)
|
|
statement = self.db.execute.await_args.args[0]
|
|
sql = compile_sql(statement)
|
|
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
|
|
self.assertIn(f'{model.__tablename__}.{id_field} = {resource_id}', sql)
|
|
self.assertIn('FOR UPDATE', sql)
|
|
|
|
async def test_assignment_resource_reads_are_unlocked_by_default(self) -> None:
|
|
cases = (
|
|
(MasterDao.get_warehouse, BizWarehouse, 7),
|
|
(MasterDao.get_site, BizCustomerSite, 9),
|
|
)
|
|
for method, model, resource_id in cases:
|
|
with self.subTest(resource=model.__tablename__):
|
|
self.db.reset_mock()
|
|
query_result = MagicMock()
|
|
query_result.scalar_one_or_none.return_value = SimpleNamespace()
|
|
self.db.execute.return_value = query_result
|
|
|
|
await method(self.db, resource_id, TestBatchScope.formal())
|
|
|
|
statement = self.db.execute.await_args.args[0]
|
|
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
|
|
self.assertNotIn('FOR UPDATE', compile_sql(statement))
|
|
|
|
async def test_assigned_user_id_queries_target_exact_resource_association(self) -> None:
|
|
cases = (
|
|
(
|
|
MasterDao.list_warehouse_assigned_user_ids,
|
|
BizUserWarehouse,
|
|
BizUserWarehouse.user_id,
|
|
'warehouse_id',
|
|
BizWarehouse,
|
|
7,
|
|
),
|
|
(
|
|
MasterDao.list_site_assigned_user_ids,
|
|
BizUserSite,
|
|
BizUserSite.user_id,
|
|
'site_id',
|
|
BizCustomerSite,
|
|
9,
|
|
),
|
|
)
|
|
for method, model, selected_column, id_field, scope_model, resource_id in cases:
|
|
with self.subTest(resource=model.__tablename__):
|
|
self.db.reset_mock()
|
|
query_result = MagicMock()
|
|
query_result.scalars.return_value.all.return_value = [2, 5]
|
|
self.db.execute.return_value = query_result
|
|
|
|
actual = await method(self.db, resource_id, TestBatchScope.formal())
|
|
|
|
self.assertEqual([2, 5], actual)
|
|
self.db.execute.assert_awaited_once()
|
|
statement = self.db.execute.await_args.args[0]
|
|
self.assertEqual([selected_column], list(statement.selected_columns))
|
|
sql = compile_sql(statement)
|
|
self.assertIn(
|
|
f'FROM {model.__tablename__} JOIN {scope_model.__tablename__}',
|
|
sql,
|
|
)
|
|
self.assertIn(
|
|
f'{model.__tablename__}.{id_field} = {resource_id}',
|
|
sql,
|
|
)
|
|
self.assertIn(f'{scope_model.__tablename__}.test_batch_id IS NULL', sql)
|
|
|
|
async def test_assignable_user_queries_filter_disabled_and_deleted_rows(self) -> None:
|
|
list_result = MagicMock()
|
|
list_result.all.return_value = ()
|
|
selected_result = MagicMock()
|
|
selected_result.all.return_value = ()
|
|
self.db.execute.side_effect = [list_result, selected_result]
|
|
|
|
self.assertEqual([], await MasterDao.list_assignable_users(self.db))
|
|
self.assertEqual([], await MasterDao.list_assignable_users_by_ids(self.db, [2, 5]))
|
|
|
|
for statement in (call.args[0] for call in self.db.execute.await_args_list):
|
|
sql = compile_sql(statement)
|
|
self.assertIn('FROM sys_user', sql)
|
|
self.assertIn("sys_user.status = '0'", sql)
|
|
self.assertIn("sys_user.del_flag = '0'", sql)
|
|
self.assertNotIn('password', sql)
|
|
self.assertEqual(
|
|
[SysUser.user_id, SysUser.user_name, SysUser.nick_name],
|
|
list(statement.selected_columns),
|
|
)
|
|
self.assertIn('ORDER BY sys_user.user_id', compile_sql(self.db.execute.await_args_list[0].args[0]))
|
|
selected_sql = compile_sql(self.db.execute.await_args_list[1].args[0])
|
|
self.assertIn('sys_user.user_id IN (2, 5)', selected_sql)
|
|
|
|
|
|
class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.admin = make_admin()
|
|
self.scope = TestBatchScope.formal()
|
|
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=20, remark='测试托盘')
|
|
self.fixed_time = datetime(2026, 7, 26, 11, 30)
|
|
|
|
async def test_add_generates_server_token_and_initial_inventory_in_one_transaction(self) -> None:
|
|
actions: list[str] = []
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def add_pallet(_db: AsyncSession, pallet: BizPallet) -> BizPallet:
|
|
actions.append('pallet')
|
|
captured['pallet'] = pallet
|
|
pallet.pallet_id = 17
|
|
return pallet
|
|
|
|
async def add_inventory(_db: AsyncSession, inventory: BizPalletInventory) -> BizPalletInventory:
|
|
actions.append('inventory')
|
|
captured['inventory'] = inventory
|
|
return inventory
|
|
|
|
async def commit() -> None:
|
|
actions.append('commit')
|
|
|
|
self.db.commit.side_effect = commit
|
|
with (
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
|
|
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=add_pallet)),
|
|
patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock(side_effect=add_inventory)),
|
|
patch.object(secrets, 'token_urlsafe', return_value='server-fixed-token') as token_urlsafe,
|
|
patch.object(pallet_service_module, 'datetime', create=True) as service_datetime,
|
|
):
|
|
service_datetime.now.return_value = self.fixed_time
|
|
result = await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
|
|
token_urlsafe.assert_called_once_with(32)
|
|
pallet = captured['pallet']
|
|
self.assertEqual('P-001', pallet.pallet_code)
|
|
self.assertEqual('server-fixed-token', pallet.qr_token)
|
|
self.assertEqual(20, pallet.capacity_packages)
|
|
self.assertEqual('0', pallet.asset_status)
|
|
self.assertEqual('admin', pallet.create_by)
|
|
self.assertEqual(self.fixed_time, pallet.create_time)
|
|
self.assertEqual('admin', pallet.update_by)
|
|
self.assertEqual(self.fixed_time, pallet.update_time)
|
|
self.assertIsNone(pallet.test_batch_id)
|
|
inventory = captured['inventory']
|
|
self.assertEqual(17, inventory.pallet_id)
|
|
self.assertEqual('UNSTOCKED', inventory.flow_state)
|
|
self.assertEqual(0, inventory.remaining_packages)
|
|
self.assertEqual(0, inventory.version)
|
|
self.assertIsNone(inventory.test_batch_id)
|
|
self.assertEqual(['pallet', 'inventory', 'commit'], actions)
|
|
self.db.commit.assert_awaited_once()
|
|
self.db.rollback.assert_not_awaited()
|
|
self.assertIsInstance(result, CrudResponseModel)
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_add_rolls_back_when_pallet_flush_fails(self) -> None:
|
|
failure = RuntimeError('flush failed')
|
|
add_inventory = AsyncMock()
|
|
with (
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
|
|
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=failure)),
|
|
patch.object(MasterDao, 'add_pallet_inventory', new=add_inventory),
|
|
self.assertRaisesRegex(RuntimeError, 'flush failed'),
|
|
):
|
|
await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
|
|
add_inventory.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_add_rolls_back_when_commit_fails(self) -> None:
|
|
async def assign_id(_db: AsyncSession, pallet: BizPallet) -> BizPallet:
|
|
pallet.pallet_id = 17
|
|
return pallet
|
|
|
|
self.db.commit.side_effect = RuntimeError('commit failed')
|
|
with (
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
|
|
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=assign_id)),
|
|
patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock()),
|
|
self.assertRaisesRegex(RuntimeError, 'commit failed'),
|
|
):
|
|
await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_add_converts_integrity_error_to_stable_service_exception(self) -> None:
|
|
with (
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
|
|
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=integrity_error())),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
|
|
self.assertEqual('新增托盘失败,托盘编号或二维码标识已存在', raised.exception.message)
|
|
self.db.rollback.assert_awaited_once()
|
|
self.db.commit.assert_not_awaited()
|
|
|
|
async def test_add_rejects_duplicate_code_before_writing(self) -> None:
|
|
add_pallet = AsyncMock()
|
|
with (
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=object())),
|
|
patch.object(MasterDao, 'add_pallet', new=add_pallet),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
|
|
self.assertEqual('托盘编号已存在', raised.exception.message)
|
|
add_pallet.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
|
|
class TestPalletQrService(unittest.IsolatedAsyncioTestCase):
|
|
def test_render_qr_png_returns_real_stable_png(self) -> None:
|
|
first = PalletService.render_qr_png('fixed-original-token')
|
|
second = PalletService.render_qr_png('fixed-original-token')
|
|
|
|
self.assertEqual(b'\x89PNG\r\n\x1a\n', first[:8])
|
|
self.assertEqual(first, second)
|
|
|
|
async def test_get_qrcode_uses_original_persisted_token(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
scope = TestBatchScope.formal()
|
|
get_pallet = AsyncMock(return_value=SimpleNamespace(qr_token='persisted-token', test_batch_id=None))
|
|
with (
|
|
patch.object(MasterDao, 'get_pallet', new=get_pallet),
|
|
patch.object(PalletService, 'render_qr_png', return_value=b'png-bytes') as render,
|
|
patch.object(secrets, 'token_urlsafe') as token_urlsafe,
|
|
):
|
|
actual = await PalletService.get_pallet_qrcode(db, pallet_id=7, scope=scope)
|
|
|
|
self.assertEqual(b'png-bytes', actual)
|
|
get_pallet.assert_awaited_once_with(db, 7, scope)
|
|
render.assert_called_once_with('persisted-token')
|
|
token_urlsafe.assert_not_called()
|
|
|
|
async def test_get_qrcode_rejects_missing_pallet(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
scope = TestBatchScope.formal()
|
|
with (
|
|
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=None)),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.get_pallet_qrcode(db, pallet_id=404, scope=scope)
|
|
|
|
self.assertEqual('托盘不存在', raised.exception.message)
|
|
|
|
|
|
class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.admin = make_admin()
|
|
self.scope = TestBatchScope.formal()
|
|
|
|
async def test_list_returns_models_without_removability_lookup(self) -> None:
|
|
page = PageModel[Any](
|
|
rows=[make_pallet_record(pallet_id) for pallet_id in range(1, 1001)],
|
|
pageNum=1,
|
|
pageSize=1000,
|
|
total=1000,
|
|
hasNext=False,
|
|
)
|
|
query = PalletPageQueryModel()
|
|
list_pallet = AsyncMock(return_value=page)
|
|
with patch.object(MasterDao, 'list_pallet', new=list_pallet):
|
|
actual = await PalletService.list_pallet(self.db, query, self.scope, is_page=True)
|
|
|
|
self.assertIsInstance(actual, PageModel)
|
|
self.assertEqual(1000, len(actual.rows))
|
|
self.assertTrue(all(isinstance(row, PalletModel) for row in actual.rows))
|
|
self.assertNotIn('can_remove', PalletModel.model_fields)
|
|
list_pallet.assert_awaited_once_with(self.db, query, self.scope, True)
|
|
|
|
async def test_detail_returns_persisted_pallet_without_removability_lookup(self) -> None:
|
|
get_pallet = AsyncMock(return_value=make_pallet_record(3))
|
|
with patch.object(MasterDao, 'get_pallet', new=get_pallet):
|
|
actual = await PalletService.detail_pallet(self.db, pallet_id=3, scope=self.scope)
|
|
|
|
self.assertIsInstance(actual, PalletModel)
|
|
self.assertNotIn('can_remove', PalletModel.model_fields)
|
|
get_pallet.assert_awaited_once_with(self.db, 3, self.scope)
|
|
|
|
async def test_detail_rejects_missing_pallet(self) -> None:
|
|
with (
|
|
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=None)),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.detail_pallet(self.db, pallet_id=404, scope=self.scope)
|
|
|
|
self.assertEqual('托盘不存在', raised.exception.message)
|
|
|
|
async def test_edit_updates_only_mutable_fields_with_server_audit(self) -> None:
|
|
edit = PalletEditModel(palletId=3, capacityPackages=12, assetStatus='1', remark='停用')
|
|
updated_at = datetime(2026, 7, 26, 12, 30)
|
|
update_pallet = AsyncMock()
|
|
lock_actions: list[str] = []
|
|
|
|
async def get_pallet(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
lock_actions.append('pallet')
|
|
return make_pallet_record(3)
|
|
|
|
async def get_inventory(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
lock_actions.append('inventory')
|
|
return SimpleNamespace(remaining_packages=12, test_batch_id=None)
|
|
|
|
get_pallet_mock = AsyncMock(side_effect=get_pallet)
|
|
get_inventory_mock = AsyncMock(side_effect=get_inventory)
|
|
with (
|
|
patch.object(MasterDao, 'get_pallet', new=get_pallet_mock),
|
|
patch.object(MasterDao, 'get_pallet_inventory', new=get_inventory_mock),
|
|
patch.object(MasterDao, 'update_pallet', new=update_pallet),
|
|
patch.object(pallet_service_module, 'datetime') as service_datetime,
|
|
):
|
|
service_datetime.now.return_value = updated_at
|
|
result = await PalletService.edit_pallet(self.db, edit, self.admin, self.scope)
|
|
|
|
update_pallet.assert_awaited_once_with(
|
|
self.db,
|
|
edit,
|
|
self.scope,
|
|
update_by='admin',
|
|
update_time=updated_at,
|
|
)
|
|
self.assertEqual(['inventory', 'pallet'], lock_actions)
|
|
get_pallet_mock.assert_awaited_once_with(self.db, 3, self.scope, for_update=True)
|
|
get_inventory_mock.assert_awaited_once_with(self.db, 3, self.scope, for_update=True)
|
|
self.db.commit.assert_awaited_once()
|
|
self.db.rollback.assert_not_awaited()
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_edit_rejects_capacity_below_remaining_packages(self) -> None:
|
|
edit = PalletEditModel(palletId=3, capacityPackages=11, assetStatus='0')
|
|
update_pallet = AsyncMock()
|
|
with (
|
|
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_pallet_inventory',
|
|
new=AsyncMock(return_value=SimpleNamespace(remaining_packages=12, test_batch_id=None)),
|
|
),
|
|
patch.object(MasterDao, 'update_pallet', new=update_pallet),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.edit_pallet(self.db, edit, self.admin, self.scope)
|
|
|
|
self.assertEqual('托盘容量不能小于当前剩余包数', raised.exception.message)
|
|
update_pallet.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_edit_rejects_missing_inventory(self) -> None:
|
|
edit = PalletEditModel(palletId=3, capacityPackages=20, assetStatus='0')
|
|
with (
|
|
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
|
|
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.edit_pallet(self.db, edit, self.admin, self.scope)
|
|
|
|
self.assertEqual('托盘库存不存在', raised.exception.message)
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
|
|
class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.admin = make_admin()
|
|
self.scope = TestBatchScope.formal()
|
|
self.users = [
|
|
SimpleNamespace(
|
|
user_id=2,
|
|
user_name='alice',
|
|
nick_name='艾丽丝',
|
|
password='secret-a',
|
|
email='alice@example.com',
|
|
phonenumber='13800000000',
|
|
),
|
|
SimpleNamespace(
|
|
user_id=5,
|
|
user_name='bob',
|
|
nick_name=None,
|
|
password='secret-b',
|
|
email='bob@example.com',
|
|
phonenumber='13900000000',
|
|
),
|
|
]
|
|
|
|
async def test_assignment_get_returns_only_safe_fields_with_assigned_marker(self) -> None:
|
|
for resource, resource_id in (('warehouse', 7), ('site', 9)):
|
|
with self.subTest(resource=resource):
|
|
get_resource = AsyncMock(return_value=SimpleNamespace(test_batch_id=None))
|
|
list_users = AsyncMock(return_value=self.users)
|
|
list_assigned_ids = AsyncMock(return_value=[2])
|
|
list_user_resource_ids = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
f'get_{resource}',
|
|
new=get_resource,
|
|
),
|
|
patch.object(MasterDao, 'list_assignable_users', new=list_users),
|
|
patch.object(
|
|
MasterDao,
|
|
f'list_{resource}_assigned_user_ids',
|
|
new=list_assigned_ids,
|
|
create=True,
|
|
),
|
|
patch.object(AccessDao, f'list_user_{resource}_ids', new=list_user_resource_ids),
|
|
):
|
|
actual = await getattr(PalletService, f'list_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
)
|
|
|
|
self.assertEqual(
|
|
[
|
|
{'userId': 2, 'userName': 'alice', 'nickName': '艾丽丝', 'assigned': True},
|
|
{'userId': 5, 'userName': 'bob', 'nickName': None, 'assigned': False},
|
|
],
|
|
[option.model_dump(by_alias=True) for option in actual],
|
|
)
|
|
self.assertTrue(all(isinstance(option, BusinessUserOptionModel) for option in actual))
|
|
get_resource.assert_awaited_once_with(self.db, resource_id, self.scope)
|
|
list_users.assert_awaited_once_with(self.db)
|
|
list_assigned_ids.assert_awaited_once_with(self.db, resource_id, self.scope)
|
|
list_user_resource_ids.assert_not_awaited()
|
|
|
|
async def test_assignment_get_uses_three_fixed_queries_for_1000_users(self) -> None:
|
|
users = [
|
|
SimpleNamespace(user_id=user_id, user_name=f'user-{user_id}', nick_name=None) for user_id in range(1, 1001)
|
|
]
|
|
assigned_ids = [2, 500, 1000]
|
|
for resource, resource_id in (('warehouse', 7), ('site', 9)):
|
|
with self.subTest(resource=resource):
|
|
get_resource = AsyncMock(return_value=SimpleNamespace(test_batch_id=None))
|
|
list_users = AsyncMock(return_value=users)
|
|
list_assigned = AsyncMock(return_value=assigned_ids)
|
|
list_user_resource_ids = AsyncMock()
|
|
with (
|
|
patch.object(MasterDao, f'get_{resource}', new=get_resource),
|
|
patch.object(MasterDao, 'list_assignable_users', new=list_users),
|
|
patch.object(
|
|
MasterDao,
|
|
f'list_{resource}_assigned_user_ids',
|
|
new=list_assigned,
|
|
create=True,
|
|
),
|
|
patch.object(AccessDao, f'list_user_{resource}_ids', new=list_user_resource_ids),
|
|
):
|
|
actual = await getattr(PalletService, f'list_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
)
|
|
|
|
self.assertEqual(1000, len(actual))
|
|
self.assertEqual(assigned_ids, [option.user_id for option in actual if option.assigned])
|
|
get_resource.assert_awaited_once_with(self.db, resource_id, self.scope)
|
|
list_users.assert_awaited_once_with(self.db)
|
|
list_assigned.assert_awaited_once_with(self.db, resource_id, self.scope)
|
|
list_user_resource_ids.assert_not_awaited()
|
|
|
|
async def test_assignment_get_rejects_missing_resource_before_user_query(self) -> None:
|
|
cases = (('warehouse', 7, '仓库不存在'), ('site', 9, '客户现场不存在'))
|
|
for resource, resource_id, message in cases:
|
|
with self.subTest(resource=resource):
|
|
get_resource = AsyncMock(return_value=None)
|
|
list_users = AsyncMock()
|
|
with (
|
|
patch.object(MasterDao, f'get_{resource}', new=get_resource),
|
|
patch.object(MasterDao, 'list_assignable_users', new=list_users),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await getattr(PalletService, f'list_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
)
|
|
|
|
self.assertEqual(message, raised.exception.message)
|
|
get_resource.assert_awaited_once_with(self.db, resource_id, self.scope)
|
|
list_users.assert_not_awaited()
|
|
|
|
async def test_assignment_put_validates_users_replaces_and_commits(self) -> None:
|
|
assignment = UserAssignModel(userIds=[2, 5])
|
|
for resource, resource_id in (('warehouse', 7), ('site', 9)):
|
|
with self.subTest(resource=resource):
|
|
self.db.reset_mock()
|
|
calls = MagicMock()
|
|
get_resource_mock = AsyncMock(return_value=SimpleNamespace(test_batch_id=None))
|
|
list_users_mock = AsyncMock(return_value=self.users)
|
|
replace_mock = AsyncMock()
|
|
calls.attach_mock(get_resource_mock, 'get_resource')
|
|
calls.attach_mock(list_users_mock, 'list_users')
|
|
calls.attach_mock(replace_mock, 'replace')
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
f'get_{resource}',
|
|
new=get_resource_mock,
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'list_assignable_users_by_ids',
|
|
new=list_users_mock,
|
|
),
|
|
patch.object(AccessDao, f'replace_{resource}_users', new=replace_mock),
|
|
):
|
|
result = await getattr(PalletService, f'assign_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
assignment,
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
|
|
self.assertEqual(
|
|
[
|
|
mock_call.get_resource(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
for_update=True,
|
|
),
|
|
mock_call.list_users(self.db, [2, 5]),
|
|
mock_call.replace(self.db, resource_id, [2, 5]),
|
|
],
|
|
calls.mock_calls,
|
|
)
|
|
get_resource_mock.assert_awaited_once_with(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
for_update=True,
|
|
)
|
|
list_users_mock.assert_awaited_once_with(self.db, [2, 5])
|
|
replace_mock.assert_awaited_once_with(self.db, resource_id, [2, 5])
|
|
self.db.commit.assert_awaited_once()
|
|
self.db.rollback.assert_not_awaited()
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_assignment_put_allows_empty_complete_replacement(self) -> None:
|
|
try:
|
|
assignment = UserAssignModel(userIds=[])
|
|
except ValidationError as exc:
|
|
self.fail(f'完整替换用户关联必须允许空数组:{exc}')
|
|
|
|
for resource, resource_id in (('warehouse', 7), ('site', 9)):
|
|
with self.subTest(resource=resource):
|
|
self.db.reset_mock()
|
|
list_users_mock = AsyncMock(return_value=[])
|
|
replace_mock = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
f'get_{resource}',
|
|
new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)),
|
|
),
|
|
patch.object(MasterDao, 'list_assignable_users_by_ids', new=list_users_mock),
|
|
patch.object(AccessDao, f'replace_{resource}_users', new=replace_mock),
|
|
):
|
|
result = await getattr(PalletService, f'assign_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
assignment,
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
|
|
list_users_mock.assert_awaited_once_with(self.db, [])
|
|
replace_mock.assert_awaited_once_with(self.db, resource_id, [])
|
|
self.db.commit.assert_awaited_once()
|
|
self.db.rollback.assert_not_awaited()
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_empty_replacement_deletes_links_without_adding_new_rows(self) -> None:
|
|
cases = (
|
|
('warehouse', 7, 'biz_user_warehouse', 'warehouse_id'),
|
|
('site', 9, 'biz_user_site', 'site_id'),
|
|
)
|
|
for resource, resource_id, table, id_field in cases:
|
|
with self.subTest(resource=resource):
|
|
self.db.reset_mock()
|
|
|
|
await getattr(AccessDao, f'replace_{resource}_users')(self.db, resource_id, [])
|
|
|
|
delete_sql = compile_sql(self.db.execute.await_args.args[0])
|
|
self.assertIn(f'DELETE FROM {table}', delete_sql)
|
|
self.assertIn(f'{table}.{id_field} = {resource_id}', delete_sql)
|
|
self.db.add_all.assert_called_once_with([])
|
|
self.db.flush.assert_awaited_once()
|
|
|
|
async def test_assignment_put_rejects_missing_disabled_or_deleted_user(self) -> None:
|
|
replace = AsyncMock()
|
|
assignment = UserAssignModel(userIds=[2, 5])
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'list_assignable_users_by_ids',
|
|
new=AsyncMock(return_value=[self.users[0]]),
|
|
),
|
|
patch.object(AccessDao, 'replace_warehouse_users', new=replace),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.assign_warehouse_users(
|
|
self.db,
|
|
7,
|
|
assignment,
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
|
|
self.assertEqual('存在不存在、停用或已删除的用户', raised.exception.message)
|
|
replace.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_replace_failure_rolls_back_and_preserves_original_exception(self) -> None:
|
|
assignment = UserAssignModel(userIds=[2, 5])
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_site',
|
|
new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'list_assignable_users_by_ids',
|
|
new=AsyncMock(return_value=self.users),
|
|
),
|
|
patch.object(
|
|
AccessDao,
|
|
'replace_site_users',
|
|
new=AsyncMock(side_effect=RuntimeError('replace failed')),
|
|
),
|
|
self.assertRaisesRegex(RuntimeError, 'replace failed'),
|
|
):
|
|
await PalletService.assign_site_users(
|
|
self.db,
|
|
9,
|
|
assignment,
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_assignment_commit_failure_rolls_back(self) -> None:
|
|
assignment = UserAssignModel(userIds=[2, 5])
|
|
self.db.commit.side_effect = RuntimeError('commit failed')
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'list_assignable_users_by_ids',
|
|
new=AsyncMock(return_value=self.users),
|
|
),
|
|
patch.object(AccessDao, 'replace_warehouse_users', new=AsyncMock()),
|
|
self.assertRaisesRegex(RuntimeError, 'commit failed'),
|
|
):
|
|
await PalletService.assign_warehouse_users(
|
|
self.db,
|
|
7,
|
|
assignment,
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_assignment_integrity_error_has_stable_message_and_rolls_back(self) -> None:
|
|
assignment = UserAssignModel(userIds=[2, 5])
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'list_assignable_users_by_ids',
|
|
new=AsyncMock(return_value=self.users),
|
|
),
|
|
patch.object(
|
|
AccessDao,
|
|
'replace_warehouse_users',
|
|
new=AsyncMock(side_effect=integrity_error()),
|
|
),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await PalletService.assign_warehouse_users(
|
|
self.db,
|
|
7,
|
|
assignment,
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
|
|
self.assertEqual('分配仓库用户失败,关联数据无效', raised.exception.message)
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
|
|
class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.admin = make_admin()
|
|
self.scope = make_test_scope(9)
|
|
self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=20)
|
|
|
|
async def test_list_detail_and_qrcode_forward_scope_to_scoped_dao_reads(self) -> None:
|
|
query = PalletPageQueryModel()
|
|
page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False)
|
|
list_pallet = AsyncMock(return_value=page)
|
|
get_pallet = AsyncMock(return_value=make_pallet_record(7, test_batch_id=9))
|
|
with (
|
|
patch.object(MasterDao, 'list_pallet', new=list_pallet),
|
|
patch.object(MasterDao, 'get_pallet', new=get_pallet),
|
|
patch.object(PalletService, 'render_qr_png', return_value=b'png'),
|
|
):
|
|
listed = await PalletService.list_pallet(self.db, query, self.scope, is_page=True)
|
|
detailed = await PalletService.detail_pallet(self.db, 7, self.scope)
|
|
qr_png = await PalletService.get_pallet_qrcode(self.db, 7, self.scope)
|
|
|
|
self.assertIsInstance(listed, PageModel)
|
|
self.assertIsInstance(detailed, PalletModel)
|
|
self.assertEqual(b'png', qr_png)
|
|
list_pallet.assert_awaited_once_with(self.db, query, self.scope, True)
|
|
self.assertEqual(
|
|
[mock_call(self.db, 7, self.scope), mock_call(self.db, 7, self.scope)],
|
|
get_pallet.await_args_list,
|
|
)
|
|
|
|
async def test_test_scope_add_locks_first_and_marks_pallet_and_inventory(self) -> None:
|
|
events: list[str] = []
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def lock_batch(*_args: Any) -> None:
|
|
events.append('lock-batch')
|
|
|
|
async def find_duplicate(*_args: Any) -> None:
|
|
events.append('find-duplicate')
|
|
|
|
async def add_pallet(_db: AsyncSession, pallet: BizPallet) -> BizPallet:
|
|
events.append('add-pallet')
|
|
captured['pallet'] = pallet
|
|
pallet.pallet_id = 17
|
|
return pallet
|
|
|
|
async def add_inventory(_db: AsyncSession, inventory: BizPalletInventory) -> BizPalletInventory:
|
|
events.append('add-inventory')
|
|
captured['inventory'] = inventory
|
|
return inventory
|
|
|
|
with (
|
|
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)),
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(side_effect=find_duplicate)),
|
|
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=add_pallet)),
|
|
patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock(side_effect=add_inventory)),
|
|
):
|
|
result = await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
|
|
self.assertTrue(result.is_success)
|
|
self.assertEqual('lock-batch', events[0])
|
|
self.assertEqual(9, captured['pallet'].test_batch_id)
|
|
self.assertEqual(9, captured['inventory'].test_batch_id)
|
|
self.assertEqual(17, captured['inventory'].pallet_id)
|
|
|
|
async def test_test_scope_edit_locks_batch_before_inventory_or_pallet(self) -> None:
|
|
events: list[str] = []
|
|
|
|
async def lock_batch(*_args: Any) -> None:
|
|
events.append('lock-batch')
|
|
|
|
async def get_inventory(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
events.append('get-inventory')
|
|
return SimpleNamespace(remaining_packages=10, test_batch_id=9)
|
|
|
|
async def get_pallet(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
events.append('get-pallet')
|
|
return make_pallet_record(7, test_batch_id=9)
|
|
|
|
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0')
|
|
update_pallet = AsyncMock()
|
|
with (
|
|
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)),
|
|
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(side_effect=get_inventory)),
|
|
patch.object(MasterDao, 'get_pallet', new=AsyncMock(side_effect=get_pallet)),
|
|
patch.object(MasterDao, 'update_pallet', new=update_pallet),
|
|
):
|
|
result = await PalletService.edit_pallet(self.db, edit, self.admin, self.scope)
|
|
|
|
self.assertTrue(result.is_success)
|
|
self.assertEqual(['lock-batch', 'get-inventory', 'get-pallet'], events)
|
|
self.assertIs(self.scope, update_pallet.await_args.args[2])
|
|
|
|
async def test_regular_user_and_closed_batch_reject_pallet_writes_before_business_queries(self) -> None:
|
|
business_query = AsyncMock()
|
|
lock_batch = AsyncMock()
|
|
with (
|
|
patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch),
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=business_query),
|
|
self.assertRaisesRegex(PermissionException, '仅超级管理员'),
|
|
):
|
|
await PalletService.add_pallet(self.db, self.create, make_current_user(8), self.scope)
|
|
lock_batch.assert_not_awaited()
|
|
business_query.assert_not_awaited()
|
|
|
|
closed = PermissionException('', '当前测试批次已关闭')
|
|
with (
|
|
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=closed)),
|
|
patch.object(MasterDao, 'find_pallet_by_code', new=business_query),
|
|
self.assertRaisesRegex(PermissionException, '已关闭'),
|
|
):
|
|
await PalletService.add_pallet(self.db, self.create, self.admin, self.scope)
|
|
business_query.assert_not_awaited()
|
|
|
|
async def test_assignment_reads_are_scoped_and_test_writes_lock_before_resource_and_users(self) -> None:
|
|
for resource, resource_id in (('warehouse', 7), ('site', 9)):
|
|
get_resource = AsyncMock(return_value=SimpleNamespace(test_batch_id=9))
|
|
list_users = AsyncMock(return_value=[])
|
|
list_assigned = AsyncMock(return_value=[])
|
|
with (
|
|
self.subTest(resource=resource, operation='read'),
|
|
patch.object(MasterDao, f'get_{resource}', new=get_resource),
|
|
patch.object(MasterDao, 'list_assignable_users', new=list_users),
|
|
patch.object(MasterDao, f'list_{resource}_assigned_user_ids', new=list_assigned),
|
|
):
|
|
await getattr(PalletService, f'list_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
)
|
|
get_resource.assert_awaited_once_with(self.db, resource_id, self.scope)
|
|
|
|
events: list[str] = []
|
|
lock_batch = AsyncMock(side_effect=lambda *_args, _events=events: _events.append('lock-batch'))
|
|
get_resource = AsyncMock(
|
|
side_effect=lambda *_args, _events=events, **_kwargs: (
|
|
_events.append('get-resource')
|
|
or SimpleNamespace(test_batch_id=9)
|
|
)
|
|
)
|
|
list_users = AsyncMock(
|
|
side_effect=lambda *_args, _events=events: _events.append('list-users') or []
|
|
)
|
|
replace_users = AsyncMock(
|
|
side_effect=lambda *_args, _events=events: _events.append('replace-users')
|
|
)
|
|
with (
|
|
self.subTest(resource=resource, operation='write'),
|
|
patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch),
|
|
patch.object(MasterDao, f'get_{resource}', new=get_resource),
|
|
patch.object(MasterDao, 'list_assignable_users_by_ids', new=list_users),
|
|
patch.object(AccessDao, f'replace_{resource}_users', new=replace_users),
|
|
):
|
|
await getattr(PalletService, f'assign_{resource}_users')(
|
|
self.db,
|
|
resource_id,
|
|
UserAssignModel(userIds=[]),
|
|
self.admin,
|
|
self.scope,
|
|
)
|
|
self.assertEqual('lock-batch', events[0])
|
|
get_resource.assert_awaited_once_with(
|
|
self.db,
|
|
resource_id,
|
|
self.scope,
|
|
for_update=True,
|
|
)
|
|
|
|
async def test_regular_user_cannot_assign_test_resource_users(self) -> None:
|
|
lock_batch = AsyncMock()
|
|
get_resource = AsyncMock()
|
|
with (
|
|
patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch),
|
|
patch.object(MasterDao, 'get_warehouse', new=get_resource),
|
|
self.assertRaisesRegex(PermissionException, '仅超级管理员'),
|
|
):
|
|
await PalletService.assign_warehouse_users(
|
|
self.db,
|
|
7,
|
|
UserAssignModel(userIds=[]),
|
|
make_current_user(8),
|
|
self.scope,
|
|
)
|
|
lock_batch.assert_not_awaited()
|
|
get_resource.assert_not_awaited()
|
|
|
|
async def test_legacy_pallet_delete_is_rejected_before_any_delete_dao(self) -> None:
|
|
lock_inventory = AsyncMock()
|
|
delete_inventory = AsyncMock()
|
|
delete_pallet = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
PalletService,
|
|
'_lock_pallet_inventory',
|
|
new=lock_inventory,
|
|
),
|
|
patch.object(MasterDao, 'delete_pallet_inventory', new=delete_inventory, create=True),
|
|
patch.object(MasterDao, 'delete_pallet', new=delete_pallet, create=True),
|
|
self.assertRaisesRegex(ServiceWarning, '不支持单条物理删除,请通过测试数据批次清理'),
|
|
):
|
|
await PalletService.remove_pallet(self.db, DeletePalletModel(palletIds=[4]))
|
|
|
|
lock_inventory.assert_not_awaited()
|
|
delete_inventory.assert_not_awaited()
|
|
delete_pallet.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
|
|
|
|
class TestPalletDaoBatchScope(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
|
|
async def test_pallet_list_detail_and_inventory_queries_apply_scope(self) -> None:
|
|
query = PalletPageQueryModel()
|
|
for scope, expected in (
|
|
(TestBatchScope.formal(), 'test_batch_id IS NULL'),
|
|
(make_test_scope(9), 'test_batch_id = 9'),
|
|
):
|
|
with (
|
|
self.subTest(scope=scope.mode, operation='list'),
|
|
patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=[])) as paginate,
|
|
):
|
|
await MasterDao.list_pallet(self.db, query, scope, is_page=False)
|
|
sql = compile_sql(paginate.await_args.args[1])
|
|
self.assertIn(f'biz_pallet.{expected}', sql)
|
|
|
|
for method_name, table in (
|
|
('get_pallet', 'biz_pallet'),
|
|
('get_pallet_inventory', 'biz_pallet_inventory'),
|
|
):
|
|
result = MagicMock()
|
|
result.scalar_one_or_none.return_value = None
|
|
self.db.execute = AsyncMock(return_value=result)
|
|
with self.subTest(scope=scope.mode, operation=method_name):
|
|
await getattr(MasterDao, method_name)(self.db, 7, scope, for_update=True)
|
|
sql = compile_sql(self.db.execute.await_args.args[0])
|
|
self.assertIn(f'{table}.{expected}', sql)
|
|
self.assertIn('FOR UPDATE', sql)
|
|
|
|
async def test_pallet_update_is_scoped_and_never_updates_batch_id(self) -> None:
|
|
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0')
|
|
await MasterDao.update_pallet(
|
|
self.db,
|
|
edit,
|
|
make_test_scope(9),
|
|
update_by='admin',
|
|
update_time=datetime(2026, 9, 22),
|
|
)
|
|
sql = compile_sql(self.db.execute.await_args.args[0])
|
|
self.assertIn('biz_pallet.test_batch_id = 9', sql)
|
|
self.assertNotIn('SET test_batch_id=', sql)
|
|
|
|
|
|
class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase):
|
|
@staticmethod
|
|
def _routers() -> tuple[Any, ...]:
|
|
return (
|
|
pallet_controller_module.pallet_controller,
|
|
warehouse_controller_module.warehouse_user_controller,
|
|
site_controller_module.site_user_controller,
|
|
)
|
|
|
|
@classmethod
|
|
def _routes(cls) -> dict[tuple[str, str], APIRoute]:
|
|
routes = {}
|
|
for router in cls._routers():
|
|
for route in router.routes:
|
|
if isinstance(route, APIRoute):
|
|
routes[(route.path, next(iter(route.methods)))] = route
|
|
return routes
|
|
|
|
def test_openapi_has_all_pallet_and_assignment_operations(self) -> None:
|
|
app = FastAPI()
|
|
for router in self._routers():
|
|
app.include_router(router)
|
|
schema = app.openapi()
|
|
|
|
expected_operations = {
|
|
('/master/pallet/list', 'get'),
|
|
('/master/pallet/{pallet_id}', 'get'),
|
|
('/master/pallet', 'post'),
|
|
('/master/pallet', 'put'),
|
|
('/master/pallet/{pallet_ids}', 'delete'),
|
|
('/master/pallet/{pallet_id}/qrcode', 'get'),
|
|
('/master/warehouse/{warehouse_id}/users', 'get'),
|
|
('/master/warehouse/{warehouse_id}/users', 'put'),
|
|
('/master/site/{site_id}/users', 'get'),
|
|
('/master/site/{site_id}/users', 'put'),
|
|
}
|
|
for path, method in expected_operations:
|
|
with self.subTest(path=path, method=method):
|
|
self.assertIn(path, schema['paths'])
|
|
self.assertIn(method, schema['paths'][path])
|
|
self.assertIn(
|
|
'image/png',
|
|
schema['paths']['/master/pallet/{pallet_id}/qrcode']['get']['responses']['200']['content'],
|
|
)
|
|
|
|
def test_route_response_models_are_exact(self) -> None:
|
|
routes = self._routes()
|
|
self.assertEqual(
|
|
PageResponseModel[PalletModel],
|
|
routes[('/master/pallet/list', 'GET')].response_model,
|
|
)
|
|
self.assertEqual(
|
|
DataResponseModel[PalletModel],
|
|
routes[('/master/pallet/{pallet_id}', 'GET')].response_model,
|
|
)
|
|
for path, method in (
|
|
('/master/pallet', 'POST'),
|
|
('/master/pallet', 'PUT'),
|
|
('/master/pallet/{pallet_ids}', 'DELETE'),
|
|
('/master/warehouse/{warehouse_id}/users', 'PUT'),
|
|
('/master/site/{site_id}/users', 'PUT'),
|
|
):
|
|
self.assertEqual(ResponseBaseModel, routes[(path, method)].response_model)
|
|
user_response = DataResponseModel[list[BusinessUserOptionModel]]
|
|
self.assertEqual(
|
|
user_response,
|
|
routes[('/master/warehouse/{warehouse_id}/users', 'GET')].response_model,
|
|
)
|
|
self.assertEqual(
|
|
user_response,
|
|
routes[('/master/site/{site_id}/users', 'GET')].response_model,
|
|
)
|
|
self.assertIsNone(routes[('/master/pallet/{pallet_id}/qrcode', 'GET')].response_model)
|
|
|
|
def test_permissions_are_exact_and_all_routes_require_pre_auth(self) -> None:
|
|
expected_permissions = {
|
|
('/master/pallet/list', 'GET'): 'master:pallet:list',
|
|
('/master/pallet/{pallet_id}', 'GET'): 'master:pallet:query',
|
|
('/master/pallet', 'POST'): 'master:pallet:add',
|
|
('/master/pallet', 'PUT'): 'master:pallet:edit',
|
|
('/master/pallet/{pallet_ids}', 'DELETE'): 'master:pallet:remove',
|
|
('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print',
|
|
('/master/warehouse/{warehouse_id}/users', 'GET'): 'master:warehouse:assign',
|
|
('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign',
|
|
('/master/site/{site_id}/users', 'GET'): 'master:site:assign',
|
|
('/master/site/{site_id}/users', 'PUT'): 'master:site:assign',
|
|
}
|
|
|
|
actual_permissions = {}
|
|
for key, route in self._routes().items():
|
|
dependencies = [
|
|
dependency.dependency
|
|
for dependency in route.dependencies
|
|
if isinstance(dependency.dependency, CheckUserInterfaceAuth)
|
|
]
|
|
self.assertEqual(1, len(dependencies))
|
|
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
|
|
actual_permissions[key] = dependencies[0].perm
|
|
|
|
self.assertEqual(expected_permissions, actual_permissions)
|
|
self.assertEqual(8, len(set(actual_permissions.values())))
|
|
|
|
def test_write_routes_have_log_and_current_user(self) -> None:
|
|
write_routes = (
|
|
('/master/pallet', 'POST'),
|
|
('/master/pallet', 'PUT'),
|
|
('/master/pallet/{pallet_ids}', 'DELETE'),
|
|
('/master/warehouse/{warehouse_id}/users', 'PUT'),
|
|
('/master/site/{site_id}/users', 'PUT'),
|
|
)
|
|
routes = self._routes()
|
|
for key in write_routes:
|
|
route = routes[key]
|
|
self.assertTrue(hasattr(route.endpoint, '__wrapped__'))
|
|
self.assertTrue(
|
|
any(
|
|
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
|
|
for dependency in route.dependant.dependencies
|
|
)
|
|
)
|
|
|
|
def test_all_non_delete_routes_have_matching_batch_scope_dependency(self) -> None:
|
|
for (path, method), route in self._routes().items():
|
|
resolvers = [
|
|
dependency.call
|
|
for dependency in route.dependant.dependencies
|
|
if isinstance(dependency.call, TestBatchScopeResolver)
|
|
]
|
|
with self.subTest(path=path, method=method):
|
|
if method == 'DELETE':
|
|
self.assertEqual([], resolvers)
|
|
else:
|
|
self.assertEqual(1, len(resolvers))
|
|
self.assertIs(method in {'POST', 'PUT'}, resolvers[0].write)
|
|
|
|
def test_qrcode_route_is_declared_before_dynamic_detail(self) -> None:
|
|
route_paths = [route.path for route in pallet_controller_module.pallet_controller.routes]
|
|
self.assertLess(
|
|
route_paths.index('/master/pallet/{pallet_id}/qrcode'),
|
|
route_paths.index('/master/pallet/{pallet_id}'),
|
|
)
|
|
|
|
async def test_qrcode_endpoint_returns_real_image_png_body(self) -> None:
|
|
png = PalletService.render_qr_png('persisted-token')
|
|
db = MagicMock(spec=AsyncSession)
|
|
scope = TestBatchScope.formal()
|
|
with patch.object(PalletService, 'get_pallet_qrcode', new=AsyncMock(return_value=png)) as get_qrcode:
|
|
response = await pallet_controller_module.query_master_pallet_qrcode(
|
|
request=MagicMock(),
|
|
pallet_id=7,
|
|
query_db=db,
|
|
scope=scope,
|
|
)
|
|
|
|
get_qrcode.assert_awaited_once_with(db, 7, scope)
|
|
self.assertEqual('image/png', response.media_type)
|
|
self.assertEqual(b'\x89PNG\r\n\x1a\n', response.body[:8])
|
|
|
|
def test_controller_sources_do_not_require_system_user_list_or_cache(self) -> None:
|
|
sources = '\n'.join(
|
|
inspect.getsource(module)
|
|
for module in (
|
|
pallet_controller_module,
|
|
warehouse_controller_module,
|
|
site_controller_module,
|
|
)
|
|
)
|
|
self.assertNotIn('system:user:list', sources)
|
|
self.assertNotIn('ApiCache', sources)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|