1961 lines
84 KiB
Python
1961 lines
84 KiB
Python
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.domain.test_batch_scope import TestBatchScope
|
||
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
|
||
from tests.module_business.test_batch_fixtures import make_test_scope
|
||
|
||
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',
|
||
test_batch_id: int | None = None,
|
||
) -> 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,
|
||
test_batch_id=test_batch_id,
|
||
)
|
||
|
||
|
||
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,
|
||
test_batch_id: int | None = None,
|
||
) -> 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),
|
||
test_batch_id=test_batch_id,
|
||
)
|
||
|
||
|
||
def make_filling(
|
||
filling_id: int = 31,
|
||
*,
|
||
pallet_id: int = 2,
|
||
active_slot: str | None = 'ACTIVE',
|
||
test_batch_id: int | None = None,
|
||
) -> 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),
|
||
test_batch_id=test_batch_id,
|
||
)
|
||
|
||
|
||
def make_site(
|
||
site_id: int = 9,
|
||
*,
|
||
status: str = '0',
|
||
test_batch_id: int | None = None,
|
||
) -> BizCustomerSite:
|
||
return BizCustomerSite(
|
||
site_id=site_id,
|
||
customer_id=3,
|
||
site_code=f'SITE-{site_id}',
|
||
site_name=f'现场 {site_id}',
|
||
status=status,
|
||
test_batch_id=test_batch_id,
|
||
)
|
||
|
||
|
||
def make_line(
|
||
line_id: int = 4,
|
||
*,
|
||
site_id: int = 9,
|
||
status: str = '0',
|
||
test_batch_id: int | None = None,
|
||
) -> BizProductionLine:
|
||
return BizProductionLine(
|
||
line_id=line_id,
|
||
site_id=site_id,
|
||
line_code=f'L-{line_id}',
|
||
line_name=f'产线 {line_id}',
|
||
status=status,
|
||
test_batch_id=test_batch_id,
|
||
)
|
||
|
||
|
||
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,
|
||
test_batch_id: int | None = None,
|
||
) -> 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),
|
||
test_batch_id=test_batch_id,
|
||
)
|
||
|
||
|
||
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_public_business_methods_require_explicit_test_scope(self) -> None:
|
||
for method_name in (
|
||
'get_usage_list_services',
|
||
'usage_detail_services',
|
||
'get_line_options_services',
|
||
'scan_usage_pallet_services',
|
||
'record_usage_services',
|
||
):
|
||
with self.subTest(method=method_name):
|
||
self.assertIn('test_scope', inspect.signature(getattr(UsageService, method_name)).parameters)
|
||
|
||
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', 'test_scope'), 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(TestBatchScope, hints['test_scope'])
|
||
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, TestBatchScope.formal())
|
||
|
||
get_list.assert_awaited_once_with(
|
||
self.db,
|
||
query,
|
||
site_ids=[9, 11],
|
||
scope=TestBatchScope.formal(),
|
||
is_page=True,
|
||
)
|
||
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,
|
||
TestBatchScope.formal(),
|
||
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,
|
||
TestBatchScope.formal(),
|
||
)
|
||
|
||
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),
|
||
TestBatchScope.formal(),
|
||
is_page=False,
|
||
)
|
||
|
||
get_scope.assert_not_awaited()
|
||
get_list.assert_awaited_once_with(
|
||
self.db,
|
||
query,
|
||
site_ids=None,
|
||
scope=TestBatchScope.formal(),
|
||
is_page=False,
|
||
)
|
||
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, TestBatchScope.formal())
|
||
|
||
get_detail.assert_awaited_once_with(
|
||
self.db,
|
||
61,
|
||
TestBatchScope.formal(),
|
||
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, TestBatchScope.formal())
|
||
|
||
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.assertEqual(TestBatchScope.formal(), list_line.await_args.args[2])
|
||
self.assertFalse(list_line.await_args.args[3])
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
|
||
access.assert_awaited_once_with(self.db, self.user, 9)
|
||
get_filling.assert_awaited_once_with(self.db, 31, TestBatchScope.formal())
|
||
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_test_scope_scan_rejects_cross_batch_pallet(self) -> None:
|
||
scope = make_test_scope(4)
|
||
with (
|
||
patch.object(
|
||
InventoryDao,
|
||
'get_pallet_by_qr_token',
|
||
new=AsyncMock(return_value=make_pallet(test_batch_id=5)),
|
||
),
|
||
patch.object(
|
||
MasterDao,
|
||
'get_pallet_inventory',
|
||
new=AsyncMock(return_value=make_inventory(test_batch_id=4)),
|
||
),
|
||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||
patch.object(
|
||
OperationDao,
|
||
'get_filling_by_id',
|
||
new=AsyncMock(return_value=make_filling(test_batch_id=4)),
|
||
),
|
||
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
|
||
):
|
||
await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, scope)
|
||
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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), TestBatchScope.formal())
|
||
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()
|
||
test_scope = make_test_scope(9)
|
||
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,
|
||
test_scope=test_scope,
|
||
)
|
||
|
||
service.assert_awaited_once_with(db, page, user, test_scope)
|
||
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,
|
||
scope: TestBatchScope,
|
||
*,
|
||
for_update: bool = False,
|
||
) -> BizProductionLine | None:
|
||
if for_update:
|
||
return await line_lock(db, line_id, scope, for_update=True)
|
||
return await preflight_line_read(db, line_id, scope)
|
||
|
||
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,
|
||
_scope: TestBatchScope,
|
||
) -> 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, TestBatchScope.formal())
|
||
|
||
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,
|
||
TestBatchScope.formal(),
|
||
)
|
||
|
||
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,
|
||
TestBatchScope.formal(),
|
||
)
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
|
||
self.assertEqual('无权访问该托盘', raised.exception.message)
|
||
mocks['site_lock'].assert_awaited_once_with(
|
||
self.db,
|
||
9,
|
||
TestBatchScope.formal(),
|
||
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), TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
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), TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
|
||
get_key.assert_awaited_once_with(
|
||
self.db,
|
||
'usage-key',
|
||
TestBatchScope.formal(),
|
||
site_ids=[9],
|
||
)
|
||
site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), 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, TestBatchScope.formal())
|
||
|
||
line_read.assert_awaited_once_with(self.db, 4, TestBatchScope.formal())
|
||
get_key.assert_awaited_once_with(
|
||
self.db,
|
||
'usage-key',
|
||
TestBatchScope.formal(),
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
|
||
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,
|
||
TestBatchScope.formal(),
|
||
)
|
||
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,
|
||
TestBatchScope.formal(),
|
||
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, TestBatchScope.formal()
|
||
)
|
||
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, TestBatchScope.formal())
|
||
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
|
||
self.test_batch_id = None
|
||
|
||
@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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
mocks['pallet_lock'].assert_awaited_once_with(self.db, [2], TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
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,
|
||
TestBatchScope.formal(),
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
|
||
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, TestBatchScope.formal())
|
||
|
||
self.db.rollback.assert_awaited_once()
|
||
self.db.commit.assert_not_awaited()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|