数据:增加测试批次模型与迁移契约

This commit is contained in:
焦龙言
2026-09-22 14:37:31 +08:00
parent 40c56c6b33
commit 17bfa2c5fe
12 changed files with 1278 additions and 32 deletions
@@ -1,11 +1,32 @@
import unittest
from sqlalchemy import CHAR, BigInteger, CheckConstraint, DateTime, Integer, String, Table, UniqueConstraint
from sqlalchemy import CHAR, BigInteger, CheckConstraint, DateTime, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.schema import CreateColumn
from sqlalchemy.schema import CreateColumn, CreateTable
from config.database import Base
from module_business.entity.do import master_do, operation_do
from module_admin.entity.do import user_do
from module_business.entity.do import master_do, operation_do, test_batch_do
EXPECTED_TEST_BATCH_TABLES = {'biz_test_batch', 'biz_test_batch_user'}
TEST_BATCH_OWNED_TABLES = {
'biz_strain',
'biz_customer',
'biz_customer_site',
'biz_production_line',
'biz_warehouse',
'biz_pallet',
'biz_inbound_order',
'biz_inbound_item',
'biz_filling_record',
'biz_shipment_order',
'biz_shipment_item',
'biz_usage_record',
'biz_return_order',
'biz_return_item',
'biz_pallet_inventory',
'biz_pallet_event',
}
EXPECTED_COLUMNS = {
'biz_strain': {
@@ -892,6 +913,136 @@ EXPECTED_UNIQUE_SETS = {
},
}
for table_name in TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_COLUMN_TYPES[table_name]['test_batch_id'] = (BigInteger, None)
EXPECTED_NULLABILITY[table_name]['test_batch_id'] = True
EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id'))
EXPECTED_COLUMNS.update(
{
'biz_test_batch': {
'batch_id',
'batch_code',
'batch_name',
'status',
'create_by',
'create_time',
'update_by',
'update_time',
'closed_by',
'closed_at',
'purged_by',
'purged_at',
'purge_summary',
'preview_action',
'preview_token_hash',
'preview_scope_hash',
'preview_expires_at',
'remark',
},
'biz_test_batch_user': {'batch_id', 'user_id', 'create_by', 'create_time'},
}
)
EXPECTED_COLUMN_TYPES.update(
{
'biz_test_batch': {
'batch_id': (BigInteger, None),
'batch_code': (String, 64),
'batch_name': (String, 100),
'status': (String, 16),
'create_by': (String, 64),
'create_time': (DateTime, None),
'update_by': (String, 64),
'update_time': (DateTime, None),
'closed_by': (BigInteger, None),
'closed_at': (DateTime, None),
'purged_by': (BigInteger, None),
'purged_at': (DateTime, None),
'purge_summary': (Text, None),
'preview_action': (String, 16),
'preview_token_hash': (CHAR, 64),
'preview_scope_hash': (CHAR, 64),
'preview_expires_at': (DateTime, None),
'remark': (String, 500),
},
'biz_test_batch_user': {
'batch_id': (BigInteger, None),
'user_id': (BigInteger, None),
'create_by': (String, 64),
'create_time': (DateTime, None),
},
}
)
EXPECTED_NULLABILITY.update(
{
'biz_test_batch': {
'batch_id': False,
'batch_code': False,
'batch_name': False,
'status': False,
'create_by': False,
'create_time': False,
'update_by': False,
'update_time': False,
'closed_by': True,
'closed_at': True,
'purged_by': True,
'purged_at': True,
'purge_summary': True,
'preview_action': True,
'preview_token_hash': True,
'preview_scope_hash': True,
'preview_expires_at': True,
'remark': True,
},
'biz_test_batch_user': {
'batch_id': False,
'user_id': False,
'create_by': False,
'create_time': False,
},
}
)
EXPECTED_CALLABLE_DATETIME_DEFAULTS.update(
{
('biz_test_batch', 'create_time'),
('biz_test_batch', 'update_time'),
('biz_test_batch_user', 'create_time'),
}
)
EXPECTED_EMPTY_AUDIT_SERVER_DEFAULTS.update(
{
('biz_test_batch', 'create_by'),
('biz_test_batch', 'update_by'),
('biz_test_batch_user', 'create_by'),
}
)
EXPECTED_PRIMARY_KEYS.update(
{
'biz_test_batch': ('batch_id',),
'biz_test_batch_user': ('batch_id', 'user_id'),
}
)
EXPECTED_FOREIGN_KEYS.update(
{
'biz_test_batch': {
('closed_by', 'sys_user.user_id'),
('purged_by', 'sys_user.user_id'),
},
'biz_test_batch_user': {
('batch_id', 'biz_test_batch.batch_id'),
('user_id', 'sys_user.user_id'),
},
}
)
EXPECTED_UNIQUE_SETS.update(
{
'biz_test_batch': {frozenset({'batch_code'})},
'biz_test_batch_user': set(),
}
)
def _unique_column_sets(table: Table) -> set[frozenset[str]]:
return {
@@ -910,6 +1061,16 @@ def _normalized_check_sql(table: Table) -> dict[str, str]:
class BusinessModelContractTest(unittest.TestCase):
def test_batch_related_tables_compile_for_mysql_and_postgresql(self) -> None:
self.assertIs(Base.metadata.tables['sys_user'], user_do.SysUser.__table__)
batch_tables = EXPECTED_TEST_BATCH_TABLES | TEST_BATCH_OWNED_TABLES
self.assertEqual(18, len(batch_tables))
for table_name in batch_tables:
for dialect_name, dialect in (('mysql', mysql.dialect()), ('postgresql', postgresql.dialect())):
with self.subTest(table_name=table_name, dialect=dialect_name):
compiled = str(CreateTable(Base.metadata.tables[table_name]).compile(dialect=dialect))
self.assertIn(table_name, compiled)
def test_exact_business_tables_are_registered(self) -> None:
expected = {
'biz_strain',
@@ -930,7 +1091,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_return_item',
'biz_pallet_inventory',
'biz_pallet_event',
}
} | EXPECTED_TEST_BATCH_TABLES
actual = {name for name in Base.metadata.tables if name.startswith('biz_')}
self.assertEqual(expected, actual)
@@ -954,6 +1115,8 @@ class BusinessModelContractTest(unittest.TestCase):
operation_do.BizReturnItem: 'biz_return_item',
operation_do.BizPalletInventory: 'biz_pallet_inventory',
operation_do.BizPalletEvent: 'biz_pallet_event',
test_batch_do.BizTestBatch: 'biz_test_batch',
test_batch_do.BizTestBatchUser: 'biz_test_batch_user',
}
self.assertEqual(set(EXPECTED_COLUMNS), set(models.values()))
for model, table_name in models.items():
@@ -1032,6 +1195,7 @@ class BusinessModelContractTest(unittest.TestCase):
_, separator, compiled_default = compiled_column.partition(' DEFAULT ')
if separator:
compiled_default = compiled_default.split(' COMMENT ', maxsplit=1)[0].strip()
compiled_default = compiled_default.removesuffix(' NOT NULL')
if compiled_default != "''":
violations.append((dialect_name, table_name, column_name, compiled_default))
self.assertEqual([], violations)
@@ -1058,6 +1222,26 @@ class BusinessModelContractTest(unittest.TestCase):
with self.subTest(table_name=table_name):
self.assertEqual(expected_unique_sets, _unique_column_sets(Base.metadata.tables[table_name]))
def test_all_owned_models_have_nullable_indexed_batch_fk(self) -> None:
for table_name in TEST_BATCH_OWNED_TABLES:
table = Base.metadata.tables[table_name]
column = table.c.test_batch_id
self.assertTrue(column.nullable, table_name)
self.assertTrue(column.index, table_name)
fk = next(iter(column.foreign_keys))
self.assertEqual('biz_test_batch.batch_id', fk.target_fullname)
self.assertIsNone(fk.ondelete)
def test_test_batch_model_defaults_and_comments_match_contract(self) -> None:
table = Base.metadata.tables['biz_test_batch']
self.assertEqual('测试数据批次', table.comment)
self.assertEqual('ACTIVE', str(table.c.status.server_default.arg))
for table_name in TEST_BATCH_OWNED_TABLES:
self.assertEqual(
'测试数据批次ID;NULL表示正式数据',
Base.metadata.tables[table_name].c.test_batch_id.comment,
)
def test_pallet_identifiers_are_unique(self) -> None:
table = Base.metadata.tables['biz_pallet']
unique_columns = {column.name for column in table.columns if column.unique}
@@ -1065,6 +1249,10 @@ class BusinessModelContractTest(unittest.TestCase):
def test_checks_lock_quantities_states_active_slot_and_location(self) -> None:
expected_checks = {
'biz_test_batch': {
'ck_biz_test_batch_status': "statusIN('ACTIVE','CLOSED','PURGED')",
'ck_biz_test_batch_preview_action': "preview_actionISNULLORpreview_actionIN('ADOPT','PURGE')",
},
'biz_strain': {'ck_biz_strain_status': "statusIN('0','1')"},
'biz_warehouse': {'ck_biz_warehouse_status': "statusIN('0','1')"},
'biz_customer': {'ck_biz_customer_status': "statusIN('0','1')"},
@@ -9,11 +9,33 @@ INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql')
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql'
TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql'
TEST_BATCH_PG_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_pg.sql'
SYS_MENU_VALUE_COUNT = 20
SYS_MENU_PERMS_INDEX = 13
SYS_MENU_ICON_INDEX = 14
REMOVED_AI_DICT_CODES = set(range(33, 70))
EXPECTED_TEST_BATCH_TABLES = {'biz_test_batch', 'biz_test_batch_user'}
TEST_BATCH_OWNED_TABLES = {
'biz_strain',
'biz_customer',
'biz_customer_site',
'biz_production_line',
'biz_warehouse',
'biz_pallet',
'biz_inbound_order',
'biz_inbound_item',
'biz_filling_record',
'biz_shipment_order',
'biz_shipment_item',
'biz_usage_record',
'biz_return_order',
'biz_return_item',
'biz_pallet_inventory',
'biz_pallet_event',
}
EXPECTED_TABLES = {
'biz_strain',
'biz_warehouse',
@@ -346,6 +368,83 @@ EXPECTED_CHECKS = {
},
}
LEGACY_EXPECTED_TABLES = set(EXPECTED_TABLES)
LEGACY_EXPECTED_COLUMNS = {table: set(columns) for table, columns in EXPECTED_COLUMNS.items()}
LEGACY_EXPECTED_PRIMARY_KEYS = dict(EXPECTED_PRIMARY_KEYS)
LEGACY_EXPECTED_UNIQUE_KEYS = {table: set(keys) for table, keys in EXPECTED_UNIQUE_KEYS.items()}
LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()}
LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()}
EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES
for table_name in TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id'))
EXPECTED_COLUMNS.update(
{
'biz_test_batch': {
'batch_id',
'batch_code',
'batch_name',
'status',
'create_by',
'create_time',
'update_by',
'update_time',
'closed_by',
'closed_at',
'purged_by',
'purged_at',
'purge_summary',
'preview_action',
'preview_token_hash',
'preview_scope_hash',
'preview_expires_at',
'remark',
},
'biz_test_batch_user': {'batch_id', 'user_id', 'create_by', 'create_time'},
}
)
EXPECTED_DATETIME_COLUMNS.update(
{
'biz_test_batch': {'create_time', 'update_time', 'closed_at', 'purged_at', 'preview_expires_at'},
'biz_test_batch_user': {'create_time'},
}
)
EXPECTED_PRIMARY_KEYS.update(
{
'biz_test_batch': ('batch_id',),
'biz_test_batch_user': ('batch_id', 'user_id'),
}
)
EXPECTED_UNIQUE_KEYS.update(
{
'biz_test_batch': {('batch_code',)},
'biz_test_batch_user': set(),
}
)
EXPECTED_FOREIGN_KEYS.update(
{
'biz_test_batch': {
('closed_by', 'sys_user.user_id'),
('purged_by', 'sys_user.user_id'),
},
'biz_test_batch_user': {
('batch_id', 'biz_test_batch.batch_id'),
('user_id', 'sys_user.user_id'),
},
}
)
EXPECTED_CHECKS.update(
{
'biz_test_batch': {
"statusin('active','closed','purged')",
"preview_actionisnullorpreview_actionin('adopt','purge')",
},
'biz_test_batch_user': set(),
}
)
EXPECTED_PAGES = {
502: ('菌种资料', 121, 1, 'strain', 'master/strain/index', 'master:strain:list'),
503: ('客户资料', 121, 2, 'customer', 'master/customer/index', 'master:customer:list'),
@@ -873,6 +972,74 @@ def _is_postgres_naive_timestamp_with_default_precision(definition: str) -> bool
)
def _assert_test_batch_sql_contract(
test_case: unittest.TestCase,
sql: str,
*,
dialect: str,
is_patch: bool,
) -> None:
clean_sql = _strip_sql_comments(sql).replace('`', '')
normalized = re.sub(r'\s+', ' ', clean_sql).strip().lower()
tables = _create_table_bodies(sql)
test_case.assertNotRegex(normalized, r'\bon\s+delete\s+cascade\b')
if is_patch:
test_case.assertNotRegex(normalized, r'\bupdate\b')
for table_name in EXPECTED_TEST_BATCH_TABLES:
test_case.assertIn(table_name, tables)
columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table_name])
test_case.assertEqual(EXPECTED_COLUMNS[table_name], columns, table_name)
test_case.assertEqual(EXPECTED_PRIMARY_KEYS[table_name], primary_key, table_name)
test_case.assertEqual(EXPECTED_UNIQUE_KEYS[table_name], unique_keys, table_name)
test_case.assertEqual(EXPECTED_FOREIGN_KEYS[table_name], foreign_keys, table_name)
test_case.assertEqual(EXPECTED_CHECKS[table_name], checks, table_name)
create_batch = normalized.index('create table biz_test_batch (')
create_batch_user = normalized.index('create table biz_test_batch_user (')
owned_marker = 'alter table' if is_patch else 'create table'
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in TEST_BATCH_OWNED_TABLES)
test_case.assertLess(create_batch, create_batch_user)
test_case.assertLess(create_batch_user, first_owned)
for table_name in TEST_BATCH_OWNED_TABLES:
with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name):
if is_patch:
test_case.assertRegex(
normalized,
rf'\balter table {table_name}\s+add column test_batch_id bigint null\b',
)
else:
definitions = _column_definitions(tables[table_name])
test_case.assertIn('test_batch_id', definitions)
test_case.assertRegex(definitions['test_batch_id'], r'\bbigint\b')
test_case.assertNotRegex(definitions['test_batch_id'], r'\bnot\s+null\b')
index_name = f'idx_{table_name}_test_batch_id'
if dialect == 'mysql':
index_pattern = rf'\b(?:add\s+)?(?:index|key)\s+{index_name}\s*\(\s*test_batch_id\s*\)'
test_case.assertRegex(normalized, index_pattern)
test_case.assertIn("comment '测试数据批次id;null表示正式数据'", normalized)
else:
index_pattern = (
rf'\bcreate\s+index\s+{index_name}\s+on\s+{table_name}\s*\(\s*test_batch_id\s*\)'
)
test_case.assertRegex(normalized, index_pattern)
test_case.assertRegex(
normalized,
rf"comment\s+on\s+column\s+{table_name}\.test_batch_id\s+is\s+"
r"'测试数据批次id;null表示正式数据'",
)
constraint_name = f'fk_{table_name}_test_batch_id'
test_case.assertRegex(
normalized,
rf'\bconstraint\s+{constraint_name}\s+foreign\s+key\s*\(\s*test_batch_id\s*\)\s*'
r'references\s+biz_test_batch\s*\(\s*batch_id\s*\)',
)
def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]:
rows = _insert_rows(sql, 'sys_menu')
selected = [row for row in rows if row and str(row[0]).isdigit() and int(row[0]) in menu_ids]
@@ -1064,9 +1231,7 @@ def _assert_business_menu_icon_guard_primary_key(test_case: unittest.TestCase, s
def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql: str) -> tuple[str, str]:
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
guards = [
statement for statement in normalized if statement.startswith('insert into business_menu_icon_guard ')
]
guards = [statement for statement in normalized if statement.startswith('insert into business_menu_icon_guard ')]
test_case.assertEqual(2, len(guards))
pre_guard, post_guard = guards
identity_fields = (
@@ -1128,6 +1293,23 @@ def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql
class SqlContractTest(unittest.TestCase):
def test_test_batch_initializers_and_patches_match_contract(self) -> None:
scripts = (
(SQL_ROOT / 'shierfeng-fastapi.sql', 'mysql', False),
(SQL_ROOT / 'shierfeng-fastapi-pg.sql', 'postgresql', False),
(TEST_BATCH_MYSQL_PATCH_PATH, 'mysql', True),
(TEST_BATCH_PG_PATCH_PATH, 'postgresql', True),
)
for path, dialect, is_patch in scripts:
with self.subTest(path=path.name):
self.assertTrue(path.is_file(), f'missing SQL: {path}')
_assert_test_batch_sql_contract(
self,
path.read_text(encoding='utf-8'),
dialect=dialect,
is_patch=is_patch,
)
def test_initializers_define_exact_business_table_set(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
@@ -1149,7 +1331,7 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(EXPECTED_CHECKS[table], checks)
def test_postgres_business_datetimes_preserve_default_microsecond_precision(self) -> None:
self.assertEqual(34, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
self.assertEqual(40, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi-pg.sql').read_text(encoding='utf-8'))
invalid_columns = []
for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items():
@@ -1290,9 +1472,7 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(1, normalized.count('start transaction'))
self.assertEqual(1, normalized.count('commit'))
transaction_body = normalized[start_index + 1 : commit_index]
lock_statement = (
'select menu_id from sys_menu where menu_id in (121, 122, 123) order by menu_id for update'
)
lock_statement = 'select menu_id from sys_menu where menu_id in (121, 122, 123) order by menu_id for update'
expected_update = (
"update sys_menu set icon = case menu_id when 121 then 'documentation' when 122 then 'build' "
"when 123 then 'chart' else icon end where menu_id in (121, 122, 123)"
@@ -1476,6 +1656,8 @@ class SqlContractTest(unittest.TestCase):
'biz_customer',
'biz_warehouse',
'biz_strain',
'biz_test_batch_user',
'biz_test_batch',
)
for filename in INITIALIZERS:
with self.subTest(filename=filename):
@@ -1777,15 +1959,15 @@ class SqlContractTest(unittest.TestCase):
self.assertLess(lower.index('ddl'), lower.index('dml'))
self.assertEqual(18, len(re.findall(r'create\s+table\s+if\s+not\s+exists\s+`?biz_', lower)))
tables = _create_table_bodies(raw)
self.assertEqual(EXPECTED_TABLES, {table for table in tables if table.startswith('biz_')})
for table in EXPECTED_TABLES:
self.assertEqual(LEGACY_EXPECTED_TABLES, {table for table in tables if table.startswith('biz_')})
for table in LEGACY_EXPECTED_TABLES:
with self.subTest(table=table):
columns, primary_key, unique_keys, foreign_keys, checks = _table_contract(tables[table])
self.assertEqual(EXPECTED_COLUMNS[table], columns)
self.assertEqual(EXPECTED_PRIMARY_KEYS[table], primary_key)
self.assertEqual(EXPECTED_UNIQUE_KEYS[table], unique_keys)
self.assertEqual(EXPECTED_FOREIGN_KEYS[table], foreign_keys)
self.assertEqual(EXPECTED_CHECKS[table], checks)
self.assertEqual(LEGACY_EXPECTED_COLUMNS[table], columns)
self.assertEqual(LEGACY_EXPECTED_PRIMARY_KEYS[table], primary_key)
self.assertEqual(LEGACY_EXPECTED_UNIQUE_KEYS[table], unique_keys)
self.assertEqual(LEGACY_EXPECTED_FOREIGN_KEYS[table], foreign_keys)
self.assertEqual(LEGACY_EXPECTED_CHECKS[table], checks)
_assert_business_menu_fields(self, raw, LEGACY_PATCH_BUSINESS_MENU_FIELDS)
_assert_business_permission_inventory(self, raw)
patch_rows = _business_menu_rows(raw)
@@ -1793,9 +1975,7 @@ class SqlContractTest(unittest.TestCase):
compared_indexes = (0, 1, *range(4, 15))
expected_legacy_rows = {
menu_id: tuple(
'#'
if index == SYS_MENU_ICON_INDEX and menu_id in EXPECTED_BUSINESS_ROOT_ICONS
else row[index]
'#' if index == SYS_MENU_ICON_INDEX and menu_id in EXPECTED_BUSINESS_ROOT_ICONS else row[index]
for index in compared_indexes
)
for menu_id, row in mysql_rows.items()
@@ -1,4 +1,5 @@
import unittest
from datetime import datetime
from pydantic import ValidationError
from pydantic.alias_generators import to_camel
@@ -69,6 +70,21 @@ from module_business.entity.vo.report_vo import (
ShipmentReportModel,
ShipmentReportQueryModel,
)
from module_business.entity.vo.test_batch_vo import (
AdoptionExecuteRequestModel,
AdoptionPreviewRequestModel,
AdoptionRootModel,
BatchImpactModel,
PurgeExecuteRequestModel,
PurgePreviewModel,
TestBatchCreateModel,
TestBatchDetailModel,
TestBatchEditModel,
TestBatchModel,
TestBatchOptionModel,
TestBatchPreviewModel,
TestBatchUserAssignModel,
)
MASTER_MODEL_FIELDS = {
StrainModel: {
@@ -549,6 +565,71 @@ REPORT_MODEL_FIELDS = {
},
}
TEST_BATCH_MODEL_FIELDS = {
TestBatchCreateModel: {'batch_name', 'remark'},
TestBatchEditModel: {'remark'},
TestBatchUserAssignModel: {'user_ids'},
TestBatchModel: {
'batch_id',
'batch_code',
'batch_name',
'status',
'remark',
'create_by',
'create_time',
'update_by',
'update_time',
'closed_by',
'closed_at',
'purged_by',
'purged_at',
'purge_summary',
},
TestBatchOptionModel: {'batch_id', 'batch_code', 'batch_name', 'status'},
BatchImpactModel: {'table_name', 'label', 'count', 'keys'},
TestBatchDetailModel: {
'batch_id',
'batch_code',
'batch_name',
'status',
'remark',
'create_by',
'create_time',
'update_by',
'update_time',
'closed_by',
'closed_at',
'purged_by',
'purged_at',
'purge_summary',
'user_ids',
'data_summary',
},
TestBatchPreviewModel: {
'batch_id',
'batch_name',
'preview_token',
'expires_at',
'total_count',
'impacts',
},
PurgePreviewModel: {
'batch_id',
'batch_name',
'preview_token',
'expires_at',
'total_count',
'impacts',
'online_purge_allowed',
'online_purge_limit',
'blocking_reason',
},
AdoptionRootModel: {'resource_type', 'resource_ids'},
AdoptionPreviewRequestModel: {'roots'},
AdoptionExecuteRequestModel: {'roots', 'batch_name', 'preview_token'},
PurgeExecuteRequestModel: {'batch_name', 'preview_token'},
}
WRITE_MODELS = (
StrainModel,
DeleteStrainModel,
@@ -761,7 +842,7 @@ PACKAGE_MODEL_PAYLOADS = {
class BusinessVoContractTest(unittest.TestCase):
def test_all_models_have_exact_fields_and_camel_case_aliases(self) -> None:
expected_models = MASTER_MODEL_FIELDS | OPERATION_MODEL_FIELDS | REPORT_MODEL_FIELDS
expected_models = MASTER_MODEL_FIELDS | OPERATION_MODEL_FIELDS | REPORT_MODEL_FIELDS | TEST_BATCH_MODEL_FIELDS
for model_class, expected_fields in expected_models.items():
with self.subTest(model=model_class.__name__):
@@ -777,6 +858,44 @@ class BusinessVoContractTest(unittest.TestCase):
with self.subTest(model=model_class.__name__):
self.assertEqual('forbid', model_class.model_config.get('extra'))
def test_all_test_batch_models_forbid_extra_fields(self) -> None:
for model_class in TEST_BATCH_MODEL_FIELDS:
with self.subTest(model=model_class.__name__):
self.assertEqual('forbid', model_class.model_config.get('extra'))
def test_test_batch_models_accept_snake_case_and_camel_case(self) -> None:
camel_case = TestBatchCreateModel(batchName='验收批次', remark='camel')
snake_case = TestBatchCreateModel(batch_name='验收批次', remark='snake')
self.assertEqual('验收批次', camel_case.batch_name)
self.assertEqual('验收批次', snake_case.batch_name)
def test_test_batch_edit_forbids_immutable_fields(self) -> None:
for field_name, value in (('batchName', '新名称'), ('status', 'CLOSED')):
with self.subTest(field=field_name), self.assertRaises(ValidationError):
TestBatchEditModel.model_validate({'remark': None, field_name: value})
def test_test_batch_positive_ids_and_non_empty_roots_are_validated(self) -> None:
with self.assertRaises(ValidationError):
TestBatchUserAssignModel(userIds=[0])
with self.assertRaises(ValidationError):
AdoptionRootModel(resourceType='strain', resourceIds=[])
with self.assertRaises(ValidationError):
AdoptionPreviewRequestModel(roots=[])
def test_test_batch_status_rejects_unknown_value(self) -> None:
with self.assertRaises(ValidationError) as context:
TestBatchModel(
batchId=1,
batchCode='TB-1',
batchName='验收批次',
status='UNKNOWN',
createBy='admin',
createTime=datetime(2026, 9, 22),
updateBy='admin',
updateTime=datetime(2026, 9, 22),
)
self.assertIn(('status',), {tuple(error['loc']) for error in context.exception.errors()})
def test_page_queries_have_positive_defaults(self) -> None:
for model_class in PAGE_QUERY_MODELS:
with self.subTest(model=model_class.__name__):