修复:限定幂等键精确业务域
This commit is contained in:
@@ -732,8 +732,19 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
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))
|
||||
@@ -742,7 +753,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
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=line_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))
|
||||
@@ -757,6 +768,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
'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,
|
||||
@@ -877,6 +889,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(
|
||||
[
|
||||
'preflight-scope',
|
||||
'line-lock',
|
||||
'usage-preflight-read',
|
||||
'qr-snapshot-read',
|
||||
'rollback',
|
||||
@@ -1044,6 +1057,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
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)
|
||||
@@ -1169,6 +1183,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
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,
|
||||
@@ -1179,6 +1194,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
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,
|
||||
@@ -1239,10 +1255,11 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
async def read_key(*_args: Any, **kwargs: Any) -> BizUsageRecord | None:
|
||||
nonlocal key_call_count
|
||||
key_call_count += 1
|
||||
actions.append('usage-preflight-read' if key_call_count == 1 else 'usage-fresh-read')
|
||||
if 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') == scopes[1] else 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')
|
||||
@@ -1301,6 +1318,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
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',
|
||||
@@ -1334,10 +1352,34 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
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,
|
||||
@@ -1353,6 +1395,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
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),
|
||||
@@ -1505,7 +1548,6 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(
|
||||
[
|
||||
'preflight-scope',
|
||||
'usage-preflight-read',
|
||||
'qr-snapshot-read',
|
||||
'preflight-rollback',
|
||||
'inventory-lock',
|
||||
@@ -1520,7 +1562,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
actions,
|
||||
)
|
||||
self.assertEqual(
|
||||
[{'site_ids': []}, {'site_ids': [9]}],
|
||||
[{'site_ids': [9]}],
|
||||
[call.kwargs for call in mocks['key_read'].await_args_list],
|
||||
)
|
||||
mocks['filling_lock'].assert_not_awaited()
|
||||
@@ -1702,11 +1744,11 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
self.fail('锁后命中的摘要或实际现场冲突不得返回成功')
|
||||
|
||||
self.assertEqual(
|
||||
[{'site_ids': [9, 10]}, {'site_ids': [9, 10]}],
|
||||
[{'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, 10] for call in key_read.await_args_list))
|
||||
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()
|
||||
@@ -1717,7 +1759,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None:
|
||||
recovered = self._exact_usage()
|
||||
scope = AsyncMock(side_effect=[[9], [9], [9]])
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user