test_mobile_quote_contact_sync.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. """Execute production routes/CRUD with an in-memory Session double, never an Engine."""
  2. from __future__ import annotations
  3. import ast
  4. import asyncio
  5. import builtins
  6. from datetime import datetime, timedelta, date
  7. from decimal import Decimal
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. import pytest
  11. from app.schemas.crm.quote import QuoteUpdateBody
  12. ROOT = Path(__file__).resolve().parents[1]
  13. def load_functions(path, names, bindings):
  14. tree = ast.parse((ROOT / path).read_text())
  15. nodes = [node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
  16. module = ast.Module(body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), *nodes], type_ignores=[])
  17. exec(compile(ast.fix_missing_locations(module), str(path), "exec"), bindings)
  18. class Field:
  19. def __init__(self, name):
  20. self.name = name
  21. def __eq__(self, value):
  22. return lambda row: getattr(row, self.name) == value
  23. def desc(self):
  24. return self
  25. class Model(type):
  26. def __getattr__(cls, name):
  27. return Field(name)
  28. class Row(SimpleNamespace):
  29. def __getattr__(self, name):
  30. return None
  31. class Query:
  32. def __init__(self, rows):
  33. self.rows = list(rows)
  34. def filter(self, *predicates):
  35. self.rows = [row for row in self.rows if all(p(row) for p in predicates)]
  36. return self
  37. def order_by(self, *_args):
  38. return self
  39. def first(self):
  40. return next(iter(self.rows), None)
  41. def all(self):
  42. return self.rows
  43. class SessionDouble:
  44. def __init__(self, rows):
  45. self.rows = rows
  46. self.commits = 0
  47. self.flushes = 0
  48. self.approval_invalidations = 0
  49. def query(self, model):
  50. return Query(self.rows[model.__name__])
  51. def flush(self):
  52. self.flushes += 1
  53. def commit(self):
  54. self.commits += 1
  55. class Router:
  56. def __init__(self):
  57. self.routes = {}
  58. def post(self, path, **_kwargs):
  59. def register(fn):
  60. self.routes[path] = fn
  61. return fn
  62. return register
  63. @pytest.fixture
  64. def h(monkeypatch):
  65. # Deny both network access and accidental SQLAlchemy engine construction/connection.
  66. def forbidden(*_args, **_kwargs):
  67. raise AssertionError("Network/Engine access is forbidden in mobile regression tests")
  68. import socket
  69. import sqlalchemy
  70. monkeypatch.setattr(socket.socket, "connect", forbidden)
  71. monkeypatch.setattr(sqlalchemy, "create_engine", forbidden)
  72. monkeypatch.setattr(sqlalchemy.engine.Engine, "connect", forbidden)
  73. quote = Row(id="q1", tenant_no="tenant-a", del_flag=0, customer_id="customer-old", contact_id="contact-old", contact_name="旧姓名", contact_mobile="111", contact_email="old@example.test", status="draft", owner_user_id="user-a", quote_type="sales", quote_no="SXDD20261021001")
  74. customers = [Row(id=id, tenant_no="tenant-a", del_flag=0, owner_user_id="user-a", short_name=id) for id in ["customer-old", "customer-new"]]
  75. contacts = [Row(id="contact-old", customer_id="customer-old", tenant_no="tenant-a", del_flag=0, contact_name="旧姓名", mobile="111", email="old@example.test"), Row(id="contact-new", customer_id="customer-new", tenant_no="tenant-a", del_flag=0, contact_name="新姓名", mobile="222", email="new@example.test")]
  76. db = SessionDouble({"CrmQuote": [quote], "CrmCustomer": customers, "CrmContact": contacts, "SysUser": []})
  77. ns = {
  78. "datetime": datetime, "timedelta": timedelta, "date": date, "Decimal": Decimal,
  79. "_FMT": "%Y-%m-%d %H:%M:%S", "_DATE_FMT": "%Y-%m-%d",
  80. "QUOTE_EDITABLE_STATUSES": frozenset({"draft", "submitted", "accepted", "rejected"}),
  81. "CRM_OWNER_FIELD": "owner_user_id",
  82. "QuoteUpdateBody": QuoteUpdateBody,
  83. "row_in_data_scope": lambda _db, row, user, tenant, **_kw: row.tenant_no == tenant and row.owner_user_id == user["user_id"],
  84. "get_tenant_no": lambda _db, user: user["tenant_no"],
  85. "Depends": lambda dep: dep,
  86. "get_db": forbidden,
  87. "require_admin_or_super_admin": forbidden,
  88. "require_menu_code": lambda *_args, **_kw: forbidden,
  89. "success_response": lambda data, message="success": {"code": 200, "data": data, "message": message},
  90. "error_response": lambda code, message: {"code": code, "message": message},
  91. "list_quote_products": lambda *_args, **_kw: [],
  92. "get_supplier_info": lambda *_args, **_kw: None,
  93. "assert_products_allowed_for_supplier": lambda *_args, **_kw: None,
  94. "_resolve_quote_product_ids": lambda *_args, **_kw: None,
  95. "delete_products_by_quote_id": lambda *_args, **_kw: None,
  96. "batch_insert_quote_products": lambda *_args, **_kw: None,
  97. "invalidate_all_approvals": lambda *_args, **_kw: setattr(db, "approval_invalidations", db.approval_invalidations + 1),
  98. }
  99. for name in ["CrmQuote", "CrmCustomer", "CrmContact", "SysUser"]:
  100. ns[name] = Model(name, (), {})
  101. load_functions("app/crud/crm/data_scope_helpers.py", {"crm_user_info", "ensure_crm_owner_row_in_scope"}, ns)
  102. load_functions("app/crud/crm/customer.py", {"get_customer"}, ns)
  103. # The real contact helper imports get_customer lazily; supply the real extracted function.
  104. real_import = builtins.__import__
  105. def isolated_import(name, *args, **kwargs):
  106. if name == "app.crud.crm.customer":
  107. return SimpleNamespace(get_customer=ns["get_customer"])
  108. return real_import(name, *args, **kwargs)
  109. ns["__builtins__"] = {**vars(builtins), "__import__": isolated_import}
  110. contact_ns = dict(ns)
  111. load_functions("app/crud/crm/contact.py", {"_row_to_dict", "get_contact", "list_contacts_by_customer"}, contact_ns)
  112. ns.update({key: contact_ns[key] for key in ["get_contact", "list_contacts_by_customer"]})
  113. load_functions("app/crud/crm/quote.py", {"_row_to_dict", "_get_field", "_decimal_value", "_parse_optional_date", "_ensure_quote_in_scope", "_get_quote_in_scope", "get_quote", "get_quote_detail", "assert_quote_editable_for_update", "update_quote", "_enrich_contact_fields", "_enrich_supplier_name", "_get_supplier_short_name", "generate_quote_title", "calculate_quote_amount"}, ns)
  114. route_tree = ast.parse((ROOT / "app/api/crm_quote_routes.py").read_text())
  115. load_functions("app/api/crm_quote_routes.py", {node.name for node in route_tree.body if isinstance(node, ast.FunctionDef)}, ns)
  116. router = Router()
  117. ns["register_quote_routes"](router)
  118. user = {"user_id": "user-a", "tenant_no": "tenant-a", "role": "admin"}
  119. def call(payload, mobile=True):
  120. path = "/quote/mobile-update" if mobile else "/quote/update"
  121. assert path in router.routes, "The mobile contact-safe update entry is missing"
  122. body = QuoteUpdateBody(**{"id": "q1", "customerId": "customer-new", "contactId": "contact-new", **payload})
  123. return asyncio.run(router.routes[path](body, _user=user, db=db))
  124. return SimpleNamespace(call=call, quote=quote, customers=customers, contacts=contacts, db=db, ns=ns, router=router)
  125. def test_mobile_save_uses_canonical_contact_and_keeps_product_amount(h):
  126. result = h.call({"contactName": "伪造姓名", "tenantNo": "tenant-b", "freight": 5, "barrelDeposit": 3, "products": [{"quantity": 2, "price": 12.5}]})
  127. assert result["code"] == 200, result
  128. assert (h.quote.contact_id, h.quote.contact_name, h.quote.contact_mobile, h.quote.contact_email) == ("contact-new", "新姓名", "222", "new@example.test")
  129. assert result["data"]["finalAmount"] == 25
  130. assert h.quote.tenant_no == "tenant-a"
  131. assert h.db.commits == 1
  132. @pytest.mark.parametrize("missing", ["mobile", "email", "both"])
  133. def test_new_contact_empty_fields_do_not_retain_previous_snapshot(h, missing):
  134. if missing in {"mobile", "both"}:
  135. h.contacts[1].mobile = ""
  136. if missing in {"email", "both"}:
  137. h.contacts[1].email = None
  138. result = h.call({})
  139. assert result["code"] == 200, result
  140. assert h.quote.contact_mobile == ("" if missing in {"mobile", "both"} else "222")
  141. assert h.quote.contact_email == ("" if missing in {"email", "both"} else "new@example.test")
  142. @pytest.mark.parametrize("contact_id", [None, "", " "])
  143. def test_explicit_clear_removes_the_whole_snapshot(h, contact_id):
  144. result = h.call({"contactId": contact_id})
  145. assert result["code"] == 200, result
  146. assert (h.quote.contact_id, h.quote.contact_name, h.quote.contact_mobile, h.quote.contact_email) == ("", "", "", "")
  147. @pytest.mark.parametrize("customer_id, expected_id", [("customer-old", "contact-old"), ("customer-new", "")])
  148. def test_omitted_contact_keeps_same_customer_but_clears_for_changed_customer(h, customer_id, expected_id):
  149. path = "/quote/mobile-update"
  150. assert path in h.router.routes, "The mobile contact-safe update entry is missing"
  151. body = QuoteUpdateBody(id="q1", customerId=customer_id)
  152. result = asyncio.run(h.router.routes[path](body, _user={"user_id": "user-a", "tenant_no": "tenant-a", "role": "admin"}, db=h.db))
  153. assert result["code"] == 200, result
  154. assert h.quote.contact_id == expected_id
  155. assert h.quote.contact_name == ("旧姓名" if expected_id else "")
  156. @pytest.mark.parametrize("contact_mode", ["null", "omitted"])
  157. @pytest.mark.parametrize("invalid_customer", ["deleted", "wrong-tenant", "out-of-scope"])
  158. def test_empty_contact_still_validates_customer_before_any_mutation(
  159. h, contact_mode, invalid_customer
  160. ):
  161. if invalid_customer == "deleted":
  162. h.customers[1].del_flag = 1
  163. elif invalid_customer == "wrong-tenant":
  164. h.customers[1].tenant_no = "tenant-b"
  165. else:
  166. h.customers[1].owner_user_id = "user-b"
  167. payload = {"id": "q1", "customerId": "customer-new"}
  168. if contact_mode == "null":
  169. payload["contactId"] = None
  170. before = dict(vars(h.quote))
  171. result = asyncio.run(
  172. h.router.routes["/quote/mobile-update"](
  173. QuoteUpdateBody(**payload),
  174. _user={"user_id": "user-a", "tenant_no": "tenant-a", "role": "admin"},
  175. db=h.db,
  176. )
  177. )
  178. assert result["code"] == 400, result
  179. assert vars(h.quote) == before
  180. assert (h.db.commits, h.db.flushes, h.db.approval_invalidations) == (0, 0, 0)
  181. @pytest.mark.parametrize("invalid", ["missing", "deleted", "wrong-customer", "wrong-tenant", "customer-deleted", "customer-wrong-tenant", "customer-out-of-scope", "quote-out-of-scope", "quote-wrong-tenant", "quote-not-editable"])
  182. def test_invalid_selection_rejects_before_quote_or_approval_persistence(h, invalid):
  183. if invalid == "missing":
  184. h.contacts[1].id = "different"
  185. elif invalid == "deleted":
  186. h.contacts[1].del_flag = 1
  187. elif invalid == "wrong-customer":
  188. h.contacts[1].customer_id = "customer-old"
  189. elif invalid == "wrong-tenant":
  190. h.contacts[1].tenant_no = "tenant-b"
  191. elif invalid == "customer-deleted":
  192. h.customers[1].del_flag = 1
  193. elif invalid == "customer-wrong-tenant":
  194. h.customers[1].tenant_no = "tenant-b"
  195. elif invalid == "customer-out-of-scope":
  196. h.customers[1].owner_user_id = "user-b"
  197. elif invalid == "quote-out-of-scope":
  198. h.quote.owner_user_id = "user-b"
  199. elif invalid == "quote-wrong-tenant":
  200. h.quote.tenant_no = "tenant-b"
  201. elif invalid == "quote-not-editable":
  202. h.quote.status = "invalid"
  203. before = dict(vars(h.quote))
  204. result = h.call({})
  205. assert result["code"] == 400, result
  206. assert vars(h.quote) == before
  207. assert (h.db.commits, h.db.flushes, h.db.approval_invalidations) == (0, 0, 0)
  208. def test_customer_used_for_contact_validation_matches_the_persisted_id(h):
  209. before = dict(vars(h.quote))
  210. result = h.call({"customerId": " customer-new "})
  211. assert result["code"] == 400, result
  212. assert vars(h.quote) == before
  213. assert h.db.commits == 0
  214. @pytest.mark.parametrize("contact_id", ["0", "42", "9007199254740993", "uuid-contact"])
  215. def test_string_ids_keep_precision_and_resolve_the_same_contact(h, contact_id):
  216. h.contacts[1].id = contact_id
  217. result = h.call({"contactId": contact_id})
  218. assert result["code"] == 200, result
  219. assert h.quote.contact_id == contact_id
  220. assert h.quote.contact_name == "新姓名"
  221. def test_mobile_and_web_routes_share_the_existing_auth_and_schema(h):
  222. import inspect
  223. mobile = inspect.signature(h.router.routes["/quote/mobile-update"])
  224. web = inspect.signature(h.router.routes["/quote/update"])
  225. assert mobile.parameters["_user"].default is web.parameters["_user"].default
  226. assert mobile.parameters["db"].default is web.parameters["db"].default
  227. assert mobile.parameters["body"].annotation == web.parameters["body"].annotation
  228. def test_original_web_update_retains_existing_schema_and_default_behavior(h):
  229. result = h.call({"contactName": "客户端姓名"}, mobile=False)
  230. assert result["code"] == 200, result
  231. assert "contactName" not in QuoteUpdateBody.model_fields
  232. assert h.quote.contact_name == "旧姓名"
  233. assert h.quote.contact_mobile == "222"
  234. assert h.db.commits == 1
  235. @pytest.mark.parametrize("status", ["draft", "submitted", "accepted", "rejected"])
  236. def test_mobile_reuses_existing_reedit_to_draft_rules(h, status):
  237. h.quote.status = status
  238. result = h.call({})
  239. assert result["code"] == 200, result
  240. assert h.quote.status == "draft"
  241. assert h.db.approval_invalidations == 1