补充主数据删除能力契约
This commit is contained in:
@@ -108,6 +108,91 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0')))
|
||||
)
|
||||
|
||||
async def test_list_populates_true_and_false_removability_for_all_resources(self) -> None:
|
||||
for resource, id_field in RESOURCE_IDS.items():
|
||||
rows = [make_models(1)[resource], make_models(2)[resource]]
|
||||
page = PageModel(rows=rows, pageNum=1, pageSize=10, total=2, hasNext=False)
|
||||
removability_mock = AsyncMock(return_value={1: True, 2: False})
|
||||
with (
|
||||
self.subTest(resource=resource),
|
||||
patch.object(MasterDao, f'list_{resource}', new=AsyncMock(return_value=page)),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_master_removability',
|
||||
new=removability_mock,
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = await getattr(MasterService, f'list_{resource}')(self.db, MagicMock())
|
||||
|
||||
self.assertEqual(
|
||||
[True, False],
|
||||
[row.model_dump().get('can_remove') for row in result.rows],
|
||||
)
|
||||
removability_mock.assert_awaited_once_with(
|
||||
self.db,
|
||||
resource,
|
||||
[getattr(row, id_field) for row in rows],
|
||||
)
|
||||
|
||||
async def test_detail_populates_true_and_false_removability_for_all_resources(self) -> None:
|
||||
for resource in RESOURCE_IDS:
|
||||
for can_remove in (True, False):
|
||||
record = make_models(7)[resource]
|
||||
removability_mock = AsyncMock(return_value={7: can_remove})
|
||||
with (
|
||||
self.subTest(resource=resource, can_remove=can_remove),
|
||||
patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=record)),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_master_removability',
|
||||
new=removability_mock,
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = await getattr(MasterService, f'detail_{resource}')(self.db, 7)
|
||||
|
||||
self.assertIs(can_remove, result.model_dump().get('can_remove'))
|
||||
removability_mock.assert_awaited_once_with(self.db, resource, [7])
|
||||
|
||||
async def test_non_paginated_list_populates_removability_for_all_resources(self) -> None:
|
||||
for resource, id_field in RESOURCE_IDS.items():
|
||||
records = [make_models(1)[resource], make_models(2)[resource]]
|
||||
removability_mock = AsyncMock(return_value={1: False, 2: True})
|
||||
with (
|
||||
self.subTest(resource=resource),
|
||||
patch.object(MasterDao, f'list_{resource}', new=AsyncMock(return_value=records)) as list_mock,
|
||||
patch.object(MasterDao, 'get_master_removability', new=removability_mock),
|
||||
):
|
||||
query = MagicMock()
|
||||
result = await getattr(MasterService, f'list_{resource}')(self.db, query, is_page=False)
|
||||
|
||||
self.assertIsInstance(result, list)
|
||||
self.assertEqual(
|
||||
[False, True],
|
||||
[row.model_dump().get('can_remove') for row in result],
|
||||
)
|
||||
list_mock.assert_awaited_once_with(self.db, query, False)
|
||||
removability_mock.assert_awaited_once_with(
|
||||
self.db,
|
||||
resource,
|
||||
[getattr(record, id_field) for record in records],
|
||||
)
|
||||
|
||||
def test_removability_uses_camel_case_json_alias_for_all_resources(self) -> None:
|
||||
for resource, model in make_models(1).items():
|
||||
with self.subTest(resource=resource):
|
||||
response_model = model.model_copy(update={'can_remove': True})
|
||||
|
||||
payload = response_model.model_dump(by_alias=True)
|
||||
|
||||
self.assertIs(True, payload.get('canRemove'))
|
||||
self.assertNotIn('can_remove', payload)
|
||||
self.assertIs(
|
||||
True,
|
||||
type(model).model_json_schema(by_alias=True)['properties']['canRemove'].get('readOnly'),
|
||||
)
|
||||
|
||||
async def test_duplicate_strain_code_is_rejected(self) -> None:
|
||||
with (
|
||||
patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=object())),
|
||||
@@ -560,6 +645,33 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_not_awaited()
|
||||
|
||||
async def test_add_and_update_exclude_read_only_removability_from_orm_payloads(self) -> None:
|
||||
table_names = {
|
||||
'strain': 'biz_strain',
|
||||
'customer': 'biz_customer',
|
||||
'site': 'biz_customer_site',
|
||||
'line': 'biz_production_line',
|
||||
'warehouse': 'biz_warehouse',
|
||||
}
|
||||
for resource, base_model in make_models(7).items():
|
||||
payload = base_model.model_dump(by_alias=True)
|
||||
payload['canRemove'] = False
|
||||
try:
|
||||
model = type(base_model).model_validate(payload)
|
||||
except ValueError as exc:
|
||||
self.fail(f'{resource} 缺少只读 canRemove 契约:{exc}')
|
||||
|
||||
with self.subTest(resource=resource):
|
||||
self.db.reset_mock()
|
||||
record = await getattr(MasterDao, f'add_{resource}')(self.db, model)
|
||||
self.assertNotIn('can_remove', record.__dict__)
|
||||
|
||||
self.db.reset_mock()
|
||||
await getattr(MasterDao, f'update_{resource}')(self.db, model)
|
||||
update_sql = compile_sql(self.db.execute.await_args.args[0])
|
||||
self.assertIn(f'UPDATE {table_names[resource]} SET', update_sql)
|
||||
self.assertNotIn('can_remove', update_sql)
|
||||
|
||||
async def test_update_and_delete_statements_use_primary_key_and_preserve_creation_audit(self) -> None:
|
||||
table_names = {
|
||||
'strain': 'biz_strain',
|
||||
@@ -639,6 +751,51 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn(f'{table}.{column} = 9', sql)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_master_removability_uses_the_same_reference_constraints_as_delete(self) -> None:
|
||||
expected = {
|
||||
'strain': (('biz_filling_record', ('strain_id',)),),
|
||||
'customer': (('biz_customer_site', ('customer_id',)),),
|
||||
'site': (
|
||||
('biz_production_line', ('site_id',)),
|
||||
('biz_user_site', ('site_id',)),
|
||||
('biz_shipment_order', ('target_site_id',)),
|
||||
('biz_usage_record', ('site_id',)),
|
||||
('biz_return_order', ('source_site_id',)),
|
||||
('biz_pallet_inventory', ('site_id',)),
|
||||
('biz_pallet_event', ('before_site_id', 'after_site_id')),
|
||||
),
|
||||
'line': (
|
||||
('biz_usage_record', ('line_id',)),
|
||||
('biz_pallet_inventory', ('current_line_id',)),
|
||||
('biz_pallet_event', ('before_line_id', 'after_line_id')),
|
||||
),
|
||||
'warehouse': (
|
||||
('biz_user_warehouse', ('warehouse_id',)),
|
||||
('biz_inbound_order', ('warehouse_id',)),
|
||||
('biz_filling_record', ('warehouse_id',)),
|
||||
('biz_shipment_order', ('source_warehouse_id',)),
|
||||
('biz_return_order', ('target_warehouse_id',)),
|
||||
('biz_pallet_inventory', ('warehouse_id',)),
|
||||
('biz_pallet_event', ('before_warehouse_id', 'after_warehouse_id')),
|
||||
),
|
||||
}
|
||||
method = getattr(MasterDao, 'get_master_removability', None)
|
||||
self.assertIsNotNone(method)
|
||||
|
||||
for resource, references in expected.items():
|
||||
result = MagicMock()
|
||||
result.all.return_value = [(1, True), (2, False)]
|
||||
self.db.execute = AsyncMock(return_value=result)
|
||||
|
||||
removability = await method(self.db, resource, [1, 2, 3])
|
||||
|
||||
self.assertEqual({1: True, 2: False, 3: False}, removability)
|
||||
sql = compile_sql(self.db.execute.await_args.args[0])
|
||||
for table, columns in references:
|
||||
self.assertIn(f'FROM {table}', sql)
|
||||
for column in columns:
|
||||
self.assertIn(f'{table}.{column}', sql)
|
||||
|
||||
|
||||
class TestMasterControllers(unittest.TestCase):
|
||||
ROUTERS = {
|
||||
|
||||
Reference in New Issue
Block a user