更新登录页及优化相关功能
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
"""收货独立回归:真实内存查询/序列化,以及确认服务的确定性并发边界。
|
||||
|
||||
从 backend 目录运行(不连接业务数据库、不生成日志文件或 pyc):
|
||||
PYTHONDONTWRITEBYTECODE=1 LOG_FILE_ENABLED=false <python> -B -m unittest \
|
||||
tests.module_business.test_shipment_receipt_reads -v
|
||||
|
||||
SQLite 仅验证读取;确认测试的锁替身不代表 MySQL 的实际锁调度。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import create_engine, event, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.pre_auth import PreAuth
|
||||
from common.context import RequestContext
|
||||
from config.database import async_engine as business_engine
|
||||
from config.get_db import get_db
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
|
||||
from module_admin.service.log_service import LogQueueService
|
||||
from module_admin.service.login_service import LoginService
|
||||
from module_business.controller.shipment_receipt_controller import shipment_receipt_controller
|
||||
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.dependency.test_batch_dependency import TestBatchScopeResolver
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import (
|
||||
BizCustomerSite, BizPallet, BizUserSite, BizUserWarehouse, BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord, BizPalletInventory, BizShipmentItem, BizShipmentOrder,
|
||||
)
|
||||
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel, ShipmentReceiptQueryModel
|
||||
from module_business.service.shipment_receipt_service import ShipmentReceiptService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
|
||||
NOW = datetime(2026, 10, 8, 8)
|
||||
|
||||
|
||||
def forbid_business_connection(*_args, **_kwargs):
|
||||
raise AssertionError('收货测试禁止连接业务数据库')
|
||||
|
||||
|
||||
def setUpModule():
|
||||
event.listen(business_engine.sync_engine, 'do_connect', forbid_business_connection)
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
event.remove(business_engine.sync_engine, 'do_connect', forbid_business_connection)
|
||||
|
||||
|
||||
def user(user_id=7):
|
||||
return CurrentUserModel(
|
||||
permissions=['business:shipment:receive'], roles=[],
|
||||
user=UserInfoModel(userId=user_id, userName=f'user-{user_id}'),
|
||||
)
|
||||
|
||||
|
||||
def batch_scope(batch_id=None):
|
||||
if batch_id is None:
|
||||
return TestBatchScope.formal()
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status='ACTIVE', writable=True)
|
||||
|
||||
|
||||
def order_values(shipment_id=12, site_id=9, batch_id=None, state='PENDING', status='CONFIRMED'):
|
||||
return dict(
|
||||
shipment_id=shipment_id, shipment_no=f'SH-{shipment_id}', source_warehouse_id=7,
|
||||
target_site_id=site_id, test_batch_id=batch_id, status=status, receipt_state=state,
|
||||
confirmed_at=NOW, confirmed_by=7,
|
||||
)
|
||||
|
||||
|
||||
def item_values(shipment_id, pallet_id, batch_id=None):
|
||||
return dict(
|
||||
shipment_item_id=shipment_id * 100 + pallet_id, shipment_id=shipment_id,
|
||||
pallet_id=pallet_id, active_filling_id=100 + pallet_id, package_qty=100, test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
def pallet_values(pallet_id, batch_id=None):
|
||||
return dict(
|
||||
pallet_id=pallet_id, pallet_code=f'P-{pallet_id}', qr_token=f'QR-{pallet_id}',
|
||||
capacity_packages=100, asset_status='0', test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
def filling_values(pallet_id, batch_id=None):
|
||||
return dict(
|
||||
filling_id=100 + pallet_id, filling_no=f'F-{pallet_id}', pallet_id=pallet_id,
|
||||
warehouse_id=7, strain_id=1, strain_code_snapshot='S-1', strain_name_snapshot='原菌种',
|
||||
batch_no=f'B-OLD-{pallet_id}', production_date=date(2026, 10, 1), expiry_date=date(2026, 11, 1),
|
||||
package_spec='标准包', initial_packages=100, active_slot='ACTIVE',
|
||||
operator_id=7, operated_at=NOW, idempotency_key=f'fill-{pallet_id}', request_hash='f' * 64,
|
||||
test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
def inventory_values(pallet_id, site_id=9, batch_id=None):
|
||||
return dict(
|
||||
pallet_id=pallet_id, active_filling_id=100 + pallet_id, site_id=site_id,
|
||||
warehouse_id=None, tunnel_id=None, trace_no=None, current_line_id=None,
|
||||
flow_state='IN_TRANSIT', remaining_packages=100, version=3, test_batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
class MemorySession:
|
||||
"""保留真实 SQLAlchemy ORM Row,仅适配 DAO 所需的异步 execute 接口。"""
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
async def execute(self, statement):
|
||||
return self.session.execute(statement)
|
||||
|
||||
|
||||
class TestShipmentReceiptReads(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine('sqlite+pysqlite:///:memory:')
|
||||
self.addCleanup(self.engine.dispose)
|
||||
# 仅建读取涉及的表;外键/迁移契约由独立 SQL 测试负责。
|
||||
models = (BizCustomerSite, BizUserSite, BizUserWarehouse, BizWarehouse, BizPallet,
|
||||
BizFillingRecord, BizPalletInventory, BizShipmentOrder, BizShipmentItem)
|
||||
with self.engine.begin() as connection:
|
||||
for model in models:
|
||||
connection.execute(CreateTable(model.__table__, include_foreign_key_constraints=[]))
|
||||
for site_id, batch_id in ((9, None), (10, None), (19, 41), (29, 42)):
|
||||
connection.execute(BizCustomerSite.__table__.insert(), dict(
|
||||
site_id=site_id, customer_id=1, site_code=f'SITE-{site_id}',
|
||||
site_name=f'现场{site_id}', status='0', test_batch_id=batch_id,
|
||||
))
|
||||
connection.execute(BizWarehouse.__table__.insert(), dict(
|
||||
warehouse_id=7, warehouse_code='W-7', warehouse_name='原仓库', status='0',
|
||||
))
|
||||
for site_id in (9, 19, 29):
|
||||
connection.execute(BizUserSite.__table__.insert(), dict(user_id=7, site_id=site_id))
|
||||
connection.execute(BizUserWarehouse.__table__.insert(), dict(user_id=8, warehouse_id=7))
|
||||
for pallet_id, site_id, batch_id in ((2, 9, None), (5, 9, None), (3, 10, None), (52, 19, 41), (62, 29, 42)):
|
||||
connection.execute(BizPallet.__table__.insert(), pallet_values(pallet_id, batch_id))
|
||||
connection.execute(BizFillingRecord.__table__.insert(), filling_values(pallet_id, batch_id))
|
||||
connection.execute(BizPalletInventory.__table__.insert(), inventory_values(pallet_id, site_id, batch_id))
|
||||
orders = [order_values(), order_values(13, 10), order_values(14, status='DRAFT'),
|
||||
order_values(15, 19, 41), order_values(16, 29, 42),
|
||||
order_values(17, state='RECEIVED'), order_values(18, state='LEGACY')]
|
||||
for order in orders:
|
||||
connection.execute(BizShipmentOrder.__table__.insert(), order)
|
||||
for shipment_id, pallet_ids, batch_id in ((12, (2, 5), None), (13, (3,), None),
|
||||
(15, (52,), 41), (16, (62,), 42),
|
||||
(17, (2,), None), (18, (5,), None)):
|
||||
for pallet_id in pallet_ids:
|
||||
connection.execute(BizShipmentItem.__table__.insert(), item_values(shipment_id, pallet_id, batch_id))
|
||||
self.session = Session(self.engine, autoflush=False)
|
||||
self.addCleanup(self.session.close)
|
||||
self.db = MemorySession(self.session)
|
||||
self.user = user()
|
||||
self.scope = batch_scope()
|
||||
|
||||
async def listing(self, **query):
|
||||
return await ShipmentReceiptService.list_services(
|
||||
self.db, ShipmentReceiptQueryModel(**query), self.user, self.scope,
|
||||
)
|
||||
|
||||
async def detail(self, shipment_id=12):
|
||||
return await ShipmentReceiptService.detail_services(self.db, shipment_id, self.user, self.scope)
|
||||
|
||||
async def scan(self, identifier='QR-2', shipment_id=12):
|
||||
return await ShipmentReceiptService.scan_services(self.db, shipment_id, identifier, self.user, self.scope)
|
||||
|
||||
async def test_list_executes_real_query_and_pageutil_serialization(self):
|
||||
page = await self.listing(pageSize=1)
|
||||
payload = page.model_dump(by_alias=True, mode='json')
|
||||
self.assertEqual(payload['total'], 3)
|
||||
self.assertTrue(payload['hasNext'])
|
||||
self.assertEqual(payload['rows'][0]['shipmentId'], 12)
|
||||
self.assertEqual(payload['rows'][0]['receiptState'], 'PENDING')
|
||||
self.assertNotIn('testBatchId', json.dumps(payload))
|
||||
next_page = await self.listing(pageSize=1, pageNum=2)
|
||||
self.assertEqual(next_page.rows[0].shipment_id, 17)
|
||||
|
||||
async def test_list_filters_original_route_receipt_state_and_confirmed_date(self):
|
||||
page = await self.listing(shipmentNo='SH-12', sourceWarehouseId=7, targetSiteId=9,
|
||||
receiptState='PENDING', beginTime='2026-10-08', endTime='2026-10-08')
|
||||
self.assertEqual([row.shipment_id for row in page.rows], [12])
|
||||
self.assertEqual((await self.listing(beginTime='2026-10-09')).total, 0)
|
||||
|
||||
async def test_list_keeps_formal_and_each_test_batch_separate(self):
|
||||
for batch_id, expected in ((None, [12, 17, 18]), (41, [15]), (42, [16])):
|
||||
with self.subTest(batch_id=batch_id):
|
||||
self.scope = batch_scope(batch_id)
|
||||
self.assertEqual([row.shipment_id for row in (await self.listing()).rows], expected)
|
||||
|
||||
async def test_warehouse_only_user_cannot_list_detail_or_scan_receipts(self):
|
||||
self.user = user(8)
|
||||
self.assertEqual((await self.listing()).total, 0)
|
||||
for operation in (self.detail, self.scan):
|
||||
with self.subTest(operation=operation.__name__), self.assertRaises(PermissionException):
|
||||
await operation()
|
||||
|
||||
async def test_target_site_filter_cannot_expand_user_access(self):
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.listing(targetSiteId=10)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.detail(13)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.scan('QR-3', 13)
|
||||
|
||||
async def test_detail_and_scan_cannot_read_other_batch_order(self):
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.detail(15)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.scan('QR-52', 15)
|
||||
self.scope = batch_scope(41)
|
||||
self.assertEqual((await self.detail(15)).items[0].pallet_id, 52)
|
||||
self.assertEqual((await self.scan('QR-52', 15)).pallet_id, 52)
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.detail(16)
|
||||
|
||||
async def test_detail_reads_frozen_filling_after_pallet_refill(self):
|
||||
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(
|
||||
active_slot=None, closed_at=NOW,
|
||||
))
|
||||
replacement = filling_values(2)
|
||||
replacement.update(filling_id=202, filling_no='F-NEW-2', batch_no='B-NEW', idempotency_key='new-fill')
|
||||
self.session.execute(BizFillingRecord.__table__.insert(), replacement)
|
||||
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(
|
||||
active_filling_id=202, remaining_packages=20, flow_state='AT_SITE_PARTIAL',
|
||||
))
|
||||
item = (await self.detail(17)).items[0]
|
||||
self.assertEqual((item.active_filling_id, item.package_qty, item.batch_no), (102, 100, 'B-OLD-2'))
|
||||
self.assertEqual(item.production_date, date(2026, 10, 1))
|
||||
self.assertEqual(item.expiry_date, date(2026, 11, 1))
|
||||
|
||||
async def test_legacy_detail_preserves_unknown_dates(self):
|
||||
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 105).values(
|
||||
production_date=None, expiry_date=None,
|
||||
))
|
||||
detail = await self.detail(18)
|
||||
self.assertEqual(detail.receipt_state, 'LEGACY')
|
||||
self.assertIsNone(detail.items[0].production_date)
|
||||
self.assertIsNone(detail.items[0].expiry_date)
|
||||
|
||||
async def test_detail_rejects_original_filling_from_another_batch(self):
|
||||
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(test_batch_id=41))
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.detail()
|
||||
|
||||
async def test_detail_rejects_filling_belonging_to_another_pallet(self):
|
||||
self.session.execute(update(BizShipmentItem).where(BizShipmentItem.shipment_item_id == 1202).values(active_filling_id=105))
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.detail()
|
||||
|
||||
async def test_scan_accepts_printed_pallet_and_returns_canonical_identity(self):
|
||||
for identifier in ('QR-2', ' P-2 '):
|
||||
with self.subTest(identifier=identifier):
|
||||
item = await self.scan(identifier)
|
||||
self.assertEqual((item.pallet_id, item.qr_token, item.package_qty), (2, 'QR-2', 100))
|
||||
|
||||
async def test_scan_rejects_wrong_order_and_cross_batch_pallet(self):
|
||||
for identifier in ('QR-3', 'QR-52', 'MISSING'):
|
||||
with self.subTest(identifier=identifier), self.assertRaises(ServiceException):
|
||||
await self.scan(identifier)
|
||||
|
||||
async def test_scan_rejects_changed_quantity_before_receiving(self):
|
||||
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(remaining_packages=90))
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.scan()
|
||||
|
||||
async def test_scan_rejects_received_and_legacy_orders(self):
|
||||
for shipment_id in (17, 18):
|
||||
with self.subTest(shipment_id=shipment_id), self.assertRaises(ServiceException):
|
||||
await self.scan('QR-2', shipment_id)
|
||||
|
||||
async def test_controller_list_detail_scan_run_through_asgi(self):
|
||||
app = FastAPI()
|
||||
app.include_router(shipment_receipt_controller)
|
||||
|
||||
async def current_user():
|
||||
RequestContext.set_current_user(self.user)
|
||||
RequestContext.set_current_exclude_patterns([])
|
||||
return self.user
|
||||
|
||||
async def current_db():
|
||||
return self.db
|
||||
|
||||
async def current_scope():
|
||||
return self.scope
|
||||
|
||||
app.dependency_overrides[get_db] = current_db
|
||||
app.dependency_overrides[LoginService.get_current_user] = current_user
|
||||
for route in shipment_receipt_controller.routes:
|
||||
for dependency in route.dependant.dependencies:
|
||||
if isinstance(dependency.call, PreAuth):
|
||||
app.dependency_overrides[dependency.call] = current_user
|
||||
if isinstance(dependency.call, TestBatchScopeResolver):
|
||||
app.dependency_overrides[dependency.call] = current_scope
|
||||
# 只替换登录来源、批次解析、日志队列和 IP 归属;接口权限校验仍真实执行。
|
||||
with patch.object(Log, '_get_oper_location', new=AsyncMock(return_value='内存测试')), \
|
||||
patch.object(LogQueueService, 'enqueue_operation_log', new=AsyncMock()):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://receipt.test') as client:
|
||||
listing = await client.get('/business/shipment-receipt/list', params={'receiptState': 'PENDING'})
|
||||
self.assertEqual(listing.status_code, 200)
|
||||
self.assertEqual([r['shipmentId'] for r in listing.json()['rows']], [12])
|
||||
detail = await client.get('/business/shipment-receipt/12')
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(detail.json()['data']['items'][0]['batchNo'], 'B-OLD-2')
|
||||
scanned = await client.get('/business/shipment-receipt/12/scan/P-2')
|
||||
self.assertEqual(scanned.status_code, 200)
|
||||
self.assertEqual(scanned.json()['data']['qrToken'], 'QR-2')
|
||||
|
||||
|
||||
class TestShipmentReceiptConfirmBoundaries(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.user = user()
|
||||
self.scope = batch_scope()
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.order = BizShipmentOrder(**order_values())
|
||||
self.order.items = [BizShipmentItem(**item_values(12, pallet_id)) for pallet_id in (2, 5)]
|
||||
self.inventories = [BizPalletInventory(**inventory_values(pallet_id)) for pallet_id in (2, 5)]
|
||||
self.events = []
|
||||
self.batch_lock = self.patch(TestBatchAccessService, 'lock_active_batch', AsyncMock())
|
||||
self.site_ids = self.patch(AccessDao, 'list_user_site_ids', AsyncMock(return_value=[9]))
|
||||
self.site_access = self.patch(AccessDao, 'user_has_site', AsyncMock(return_value=True))
|
||||
self.order_lock = self.patch(OperationDao, 'get_shipment_for_update', AsyncMock(return_value=self.order))
|
||||
self.inventory_lock = self.patch(InventoryDao, 'get_inventories_for_update', AsyncMock(return_value=self.inventories))
|
||||
self.patch(InventoryDao, 'get_pallets_by_ids', AsyncMock(return_value=[BizPallet(**pallet_values(i)) for i in (2, 5)]))
|
||||
self.patch(MasterDao, 'get_site', AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)))
|
||||
self.patch(InventoryDao, 'add_event', AsyncMock(side_effect=self.capture_event))
|
||||
|
||||
def patch(self, owner, name, replacement):
|
||||
patcher = patch.object(owner, name, new=replacement)
|
||||
result = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return result
|
||||
|
||||
async def capture_event(self, _db, _inventory, receipt_event, _scope):
|
||||
self.events.append(receipt_event)
|
||||
await asyncio.sleep(0)
|
||||
return receipt_event
|
||||
|
||||
def command(self, ids=(5, 2), key='receipt-key'):
|
||||
return ShipmentReceiptCommandModel(scannedPalletIds=list(ids), idempotencyKey=key)
|
||||
|
||||
async def confirm(self, command=None, db=None):
|
||||
return await ShipmentReceiptService.confirm_services(
|
||||
db if db is not None else self.db, 12,
|
||||
command if command is not None else self.command(), self.user, self.scope,
|
||||
)
|
||||
|
||||
async def test_permission_revoked_after_order_lock_prevents_any_mutation(self):
|
||||
async def revoked(_db, _user_id, _site_id, *, for_update=False):
|
||||
self.order_lock.assert_awaited_once()
|
||||
self.assertTrue(for_update)
|
||||
return False
|
||||
self.site_access.side_effect = revoked
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.confirm()
|
||||
self.inventory_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.assertEqual(self.order.receipt_state, 'PENDING')
|
||||
|
||||
async def test_idempotent_replay_also_requires_current_target_permission(self):
|
||||
await self.confirm()
|
||||
self.site_access.return_value = False
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.confirm()
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual(self.db.commit.await_count, 1)
|
||||
|
||||
async def test_same_key_different_payload_is_rejected(self):
|
||||
await self.confirm()
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.confirm(self.command(ids=(2,)))
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual(self.db.commit.await_count, 1)
|
||||
|
||||
async def test_same_key_permuted_set_replays_without_restoring_used_inventory(self):
|
||||
await self.confirm()
|
||||
self.inventories[0].remaining_packages = 80
|
||||
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
|
||||
await self.confirm(self.command(ids=(2, 5)))
|
||||
self.assertEqual(self.inventories[0].remaining_packages, 80)
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual(self.db.commit.await_count, 1)
|
||||
|
||||
async def test_closed_test_batch_is_rejected_before_order_lock(self):
|
||||
self.scope = batch_scope(41)
|
||||
self.batch_lock.side_effect = PermissionException(data='', message='测试批次已关闭')
|
||||
with self.assertRaises(PermissionException):
|
||||
await self.confirm()
|
||||
self.order_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_cross_batch_order_is_rejected_even_if_dao_returns_it(self):
|
||||
self.order.test_batch_id = 41
|
||||
with self.assertRaises(ServiceException):
|
||||
await self.confirm()
|
||||
self.assertFalse(self.events)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def race(self, second_command):
|
||||
"""模拟 DAO 的父单排他锁,第二个请求拿锁后必须重读已提交状态。"""
|
||||
gate = asyncio.Lock()
|
||||
attempted = []
|
||||
|
||||
async def lock_order(db, _shipment_id, _scope, **access):
|
||||
self.assertEqual(access, {'warehouse_ids': None, 'site_ids': [9]})
|
||||
attempted.append(db)
|
||||
await gate.acquire()
|
||||
db.owns_lock = True
|
||||
return self.order
|
||||
|
||||
def connection():
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.owns_lock = False
|
||||
|
||||
async def release():
|
||||
if db.owns_lock:
|
||||
db.owns_lock = False
|
||||
gate.release()
|
||||
|
||||
db.commit = AsyncMock(side_effect=release)
|
||||
db.rollback = AsyncMock(side_effect=release)
|
||||
return db
|
||||
|
||||
first, second = connection(), connection()
|
||||
self.order_lock.side_effect = lock_order
|
||||
results = await asyncio.wait_for(asyncio.gather(
|
||||
self.confirm(db=first), self.confirm(second_command, db=second), return_exceptions=True,
|
||||
), timeout=2)
|
||||
self.assertEqual(len(attempted), 2)
|
||||
self.assertEqual(first.commit.await_count + second.commit.await_count, 1)
|
||||
self.assertEqual(len(self.events), 2)
|
||||
self.assertEqual([inventory.version for inventory in self.inventories], [4, 4])
|
||||
self.assertTrue(all(inventory.remaining_packages == 100 for inventory in self.inventories))
|
||||
return results
|
||||
|
||||
async def test_concurrent_identical_confirmations_append_events_once(self):
|
||||
results = await self.race(self.command(ids=(2, 5)))
|
||||
self.assertTrue(all(not isinstance(result, Exception) and result.is_success for result in results))
|
||||
|
||||
async def test_concurrent_same_key_different_payload_rejects_second_request(self):
|
||||
results = await self.race(self.command(ids=(2,)))
|
||||
self.assertTrue(results[0].is_success)
|
||||
self.assertIsInstance(results[1], ServiceException)
|
||||
|
||||
async def test_concurrent_different_keys_do_not_confirm_twice(self):
|
||||
results = await self.race(self.command(key='other-key'))
|
||||
self.assertTrue(results[0].is_success)
|
||||
self.assertIsInstance(results[1], ServiceException)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user