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

245 lines
14 KiB
Python

"""标签只读回归:执行真实服务/DAO,以记录型会话替代数据库连接。"""
import base64
import importlib
import importlib.util
import json
import unittest
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi import FastAPI, Request
from sqlalchemy.dialects import mysql
from common.aspect.interface_auth import CheckUserInterfaceAuth
from exceptions.exception import PermissionException, ServiceException
from module_business.controller import shipment_controller as shipment_controller_module
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 BizCustomer, BizCustomerSite, BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizShipmentItem, BizShipmentOrder
from module_business.service.pallet_service import PalletService
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
LabelRecords = tuple[BizShipmentOrder, BizShipmentItem, BizPallet, BizFillingRecord, BizCustomerSite, BizCustomer]
def sql(statement: Any) -> str:
return ' '.join(str(statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})).split())
class ReadOnlySession:
"""仅支持 SELECT,不提供写入、提交或真实连接能力。"""
def __init__(self, *results: Any) -> None:
self.results = list(results)
self.statements = []
async def execute(self, statement: Any) -> MagicMock:
assert statement.is_select, '标签不得产生业务写入'
self.statements.append(sql(statement))
assert self.results, '出现非预期查询'
value = self.results.pop(0)
result = MagicMock()
result.scalar_one_or_none.return_value = value
result.scalars.return_value.all.return_value = value
result.all.return_value = value
return result
class ShipmentLabelsTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
spec = importlib.util.find_spec('module_business.service.shipment_label_service')
self.assertIsNotNone(spec, '缺少可执行的发货标签服务')
self.service = importlib.import_module('module_business.service.shipment_label_service').ShipmentLabelService
def records(self, status: str = 'CONFIRMED', batch_id: int | None = None, dates: bool = True) -> LabelRecords:
order = BizShipmentOrder(shipment_id=12, shipment_no='原发货-12', source_warehouse_id=7,
target_site_id=9, status=status, test_batch_id=batch_id)
item = BizShipmentItem(shipment_item_id=1, shipment_id=12, pallet_id=5,
active_filling_id=105, package_qty=37, test_batch_id=batch_id)
pallet = BizPallet(pallet_id=5, pallet_code='原托盘-005', qr_token='fixed-qr-005',
asset_status='0', capacity_packages=100, test_batch_id=batch_id)
filling = BizFillingRecord(filling_id=105, pallet_id=5, strain_id=3,
strain_name_snapshot='<img src=x onerror=alert(1)>',
strain_code_snapshot='S03', batch_no='原生产批号-03',
active_slot='ACTIVE' if status == 'DRAFT' else None,
production_date=date(2026, 9, 1) if dates else None,
expiry_date=date(2027, 9, 1) if dates else None,
test_batch_id=batch_id)
site = BizCustomerSite(site_id=9, customer_id=4, site_name='原现场', test_batch_id=batch_id)
customer = BizCustomer(customer_id=4, customer_name='客户 <script>攻击</script>', test_batch_id=batch_id)
order.items = [item]
return order, item, pallet, filling, site, customer
def session(self, records: LabelRecords, *, admin: bool = True, qty: int | None = None) -> ReadOnlySession:
order, item, pallet, filling, site, customer = records
current_qty = 62 if qty is None else qty
detail_items = [(item, current_qty)] if order.status == 'DRAFT' else [item]
rows = [(item, pallet, filling, current_qty if order.status == 'DRAFT' else item.package_qty)]
values = [order, detail_items, site, customer, rows]
return ReadOnlySession(*([] if admin else [[7], [9]]), *values)
async def test_confirmed_uses_original_filling_and_package_snapshot_without_inventory(self) -> None:
records = self.records()
db = self.session(records)
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
self.assertEqual(1, len(labels))
payload = labels[0].model_dump(mode='json', by_alias=True)
self.assertEqual('原托盘-005', payload['palletCode'])
self.assertEqual('原发货-12', payload['shipmentNo'])
self.assertEqual('原生产批号-03', payload['batchNo'])
self.assertEqual('客户 <script>攻击</script>', payload['customerName'])
self.assertEqual('原现场', payload['siteName'])
self.assertEqual('<img src=x onerror=alert(1)>', payload['strainName'])
self.assertEqual(37, payload['packageQty'])
self.assertEqual('2026-09-01', payload['productionDate'])
self.assertEqual('2027-09-01', payload['expiryDate'])
prefix, encoded = payload['qrCodeDataUrl'].split(',', 1)
self.assertEqual('data:image/png;base64', prefix)
self.assertEqual(PalletService.render_qr_png('fixed-qr-005'), base64.b64decode(encoded))
self.assertNotIn('biz_pallet_inventory', ' '.join(db.statements))
self.assertIn('biz_shipment_item.active_filling_id = biz_filling_record.filling_id', db.statements[-1])
self.assertNotIn("active_slot = 'ACTIVE'", db.statements[-1])
self.assertEqual([], db.results)
async def test_draft_uses_current_quantity_and_active_filling_with_null_dates(self) -> None:
records = self.records('DRAFT', dates=False)
records[1].active_filling_id = None
records[1].package_qty = None
db = self.session(records)
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
self.assertEqual(62, labels[0].package_qty)
self.assertIsNone(labels[0].production_date)
self.assertIsNone(labels[0].expiry_date)
statement = db.statements[-1]
for fragment in (
'biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id',
'biz_pallet_inventory.warehouse_id = 7',
"biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'",
'biz_pallet_inventory.site_id IS NULL',
'biz_pallet_inventory.current_line_id IS NULL',
'biz_pallet_inventory.remaining_packages > 0',
"biz_filling_record.active_slot = 'ACTIVE'",
):
self.assertIn(fragment, statement)
async def test_both_business_scopes_and_each_related_batch_are_in_real_queries(self) -> None:
for scope in (TestBatchScope.formal(), make_test_scope(31)):
for status in ('DRAFT', 'CONFIRMED'):
with self.subTest(scope=scope, status=status):
db = self.session(self.records(status, scope.batch_id), admin=False)
await self.service.get_labels_services(db, 12, make_current_user(), scope)
self.assertIn('source_warehouse_id IN (7)', db.statements[2])
self.assertIn('target_site_id IN (9)', db.statements[2])
combined = ' '.join(db.statements)
tables = ['biz_shipment_order', 'biz_shipment_item', 'biz_pallet',
'biz_filling_record', 'biz_customer_site', 'biz_customer']
if status == 'DRAFT':
tables.append('biz_pallet_inventory')
for table in tables:
predicate = 'IS NULL' if scope.batch_id is None else '= 31'
self.assertIn(f'{table}.test_batch_id {predicate}', combined)
self.assertNotIn('FOR UPDATE', combined)
async def test_inaccessible_order_stops_before_related_queries_or_qr_rendering(self) -> None:
for warehouses, sites in (([], [9]), ([7], []), ([8], [10])):
with self.subTest(warehouses=warehouses, sites=sites):
db = ReadOnlySession(warehouses, sites, None)
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未授权生成二维码')),
self.assertRaises(PermissionException),
):
await self.service.get_labels_services(db, 12, make_current_user(), TestBatchScope.formal())
self.assertEqual(3, len(db.statements))
async def test_cross_batch_relations_are_rejected_before_qr_generation(self) -> None:
for index in range(6):
with self.subTest(record=index):
records = self.records()
records[index].test_batch_id = 31
db = self.session(records)
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('跨批次生成二维码')),
self.assertRaises(ServiceException),
):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_incomplete_label_does_not_print_partial_or_fall_back_to_current_inventory(self) -> None:
for status in ('DRAFT', 'CONFIRMED'):
for missing in ('pallet', 'filling', 'quantity', 'snapshot'):
with self.subTest(status=status, missing=missing):
records = self.records(status)
if missing == 'snapshot':
records[3].pallet_id = 99
db = self.session(records)
row = list(db.results[-1][0])
if missing != 'snapshot':
row[{'pallet': 1, 'filling': 2, 'quantity': 3}[missing]] = None
db.results[-1] = [tuple(row)]
with self.assertRaises(ServiceException):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_missing_later_pallet_prevents_any_qr_generation(self) -> None:
records = self.records()
db = self.session(records)
second = BizShipmentItem(shipment_item_id=2, shipment_id=12, pallet_id=6,
active_filling_id=106, package_qty=20, test_batch_id=None)
db.results[1].append(second)
db.results[-1].append((second, None, None, 20))
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未完成校验已生成二维码')),
self.assertRaises(ServiceException),
):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_empty_shipment_is_not_printable(self) -> None:
records = self.records()
db = self.session(records)
db.results[1] = []
db.results[-1] = []
with self.assertRaises(ServiceException):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_route_serializes_label_dates_and_prevents_cross_scope_browser_cache(self) -> None:
db = self.session(self.records())
response = await shipment_controller_module.get_business_shipment_labels(
Request({'type': 'http'}), 12, db, make_admin(), TestBatchScope.formal(),
)
payload = json.loads(response.body)
self.assertEqual(200, response.status_code)
self.assertEqual('2026-09-01', payload['data'][0]['productionDate'])
self.assertEqual('CONFIRMED', payload['data'][0]['shipmentStatus'])
self.assertTrue(payload['data'][0]['qrCodeDataUrl'].startswith('data:image/png;base64,'))
self.assertEqual('no-store', response.headers.get('Cache-Control'))
async def test_draft_rejects_disabled_closed_or_excess_quantity(self) -> None:
for field in ('asset_status', 'active_slot', 'capacity_packages'):
with self.subTest(field=field):
records = self.records('DRAFT')
target = records[3] if field == 'active_slot' else records[2]
setattr(target, field, {'asset_status': '1', 'active_slot': None, 'capacity_packages': 20}[field])
with self.assertRaises(ServiceException):
await self.service.get_labels_services(
self.session(records), 12, make_admin(), TestBatchScope.formal(),
)
class ShipmentLabelRouteTest(unittest.TestCase):
def test_label_route_reuses_query_permission_and_read_scope(self) -> None:
app = FastAPI()
app.include_router(shipment_controller_module.shipment_controller)
routes = [route for route in app.routes if route.path == '/business/shipment/{shipment_id}/labels']
self.assertEqual(1, len(routes), '缺少标签 GET 路由')
route = routes[0]
self.assertEqual({'GET'}, route.methods)
checks = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, CheckUserInterfaceAuth)]
self.assertEqual(['business:shipment:query'], [check.perm for check in checks])
scopes = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, TestBatchScopeResolver)]
self.assertEqual([False], [scope.write for scope in scopes])
if __name__ == '__main__':
unittest.main()