Files
shanxia2.0/backend/tests/test_pl_mobile_product_identity.py
T

218 lines
13 KiB
Python

"""Pure request identity tests: no database, application startup, network or browser."""
from pathlib import Path
from types import SimpleNamespace as Row
import importlib.util
import pytest
PATH = Path(__file__).parents[1] / 'app/services/pl_mobile_product_identity.py'
def service():
assert PATH.exists(), 'mobile request identity service exists'
spec = importlib.util.spec_from_file_location('mobile_identity_test_module', PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_request_keys_bind_new_same_product_rows_independently():
m = service()
products = [Row(client_key='a', id=None, product_id='p'), Row(client_key='b', id=None, product_id='p')]
stops = [Row(plan_product_client_key='a', plan_product_id=None, product_id='p'), Row(plan_product_client_key='b', plan_product_id=None, product_id='p')]
m.validate_mobile_product_links(products, stops)
rows = [Row(id='uuid-a', product_id='p'), Row(id='uuid-b', product_id='p')]
for row, product in zip(rows, products): m.bind_mobile_product_key(row, product)
assert [m.resolve_mobile_product_link(stop, rows) for stop in stops] == ['uuid-a','uuid-b']
def test_legacy_no_key_path_is_untouched():
m = service()
m.validate_mobile_product_links([Row(id='duplicated'), Row(id='duplicated')], [Row()])
assert m.resolve_mobile_product_link(Row(plan_product_id='legacy'), []) is None
@pytest.mark.parametrize('products,stops', [
([Row(client_key='a',id=None,product_id='p'),Row(client_key='a',id=None,product_id='p')],[]),
([Row(client_key='a',id='old',product_id='p'),Row(client_key='b',id='old',product_id='p')],[]),
([Row(client_key='a',id=None,product_id='p')],[Row(plan_product_client_key='unknown')]),
([Row(client_key='a',id='old',product_id='p')],[Row(plan_product_client_key='a',plan_product_id='other',product_id='p')]),
([Row(client_key='a',id='old',product_id='p')],[Row(plan_product_client_key='a',plan_product_id='old',product_id='other')])
])
def test_invalid_request_links_reject_before_writing(products, stops):
m = service()
with pytest.raises(Exception) as error: m.validate_mobile_product_links(products,stops)
assert error.value.status_code == 422
def test_runtime_explicit_and_key_conflict_is_rejected():
m = service()
row=Row(id='a',product_id='p');m.bind_mobile_product_key(row,Row(client_key='key'))
with pytest.raises(Exception) as error:
m.resolve_mobile_product_link(Row(plan_product_client_key='key',plan_product_id='b',product_id='p'),[row])
assert error.value.status_code == 422
def production_function(path, names, bindings):
import ast
source = ast.parse((Path(__file__).parents[1] / path).read_text())
nodes = [node for node in source.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
scope = {'__builtins__': __builtins__, **bindings}
exec(compile(ast.Module(body=nodes, type_ignores=[]), path, 'exec'), scope)
return scope
def test_actual_build_apply_and_stop_resolver_keep_new_and_existing_keys():
from typing import Optional
from decimal import Decimal
from app.schemas.pl.plan import PlanProductCreate
m = service()
common = dict(Optional=Optional, PlanProductCreate=Row, PlPlanProduct=Row,
Decimal=Decimal, generate_uuid=lambda:'server-generated',
bind_mobile_product_key=m.bind_mobile_product_key)
scope = production_function('app/services/pl_plan_service.py', ['_product_quantity','_estimate_qty_fields','build_plan_product','apply_plan_product_fields'], common)
fields = dict(product_id='p',product_no='p',product_name='alias-a',brand_mode='required',brand_id='brand-a',brand_name='A',quantity=Decimal('1.125'))
for name in ['inbound_estimate_qty','outbound_estimate_qty','tax_price','model','unit','category_name','package_spec','delivery_method','actual_qty','tax_amount','tax_subtotal','is_explosive','is_toxic','purchase_cert_url','transport_cert_url','legal_use_cert_url','remark']:
fields.setdefault(name,None)
fields.update(unit='吨',is_explosive=0,is_toxic=0)
prod = PlanProductCreate(**fields,client_key='new')
row = scope['build_plan_product'](tenant_no='t',plan_id='plan',plan_type='outbound',prod=prod,order_num=0,user_id='u',dept_id='d')
assert row.id == 'server-generated'
assert row._mobile_request_client_key == 'new'
assert (row.quantity,row.brand_id,row.product_name) == (Decimal('1.125'),'brand-a','alias-a')
prod.client_key='existing'
scope['apply_plan_product_fields'](row,plan_type='outbound',prod=prod,order_num=0,user_id='u')
assert row._mobile_request_client_key == 'existing'
from fastapi import HTTPException
stop_scope = production_function('app/services/pl_plan_stop_service.py', ['_resolve_plan_product_id'], dict(PlanStopProductBody=Row, resolve_mobile_product_link=m.resolve_mobile_product_link, HTTPException=HTTPException))
wrong = Row(id='first-same-product',product_id='p',product_no='p')
stop=Row(plan_product_client_key='existing',plan_product_id=None,product_id='p',product_no='p',unit='吨')
assert stop_scope['_resolve_plan_product_id'](stop,[wrong,row]) == row.id
@pytest.mark.parametrize('field', ['model','unit'])
def test_mobile_request_rejects_incompatible_model_unit(field):
product=Row(client_key='a',id='pp',product_id='p',package_spec='桶',model='M',unit='吨')
stop=Row(plan_product_client_key='a',plan_product_id='pp',product_id='p',package_spec='桶',model='M',unit='吨')
setattr(stop,field,'different')
with pytest.raises(Exception) as error:service().validate_mobile_product_links([product],[stop])
assert error.value.status_code==422
def test_ton_unit_label_and_dict_value_are_compatible():
"""装货添加产品可能带「吨」,合同行带 dun;二者视为同一单位。"""
m = service()
product = Row(client_key='a', id='pp', product_id='p', model='食品级', unit='吨')
stop = Row(
plan_product_client_key='a', plan_product_id='pp', product_id='p', model='食品级', unit='dun'
)
m.validate_mobile_product_links([product], [stop])
row = Row(id='pp', product_id='p', model='食品级', unit='吨')
m.bind_mobile_product_key(row, product)
assert m.resolve_mobile_product_link(stop, [row]) == 'pp'
def test_new_mobile_plan_cannot_claim_existing_product_id():
with pytest.raises(Exception) as error:
service().validate_mobile_product_links([Row(client_key='a',id='fake')],[],creating=True)
assert error.value.status_code==422
@pytest.mark.parametrize('route_name',['create_plan_full','update_plan'])
def test_actual_route_rejects_unknown_keys_before_any_write(route_name):
import ast
from fastapi import HTTPException
path=Path(__file__).parents[1]/'app/api/pl/plan.py'
node=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name==route_name)
node.decorator_list=[];node.returns=None
for arg in node.args.args:arg.annotation=None
node.args.defaults=[ast.Constant(None) for _ in node.args.defaults]
ast.fix_missing_locations(node)
class NoWrite:
def add(self,*a):raise AssertionError('must validate before write')
def commit(self):raise AssertionError('must never commit invalid request')
payload=Row(mobile_product_meta=False,products=[Row(client_key='known',id=None,product_id='p')],load=Row(id=None,products=[Row(id=None,plan_product_id=None,plan_product_client_key='unknown',product_id='p')]),deliveries=[])
scope=dict(get_tenant_no=lambda *a:'t',_get_plan=lambda *a:Row(follow_status='draft'),settings=Row(PL_MOBILE_PLAN_PRODUCT_META_ENABLED=False),fold_mobile_selection_into_web_fields=lambda payload: None,validate_mobile_meta_request=lambda *a:None,validate_mobile_product_links=service().validate_mobile_product_links,HTTPException=HTTPException)
exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),scope)
args=dict(payload=payload,db=NoWrite(),user=Row(to_tenant_user_dict=lambda:{}))
if route_name=='update_plan':args['id']='plan'
with pytest.raises(HTTPException) as error:scope[route_name](**args)
assert error.value.status_code==422
def test_real_schema_accepts_aliases_but_keys_are_not_orm_fields():
import ast
from typing import Optional
from decimal import Decimal
from pydantic import BaseModel,Field
path=Path(__file__).parents[1]/'app/schemas/pl/plan.py'
names={'PlanProductBase','PlanProductCreate','PlanStopProductMeta','PlanStopProductBody'}
nodes=[n for n in ast.parse(path.read_text()).body if isinstance(n,ast.ClassDef) and n.name in names]
scope=dict(BaseModel=BaseModel,Field=Field,Optional=Optional,Decimal=Decimal)
exec(compile(ast.Module(body=nodes,type_ignores=[]),str(path),'exec'),scope)
product=scope['PlanProductCreate'](clientKey='client',productId='p',productNo='P',productName='alias')
stop=scope['PlanStopProductBody'](planProductClientKey='client',productId='p',productNo='P',productName='alias',mobileProductMeta={'aliasName':'alias','brandMode':'required','brandId':'brand'})
assert product.client_key==stop.plan_product_client_key=='client'
assert stop.mobile_product_meta.brand_id=='brand'
model=(Path(__file__).parents[1]/'app/models/pl_models.py').read_text()
assert 'client_key' not in model and 'mobile_product_meta' not in model
def test_unlinked_draft_is_explicit_and_cannot_bypass_formal_or_claim_an_id():
m=service();stop=Row(unlinked_draft=True,plan_product_id=None,plan_product_client_key=None,product_id='p')
m.validate_mobile_product_links([], [stop], allow_unlinked_draft=True)
with pytest.raises(Exception) as error:m.validate_mobile_product_links([], [stop])
assert error.value.status_code==422
stop.plan_product_id='fake'
with pytest.raises(Exception):m.validate_mobile_product_links([], [stop],allow_unlinked_draft=True)
def test_actual_stop_resolver_persists_empty_pending_reference_only_when_explicitly_allowed():
from fastapi import HTTPException
scope=production_function('app/services/pl_plan_stop_service.py',['_resolve_plan_product_id'],dict(PlanStopProductBody=Row,resolve_mobile_product_link=service().resolve_mobile_product_link,HTTPException=HTTPException))
stop=Row(unlinked_draft=True,plan_product_id=None,plan_product_client_key=None)
assert scope['_resolve_plan_product_id'](stop,[],True)==''
with pytest.raises(HTTPException):scope['_resolve_plan_product_id'](stop,[])
def test_default_stop_resolver_rejects_ambiguous_legacy_web_product_mapping():
from fastapi import HTTPException
scope=production_function('app/services/pl_plan_stop_service.py',['_resolve_plan_product_id'],dict(PlanStopProductBody=Row,resolve_mobile_product_link=service().resolve_mobile_product_link,HTTPException=HTTPException))
rows=[Row(id='a',product_id='p'),Row(id='b',product_id='p')]
with pytest.raises(HTTPException) as error:
scope['_resolve_plan_product_id'](Row(plan_product_id=None,product_id='p',product_no='P'),rows)
assert error.value.status_code==422
def test_existing_pending_web_draft_matches_only_its_original_segment_and_contract_line():
m=service()
assert hasattr(m,'is_existing_pending_delivery')
old=Row(delivery_id='A',contract_id='c',contract_product_id='line',product_id='p',package_spec='bag',model='M',unit='t')
product=Row(plan_product_id=None,plan_product_client_key=None,contract_id='c',contract_product_id='line',product_id='p',package_spec='bag',model='M',unit='t')
assert m.is_existing_pending_delivery(product,'A',[old])
assert not m.is_existing_pending_delivery(product,'B',[old])
product.contract_product_id='replacement'
assert not m.is_existing_pending_delivery(product,'A',[old])
@pytest.mark.parametrize('existing', [False, True])
def test_stop_packaging_override_keeps_shared_plan_identity_without_rewriting_packaging(existing):
m = service()
product = Row(client_key='load', id='uuid-load' if existing else None,
product_id='p', model='M', unit='吨', package_spec='25Kg/袋')
delivery = Row(plan_product_client_key='load', plan_product_id=product.id,
product_id='p', model='M', unit='吨', package_spec='20kg/袋')
m.validate_mobile_product_links([product], [delivery], creating=not existing)
stored = Row(id='uuid-load', product_id='p', model='M', unit='吨', package_spec='25Kg/袋')
m.bind_mobile_product_key(stored, product)
assert m.resolve_mobile_product_link(delivery, [stored]) == 'uuid-load'
assert product.package_spec == stored.package_spec == '25Kg/袋'
assert delivery.package_spec == '20kg/袋'
@pytest.mark.parametrize('field', ['product_id', 'model', 'unit'])
def test_packaging_override_does_not_relax_product_model_or_unit_checks(field):
m = service()
product = Row(client_key='load', id='uuid-load', product_id='p', model='M', unit='吨', package_spec='25Kg/袋')
delivery = Row(plan_product_client_key='load', plan_product_id='uuid-load', product_id='p', model='M', unit='吨', package_spec='20kg/袋')
setattr(delivery, field, 'different')
with pytest.raises(Exception) as error:
m.validate_mobile_product_links([product], [delivery])
assert error.value.status_code == 422
m.bind_mobile_product_key(product, product)
with pytest.raises(Exception) as error:
m.resolve_mobile_product_link(delivery, [product])
assert error.value.status_code == 422