test_pl_mobile_product_identity.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Pure request identity tests: no database, application startup, network or browser."""
  2. from pathlib import Path
  3. from types import SimpleNamespace as Row
  4. import importlib.util
  5. import pytest
  6. PATH = Path(__file__).parents[1] / 'app/services/pl_mobile_product_identity.py'
  7. def service():
  8. assert PATH.exists(), 'mobile request identity service exists'
  9. spec = importlib.util.spec_from_file_location('mobile_identity_test_module', PATH)
  10. module = importlib.util.module_from_spec(spec)
  11. spec.loader.exec_module(module)
  12. return module
  13. def test_request_keys_bind_new_same_product_rows_independently():
  14. m = service()
  15. products = [Row(client_key='a', id=None, product_id='p'), Row(client_key='b', id=None, product_id='p')]
  16. 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')]
  17. m.validate_mobile_product_links(products, stops)
  18. rows = [Row(id='uuid-a', product_id='p'), Row(id='uuid-b', product_id='p')]
  19. for row, product in zip(rows, products): m.bind_mobile_product_key(row, product)
  20. assert [m.resolve_mobile_product_link(stop, rows) for stop in stops] == ['uuid-a','uuid-b']
  21. def test_legacy_no_key_path_is_untouched():
  22. m = service()
  23. m.validate_mobile_product_links([Row(id='duplicated'), Row(id='duplicated')], [Row()])
  24. assert m.resolve_mobile_product_link(Row(plan_product_id='legacy'), []) is None
  25. @pytest.mark.parametrize('products,stops', [
  26. ([Row(client_key='a',id=None,product_id='p'),Row(client_key='a',id=None,product_id='p')],[]),
  27. ([Row(client_key='a',id='old',product_id='p'),Row(client_key='b',id='old',product_id='p')],[]),
  28. ([Row(client_key='a',id=None,product_id='p')],[Row(plan_product_client_key='unknown')]),
  29. ([Row(client_key='a',id='old',product_id='p')],[Row(plan_product_client_key='a',plan_product_id='other',product_id='p')]),
  30. ([Row(client_key='a',id='old',product_id='p')],[Row(plan_product_client_key='a',plan_product_id='old',product_id='other')])
  31. ])
  32. def test_invalid_request_links_reject_before_writing(products, stops):
  33. m = service()
  34. with pytest.raises(Exception) as error: m.validate_mobile_product_links(products,stops)
  35. assert error.value.status_code == 422
  36. def test_runtime_explicit_and_key_conflict_is_rejected():
  37. m = service()
  38. row=Row(id='a',product_id='p');m.bind_mobile_product_key(row,Row(client_key='key'))
  39. with pytest.raises(Exception) as error:
  40. m.resolve_mobile_product_link(Row(plan_product_client_key='key',plan_product_id='b',product_id='p'),[row])
  41. assert error.value.status_code == 422
  42. def production_function(path, names, bindings):
  43. import ast
  44. source = ast.parse((Path(__file__).parents[1] / path).read_text())
  45. nodes = [node for node in source.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
  46. scope = {'__builtins__': __builtins__, **bindings}
  47. exec(compile(ast.Module(body=nodes, type_ignores=[]), path, 'exec'), scope)
  48. return scope
  49. def test_actual_build_apply_and_stop_resolver_keep_new_and_existing_keys():
  50. from typing import Optional
  51. from decimal import Decimal
  52. from app.schemas.pl.plan import PlanProductCreate
  53. m = service()
  54. common = dict(Optional=Optional, PlanProductCreate=Row, PlPlanProduct=Row,
  55. Decimal=Decimal, generate_uuid=lambda:'server-generated',
  56. bind_mobile_product_key=m.bind_mobile_product_key)
  57. scope = production_function('app/services/pl_plan_service.py', ['_product_quantity','_estimate_qty_fields','build_plan_product','apply_plan_product_fields'], common)
  58. 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'))
  59. 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']:
  60. fields.setdefault(name,None)
  61. fields.update(unit='吨',is_explosive=0,is_toxic=0)
  62. prod = PlanProductCreate(**fields,client_key='new')
  63. 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')
  64. assert row.id == 'server-generated'
  65. assert row._mobile_request_client_key == 'new'
  66. assert (row.quantity,row.brand_id,row.product_name) == (Decimal('1.125'),'brand-a','alias-a')
  67. prod.client_key='existing'
  68. scope['apply_plan_product_fields'](row,plan_type='outbound',prod=prod,order_num=0,user_id='u')
  69. assert row._mobile_request_client_key == 'existing'
  70. from fastapi import HTTPException
  71. 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))
  72. wrong = Row(id='first-same-product',product_id='p',product_no='p')
  73. stop=Row(plan_product_client_key='existing',plan_product_id=None,product_id='p',product_no='p',unit='吨')
  74. assert stop_scope['_resolve_plan_product_id'](stop,[wrong,row]) == row.id
  75. @pytest.mark.parametrize('field', ['model','unit'])
  76. def test_mobile_request_rejects_incompatible_model_unit(field):
  77. product=Row(client_key='a',id='pp',product_id='p',package_spec='桶',model='M',unit='吨')
  78. stop=Row(plan_product_client_key='a',plan_product_id='pp',product_id='p',package_spec='桶',model='M',unit='吨')
  79. setattr(stop,field,'different')
  80. with pytest.raises(Exception) as error:service().validate_mobile_product_links([product],[stop])
  81. assert error.value.status_code==422
  82. def test_ton_unit_label_and_dict_value_are_compatible():
  83. """装货添加产品可能带「吨」,合同行带 dun;二者视为同一单位。"""
  84. m = service()
  85. product = Row(client_key='a', id='pp', product_id='p', model='食品级', unit='吨')
  86. stop = Row(
  87. plan_product_client_key='a', plan_product_id='pp', product_id='p', model='食品级', unit='dun'
  88. )
  89. m.validate_mobile_product_links([product], [stop])
  90. row = Row(id='pp', product_id='p', model='食品级', unit='吨')
  91. m.bind_mobile_product_key(row, product)
  92. assert m.resolve_mobile_product_link(stop, [row]) == 'pp'
  93. def test_new_mobile_plan_cannot_claim_existing_product_id():
  94. with pytest.raises(Exception) as error:
  95. service().validate_mobile_product_links([Row(client_key='a',id='fake')],[],creating=True)
  96. assert error.value.status_code==422
  97. @pytest.mark.parametrize('route_name',['create_plan_full','update_plan'])
  98. def test_actual_route_rejects_unknown_keys_before_any_write(route_name):
  99. import ast
  100. from fastapi import HTTPException
  101. path=Path(__file__).parents[1]/'app/api/pl/plan.py'
  102. node=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name==route_name)
  103. node.decorator_list=[];node.returns=None
  104. for arg in node.args.args:arg.annotation=None
  105. node.args.defaults=[ast.Constant(None) for _ in node.args.defaults]
  106. ast.fix_missing_locations(node)
  107. class NoWrite:
  108. def add(self,*a):raise AssertionError('must validate before write')
  109. def commit(self):raise AssertionError('must never commit invalid request')
  110. 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=[])
  111. 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)
  112. exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),scope)
  113. args=dict(payload=payload,db=NoWrite(),user=Row(to_tenant_user_dict=lambda:{}))
  114. if route_name=='update_plan':args['id']='plan'
  115. with pytest.raises(HTTPException) as error:scope[route_name](**args)
  116. assert error.value.status_code==422
  117. def test_real_schema_accepts_aliases_but_keys_are_not_orm_fields():
  118. import ast
  119. from typing import Optional
  120. from decimal import Decimal
  121. from pydantic import BaseModel,Field
  122. path=Path(__file__).parents[1]/'app/schemas/pl/plan.py'
  123. names={'PlanProductBase','PlanProductCreate','PlanStopProductMeta','PlanStopProductBody'}
  124. nodes=[n for n in ast.parse(path.read_text()).body if isinstance(n,ast.ClassDef) and n.name in names]
  125. scope=dict(BaseModel=BaseModel,Field=Field,Optional=Optional,Decimal=Decimal)
  126. exec(compile(ast.Module(body=nodes,type_ignores=[]),str(path),'exec'),scope)
  127. product=scope['PlanProductCreate'](clientKey='client',productId='p',productNo='P',productName='alias')
  128. stop=scope['PlanStopProductBody'](planProductClientKey='client',productId='p',productNo='P',productName='alias',mobileProductMeta={'aliasName':'alias','brandMode':'required','brandId':'brand'})
  129. assert product.client_key==stop.plan_product_client_key=='client'
  130. assert stop.mobile_product_meta.brand_id=='brand'
  131. model=(Path(__file__).parents[1]/'app/models/pl_models.py').read_text()
  132. assert 'client_key' not in model and 'mobile_product_meta' not in model
  133. def test_unlinked_draft_is_explicit_and_cannot_bypass_formal_or_claim_an_id():
  134. m=service();stop=Row(unlinked_draft=True,plan_product_id=None,plan_product_client_key=None,product_id='p')
  135. m.validate_mobile_product_links([], [stop], allow_unlinked_draft=True)
  136. with pytest.raises(Exception) as error:m.validate_mobile_product_links([], [stop])
  137. assert error.value.status_code==422
  138. stop.plan_product_id='fake'
  139. with pytest.raises(Exception):m.validate_mobile_product_links([], [stop],allow_unlinked_draft=True)
  140. def test_actual_stop_resolver_persists_empty_pending_reference_only_when_explicitly_allowed():
  141. from fastapi import HTTPException
  142. 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))
  143. stop=Row(unlinked_draft=True,plan_product_id=None,plan_product_client_key=None)
  144. assert scope['_resolve_plan_product_id'](stop,[],True)==''
  145. with pytest.raises(HTTPException):scope['_resolve_plan_product_id'](stop,[])
  146. def test_default_stop_resolver_rejects_ambiguous_legacy_web_product_mapping():
  147. from fastapi import HTTPException
  148. 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))
  149. rows=[Row(id='a',product_id='p'),Row(id='b',product_id='p')]
  150. with pytest.raises(HTTPException) as error:
  151. scope['_resolve_plan_product_id'](Row(plan_product_id=None,product_id='p',product_no='P'),rows)
  152. assert error.value.status_code==422
  153. def test_existing_pending_web_draft_matches_only_its_original_segment_and_contract_line():
  154. m=service()
  155. assert hasattr(m,'is_existing_pending_delivery')
  156. old=Row(delivery_id='A',contract_id='c',contract_product_id='line',product_id='p',package_spec='bag',model='M',unit='t')
  157. 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')
  158. assert m.is_existing_pending_delivery(product,'A',[old])
  159. assert not m.is_existing_pending_delivery(product,'B',[old])
  160. product.contract_product_id='replacement'
  161. assert not m.is_existing_pending_delivery(product,'A',[old])
  162. @pytest.mark.parametrize('existing', [False, True])
  163. def test_stop_packaging_override_keeps_shared_plan_identity_without_rewriting_packaging(existing):
  164. m = service()
  165. product = Row(client_key='load', id='uuid-load' if existing else None,
  166. product_id='p', model='M', unit='吨', package_spec='25Kg/袋')
  167. delivery = Row(plan_product_client_key='load', plan_product_id=product.id,
  168. product_id='p', model='M', unit='吨', package_spec='20kg/袋')
  169. m.validate_mobile_product_links([product], [delivery], creating=not existing)
  170. stored = Row(id='uuid-load', product_id='p', model='M', unit='吨', package_spec='25Kg/袋')
  171. m.bind_mobile_product_key(stored, product)
  172. assert m.resolve_mobile_product_link(delivery, [stored]) == 'uuid-load'
  173. assert product.package_spec == stored.package_spec == '25Kg/袋'
  174. assert delivery.package_spec == '20kg/袋'
  175. @pytest.mark.parametrize('field', ['product_id', 'model', 'unit'])
  176. def test_packaging_override_does_not_relax_product_model_or_unit_checks(field):
  177. m = service()
  178. product = Row(client_key='load', id='uuid-load', product_id='p', model='M', unit='吨', package_spec='25Kg/袋')
  179. delivery = Row(plan_product_client_key='load', plan_product_id='uuid-load', product_id='p', model='M', unit='吨', package_spec='20kg/袋')
  180. setattr(delivery, field, 'different')
  181. with pytest.raises(Exception) as error:
  182. m.validate_mobile_product_links([product], [delivery])
  183. assert error.value.status_code == 422
  184. m.bind_mobile_product_key(product, product)
  185. with pytest.raises(Exception) as error:
  186. m.resolve_mobile_product_link(delivery, [product])
  187. assert error.value.status_code == 422