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

922 lines
42 KiB
Python

import inspect
import unittest
from contextlib import ExitStack
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi.routing import APIRoute
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 ServiceException, ServiceWarning
from module_admin.service.login_service import LoginService
from module_business.controller import (
customer_controller as customer_controller_module,
)
from module_business.controller import line_controller as line_controller_module
from module_business.controller import site_controller as site_controller_module
from module_business.controller import strain_controller as strain_controller_module
from module_business.controller import warehouse_controller as warehouse_controller_module
from module_business.dao.master_dao import MasterDao
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
BizProductionLine,
BizStrain,
BizWarehouse,
)
from module_business.entity.vo.master_vo import (
CustomerModel,
CustomerPageQueryModel,
DeleteCustomerModel,
DeleteLineModel,
DeleteSiteModel,
DeleteStrainModel,
DeleteWarehouseModel,
LineModel,
LinePageQueryModel,
SiteModel,
SitePageQueryModel,
StrainModel,
StrainPageQueryModel,
WarehouseModel,
WarehousePageQueryModel,
)
from module_business.service.master_service import MasterService
from utils.page_util import PageUtil
RESOURCE_IDS = {
'strain': 'strain_id',
'customer': 'customer_id',
'site': 'site_id',
'line': 'line_id',
'warehouse': 'warehouse_id',
}
def make_models(resource_id: int | None = None) -> dict[str, object]:
return {
'strain': StrainModel(strainId=resource_id, strainCode='S001', strainName='测试菌种'),
'customer': CustomerModel(customerId=resource_id, customerCode='C001', customerName='测试客户'),
'site': SiteModel(
siteId=resource_id,
customerId=11,
siteCode='SITE001',
siteName='测试现场',
),
'line': LineModel(
lineId=resource_id,
siteId=22,
lineCode='L001',
lineName='测试产线',
),
'warehouse': WarehouseModel(
warehouseId=resource_id,
warehouseCode='W001',
warehouseName='测试仓库',
),
}
def compile_sql(statement: Any) -> str:
return ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
def integrity_error() -> IntegrityError:
return IntegrityError('statement', {}, RuntimeError('duplicate'))
class TestMasterService(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.strain = StrainModel(strainCode='S001', strainName='测试菌种')
self.line = LineModel(siteId=1, lineCode='L001', lineName='测试产线')
def _patch_enabled_parent(self, stack: ExitStack, resource: str) -> None:
if resource == 'site':
stack.enter_context(
patch.object(MasterDao, 'get_customer', new=AsyncMock(return_value=SimpleNamespace(status='0')))
)
elif resource == 'line':
stack.enter_context(
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())),
self.assertRaises(ServiceException),
):
await MasterService.add_strain(self.db, self.strain)
async def test_line_must_belong_to_enabled_site(self) -> None:
with (
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException),
):
await MasterService.add_line(self.db, self.line)
async def test_referenced_master_can_only_be_disabled(self) -> None:
with (
patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=1)),
self.assertRaises(ServiceWarning),
):
await MasterService.remove_strain(self.db, 1)
async def test_duplicate_code_is_rejected_for_all_five_resources(self) -> None:
for resource, model in make_models().items():
with self.subTest(resource=resource), ExitStack() as stack:
self._patch_enabled_parent(stack, resource)
stack.enter_context(
patch.object(
MasterDao,
f'find_{resource}_by_code',
new=AsyncMock(return_value=SimpleNamespace()),
)
)
with self.assertRaises(ServiceException):
await getattr(MasterService, f'add_{resource}')(self.db, model)
async def test_edit_unique_check_excludes_current_record_for_all_resources(self) -> None:
for resource, model in make_models(7).items():
id_field = RESOURCE_IDS[resource]
current = SimpleNamespace(**{id_field: 7})
with self.subTest(resource=resource), ExitStack() as stack:
self.db.reset_mock()
self._patch_enabled_parent(stack, resource)
stack.enter_context(patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=current)))
stack.enter_context(
patch.object(MasterDao, f'find_{resource}_by_code', new=AsyncMock(return_value=current))
)
update_mock = stack.enter_context(patch.object(MasterDao, f'update_{resource}', new=AsyncMock()))
result = await getattr(MasterService, f'edit_{resource}')(self.db, model)
self.assertIsInstance(result, CrudResponseModel)
self.assertTrue(result.is_success)
update_mock.assert_awaited_once()
self.db.commit.assert_awaited_once()
async def test_edit_duplicate_code_from_another_record_is_rejected_for_all_resources(self) -> None:
for resource, model in make_models(7).items():
id_field = RESOURCE_IDS[resource]
with self.subTest(resource=resource), ExitStack() as stack:
self._patch_enabled_parent(stack, resource)
stack.enter_context(
patch.object(
MasterDao,
f'get_{resource}',
new=AsyncMock(return_value=SimpleNamespace(**{id_field: 7})),
)
)
stack.enter_context(
patch.object(
MasterDao,
f'find_{resource}_by_code',
new=AsyncMock(return_value=SimpleNamespace(**{id_field: 8})),
)
)
with self.assertRaises(ServiceException):
await getattr(MasterService, f'edit_{resource}')(self.db, model)
async def test_site_add_requires_existing_enabled_customer(self) -> None:
site = make_models()['site']
for parent in (None, SimpleNamespace(status='1')):
with (
self.subTest(parent=parent),
patch.object(MasterDao, 'get_customer', new=AsyncMock(return_value=parent)),
self.assertRaises(ServiceException),
):
await MasterService.add_site(self.db, site)
with (
patch.object(
MasterDao,
'get_customer',
new=AsyncMock(return_value=SimpleNamespace(status='0')),
),
patch.object(MasterDao, 'find_site_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_site', new=AsyncMock()),
):
result = await MasterService.add_site(self.db, site)
self.assertTrue(result.is_success)
async def test_line_add_requires_existing_enabled_site(self) -> None:
line = make_models()['line']
for parent in (None, SimpleNamespace(status='1')):
with (
self.subTest(parent=parent),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=parent)),
self.assertRaises(ServiceException),
):
await MasterService.add_line(self.db, line)
with (
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(MasterDao, 'find_line_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_line', new=AsyncMock()),
):
result = await MasterService.add_line(self.db, line)
self.assertTrue(result.is_success)
async def test_site_and_line_edit_require_enabled_target_parent(self) -> None:
cases = (
('site', make_models(7)['site'], 'get_customer'),
('line', make_models(7)['line'], 'get_site'),
)
for resource, model, parent_method in cases:
for parent in (None, SimpleNamespace(status='1')):
with self.subTest(resource=resource, parent=parent), ExitStack() as stack:
stack.enter_context(
patch.object(
MasterDao,
f'get_{resource}',
new=AsyncMock(return_value=SimpleNamespace(**{RESOURCE_IDS[resource]: 7})),
)
)
stack.enter_context(patch.object(MasterDao, parent_method, new=AsyncMock(return_value=parent)))
with self.assertRaises(ServiceException):
await getattr(MasterService, f'edit_{resource}')(self.db, model)
with self.subTest(resource=resource, parent='enabled'), ExitStack() as stack:
stack.enter_context(
patch.object(
MasterDao,
f'get_{resource}',
new=AsyncMock(return_value=SimpleNamespace(**{RESOURCE_IDS[resource]: 7})),
)
)
stack.enter_context(
patch.object(
MasterDao,
parent_method,
new=AsyncMock(return_value=SimpleNamespace(status='0')),
)
)
stack.enter_context(
patch.object(MasterDao, f'find_{resource}_by_code', new=AsyncMock(return_value=None))
)
stack.enter_context(patch.object(MasterDao, f'update_{resource}', new=AsyncMock()))
result = await getattr(MasterService, f'edit_{resource}')(self.db, model)
self.assertTrue(result.is_success)
async def test_detail_missing_is_explicit_for_all_resources(self) -> None:
for resource in RESOURCE_IDS:
with (
self.subTest(resource=resource),
patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException),
):
await getattr(MasterService, f'detail_{resource}')(self.db, 99)
async def test_reference_delete_protection_covers_all_resources(self) -> None:
for resource in RESOURCE_IDS:
with (
self.subTest(resource=resource),
patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=1)),
):
self.db.reset_mock()
with self.assertRaises(ServiceWarning):
await getattr(MasterService, f'remove_{resource}')(self.db, 1)
self.db.rollback.assert_awaited_once()
self.db.commit.assert_not_awaited()
async def test_unreferenced_existing_records_are_deleted_for_all_resources(self) -> None:
for resource in RESOURCE_IDS:
with self.subTest(resource=resource), ExitStack() as stack:
self.db.reset_mock()
count_mock = stack.enter_context(
patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=0))
)
stack.enter_context(
patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=SimpleNamespace()))
)
delete_mock = stack.enter_context(patch.object(MasterDao, f'delete_{resource}', new=AsyncMock()))
result = await getattr(MasterService, f'remove_{resource}')(self.db, 1)
self.assertTrue(result.is_success)
count_mock.assert_awaited_once_with(self.db, resource, 1)
delete_mock.assert_awaited_once_with(self.db, 1)
self.db.commit.assert_awaited_once()
self.db.rollback.assert_not_awaited()
async def test_remove_missing_record_is_explicit_for_all_resources(self) -> None:
for resource in RESOURCE_IDS:
with self.subTest(resource=resource), ExitStack() as stack:
stack.enter_context(patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=0)))
stack.enter_context(patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=None)))
with self.assertRaises(ServiceException):
await getattr(MasterService, f'remove_{resource}')(self.db, 1)
async def test_batch_remove_failure_rolls_back_without_delete_or_commit(self) -> None:
delete_model = DeleteStrainModel(strainIds=[1, 2])
with (
patch.object(MasterDao, 'count_references', new=AsyncMock(side_effect=[0, 1])),
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=SimpleNamespace())),
patch.object(MasterDao, 'delete_strain', new=AsyncMock()) as delete_mock,
self.assertRaises(ServiceWarning),
):
await MasterService.remove_strain(self.db, delete_model)
delete_mock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_add_ignores_client_primary_key_before_calling_dao(self) -> None:
model = make_models(77)['strain']
with (
patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_mock,
):
await MasterService.add_strain(self.db, model)
persisted_model = add_mock.await_args.args[1]
self.assertIsNone(persisted_model.strain_id)
async def test_integrity_errors_from_add_update_delete_and_commit_are_stable(self) -> None:
for phase in ('add', 'update', 'delete', 'commit'):
db = MagicMock(spec=AsyncSession)
with self.subTest(phase=phase), ExitStack() as stack:
if phase in {'add', 'commit'}:
stack.enter_context(
patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None))
)
stack.enter_context(
patch.object(
MasterDao,
'add_strain',
new=AsyncMock(side_effect=integrity_error() if phase == 'add' else None),
)
)
if phase == 'commit':
db.commit.side_effect = integrity_error()
call = MasterService.add_strain(db, make_models()['strain'])
elif phase == 'update':
stack.enter_context(
patch.object(
MasterDao,
'get_strain',
new=AsyncMock(return_value=SimpleNamespace(strain_id=7)),
)
)
stack.enter_context(
patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None))
)
stack.enter_context(
patch.object(MasterDao, 'update_strain', new=AsyncMock(side_effect=integrity_error()))
)
call = MasterService.edit_strain(db, make_models(7)['strain'])
else:
stack.enter_context(patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=0)))
stack.enter_context(
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=SimpleNamespace()))
)
stack.enter_context(
patch.object(MasterDao, 'delete_strain', new=AsyncMock(side_effect=integrity_error()))
)
call = MasterService.remove_strain(db, 7)
with self.assertRaises(ServiceException) as raised:
await call
self.assertIsNotNone(raised.exception.message)
self.assertNotIn('duplicate', raised.exception.message)
db.rollback.assert_awaited_once()
if phase != 'commit':
db.commit.assert_not_awaited()
async def test_non_integrity_exception_rolls_back_and_is_reraised(self) -> None:
error = RuntimeError('unexpected')
with (
patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_strain', new=AsyncMock(side_effect=error)),
self.assertRaises(RuntimeError) as raised,
):
await MasterService.add_strain(self.db, make_models()['strain'])
self.assertIs(error, raised.exception)
self.db.rollback.assert_awaited_once()
self.db.commit.assert_not_awaited()
class TestMasterDao(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_list_queries_cover_vo_filters_and_stable_primary_key_order(self) -> None:
cases = (
(
'strain',
StrainPageQueryModel(strainCode='S001', strainName='菌种', status='0', pageNum=2, pageSize=5),
('biz_strain.strain_code LIKE', 'biz_strain.strain_name LIKE', 'biz_strain.status =', 'strain_id'),
),
(
'customer',
CustomerPageQueryModel(customerCode='C001', customerName='客户', status='0', pageNum=2, pageSize=5),
(
'biz_customer.customer_code LIKE',
'biz_customer.customer_name LIKE',
'biz_customer.status =',
'customer_id',
),
),
(
'site',
SitePageQueryModel(
customerId=11,
siteCode='SITE001',
siteName='现场',
status='0',
pageNum=2,
pageSize=5,
),
(
'biz_customer_site.customer_id = 11',
'biz_customer_site.site_code LIKE',
'biz_customer_site.site_name LIKE',
'biz_customer_site.status =',
'site_id',
),
),
(
'line',
LinePageQueryModel(siteId=22, lineCode='L001', lineName='产线', status='0', pageNum=2, pageSize=5),
(
'biz_production_line.site_id = 22',
'biz_production_line.line_code LIKE',
'biz_production_line.line_name LIKE',
'biz_production_line.status =',
'line_id',
),
),
(
'warehouse',
WarehousePageQueryModel(warehouseCode='W001', warehouseName='仓库', status='0', pageNum=2, pageSize=5),
(
'biz_warehouse.warehouse_code LIKE',
'biz_warehouse.warehouse_name LIKE',
'biz_warehouse.status =',
'warehouse_id',
),
),
)
page = PageModel(rows=[], pageNum=2, pageSize=5, total=0, hasNext=False)
for resource, query_model, expected_tokens in cases:
with (
self.subTest(resource=resource),
patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=page)) as paginate_mock,
):
result = await getattr(MasterDao, f'list_{resource}')(self.db, query_model, is_page=True)
statement = paginate_mock.await_args.args[1]
sql = compile_sql(statement)
self.assertIs(page, result)
for token in expected_tokens[:-1]:
self.assertIn(token, sql)
self.assertIn(f'ORDER BY {statement.get_final_froms()[0].name}.{expected_tokens[-1]}', sql)
self.assertEqual((2, 5, True), paginate_mock.await_args.args[2:])
async def test_get_queries_use_expected_table_and_primary_key(self) -> None:
cases = (
('strain', 'biz_strain', 'strain_id'),
('customer', 'biz_customer', 'customer_id'),
('site', 'biz_customer_site', 'site_id'),
('line', 'biz_production_line', 'line_id'),
('warehouse', 'biz_warehouse', 'warehouse_id'),
)
for resource, table, id_field in cases:
result = MagicMock()
result.scalar_one_or_none.return_value = None
self.db.execute = AsyncMock(return_value=result)
await getattr(MasterDao, f'get_{resource}')(self.db, 9)
sql = compile_sql(self.db.execute.await_args.args[0])
self.assertIn(f'FROM {table}', sql)
self.assertIn(f'{table}.{id_field} = 9', sql)
async def test_locking_master_gets_use_current_reads_and_refresh_identity_map(self) -> None:
for resource in ('warehouse', 'strain', 'line'):
with self.subTest(resource=resource):
lock_parameter = inspect.signature(getattr(MasterDao, f'get_{resource}')).parameters.get('for_update')
self.assertIsNotNone(lock_parameter)
self.assertIs(inspect.Parameter.KEYWORD_ONLY, lock_parameter.kind)
self.assertFalse(lock_parameter.default)
result = MagicMock()
result.scalar_one_or_none.return_value = None
self.db.execute = AsyncMock(return_value=result)
await getattr(MasterDao, f'get_{resource}')(self.db, 9, for_update=True)
statement = self.db.execute.await_args.args[0]
self.assertIn('FOR UPDATE', compile_sql(statement))
self.assertIs(True, statement.get_execution_options().get('populate_existing'))
async def test_unique_queries_use_global_or_parent_scoped_predicates(self) -> None:
cases = (
('strain', ('S001',), 'biz_strain', ('strain_code',)),
('customer', ('C001',), 'biz_customer', ('customer_code',)),
('warehouse', ('W001',), 'biz_warehouse', ('warehouse_code',)),
('site', ('SITE001', 11), 'biz_customer_site', ('site_code', 'customer_id')),
('line', ('L001', 22), 'biz_production_line', ('line_code', 'site_id')),
)
for resource, args, table, predicates in cases:
result = MagicMock()
result.scalar_one_or_none.return_value = None
self.db.execute = AsyncMock(return_value=result)
await getattr(MasterDao, f'find_{resource}_by_code')(self.db, *args)
sql = compile_sql(self.db.execute.await_args.args[0])
self.assertIn(f'FROM {table}', sql)
for predicate in predicates:
self.assertIn(f'{table}.{predicate} =', sql)
async def test_add_ignores_client_primary_keys_flushes_and_never_commits(self) -> None:
expected_classes = {
'strain': BizStrain,
'customer': BizCustomer,
'site': BizCustomerSite,
'line': BizProductionLine,
'warehouse': BizWarehouse,
}
for resource, model in make_models(77).items():
with self.subTest(resource=resource):
self.db.reset_mock()
record = await getattr(MasterDao, f'add_{resource}')(self.db, model)
self.assertIsInstance(record, expected_classes[resource])
self.assertIsNone(getattr(record, RESOURCE_IDS[resource]))
self.db.add.assert_called_once_with(record)
self.db.flush.assert_awaited_once()
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',
'customer': 'biz_customer',
'site': 'biz_customer_site',
'line': 'biz_production_line',
'warehouse': 'biz_warehouse',
}
created_at = datetime(2026, 1, 2, 3, 4, 5)
for resource, model in make_models(7).items():
model.create_by = '客户端伪造创建人'
model.create_time = created_at
model.update_by = 'server-user'
with self.subTest(resource=resource):
self.db.reset_mock()
await getattr(MasterDao, f'update_{resource}')(self.db, model)
update_sql = compile_sql(self.db.execute.await_args.args[0])
table = table_names[resource]
id_field = RESOURCE_IDS[resource]
self.assertIn(f'UPDATE {table} SET', update_sql)
self.assertIn(f'WHERE {table}.{id_field} = 7', update_sql)
self.assertNotIn('create_by', update_sql)
self.assertNotIn('create_time', update_sql)
self.db.commit.assert_not_awaited()
self.db.reset_mock()
await getattr(MasterDao, f'delete_{resource}')(self.db, 7)
delete_sql = compile_sql(self.db.execute.await_args.args[0])
self.assertIn(f'DELETE FROM {table}', delete_sql)
self.assertIn(f'{table}.{id_field} = 7', delete_sql)
self.db.commit.assert_not_awaited()
async def test_count_references_covers_every_direct_foreign_key(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')),
),
}
for resource, references in expected.items():
results = []
for _ in references:
result = MagicMock()
result.scalar.return_value = 0
results.append(result)
self.db.execute = AsyncMock(side_effect=results)
count = await MasterDao.count_references(self.db, resource, 9)
self.assertEqual(0, count)
self.assertEqual(len(references), self.db.execute.await_count)
for call, (table, columns) in zip(self.db.execute.await_args_list, references, strict=True):
sql = compile_sql(call.args[0])
self.assertIn(f'FROM {table}', sql)
for column in columns:
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 = {
'strain': strain_controller_module.strain_controller,
'customer': customer_controller_module.customer_controller,
'site': site_controller_module.site_controller,
'line': line_controller_module.line_controller,
'warehouse': warehouse_controller_module.warehouse_controller,
}
MODULES = {
'strain': strain_controller_module,
'customer': customer_controller_module,
'site': site_controller_module,
'line': line_controller_module,
'warehouse': warehouse_controller_module,
}
def test_openapi_contains_all_resource_paths_methods_and_response_models(self) -> None:
app = FastAPI()
for router in self.ROUTERS.values():
app.include_router(router)
schema = app.openapi()
for resource in self.ROUTERS:
prefix = f'/master/{resource}'
self.assertIn(f'{prefix}/list', schema['paths'])
self.assertIn('get', schema['paths'][f'{prefix}/list'])
self.assertIn(prefix, schema['paths'])
self.assertIn('post', schema['paths'][prefix])
self.assertIn('put', schema['paths'][prefix])
self.assertIn(f'{prefix}/{{{resource}_ids}}', schema['paths'])
self.assertIn('delete', schema['paths'][f'{prefix}/{{{resource}_ids}}'])
self.assertIn(f'{prefix}/{{{resource}_id}}', schema['paths'])
self.assertIn('get', schema['paths'][f'{prefix}/{{{resource}_id}}'])
def test_all_twenty_five_permission_dependencies_are_exact(self) -> None:
expected_permissions = {
(f'/master/{resource}/list', 'GET'): f'master:{resource}:list' for resource in self.ROUTERS
}
for resource in self.ROUTERS:
expected_permissions.update(
{
(f'/master/{resource}/{{{resource}_id}}', 'GET'): f'master:{resource}:query',
(f'/master/{resource}', 'POST'): f'master:{resource}:add',
(f'/master/{resource}', 'PUT'): f'master:{resource}:edit',
(f'/master/{resource}/{{{resource}_ids}}', 'DELETE'): f'master:{resource}:remove',
}
)
actual_permissions = {}
for router in self.ROUTERS.values():
for route in router.routes:
if not isinstance(route, APIRoute):
continue
method = next(iter(route.methods))
auth_dependencies = [
dependency.dependency
for dependency in route.dependencies
if isinstance(dependency.dependency, CheckUserInterfaceAuth)
]
self.assertEqual(1, len(auth_dependencies))
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
actual_permissions[(route.path, method)] = auth_dependencies[0].perm
self.assertEqual(expected_permissions, actual_permissions)
self.assertEqual(25, len(actual_permissions))
def test_write_routes_have_log_and_current_user_dependency(self) -> None:
for router in self.ROUTERS.values():
for route in router.routes:
if not isinstance(route, APIRoute) or route.methods.isdisjoint({'POST', 'PUT', 'DELETE'}):
continue
self.assertTrue(hasattr(route.endpoint, '__wrapped__'))
self.assertTrue(
any(
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
for dependency in route.dependant.dependencies
)
)
def test_controller_source_has_three_log_and_current_user_declarations(self) -> None:
for resource, module in self.MODULES.items():
with self.subTest(resource=resource):
source = inspect.getsource(module)
self.assertEqual(3, source.count('@Log('))
self.assertEqual(3, source.count('CurrentUserDependency()'))
self.assertNotIn('ApiCache', source)
def test_route_response_model_contracts(self) -> None:
model_by_resource = {
'strain': StrainModel,
'customer': CustomerModel,
'site': SiteModel,
'line': LineModel,
'warehouse': WarehouseModel,
}
for resource, router in self.ROUTERS.items():
for route in router.routes:
if not isinstance(route, APIRoute):
continue
if route.path.endswith('/list'):
self.assertEqual(PageResponseModel[model_by_resource[resource]], route.response_model)
elif route.methods == {'GET'}:
self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model)
else:
self.assertEqual('ResponseBaseModel', route.response_model.__name__)
class TestDeleteModelCoverage(unittest.TestCase):
def test_all_delete_models_are_used_by_service_contract(self) -> None:
models = (
DeleteStrainModel(strainIds=[1]),
DeleteCustomerModel(customerIds=[1]),
DeleteSiteModel(siteIds=[1]),
DeleteLineModel(lineIds=[1]),
DeleteWarehouseModel(warehouseIds=[1]),
)
self.assertEqual(5, len(models))
self.assertTrue(all(next(iter(model.model_dump().values())) == [1] for model in models))
if __name__ == '__main__':
unittest.main()