test_pl_mobile_product_identity.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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. m = service()
  53. common = dict(Optional=Optional, PlanProductCreate=Row, PlPlanProduct=Row,
  54. Decimal=Decimal, generate_uuid=lambda:'server-generated',
  55. bind_mobile_product_key=m.bind_mobile_product_key)
  56. scope = production_function('app/services/pl_plan_service.py', ['_product_quantity','_estimate_qty_fields','build_plan_product','apply_plan_product_fields'], common)
  57. 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'))
  58. 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']:
  59. fields.setdefault(name,None)
  60. prod = Row(**fields,client_key='new')
  61. 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')
  62. assert row.id == 'server-generated'
  63. assert row._mobile_request_client_key == 'new'
  64. assert (row.quantity,row.brand_id,row.product_name) == (Decimal('1.125'),'brand-a','alias-a')
  65. prod.client_key='existing'
  66. scope['apply_plan_product_fields'](row,plan_type='outbound',prod=prod,order_num=0,user_id='u')
  67. assert row._mobile_request_client_key == 'existing'
  68. from fastapi import HTTPException
  69. 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))
  70. wrong = Row(id='first-same-product',product_id='p',product_no='p')
  71. stop=Row(plan_product_client_key='existing',plan_product_id=None,product_id='p',product_no='p',unit='吨')
  72. assert stop_scope['_resolve_plan_product_id'](stop,[wrong,row]) == row.id
  73. @pytest.mark.parametrize('field', ['package_spec','model','unit'])
  74. def test_mobile_request_rejects_incompatible_package_model_unit(field):
  75. product=Row(client_key='a',id='pp',product_id='p',package_spec='桶',model='M',unit='吨')
  76. stop=Row(plan_product_client_key='a',plan_product_id='pp',product_id='p',package_spec='桶',model='M',unit='吨')
  77. setattr(stop,field,'different')
  78. with pytest.raises(Exception) as error:service().validate_mobile_product_links([product],[stop])
  79. assert error.value.status_code==422
  80. def test_new_mobile_plan_cannot_claim_existing_product_id():
  81. with pytest.raises(Exception) as error:
  82. service().validate_mobile_product_links([Row(client_key='a',id='fake')],[],creating=True)
  83. assert error.value.status_code==422
  84. @pytest.mark.parametrize('route_name',['create_plan_full','update_plan'])
  85. def test_actual_route_rejects_unknown_keys_before_any_write(route_name):
  86. import ast
  87. from fastapi import HTTPException
  88. path=Path(__file__).parents[1]/'app/api/pl/plan.py'
  89. node=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name==route_name)
  90. node.decorator_list=[];node.returns=None
  91. for arg in node.args.args:arg.annotation=None
  92. node.args.defaults=[ast.Constant(None) for _ in node.args.defaults]
  93. ast.fix_missing_locations(node)
  94. class NoWrite:
  95. def add(self,*a):raise AssertionError('must validate before write')
  96. def commit(self):raise AssertionError('must never commit invalid request')
  97. payload=Row(mobile_product_meta=False,products=[Row(client_key='known',id=None,product_id='p')],load=Row(products=[Row(plan_product_client_key='unknown',product_id='p')]),deliveries=[])
  98. 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),validate_mobile_meta_request=lambda *a:None,validate_mobile_product_links=service().validate_mobile_product_links,HTTPException=HTTPException)
  99. exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),scope)
  100. args=dict(payload=payload,db=NoWrite(),user=Row(to_tenant_user_dict=lambda:{}))
  101. if route_name=='update_plan':args['id']='plan'
  102. with pytest.raises(HTTPException) as error:scope[route_name](**args)
  103. assert error.value.status_code==422
  104. def test_real_schema_accepts_aliases_but_keys_are_not_orm_fields():
  105. import ast
  106. from typing import Optional
  107. from decimal import Decimal
  108. from pydantic import BaseModel,Field
  109. path=Path(__file__).parents[1]/'app/schemas/pl/plan.py'
  110. names={'PlanProductBase','PlanProductCreate','PlanStopProductMeta','PlanStopProductBody'}
  111. nodes=[n for n in ast.parse(path.read_text()).body if isinstance(n,ast.ClassDef) and n.name in names]
  112. scope=dict(BaseModel=BaseModel,Field=Field,Optional=Optional,Decimal=Decimal)
  113. exec(compile(ast.Module(body=nodes,type_ignores=[]),str(path),'exec'),scope)
  114. product=scope['PlanProductCreate'](clientKey='client',productId='p',productNo='P',productName='alias')
  115. stop=scope['PlanStopProductBody'](planProductClientKey='client',productId='p',productNo='P',productName='alias',mobileProductMeta={'aliasName':'alias','brandMode':'required','brandId':'brand'})
  116. assert product.client_key==stop.plan_product_client_key=='client'
  117. assert stop.mobile_product_meta.brand_id=='brand'
  118. model=(Path(__file__).parents[1]/'app/models/pl_models.py').read_text()
  119. assert 'client_key' not in model and 'mobile_product_meta' not in model
  120. def test_unlinked_draft_is_explicit_and_cannot_bypass_formal_or_claim_an_id():
  121. m=service();stop=Row(unlinked_draft=True,plan_product_id=None,plan_product_client_key=None,product_id='p')
  122. m.validate_mobile_product_links([], [stop], allow_unlinked_draft=True)
  123. with pytest.raises(Exception) as error:m.validate_mobile_product_links([], [stop])
  124. assert error.value.status_code==422
  125. stop.plan_product_id='fake'
  126. with pytest.raises(Exception):m.validate_mobile_product_links([], [stop],allow_unlinked_draft=True)
  127. def test_actual_stop_resolver_persists_empty_pending_reference_only_when_explicitly_allowed():
  128. from fastapi import HTTPException
  129. 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))
  130. stop=Row(unlinked_draft=True,plan_product_id=None,plan_product_client_key=None)
  131. assert scope['_resolve_plan_product_id'](stop,[],True)==''
  132. with pytest.raises(HTTPException):scope['_resolve_plan_product_id'](stop,[])
  133. def test_default_stop_resolver_keeps_legacy_web_first_product_mapping():
  134. from fastapi import HTTPException
  135. 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))
  136. rows=[Row(id='a',product_id='p'),Row(id='b',product_id='p')]
  137. assert scope['_resolve_plan_product_id'](Row(plan_product_id=None,product_id='p'),rows)=='a'
  138. def test_existing_pending_web_draft_matches_only_its_original_segment_and_contract_line():
  139. m=service()
  140. assert hasattr(m,'is_existing_pending_delivery')
  141. old=Row(delivery_id='A',contract_id='c',contract_product_id='line',product_id='p',package_spec='bag',model='M',unit='t')
  142. 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')
  143. assert m.is_existing_pending_delivery(product,'A',[old])
  144. assert not m.is_existing_pending_delivery(product,'B',[old])
  145. product.contract_product_id='replacement'
  146. assert not m.is_existing_pending_delivery(product,'A',[old])