Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py
T

1856 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
import importlib
import importlib.util
import inspect
import json
import unittest
from contextlib import ExitStack
from datetime import datetime
from typing import Any, NoReturn, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch
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,
)
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.login_service import LoginService
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.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletEvent,
BizPalletInventory,
BizUsageRecord,
)
from module_business.entity.vo.master_vo import LinePageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
PalletScanResponseModel,
UsageCreateModel,
UsageModel,
UsagePageQueryModel,
UsageSuccessResponseModel,
)
from module_business.service.access_service import AccessService
SERVICE_SPEC = importlib.util.find_spec('module_business.service.usage_service')
CONTROLLER_SPEC = importlib.util.find_spec('module_business.controller.usage_controller')
UsageService = None
if SERVICE_SPEC is not None:
UsageService = getattr(
importlib.import_module('module_business.service.usage_service'),
'UsageService',
None,
)
usage_controller_module = None
usage_controller = None
if CONTROLLER_SPEC is not None:
usage_controller_module = importlib.import_module('module_business.controller.usage_controller')
usage_controller = getattr(usage_controller_module, 'usage_controller', None)
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 integrity_error() -> IntegrityError:
return IntegrityError('statement', {}, RuntimeError('constraint'))
def make_page(
*,
qr_token: str = 'qr-2',
line_id: int = 4,
used_packages: int = 3,
idempotency_key: str = 'usage-key',
) -> UsageCreateModel:
return UsageCreateModel(
qrToken=qr_token,
lineId=line_id,
usedPackages=used_packages,
idempotencyKey=idempotency_key,
)
def make_pallet(
pallet_id: int = 2,
*,
qr_token: str = 'qr-2',
capacity_packages: int = 20,
asset_status: str = '0',
) -> BizPallet:
return BizPallet(
pallet_id=pallet_id,
pallet_code=f'P-{pallet_id:03d}',
qr_token=qr_token,
capacity_packages=capacity_packages,
asset_status=asset_status,
)
def make_inventory(
pallet_id: int = 2,
*,
site_id: int | None = 9,
current_line_id: int | None = None,
active_filling_id: int | None = 31,
remaining_packages: int = 8,
flow_state: str = PalletFlowState.AT_SITE_UNUSED.value,
version: int = 3,
) -> BizPalletInventory:
return BizPalletInventory(
pallet_id=pallet_id,
active_filling_id=active_filling_id,
warehouse_id=None,
site_id=site_id,
current_line_id=current_line_id,
remaining_packages=remaining_packages,
flow_state=flow_state,
last_event_id=40,
version=version,
updated_at=datetime(2026, 7, 25, 9, 0),
)
def make_filling(
filling_id: int = 31,
*,
pallet_id: int = 2,
active_slot: str | None = 'ACTIVE',
) -> BizFillingRecord:
return BizFillingRecord(
filling_id=filling_id,
filling_no=f'FL-{filling_id}',
pallet_id=pallet_id,
warehouse_id=7,
strain_id=11,
strain_code_snapshot='S-011',
strain_name_snapshot='香菇 11',
batch_no='B-20260726',
package_spec='20kg',
initial_packages=20,
active_slot=active_slot,
closed_at=None,
idempotency_key='fill-key',
request_hash='f' * 64,
operator_id=7,
operated_at=datetime(2026, 7, 25, 8, 0),
)
def make_site(site_id: int = 9, *, status: str = '0') -> BizCustomerSite:
return BizCustomerSite(
site_id=site_id,
customer_id=3,
site_code=f'SITE-{site_id}',
site_name=f'现场 {site_id}',
status=status,
)
def make_line(
line_id: int = 4,
*,
site_id: int = 9,
status: str = '0',
) -> BizProductionLine:
return BizProductionLine(
line_id=line_id,
site_id=site_id,
line_code=f'L-{line_id}',
line_name=f'产线 {line_id}',
status=status,
)
def make_usage(
usage_id: int = 61,
*,
site_id: int = 9,
line_id: int = 4,
idempotency_key: str = 'usage-key',
request_hash: str = 'a' * 64,
) -> BizUsageRecord:
return BizUsageRecord(
usage_id=usage_id,
usage_no=f'US-{usage_id}',
pallet_id=2,
active_filling_id=31,
site_id=site_id,
line_id=line_id,
used_packages=3,
remaining_packages_after=5,
idempotency_key=idempotency_key,
request_hash=request_hash,
operator_id=7,
occurred_at=datetime(2026, 7, 26, 11, 30),
)
class TestUsageBootstrap(unittest.TestCase):
def test_usage_service_module_exists(self) -> None:
self.assertIsNotNone(SERVICE_SPEC, 'UsageService 尚未实现')
def test_usage_service_class_exists(self) -> None:
self.assertIsNotNone(UsageService, 'UsageService 尚未实现')
def test_usage_controller_module_exists(self) -> None:
self.assertIsNotNone(CONTROLLER_SPEC, 'usage_controller 尚未实现')
def test_usage_controller_router_exists(self) -> None:
self.assertIsNotNone(usage_controller, 'usage_controller 路由尚未实现')
@unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现')
class TestUsagePublicContract(unittest.TestCase):
def test_service_exposes_complete_usage_workflow(self) -> None:
expected = {
'get_usage_list_services',
'usage_detail_services',
'get_line_options_services',
'scan_usage_pallet_services',
'record_usage_services',
}
self.assertEqual(expected, {name for name in expected if hasattr(UsageService, name)})
def test_record_signature_and_types_are_exact(self) -> None:
signature = inspect.signature(UsageService.record_usage_services)
self.assertEqual(('query_db', 'page_object', 'current_user'), tuple(signature.parameters))
hints = get_type_hints(UsageService.record_usage_services)
self.assertIs(AsyncSession, hints['query_db'])
self.assertIs(UsageCreateModel, hints['page_object'])
self.assertIs(CurrentUserModel, hints['current_user'])
self.assertIs(CrudResponseModel, hints['return'])
def test_request_hash_is_stable_normalized_and_excludes_idempotency_key(self) -> None:
page = make_page(qr_token=' qr-2 ', idempotency_key='key-a')
expected = hashlib.sha256(
json.dumps(
{'line_id': 4, 'qr_token': 'qr-2', 'used_packages': 3},
sort_keys=True,
separators=(',', ':'),
).encode()
).hexdigest()
self.assertEqual(expected, UsageService._build_request_hash(page))
self.assertEqual(
expected,
UsageService._build_request_hash(page.model_copy(update={'idempotency_key': 'key-b'})),
)
self.assertNotEqual(
expected,
UsageService._build_request_hash(page.model_copy(update={'used_packages': 2})),
)
def test_create_model_requires_strict_positive_integer_packages(self) -> None:
for invalid in (True, 0, -1, 1.5):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
make_page(used_packages=invalid)
def test_usage_success_response_model_has_exact_camel_case_contract(self) -> None:
operation_vo_module = importlib.import_module('module_business.entity.vo.operation_vo')
response_model = getattr(operation_vo_module, 'UsageSuccessResponseModel', None)
self.assertIsNotNone(response_model, 'UsageSuccessResponseModel 尚未实现')
result = response_model(
usageId=61,
remainingPackages=5,
flowState=PalletFlowState.AT_SITE_PARTIAL,
)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 5,
'flowState': PalletFlowState.AT_SITE_PARTIAL,
},
result.model_dump(by_alias=True),
)
self.assertEqual(
{'usage_id', 'remaining_packages', 'flow_state'},
set(response_model.model_fields),
)
for invalid_kwargs in (
{'usageId': 0, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL},
{'usageId': 61, 'remainingPackages': -1, 'flowState': PalletFlowState.AT_SITE_PARTIAL},
{'usageId': 61, 'remainingPackages': True, 'flowState': PalletFlowState.AT_SITE_PARTIAL},
{'usageId': 61, 'remainingPackages': 1.5, 'flowState': PalletFlowState.AT_SITE_PARTIAL},
):
with self.subTest(invalid_kwargs=invalid_kwargs), self.assertRaises(ValidationError):
response_model(**invalid_kwargs)
@unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现')
class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
async def test_list_pushes_current_site_scope_and_converts_rows(self) -> None:
query = UsagePageQueryModel(pageNum=1, pageSize=10)
page = PageModel(
rows=[make_usage()],
pageNum=1,
pageSize=10,
total=1,
hasNext=False,
)
get_list = AsyncMock(return_value=page)
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])),
patch.object(OperationDao, 'get_usage_list', new=get_list),
):
result = await UsageService.get_usage_list_services(self.db, query, self.user)
get_list.assert_awaited_once_with(self.db, query, True, site_ids=[9, 11])
self.assertEqual(1, result.total)
self.assertIsInstance(result.rows[0], UsageModel)
async def test_empty_scope_returns_empty_without_global_query_or_total(self) -> None:
for is_page in (True, False):
with self.subTest(is_page=is_page):
get_list = AsyncMock()
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])),
patch.object(OperationDao, 'get_usage_list', new=get_list),
):
result = await UsageService.get_usage_list_services(
self.db,
UsagePageQueryModel(pageNum=2, pageSize=7),
self.user,
is_page=is_page,
)
get_list.assert_not_awaited()
if is_page:
self.assertEqual([], result.rows)
self.assertEqual(0, result.total)
self.assertEqual(2, result.page_num)
self.assertEqual(7, result.page_size)
else:
self.assertEqual([], result)
async def test_list_rejects_explicit_site_outside_scope_before_dao(self) -> None:
get_list = AsyncMock()
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_usage_list', new=get_list),
self.assertRaises(PermissionException) as raised,
):
await UsageService.get_usage_list_services(
self.db,
UsagePageQueryModel(siteId=10),
self.user,
)
self.assertEqual('无权访问该客户现场', raised.exception.message)
get_list.assert_not_awaited()
async def test_admin_list_is_unscoped(self) -> None:
query = UsagePageQueryModel()
get_scope = AsyncMock()
get_list = AsyncMock(return_value=[])
with (
patch.object(AccessDao, 'list_user_site_ids', new=get_scope),
patch.object(OperationDao, 'get_usage_list', new=get_list),
):
result = await UsageService.get_usage_list_services(
self.db,
query,
make_user(1),
is_page=False,
)
get_scope.assert_not_awaited()
get_list.assert_awaited_once_with(self.db, query, False, site_ids=None)
self.assertEqual([], result)
async def test_detail_is_scoped_and_hides_missing_but_admin_gets_precise_error(self) -> None:
for user, expected_exception, expected_message, expected_scope in (
(self.user, PermissionException, '无权访问该使用记录', [9]),
(make_user(1), ServiceException, '使用记录不存在', None),
):
get_detail = AsyncMock(return_value=None)
with (
self.subTest(admin=user.user.admin),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_usage_by_id', new=get_detail),
self.assertRaises(expected_exception) as raised,
):
await UsageService.usage_detail_services(self.db, 61, user)
get_detail.assert_awaited_once_with(self.db, 61, site_ids=expected_scope)
self.assertEqual(expected_message, raised.exception.message)
async def test_line_options_require_site_access_and_only_return_enabled_lines_for_site(self) -> None:
rows = [
{'lineId': 6, 'siteId': 9, 'lineCode': 'L-6', 'lineName': '六线', 'status': '0'},
{'lineId': 5, 'siteId': 10, 'lineCode': 'L-5', 'lineName': '五线', 'status': '0'},
{'lineId': 4, 'siteId': 9, 'lineCode': 'L-4', 'lineName': '四线', 'status': '1'},
{'lineId': 3, 'siteId': 9, 'lineCode': 'L-3', 'lineName': '三线', 'status': '0'},
]
access = AsyncMock()
list_line = AsyncMock(return_value=rows)
with (
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
patch.object(MasterDao, 'list_line', new=list_line),
):
result = await UsageService.get_line_options_services(self.db, 9, self.user)
access.assert_awaited_once_with(self.db, self.user, 9)
query = list_line.await_args.args[1]
self.assertIsInstance(query, LinePageQueryModel)
self.assertEqual(9, query.site_id)
self.assertEqual('0', query.status)
self.assertFalse(list_line.await_args.args[2])
self.assertEqual([3, 6], [option.id for option in result])
self.assertTrue(all(isinstance(option, BusinessOptionModel) for option in result))
async def test_line_options_denial_happens_before_master_query(self) -> None:
get_site = AsyncMock()
list_line = AsyncMock()
with (
patch.object(
AccessService,
'assert_site_access',
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')),
),
patch.object(MasterDao, 'get_site', new=get_site),
patch.object(MasterDao, 'list_line', new=list_line),
self.assertRaises(PermissionException),
):
await UsageService.get_line_options_services(self.db, 9, self.user)
get_site.assert_not_awaited()
list_line.assert_not_awaited()
async def test_scan_is_read_only_and_returns_only_usage_action(self) -> None:
pallet = make_pallet()
inventory = make_inventory(
current_line_id=4,
remaining_packages=5,
flow_state=PalletFlowState.AT_SITE_PARTIAL.value,
)
filling = make_filling()
access = AsyncMock()
inventory_lock = AsyncMock()
pallet_lock = AsyncMock()
filling_lock = AsyncMock()
get_filling = AsyncMock(return_value=filling)
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(OperationDao, 'get_filling_by_id', new=get_filling),
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock),
patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock),
):
result = await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user)
access.assert_awaited_once_with(self.db, self.user, 9)
get_filling.assert_awaited_once_with(self.db, 31)
inventory_lock.assert_not_awaited()
pallet_lock.assert_not_awaited()
filling_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertIsInstance(result, PalletScanResponseModel)
self.assertEqual(PalletFlowState.AT_SITE_PARTIAL, result.flow_state)
self.assertEqual(9, result.site_id)
self.assertEqual(4, result.current_line_id)
self.assertEqual(31, result.active_filling_id)
self.assertEqual('S-011', result.strain_code)
self.assertEqual('B-20260726', result.batch_no)
self.assertEqual(5, result.remaining_packages)
self.assertEqual(['usage'], result.allowed_actions)
async def test_scan_hides_missing_non_site_and_unauthorized_qr_for_normal_user(self) -> None:
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
self.assertRaises(PermissionException) as missing,
):
await UsageService.scan_usage_pallet_services(self.db, 'missing', self.user)
self.assertEqual('无权访问该托盘', missing.exception.message)
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory(site_id=None))),
self.assertRaises(PermissionException) as non_site,
):
await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user)
self.assertEqual('无权访问该托盘', non_site.exception.message)
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory(site_id=10))),
patch.object(
AccessService,
'assert_site_access',
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')),
),
self.assertRaises(PermissionException) as unauthorized,
):
await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user)
self.assertEqual('无权访问该托盘', unauthorized.exception.message)
async def test_scan_admin_keeps_precise_missing_error(self) -> None:
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as raised,
):
await UsageService.scan_usage_pallet_services(self.db, 'missing', make_user(1))
self.assertEqual('托盘不存在', raised.exception.message)
@unittest.skipUnless(usage_controller is not None, 'usage_controller 尚未实现')
class TestUsageControllerContract(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
for route in usage_controller.routes
if isinstance(route, APIRoute)
}
def test_openapi_has_exact_five_operations_aliases_and_response_models(self) -> None:
app = FastAPI()
app.include_router(usage_controller)
schema = app.openapi()
expected = {
('/business/usage/list', 'get'),
('/business/usage/line-options', 'get'),
('/business/usage/scan/{qr_token}', 'get'),
('/business/usage/{usage_id}', 'get'),
('/business/usage', 'post'),
}
self.assertEqual(expected, {(path, method) for path, methods in schema['paths'].items() for method in methods})
self.assertEqual(
{'siteId'},
{parameter['name'] for parameter in schema['paths']['/business/usage/line-options']['get']['parameters']},
)
self.assertEqual(
{'qr_token'},
{
parameter['name']
for parameter in schema['paths']['/business/usage/scan/{qr_token}']['get']['parameters']
},
)
routes = self._routes()
self.assertEqual(PageResponseModel[UsageModel], routes[('/business/usage/list', 'GET')].response_model)
self.assertEqual(
DataResponseModel[list[BusinessOptionModel]],
routes[('/business/usage/line-options', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[PalletScanResponseModel],
routes[('/business/usage/scan/{qr_token}', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[UsageModel],
routes[('/business/usage/{usage_id}', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[UsageSuccessResponseModel],
routes[('/business/usage', 'POST')].response_model,
)
post_response_schema = schema['paths']['/business/usage']['post']['responses']['200']['content'][
'application/json'
]['schema']
response_schema = schema['components']['schemas'][post_response_schema['$ref'].rsplit('/', 1)[-1]]
self.assertIn('data', response_schema['properties'])
self.assertIn('data', response_schema['required'])
data_schema = schema['components']['schemas'][response_schema['properties']['data']['$ref'].rsplit('/', 1)[-1]]
self.assertEqual(
{'usageId', 'remainingPackages', 'flowState'},
set(data_schema['properties']),
)
def test_static_routes_precede_dynamic_detail(self) -> None:
paths = [route.path for route in usage_controller.routes]
detail_index = paths.index('/business/usage/{usage_id}')
self.assertLess(paths.index('/business/usage/line-options'), detail_index)
self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index)
def test_routes_have_exact_three_permissions_current_user_and_only_post_log(self) -> None:
expected = {
('/business/usage/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query',
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
('/business/usage', 'POST'): 'business:usage:add',
}
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), key)
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
self.assertTrue(
any(
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
for dependency in route.dependant.dependencies
),
key,
)
self.assertEqual(key == ('/business/usage', 'POST'), hasattr(route.endpoint, '__wrapped__'))
actual[key] = permissions[0].perm
self.assertEqual(expected, actual)
self.assertEqual(
{'business:usage:list', 'business:usage:query', 'business:usage:add'},
set(actual.values()),
)
def test_controller_does_not_add_forbidden_permissions(self) -> None:
source = inspect.getsource(usage_controller_module)
for forbidden in (
'business:usage:scan',
'business:usage:export',
'business:usage:edit',
'business:usage:remove',
'business:usage:confirm',
):
self.assertNotIn(forbidden, source)
@unittest.skipUnless(usage_controller is not None, 'usage_controller 尚未实现')
class TestUsageControllerResponse(unittest.IsolatedAsyncioTestCase):
async def test_post_returns_service_result_in_data(self) -> None:
db = MagicMock(spec=AsyncSession)
user = make_user()
page = make_page()
service = AsyncMock(
return_value=CrudResponseModel(
is_success=True,
message='使用成功',
result=UsageSuccessResponseModel(
usageId=61,
remainingPackages=5,
flowState=PalletFlowState.AT_SITE_PARTIAL,
),
)
)
with patch.object(UsageService, 'record_usage_services', new=service):
response = await usage_controller_module.add_business_usage.__wrapped__(
request=MagicMock(),
add_usage=page,
query_db=db,
current_user=user,
)
service.assert_awaited_once_with(db, page, user)
payload = json.loads(response.body)
self.assertEqual('使用成功', payload['msg'])
self.assertIn('data', payload)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 5,
'flowState': PalletFlowState.AT_SITE_PARTIAL.value,
},
payload['data'],
)
@unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现')
class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
self.page = make_page()
self.pallet = make_pallet()
self.inventory = make_inventory()
self.filling = make_filling()
self.site = make_site()
self.line = make_line()
self.fixed_time = datetime(2026, 7, 26, 11, 30)
def _patch_record_path( # noqa: PLR0913
self,
*,
site_scope: list[int] | None | AsyncMock = None,
key_read: BizUsageRecord | None | AsyncMock = None,
scanned_pallet: Any | None = None,
pallet: BizPallet | None = None,
inventory: BizPalletInventory | None = None,
filling: BizFillingRecord | None = None,
site: BizCustomerSite | None = None,
line: BizProductionLine | None = None,
access: AsyncMock | None = None,
add_usage: AsyncMock | None = None,
update_filling: AsyncMock | None = None,
add_event: AsyncMock | None = None,
) -> tuple[ExitStack, dict[str, AsyncMock]]:
stack = ExitStack()
if isinstance(site_scope, AsyncMock):
scope_read = site_scope
else:
scope_read = AsyncMock(return_value=[9] if site_scope is None else site_scope)
usage_key_read = key_read if isinstance(key_read, AsyncMock) else AsyncMock(return_value=key_read)
selected_pallet = pallet or self.pallet
selected_scanned_pallet = selected_pallet if scanned_pallet is None else scanned_pallet
selected_inventory = inventory or self.inventory
selected_filling = filling or self.filling
selected_site = self.site if site is None else site
selected_line = self.line if line is None else line
site_access = access or AsyncMock()
async def assign_usage_id(_db: AsyncSession, usage: BizUsageRecord) -> BizUsageRecord:
usage.usage_id = 61
return usage
usage_write = add_usage or AsyncMock(side_effect=assign_usage_id)
filling_write = update_filling or AsyncMock()
event_write = add_event or AsyncMock()
scan_pallet = AsyncMock(return_value=selected_scanned_pallet)
inventory_lock = AsyncMock(return_value=selected_inventory)
pallet_lock = AsyncMock(return_value=[selected_pallet])
filling_lock = AsyncMock(return_value=selected_filling)
site_lock = AsyncMock(return_value=selected_site)
preflight_line_read = AsyncMock(return_value=selected_line)
line_lock = AsyncMock(return_value=selected_line)
async def read_line(
db: AsyncSession,
line_id: int,
*,
for_update: bool = False,
) -> BizProductionLine | None:
if for_update:
return await line_lock(db, line_id, for_update=True)
return await preflight_line_read(db, line_id)
stack.enter_context(patch.object(AccessDao, 'list_user_site_ids', new=scope_read))
stack.enter_context(patch.object(AccessService, 'assert_site_access', new=site_access))
stack.enter_context(patch.object(OperationDao, 'get_usage_by_idempotency_key', new=usage_key_read))
stack.enter_context(patch.object(InventoryDao, 'get_pallet_by_qr_token', new=scan_pallet))
stack.enter_context(patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock))
stack.enter_context(patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock))
stack.enter_context(patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock))
stack.enter_context(patch.object(MasterDao, 'get_site', new=site_lock))
stack.enter_context(patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=read_line)))
stack.enter_context(patch.object(OperationDao, 'add_usage_record', new=usage_write))
stack.enter_context(patch.object(OperationDao, 'update_filling_record', new=filling_write))
stack.enter_context(patch.object(InventoryDao, 'add_event', new=event_write))
mocked_datetime = stack.enter_context(patch('module_business.service.usage_service.datetime'))
mocked_datetime.now.return_value = self.fixed_time
return stack, {
'scope_read': scope_read,
'key_read': usage_key_read,
'scan_pallet': scan_pallet,
'access': site_access,
'inventory_lock': inventory_lock,
'pallet_lock': pallet_lock,
'filling_lock': filling_lock,
'site_lock': site_lock,
'preflight_line_read': preflight_line_read,
'line_lock': line_lock,
'add_usage': usage_write,
'update_filling': filling_write,
'add_event': event_write,
}
async def test_first_partial_usage_locks_authoritative_rows_then_writes_one_atomic_event( # noqa: PLR0915
self,
) -> None:
actions: list[str] = []
captured: dict[str, Any] = {}
scope_call_count = 0
key_call_count = 0
async def read_site_scope(*_args: Any) -> list[int]:
nonlocal scope_call_count
scope_call_count += 1
actions.append('preflight-scope' if scope_call_count == 1 else 'fresh-scope')
return [9]
async def scan_pallet(*_args: Any) -> BizPallet:
actions.append('qr-snapshot-read')
return self.pallet
async def rollback() -> None:
actions.append('rollback')
async def lock_inventory(*_args: Any) -> BizPalletInventory:
actions.append('inventory-lock')
return self.inventory
async def lock_pallets(*_args: Any) -> list[BizPallet]:
actions.append('pallet-lock')
return [self.pallet]
async def lock_filling(*_args: Any) -> BizFillingRecord:
actions.append('filling-lock')
return self.filling
async def lock_site(*_args: Any, **_kwargs: Any) -> BizCustomerSite:
actions.append('site-lock')
return self.site
async def lock_line(*_args: Any, **_kwargs: Any) -> BizProductionLine:
actions.append('line-lock')
return self.line
async def assert_site_access(
_db: AsyncSession,
_user: CurrentUserModel,
_site_id: int,
*,
for_update: bool = False,
) -> None:
if for_update:
actions.append('site-mapping-lock')
else:
actions.append('site-access')
async def get_usage_by_key(
*_args: Any,
**_kwargs: Any,
) -> None:
nonlocal key_call_count
key_call_count += 1
actions.append('usage-preflight-read' if key_call_count == 1 else 'usage-fresh-read')
async def add_usage(_db: AsyncSession, usage: BizUsageRecord) -> BizUsageRecord:
actions.append('usage-flush')
usage.usage_id = 61
captured['usage'] = usage
return usage
async def add_event(
_db: AsyncSession,
inventory: BizPalletInventory,
event: BizPalletEvent,
) -> BizPalletEvent:
actions.append('event-flush')
event.event_id = 91
event.event_seq = 6
inventory.last_event_id = 91
captured['event'] = event
return event
async def commit() -> None:
actions.append('commit')
captured['usage'].__dict__.pop('usage_id', None)
self.db.rollback.side_effect = rollback
self.db.commit.side_effect = commit
scope_read = AsyncMock(side_effect=read_site_scope)
key_read = AsyncMock(side_effect=get_usage_by_key)
access = AsyncMock(side_effect=assert_site_access)
update_filling = AsyncMock()
with (
patch.object(AccessDao, 'list_user_site_ids', new=scope_read),
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(
OperationDao,
'get_usage_by_idempotency_key',
new=key_read,
),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(side_effect=scan_pallet)),
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(side_effect=lock_filling)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)),
patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=lock_line)),
patch.object(OperationDao, 'add_usage_record', new=AsyncMock(side_effect=add_usage)),
patch.object(OperationDao, 'update_filling_record', new=update_filling),
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
patch('module_business.service.usage_service.datetime') as mocked_datetime,
):
mocked_datetime.now.return_value = self.fixed_time
result = await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual(
[
'preflight-scope',
'line-lock',
'usage-preflight-read',
'qr-snapshot-read',
'rollback',
'inventory-lock',
'site-access',
'fresh-scope',
'pallet-lock',
'filling-lock',
'usage-fresh-read',
'site-lock',
'site-mapping-lock',
'line-lock',
'usage-flush',
'event-flush',
'commit',
],
actions,
)
self.assertEqual(2, scope_read.await_count)
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual([False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list])
self.assertEqual([9, 9], [call.args[2] for call in access.await_args_list])
self.assertEqual(4, self.inventory.current_line_id)
self.assertEqual(5, self.inventory.remaining_packages)
self.assertEqual(PalletFlowState.AT_SITE_PARTIAL.value, self.inventory.flow_state)
self.assertEqual(31, self.inventory.active_filling_id)
self.assertEqual(4, self.inventory.version)
self.assertEqual(self.fixed_time, self.inventory.updated_at)
self.assertEqual('ACTIVE', self.filling.active_slot)
self.assertIsNone(self.filling.closed_at)
update_filling.assert_not_awaited()
usage = captured['usage']
self.assertTrue(usage.usage_no.startswith('US-'))
self.assertEqual(2, usage.pallet_id)
self.assertEqual(31, usage.active_filling_id)
self.assertEqual(9, usage.site_id)
self.assertEqual(4, usage.line_id)
self.assertEqual(3, usage.used_packages)
self.assertEqual(5, usage.remaining_packages_after)
self.assertEqual('usage-key', usage.idempotency_key)
self.assertEqual(UsageService._build_request_hash(self.page), usage.request_hash)
self.assertEqual(7, usage.operator_id)
self.assertEqual(self.fixed_time, usage.occurred_at)
event = captured['event']
self.assertEqual(PalletEventType.USAGE_RECORDED.value, event.event_type)
self.assertEqual('USAGE', event.source_type)
self.assertEqual(61, event.source_id)
self.assertEqual(PalletFlowState.AT_SITE_UNUSED.value, event.before_state)
self.assertEqual(PalletFlowState.AT_SITE_PARTIAL.value, event.after_state)
self.assertIsNone(event.before_warehouse_id)
self.assertIsNone(event.after_warehouse_id)
self.assertEqual(9, event.before_site_id)
self.assertEqual(9, event.after_site_id)
self.assertIsNone(event.before_line_id)
self.assertEqual(4, event.after_line_id)
self.assertEqual(8, event.before_remaining_packages)
self.assertEqual(5, event.after_remaining_packages)
self.assertEqual(7, event.operator_id)
self.assertEqual(self.fixed_time, event.occurred_at)
self.assertEqual('usage-key', event.idempotency_key)
self.assertEqual(usage.request_hash, event.request_hash)
self.db.commit.assert_awaited_once_with()
self.db.rollback.assert_awaited_once_with()
self.assertTrue(result.is_success)
self.assertIsInstance(result.result, UsageSuccessResponseModel)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 5,
'flowState': PalletFlowState.AT_SITE_PARTIAL,
},
result.result.model_dump(by_alias=True),
)
async def test_zero_remaining_closes_filling_but_keeps_line_until_return(self) -> None:
inventory = make_inventory(
current_line_id=4,
remaining_packages=3,
flow_state=PalletFlowState.AT_SITE_PARTIAL.value,
)
update_filling = AsyncMock()
add_event = AsyncMock()
stack, mocks = self._patch_record_path(
inventory=inventory,
update_filling=update_filling,
add_event=add_event,
)
with stack:
result = await UsageService.record_usage_services(
self.db,
make_page(used_packages=3),
self.user,
)
self.assertTrue(result.is_success)
self.assertIsInstance(result.result, UsageSuccessResponseModel)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 0,
'flowState': PalletFlowState.AT_SITE_EMPTY,
},
result.result.model_dump(by_alias=True),
)
self.assertEqual(PalletFlowState.AT_SITE_EMPTY.value, inventory.flow_state)
self.assertEqual(0, inventory.remaining_packages)
self.assertIsNone(inventory.active_filling_id)
self.assertEqual(4, inventory.current_line_id)
self.assertEqual(4, inventory.version)
self.assertIsNone(self.filling.active_slot)
self.assertEqual(self.fixed_time, self.filling.closed_at)
self.assertEqual('user-7', self.filling.update_by)
self.assertEqual(self.fixed_time, self.filling.update_time)
update_filling.assert_awaited_once_with(self.db, self.filling)
event = add_event.await_args.args[2]
self.assertEqual(PalletFlowState.AT_SITE_EMPTY.value, event.after_state)
self.assertEqual(0, event.after_remaining_packages)
self.assertEqual(4, event.after_line_id)
self.db.commit.assert_awaited_once_with()
self.db.rollback.assert_awaited_once_with()
mocks['add_usage'].assert_awaited_once()
async def test_same_line_can_continue_but_different_line_is_rejected(self) -> None:
same_inventory = make_inventory(
current_line_id=4,
flow_state=PalletFlowState.AT_SITE_PARTIAL.value,
)
stack, _ = self._patch_record_path(inventory=same_inventory)
with stack:
result = await UsageService.record_usage_services(
self.db,
make_page(used_packages=2),
self.user,
)
self.assertTrue(result.is_success)
self.assertEqual(4, same_inventory.current_line_id)
self.assertEqual(6, same_inventory.remaining_packages)
self.db.reset_mock()
other_inventory = make_inventory(
current_line_id=5,
flow_state=PalletFlowState.AT_SITE_PARTIAL.value,
)
stack, mocks = self._patch_record_path(inventory=other_inventory)
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertIn('已绑定其他产线', raised.exception.message)
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_line_must_exist_be_enabled_and_belong_to_locked_site(self) -> None:
for line, message in (
(None, '产线不存在'),
(make_line(status='1'), '产线已停用'),
(make_line(site_id=10), '产线不属于托盘当前客户现场'),
):
with self.subTest(message=message):
self.db.reset_mock()
stack, mocks = self._patch_record_path(line=line)
if line is None:
mocks['preflight_line_read'].return_value = None
mocks['line_lock'].return_value = None
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual(message, raised.exception.message)
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_only_site_usage_states_and_strict_current_quantity_are_allowed(self) -> None:
cases = (
(
make_inventory(flow_state=PalletFlowState.AT_SITE_EMPTY.value, remaining_packages=0),
self.page,
'托盘当前状态不允许使用',
),
(
make_inventory(remaining_packages=8),
UsageCreateModel.model_construct(
qr_token='qr-2',
line_id=4,
used_packages=True,
idempotency_key='usage-key',
),
'使用包数必须为正整数',
),
(make_inventory(remaining_packages=8), make_page(used_packages=9), '使用包数不能超过当前余量'),
)
for inventory, page, message in cases:
with self.subTest(message=message):
self.db.reset_mock()
stack, mocks = self._patch_record_path(inventory=inventory)
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, page, self.user)
self.assertEqual(message, raised.exception.message)
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_initial_actual_site_denial_hides_qr_before_secondary_locks(self) -> None:
access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))
stack, mocks = self._patch_record_path(access=access)
with stack, self.assertRaises(PermissionException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual('无权访问该托盘', raised.exception.message)
mocks['inventory_lock'].assert_awaited_once()
mocks['pallet_lock'].assert_not_awaited()
mocks['filling_lock'].assert_not_awaited()
mocks['site_lock'].assert_not_awaited()
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_current_mapping_denial_wins_over_disabled_site_without_leaking_status(self) -> None:
async def access_check(
_db: AsyncSession,
_user: CurrentUserModel,
_site_id: int,
*,
for_update: bool = False,
) -> None:
if for_update:
raise PermissionException(data='', message='无权访问该客户现场')
access = AsyncMock(side_effect=access_check)
stack, mocks = self._patch_record_path(site=make_site(status='1'), access=access)
with stack, self.assertRaises(PermissionException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual('无权访问该托盘', raised.exception.message)
mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True)
access.assert_awaited_with(self.db, self.user, 9, for_update=True)
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_admin_bypasses_mapping_but_gets_precise_site_errors(self) -> None:
for site, message in ((None, '客户现场不存在'), (make_site(status='1'), '客户现场已停用')):
with self.subTest(message=message):
self.db.reset_mock()
stack, mocks = self._patch_record_path(site=site)
if site is None:
mocks['site_lock'].return_value = None
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, make_user(1))
self.assertEqual(message, raised.exception.message)
mocks['add_usage'].assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_locked_qr_asset_and_active_filling_invariants_fail_before_usage_write(self) -> None:
cases = (
{'pallet': make_pallet(qr_token='other-qr')},
{'pallet': make_pallet(asset_status='1')},
{'inventory': make_inventory(active_filling_id=None)},
{'filling': make_filling(pallet_id=3)},
{'filling': make_filling(active_slot=None)},
)
for overrides in cases:
with self.subTest(overrides=tuple(overrides)):
self.db.reset_mock()
stack, mocks = self._patch_record_path(**overrides)
with stack, self.assertRaises(ServiceException):
await UsageService.record_usage_services(self.db, self.page, self.user)
mocks['add_usage'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_invalid_locked_inventory_version_fails_before_usage_write(self) -> None:
inventory = make_inventory(version=-1)
stack, mocks = self._patch_record_path(inventory=inventory)
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual('托盘库存版本无效', raised.exception.message)
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_record_hides_missing_and_non_site_qr_but_admin_gets_precise_error(self) -> None:
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
self.assertRaises(PermissionException) as normal_missing,
):
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual('无权访问该托盘', normal_missing.exception.message)
self.db.rollback.assert_awaited_once()
self.db.reset_mock()
with (
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as admin_missing,
):
await UsageService.record_usage_services(self.db, self.page, make_user(1))
self.assertEqual('托盘不存在', admin_missing.exception.message)
self.db.rollback.assert_awaited_once()
self.db.reset_mock()
stack, mocks = self._patch_record_path(inventory=make_inventory(site_id=None))
with stack, self.assertRaises(PermissionException) as non_site:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual('无权访问该托盘', non_site.exception.message)
mocks['pallet_lock'].assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
@unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现')
class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
self.page = make_page()
self.core = TestRecordUsageCore(methodName='runTest')
self.core.setUp()
self.core.db = self.db
self.core.user = self.user
self.core.page = self.page
def _exact_usage(self, *, request_hash: str | None = None) -> BizUsageRecord:
return make_usage(
request_hash=request_hash or UsageService._build_request_hash(self.page),
)
def _patch_concurrent_retry_path(
self,
*,
inventory: BizPalletInventory,
recovered: BizUsageRecord,
scope_sequence: tuple[list[int], list[int]] | None = None,
site_status: str = '0',
revoke_mapping: bool = False,
revoke_initial_access: bool = False,
) -> tuple[ExitStack, dict[str, AsyncMock], list[str]]:
actions: list[str] = []
locked_site = make_site(status=site_status)
scopes = scope_sequence or ([9], [9])
scope_call_count = 0
key_call_count = 0
rollback_count = 0
async def read_site_scope(*_args: Any) -> list[int]:
nonlocal scope_call_count
scope_call_count += 1
actions.append('preflight-scope' if scope_call_count == 1 else 'fresh-scope')
return scopes[scope_call_count - 1]
async def read_key(*_args: Any, **kwargs: Any) -> BizUsageRecord | None:
nonlocal key_call_count
key_call_count += 1
is_locked_phase = 'inventory-lock' in actions
actions.append('usage-fresh-read' if is_locked_phase else 'usage-preflight-read')
if not is_locked_phase:
return None
return recovered if kwargs.get('site_ids') == [inventory.site_id] else None
async def scan_pallet(*_args: Any) -> BizPallet:
actions.append('qr-snapshot-read')
return self.core.pallet
async def rollback() -> None:
nonlocal rollback_count
rollback_count += 1
actions.append('preflight-rollback' if rollback_count == 1 else 'result-rollback')
async def lock_inventory(*_args: Any) -> BizPalletInventory:
actions.append('inventory-lock')
return inventory
async def lock_pallet(*_args: Any) -> list[BizPallet]:
actions.append('pallet-lock')
return [self.core.pallet]
async def lock_filling(*_args: Any) -> BizFillingRecord:
actions.append('filling-lock')
return self.core.filling
async def lock_site(*_args: Any, **_kwargs: Any) -> BizCustomerSite:
actions.append('site-lock')
return locked_site
async def check_access(*_args: Any, **kwargs: Any) -> None:
if kwargs.get('for_update', False):
actions.append('site-mapping-lock')
if revoke_mapping:
raise PermissionException(data='', message='无权访问该客户现场')
else:
actions.append('site-access')
if revoke_initial_access:
raise PermissionException(data='', message='无权访问该客户现场')
stack, mocks = self.core._patch_record_path(
inventory=inventory,
site=locked_site,
site_scope=AsyncMock(side_effect=read_site_scope),
key_read=AsyncMock(side_effect=read_key),
access=AsyncMock(side_effect=check_access),
)
mocks['scan_pallet'].side_effect = scan_pallet
mocks['inventory_lock'].side_effect = lock_inventory
mocks['pallet_lock'].side_effect = lock_pallet
mocks['filling_lock'].side_effect = lock_filling
mocks['site_lock'].side_effect = lock_site
self.db.rollback.side_effect = rollback
return stack, mocks, actions
async def test_exact_retry_rechecks_current_mapping_and_returns_without_mutation(self) -> None:
existing = self._exact_usage()
inventory_lock = AsyncMock()
site_lock = AsyncMock(return_value=make_site(status='1'))
access = AsyncMock()
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(
OperationDao,
'get_usage_by_idempotency_key',
new=AsyncMock(return_value=existing),
) as get_key,
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
patch.object(OperationDao, 'add_usage_record', new=AsyncMock()) as add_usage,
):
result = await UsageService.record_usage_services(self.db, self.page, self.user)
get_key.assert_awaited_once_with(
self.db,
'usage-key',
site_ids=[9],
)
site_lock.assert_awaited_once_with(self.db, 9, for_update=True)
access.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
inventory_lock.assert_not_awaited()
add_usage.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
self.assertIsInstance(result.result, UsageSuccessResponseModel)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 5,
'flowState': PalletFlowState.AT_SITE_PARTIAL,
},
result.result.model_dump(by_alias=True),
)
async def test_preflight_key_lookup_uses_line_site_for_multi_scope_and_admin(self) -> None:
for user, scope in ((self.user, [10, 9, 10]), (make_user(1), None)):
with self.subTest(admin=bool(user.user and user.user.admin)):
self.db.reset_mock()
existing = self._exact_usage()
line_read = AsyncMock(return_value=make_line(site_id=9))
get_key = AsyncMock(return_value=existing)
scope_read = AsyncMock(return_value=scope)
with (
patch.object(AccessDao, 'list_user_site_ids', new=scope_read),
patch.object(MasterDao, 'get_line', new=line_read),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=get_key),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await UsageService.record_usage_services(self.db, self.page, user)
line_read.assert_awaited_once_with(self.db, 4)
get_key.assert_awaited_once_with(self.db, 'usage-key', site_ids=[9])
self.assertEqual(61, result.result.usage_id)
if user.user and user.user.admin:
scope_read.assert_not_awaited()
async def test_same_key_with_different_hash_is_stable_conflict(self) -> None:
existing = self._exact_usage(request_hash='different-hash')
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)),
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as inventory_lock,
self.assertRaises(ServiceException) as raised,
):
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertIn('幂等键', raised.exception.message)
inventory_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_exact_retry_never_reuses_success_after_current_mapping_revocation(self) -> None:
existing = self._exact_usage()
access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
patch.object(AccessService, 'assert_site_access', new=access),
self.assertRaises(PermissionException),
):
await UsageService.record_usage_services(self.db, self.page, self.user)
access.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_concurrent_exact_retry_wins_before_stale_inventory_rejection(self) -> None:
cases = (
(
'first-request-emptied-pallet',
make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
),
False,
0,
'1',
),
(
'first-request-left-less-than-original-usage',
make_inventory(
current_line_id=4,
remaining_packages=2,
flow_state=PalletFlowState.AT_SITE_PARTIAL.value,
version=4,
),
True,
2,
'0',
),
)
for name, inventory, expect_filling_lock, remaining_after, site_status in cases:
with self.subTest(case=name):
self.db.reset_mock()
recovered = self._exact_usage()
recovered.remaining_packages_after = remaining_after
stack, mocks, actions = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
site_status=site_status,
)
key_read = mocks['key_read']
inventory_before = (
inventory.active_filling_id,
inventory.current_line_id,
inventory.remaining_packages,
inventory.flow_state,
inventory.version,
)
with stack:
try:
result = await UsageService.record_usage_services(
self.db,
self.page,
self.user,
)
except ServiceException as exc:
self.fail(f'精确并发重试被旧库存状态提前拒绝:{exc.message}')
expected_actions = [
'preflight-scope',
'usage-preflight-read',
'qr-snapshot-read',
'preflight-rollback',
'inventory-lock',
'site-access',
'fresh-scope',
'pallet-lock',
]
if expect_filling_lock:
expected_actions.append('filling-lock')
expected_actions.extend(
[
'usage-fresh-read',
'site-lock',
'site-mapping-lock',
'result-rollback',
]
)
self.assertEqual(expected_actions, actions)
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual(2, mocks['scope_read'].await_count)
self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list))
if expect_filling_lock:
mocks['filling_lock'].assert_awaited_once()
else:
mocks['filling_lock'].assert_not_awaited()
mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True)
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
self.assertEqual(
inventory_before,
(
inventory.active_filling_id,
inventory.current_line_id,
inventory.remaining_packages,
inventory.flow_state,
inventory.version,
),
)
expected_state = (
PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL
)
self.assertIsInstance(result.result, UsageSuccessResponseModel)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': remaining_after,
'flowState': expected_state,
},
result.result.model_dump(by_alias=True),
)
async def test_concurrent_retry_uses_fresh_scope_granted_during_inventory_wait(self) -> None:
inventory = make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
)
recovered = self._exact_usage()
stack, mocks, actions = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
scope_sequence=([], [9]),
)
with stack:
try:
result = await UsageService.record_usage_services(self.db, self.page, self.user)
except ServiceException as exc:
self.fail(f'锁等待期间新增授权后未使用 fresh scope:{exc.message}')
self.assertEqual(
[
'preflight-scope',
'qr-snapshot-read',
'preflight-rollback',
'inventory-lock',
'site-access',
'fresh-scope',
'pallet-lock',
'usage-fresh-read',
'site-lock',
'site-mapping-lock',
'result-rollback',
],
actions,
)
self.assertEqual(
[{'site_ids': [9]}],
[call.kwargs for call in mocks['key_read'].await_args_list],
)
mocks['filling_lock'].assert_not_awaited()
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
self.assertIsInstance(result.result, UsageSuccessResponseModel)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 5,
'flowState': PalletFlowState.AT_SITE_PARTIAL,
},
result.result.model_dump(by_alias=True),
)
async def test_preflight_grant_revoked_before_fresh_scope_stays_hidden(self) -> None:
recovered = self._exact_usage()
stack, mocks, actions = self._patch_concurrent_retry_path(
inventory=make_inventory(),
recovered=recovered,
revoke_initial_access=True,
)
with stack:
try:
await UsageService.record_usage_services(self.db, self.page, self.user)
except PermissionException as exc:
self.assertEqual('无权访问该托盘', exc.message)
else:
self.fail('锁事务开始前已撤权时不得继续 fresh scope 或返回成功')
self.assertEqual(
[
'preflight-scope',
'usage-preflight-read',
'qr-snapshot-read',
'preflight-rollback',
'inventory-lock',
'site-access',
'result-rollback',
],
actions,
)
self.assertEqual(1, mocks['scope_read'].await_count)
self.assertEqual(1, mocks['key_read'].await_count)
mocks['pallet_lock'].assert_not_awaited()
mocks['filling_lock'].assert_not_awaited()
mocks['site_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_preflight_rollback_expires_scanned_pallet_and_fresh_lock_rechecks_qr(self) -> None:
class ExpiringPalletProbe:
def __init__(self, pallet_id: int) -> None:
self.expired = False
self.pallet_id_reads = 0
self._pallet_id = pallet_id
@property
def pallet_id(self) -> int:
if self.expired:
raise AssertionError('rollback 后禁止读取旧 scanned pallet ID')
self.pallet_id_reads += 1
return self._pallet_id
def expire(self) -> None:
self.expired = True
def __getattr__(self, name: str) -> NoReturn:
raise AssertionError(f'预检 scanned pallet 只允许读取纯整数 ID:{name}')
scanned = ExpiringPalletProbe(pallet_id=2)
fresh_locked_pallet = make_pallet(qr_token='fresh-qr-mismatch')
scope = AsyncMock(side_effect=[[9], [9]])
key_read = AsyncMock(return_value=None)
stack, mocks = self.core._patch_record_path(
scanned_pallet=scanned,
pallet=fresh_locked_pallet,
site_scope=scope,
key_read=key_read,
)
async def rollback() -> None:
scanned.expire()
self.db.rollback.side_effect = rollback
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual('托盘二维码与库存不一致', raised.exception.message)
self.assertTrue(scanned.expired)
self.assertEqual(1, scanned.pallet_id_reads)
mocks['inventory_lock'].assert_awaited_once_with(self.db, 2)
mocks['pallet_lock'].assert_awaited_once_with(self.db, [2])
self.assertEqual(2, scope.await_count)
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_concurrent_exact_retry_rechecks_current_mapping_before_success(self) -> None:
inventory = make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
)
recovered = self._exact_usage()
stack, mocks, _ = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
revoke_mapping=True,
)
key_read = mocks['key_read']
access = mocks['access']
with stack:
try:
await UsageService.record_usage_services(self.db, self.page, self.user)
except PermissionException as exc:
self.assertEqual('无权访问该使用记录', exc.message)
except ServiceException as exc:
self.fail(f'撤权并发重试未进入 current mapping 复核:{exc.message}')
else:
self.fail('current mapping 已撤权时不得返回幂等成功')
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual(2, mocks['scope_read'].await_count)
mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True)
self.assertEqual([False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list])
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_concurrent_fresh_key_read_rejects_hash_or_actual_site_conflict(self) -> None:
request_hash = UsageService._build_request_hash(self.page)
conflicts = (
('different-request-hash', make_usage(request_hash='different-hash')),
('different-actual-site', make_usage(site_id=10, request_hash=request_hash)),
)
for name, recovered in conflicts:
with self.subTest(case=name):
self.db.reset_mock()
inventory = make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
)
stack, mocks, _ = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
scope_sequence=([9, 10], [9, 10]),
)
key_read = mocks['key_read']
with stack:
try:
await UsageService.record_usage_services(self.db, self.page, self.user)
except ServiceException as exc:
self.assertEqual(
'使用失败,幂等键已被其他请求使用',
exc.message,
)
else:
self.fail('锁后命中的摘要或实际现场冲突不得返回成功')
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual(2, mocks['scope_read'].await_count)
self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list))
mocks['site_lock'].assert_not_awaited()
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None:
recovered = self._exact_usage()
scope = AsyncMock(side_effect=[[10, 9], [10, 9], [10, 9]])
key_read = AsyncMock(side_effect=[None, None, recovered])
add_usage = AsyncMock(side_effect=integrity_error())
stack, mocks = self.core._patch_record_path(
site_scope=scope,
key_read=key_read,
add_usage=add_usage,
)
with stack:
result = await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertIsInstance(result.result, UsageSuccessResponseModel)
self.assertEqual(
{
'usageId': 61,
'remainingPackages': 5,
'flowState': PalletFlowState.AT_SITE_PARTIAL,
},
result.result.model_dump(by_alias=True),
)
self.assertEqual(3, scope.await_count)
self.assertEqual(3, key_read.await_count)
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual([9], key_read.await_args_list[2].kwargs['site_ids'])
self.assertEqual(3, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
mocks['add_event'].assert_not_awaited()
async def test_integrity_recovery_rejects_different_hash_or_missing_result(self) -> None:
for recovered in (self._exact_usage(request_hash='different-hash'), None):
with self.subTest(recovered=recovered is not None):
self.db.reset_mock()
key_read = AsyncMock(side_effect=[None, None, recovered])
stack, _ = self.core._patch_record_path(
key_read=key_read,
add_usage=AsyncMock(side_effect=integrity_error()),
)
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertTrue('幂等' in raised.exception.message or '冲突' in raised.exception.message)
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual(3, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_integrity_recovery_rechecks_fresh_scope_and_rejects_revoked_site(self) -> None:
scope = AsyncMock(side_effect=[[9], [9], []])
key_read = AsyncMock(return_value=None)
stack, mocks = self.core._patch_record_path(
site_scope=scope,
key_read=key_read,
add_usage=AsyncMock(side_effect=integrity_error()),
)
with stack, self.assertRaises(PermissionException):
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual(3, scope.await_count)
self.assertEqual(2, key_read.await_count)
self.assertEqual(
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual(3, self.db.rollback.await_count)
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
async def test_unexpected_write_failure_rolls_back_lock_transaction_and_never_commits(self) -> None:
stack, _ = self.core._patch_record_path(
add_event=AsyncMock(side_effect=RuntimeError('event failed')),
)
with stack, self.assertRaises(RuntimeError):
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_request_hash_failure_also_rolls_back(self) -> None:
with (
patch.object(UsageService, '_build_request_hash', side_effect=RuntimeError('hash failed')),
self.assertRaises(RuntimeError),
):
await UsageService.record_usage_services(self.db, self.page, self.user)
self.db.rollback.assert_awaited_once()
self.db.commit.assert_not_awaited()
if __name__ == '__main__':
unittest.main()