Files
shanxia2.0/backend/tests/test_mobile_quote_contact_sync.py

301 lines
13 KiB
Python

"""Execute production routes/CRUD with an in-memory Session double, never an Engine."""
from __future__ import annotations
import ast
import asyncio
import builtins
from datetime import datetime, timedelta, date
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.schemas.crm.quote import QuoteUpdateBody
ROOT = Path(__file__).resolve().parents[1]
def load_functions(path, names, bindings):
tree = ast.parse((ROOT / path).read_text())
nodes = [node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
module = ast.Module(body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), *nodes], type_ignores=[])
exec(compile(ast.fix_missing_locations(module), str(path), "exec"), bindings)
class Field:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return lambda row: getattr(row, self.name) == value
def desc(self):
return self
class Model(type):
def __getattr__(cls, name):
return Field(name)
class Row(SimpleNamespace):
def __getattr__(self, name):
return None
class Query:
def __init__(self, rows):
self.rows = list(rows)
def filter(self, *predicates):
self.rows = [row for row in self.rows if all(p(row) for p in predicates)]
return self
def order_by(self, *_args):
return self
def first(self):
return next(iter(self.rows), None)
def all(self):
return self.rows
class SessionDouble:
def __init__(self, rows):
self.rows = rows
self.commits = 0
self.flushes = 0
self.approval_invalidations = 0
def query(self, model):
return Query(self.rows[model.__name__])
def flush(self):
self.flushes += 1
def commit(self):
self.commits += 1
class Router:
def __init__(self):
self.routes = {}
def post(self, path, **_kwargs):
def register(fn):
self.routes[path] = fn
return fn
return register
@pytest.fixture
def h(monkeypatch):
# Deny both network access and accidental SQLAlchemy engine construction/connection.
def forbidden(*_args, **_kwargs):
raise AssertionError("Network/Engine access is forbidden in mobile regression tests")
import socket
import sqlalchemy
monkeypatch.setattr(socket.socket, "connect", forbidden)
monkeypatch.setattr(sqlalchemy, "create_engine", forbidden)
monkeypatch.setattr(sqlalchemy.engine.Engine, "connect", forbidden)
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")
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"]]
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")]
db = SessionDouble({"CrmQuote": [quote], "CrmCustomer": customers, "CrmContact": contacts, "SysUser": []})
ns = {
"datetime": datetime, "timedelta": timedelta, "date": date, "Decimal": Decimal,
"_FMT": "%Y-%m-%d %H:%M:%S", "_DATE_FMT": "%Y-%m-%d",
"QUOTE_EDITABLE_STATUSES": frozenset({"draft", "submitted", "accepted", "rejected"}),
"CRM_OWNER_FIELD": "owner_user_id",
"QuoteUpdateBody": QuoteUpdateBody,
"row_in_data_scope": lambda _db, row, user, tenant, **_kw: row.tenant_no == tenant and row.owner_user_id == user["user_id"],
"get_tenant_no": lambda _db, user: user["tenant_no"],
"Depends": lambda dep: dep,
"get_db": forbidden,
"require_admin_or_super_admin": forbidden,
"require_menu_code": lambda *_args, **_kw: forbidden,
"success_response": lambda data, message="success": {"code": 200, "data": data, "message": message},
"error_response": lambda code, message: {"code": code, "message": message},
"list_quote_products": lambda *_args, **_kw: [],
"get_supplier_info": lambda *_args, **_kw: None,
"assert_products_allowed_for_supplier": lambda *_args, **_kw: None,
"_resolve_quote_product_ids": lambda *_args, **_kw: None,
"prepare_quote_products": lambda *_args, **_kw: None,
"sync_quote_products": lambda *_args, **_kw: None,
"sync_approval_notifications": lambda *_args, **_kw: None,
"batch_insert_quote_products": lambda *_args, **_kw: None,
"invalidate_all_approvals": lambda *_args, **_kw: setattr(db, "approval_invalidations", db.approval_invalidations + 1),
}
for name in ["CrmQuote", "CrmCustomer", "CrmContact", "SysUser"]:
ns[name] = Model(name, (), {})
load_functions("app/crud/crm/data_scope_helpers.py", {"crm_user_info", "ensure_crm_owner_row_in_scope"}, ns)
load_functions("app/crud/crm/customer.py", {"get_customer"}, ns)
# The real contact helper imports get_customer lazily; supply the real extracted function.
real_import = builtins.__import__
def isolated_import(name, *args, **kwargs):
if name == "app.crud.crm.customer":
return SimpleNamespace(get_customer=ns["get_customer"])
return real_import(name, *args, **kwargs)
ns["__builtins__"] = {**vars(builtins), "__import__": isolated_import}
contact_ns = dict(ns)
load_functions("app/crud/crm/contact.py", {"_row_to_dict", "get_contact", "list_contacts_by_customer"}, contact_ns)
ns.update({key: contact_ns[key] for key in ["get_contact", "list_contacts_by_customer"]})
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)
route_tree = ast.parse((ROOT / "app/api/crm_quote_routes.py").read_text())
load_functions("app/api/crm_quote_routes.py", {node.name for node in route_tree.body if isinstance(node, ast.FunctionDef)}, ns)
router = Router()
ns["register_quote_routes"](router)
user = {"user_id": "user-a", "tenant_no": "tenant-a", "role": "admin"}
def call(payload, mobile=True):
path = "/quote/mobile-update" if mobile else "/quote/update"
assert path in router.routes, "The mobile contact-safe update entry is missing"
body = QuoteUpdateBody(**{"id": "q1", "customerId": "customer-new", "contactId": "contact-new", **payload})
return asyncio.run(router.routes[path](body, _user=user, db=db))
return SimpleNamespace(call=call, quote=quote, customers=customers, contacts=contacts, db=db, ns=ns, router=router)
def test_mobile_save_uses_canonical_contact_and_keeps_product_amount(h):
result = h.call({"contactName": "伪造姓名", "tenantNo": "tenant-b", "freight": 5, "barrelDeposit": 3, "products": [{"quantity": 2, "price": 12.5}]})
assert result["code"] == 200, result
assert (h.quote.contact_id, h.quote.contact_name, h.quote.contact_mobile, h.quote.contact_email) == ("contact-new", "新姓名", "222", "new@example.test")
assert result["data"]["finalAmount"] == 25
assert h.quote.tenant_no == "tenant-a"
assert h.db.commits == 1
@pytest.mark.parametrize("missing", ["mobile", "email", "both"])
def test_new_contact_empty_fields_do_not_retain_previous_snapshot(h, missing):
if missing in {"mobile", "both"}:
h.contacts[1].mobile = ""
if missing in {"email", "both"}:
h.contacts[1].email = None
result = h.call({})
assert result["code"] == 200, result
assert h.quote.contact_mobile == ("" if missing in {"mobile", "both"} else "222")
assert h.quote.contact_email == ("" if missing in {"email", "both"} else "new@example.test")
@pytest.mark.parametrize("contact_id", [None, "", " "])
def test_explicit_clear_removes_the_whole_snapshot(h, contact_id):
result = h.call({"contactId": contact_id})
assert result["code"] == 200, result
assert (h.quote.contact_id, h.quote.contact_name, h.quote.contact_mobile, h.quote.contact_email) == ("", "", "", "")
@pytest.mark.parametrize("customer_id, expected_id", [("customer-old", "contact-old"), ("customer-new", "")])
def test_omitted_contact_keeps_same_customer_but_clears_for_changed_customer(h, customer_id, expected_id):
path = "/quote/mobile-update"
assert path in h.router.routes, "The mobile contact-safe update entry is missing"
body = QuoteUpdateBody(id="q1", customerId=customer_id)
result = asyncio.run(h.router.routes[path](body, _user={"user_id": "user-a", "tenant_no": "tenant-a", "role": "admin"}, db=h.db))
assert result["code"] == 200, result
assert h.quote.contact_id == expected_id
assert h.quote.contact_name == ("旧姓名" if expected_id else "")
@pytest.mark.parametrize("contact_mode", ["null", "omitted"])
@pytest.mark.parametrize("invalid_customer", ["deleted", "wrong-tenant", "out-of-scope"])
def test_empty_contact_still_validates_customer_before_any_mutation(
h, contact_mode, invalid_customer
):
if invalid_customer == "deleted":
h.customers[1].del_flag = 1
elif invalid_customer == "wrong-tenant":
h.customers[1].tenant_no = "tenant-b"
else:
h.customers[1].owner_user_id = "user-b"
payload = {"id": "q1", "customerId": "customer-new"}
if contact_mode == "null":
payload["contactId"] = None
before = dict(vars(h.quote))
result = asyncio.run(
h.router.routes["/quote/mobile-update"](
QuoteUpdateBody(**payload),
_user={"user_id": "user-a", "tenant_no": "tenant-a", "role": "admin"},
db=h.db,
)
)
assert result["code"] == 400, result
assert vars(h.quote) == before
assert (h.db.commits, h.db.flushes, h.db.approval_invalidations) == (0, 0, 0)
@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"])
def test_invalid_selection_rejects_before_quote_or_approval_persistence(h, invalid):
if invalid == "missing":
h.contacts[1].id = "different"
elif invalid == "deleted":
h.contacts[1].del_flag = 1
elif invalid == "wrong-customer":
h.contacts[1].customer_id = "customer-old"
elif invalid == "wrong-tenant":
h.contacts[1].tenant_no = "tenant-b"
elif invalid == "customer-deleted":
h.customers[1].del_flag = 1
elif invalid == "customer-wrong-tenant":
h.customers[1].tenant_no = "tenant-b"
elif invalid == "customer-out-of-scope":
h.customers[1].owner_user_id = "user-b"
elif invalid == "quote-out-of-scope":
h.quote.owner_user_id = "user-b"
elif invalid == "quote-wrong-tenant":
h.quote.tenant_no = "tenant-b"
elif invalid == "quote-not-editable":
h.quote.status = "invalid"
before = dict(vars(h.quote))
result = h.call({})
assert result["code"] == 400, result
assert vars(h.quote) == before
assert (h.db.commits, h.db.flushes, h.db.approval_invalidations) == (0, 0, 0)
def test_customer_used_for_contact_validation_matches_the_persisted_id(h):
before = dict(vars(h.quote))
result = h.call({"customerId": " customer-new "})
assert result["code"] == 400, result
assert vars(h.quote) == before
assert h.db.commits == 0
@pytest.mark.parametrize("contact_id", ["0", "42", "9007199254740993", "uuid-contact"])
def test_string_ids_keep_precision_and_resolve_the_same_contact(h, contact_id):
h.contacts[1].id = contact_id
result = h.call({"contactId": contact_id})
assert result["code"] == 200, result
assert h.quote.contact_id == contact_id
assert h.quote.contact_name == "新姓名"
def test_mobile_and_web_routes_share_the_existing_auth_and_schema(h):
import inspect
mobile = inspect.signature(h.router.routes["/quote/mobile-update"])
web = inspect.signature(h.router.routes["/quote/update"])
assert mobile.parameters["_user"].default is web.parameters["_user"].default
assert mobile.parameters["db"].default is web.parameters["db"].default
assert mobile.parameters["body"].annotation == web.parameters["body"].annotation
def test_original_web_update_retains_existing_schema_and_default_behavior(h):
result = h.call({"contactName": "客户端姓名"}, mobile=False)
assert result["code"] == 200, result
assert "contactName" not in QuoteUpdateBody.model_fields
assert h.quote.contact_name == "旧姓名"
assert h.quote.contact_mobile == "222"
assert h.db.commits == 1
@pytest.mark.parametrize("status", ["draft", "submitted", "accepted", "rejected"])
def test_mobile_reuses_existing_reedit_to_draft_rules(h, status):
h.quote.status = status
result = h.call({})
assert result["code"] == 200, result
assert h.quote.status == "draft"
assert h.db.approval_invalidations == 1