test_api_batch4.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. # -*- coding: utf-8 -*-
  2. """客户端 api 第 4 批接口冒烟:图形/短信验证码、登录、收银台、支付回调。
  3. 用法:起 Java 18080 后执行
  4. python tools/test_api_batch4.py
  5. 环境变量:YOSHOP_API_BASE(默认 http://127.0.0.1:18080)
  6. 说明:
  7. - 图形验证码的答案从 Redis 读取(模拟用户看图输入),验证码接口本身走真实链路;
  8. - 短信通道未接入,验证码只落 Redis,脚本同样从 Redis 取值完成登录;
  9. - 会真实下单/支付/回调,finally 中删除测试订单、交易记录、充值单、余额流水与测试用户,
  10. 并恢复用户余额 / 总支付金额 / 商品销量。
  11. """
  12. import hashlib
  13. import json
  14. import os
  15. import re
  16. import socket
  17. import sys
  18. import pymysql
  19. import requests
  20. from _root import ROOT
  21. from redis_probe import cmd
  22. BASE = os.environ.get("YOSHOP_API_BASE", "http://127.0.0.1:18080")
  23. TOKEN = "apitest-batch4"
  24. USER_ID = 10001
  25. # 登录用测试手机号(脚本会创建并在 finally 删除)
  26. TEST_MOBILE = "13900001111"
  27. # 支付链路测试商品(下单减库存、单规格由 make_order 固定为 goods_sku_id)
  28. GOODS_ID = 10009
  29. SKU_ID = "10009"
  30. YML = str(ROOT / "yoshop-java/src/main/resources/application.yml")
  31. TEXT = open(YML, encoding="utf-8").read()
  32. DB_PWD = re.findall(r"password:\s*(.+)", TEXT)[0].strip().strip("\"'")
  33. REDIS_PWD = re.findall(r"password:\s*(.+)", TEXT)[1].strip().strip("\"'")
  34. # 是否校验短信验证码:跟随后端 yoshop.sms.verify-code(未配置时为 false)
  35. _m = re.search(r"verify-code:\s*(\w+)", TEXT)
  36. SMS_VERIFY = bool(_m) and _m.group(1).lower() in ("true", "1", "yes")
  37. S = requests.Session()
  38. S.trust_env = False
  39. passed = 0
  40. failed = 0
  41. created_orders = []
  42. created_trades = []
  43. created_recharge_orders = []
  44. created_payments = []
  45. backup = {}
  46. def check(name, cond, detail=""):
  47. global passed, failed
  48. if cond:
  49. passed += 1
  50. print(f" PASS {name}")
  51. else:
  52. failed += 1
  53. print(f" FAIL {name} {detail}")
  54. def call(method, path, params=None, body=None, token=TOKEN):
  55. headers = {"platform": "MP-WEIXIN"}
  56. if token:
  57. headers["Access-Token"] = token
  58. if method == "GET":
  59. resp = S.get(BASE + path, params=params or {}, headers=headers, timeout=60)
  60. else:
  61. resp = S.post(BASE + path, params=params or {}, json=body, headers=headers, timeout=60)
  62. try:
  63. return resp.json()
  64. except Exception:
  65. return {"status": resp.status_code, "raw": resp.text[:300]}
  66. def get(path, params=None, token=TOKEN):
  67. return call("GET", path, params=params, token=token)
  68. def post(path, body=None, params=None, token=TOKEN):
  69. return call("POST", path, params=params, body=body, token=token)
  70. def raw_post(path, text=None, data=None, headers=None):
  71. resp = S.post(BASE + path, data=text if text is not None else data,
  72. headers=headers or {}, timeout=60)
  73. return resp.text
  74. def snake_keys(obj, prefix="$"):
  75. found = []
  76. if isinstance(obj, dict):
  77. for k, v in obj.items():
  78. if isinstance(k, str) and re.fullmatch(r"[a-z0-9]+(_[a-z0-9]+)+", k):
  79. found.append(f"{prefix}.{k}")
  80. found.extend(snake_keys(v, f"{prefix}.{k}"))
  81. elif isinstance(obj, list):
  82. for i, v in enumerate(obj[:3]):
  83. found.extend(snake_keys(v, f"{prefix}[{i}]"))
  84. return found
  85. def db(sql):
  86. conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD,
  87. database="shop", charset="utf8mb4", autocommit=True)
  88. try:
  89. cur = conn.cursor()
  90. cur.execute(sql)
  91. return cur.fetchall()
  92. finally:
  93. conn.close()
  94. def db_exec(sql):
  95. conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD,
  96. database="shop", charset="utf8mb4", autocommit=True)
  97. try:
  98. cur = conn.cursor()
  99. cur.execute(sql)
  100. return cur.lastrowid
  101. finally:
  102. conn.close()
  103. def redis_cmd(*args):
  104. sock = socket.create_connection(("192.168.1.177", 6379), timeout=5)
  105. try:
  106. cmd(sock, "AUTH", REDIS_PWD)
  107. return cmd(sock, *args)
  108. finally:
  109. sock.close()
  110. def redis_get(key):
  111. return redis_cmd("GET", key)
  112. def redis_del(key):
  113. return redis_cmd("DEL", key)
  114. def field(obj, name, default=None):
  115. return obj.get(name, default) if isinstance(obj, dict) else default
  116. def new_captcha():
  117. """取一张新图形验证码,返回 (接口数据, 明文验证码)"""
  118. data = get("/api/captcha/image", token=None).get("data") or {}
  119. raw = redis_get("yoshop:api:captcha:" + str(data.get("key")))
  120. code = json.loads(raw)["code"] if raw else None
  121. return data, code
  122. def send_sms(mobile):
  123. """完整走一遍「图形验证码 → 发送短信」,返回 (响应, 短信验证码, 所用图形验证码)"""
  124. data, code = new_captcha()
  125. resp = post("/api/captcha/sendSmsCaptcha",
  126. {"mobile": mobile, "captchaCode": code, "captchaKey": data.get("key")}, token=None)
  127. raw = redis_get("yoshop:api:sms:" + mobile)
  128. sms = json.loads(raw)["code"] if raw else None
  129. return resp, sms, data
  130. def make_order(order_status=10, pay_status=10, order_no=None):
  131. order_no = order_no or ("TEST" + str(os.getpid()) + str(len(created_orders)))
  132. order_id = db_exec(
  133. "INSERT INTO yoshop_order (order_no, order_type, total_price, order_price, coupon_id, coupon_money, "
  134. "points_money, points_num, pay_price, update_price, buyer_remark, pay_method, pay_status, trade_id, "
  135. "delivery_type, express_price, delivery_status, receipt_status, order_status, points_bonus, "
  136. "is_settled, is_comment, order_source, order_source_id, order_source_data, platform, user_id, "
  137. "is_delete, store_id, create_time, update_time) VALUES "
  138. f"('{order_no}', 10, 80.00, 80.00, 0, 0.00, 0.00, 0, 83.00, 0.00, '', '', {pay_status}, 0, "
  139. f"10, 3.00, 10, 10, {order_status}, 0, 0, 0, 10, 0, '[]', "
  140. f"'H5', {USER_ID}, 0, 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
  141. created_orders.append(order_id)
  142. db_exec(
  143. "INSERT INTO yoshop_order_goods (goods_id, goods_type, goods_name, image_id, deduct_stock_type, "
  144. "spec_type, goods_sku_id, goods_props, content, goods_no, goods_price, line_price, goods_weight, "
  145. "is_user_grade, grade_ratio, grade_goods_price, grade_total_money, coupon_money, points_money, "
  146. "points_num, points_bonus, total_num, total_price, total_pay_price, delivery_status, delivery_num, "
  147. "is_comment, order_id, user_id, goods_source_id, store_id, create_time) VALUES "
  148. f"({GOODS_ID}, 10, '正宗荔浦芋头', 0, 10, 20, '{SKU_ID}', '', '', '', 80.00, 100.00, 4.5, "
  149. f"0, 0, 0.00, 0.00, 0.00, 0.00, 0, 0, 1, 80.00, 80.00, 10, 0, 0, {order_id}, {USER_ID}, 0, "
  150. "10001, UNIX_TIMESTAMP())")
  151. return order_id, order_no
  152. def make_trade(order_id, order_no, method="wechat", order_type=10, user_id=USER_ID):
  153. out_trade_no = "NT" + str(os.getpid()) + str(len(created_trades)) + str(int(order_id))
  154. trade_id = db_exec(
  155. "INSERT INTO yoshop_payment_trade (out_trade_no, client, pay_method, order_type, order_id, order_no, "
  156. "user_id, trade_no, prepay_id, trade_state, store_id, create_time, update_time) VALUES "
  157. f"('{out_trade_no}', 'MP-WEIXIN', '{method}', {order_type}, {order_id}, '{order_no}', {user_id}, "
  158. f"'', '', 10, 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
  159. created_trades.append(trade_id)
  160. return trade_id, out_trade_no
  161. def cleanup():
  162. try:
  163. ids = ",".join(str(i) for i in created_orders) or "0"
  164. tids = ",".join(str(i) for i in created_trades) or "0"
  165. rids = ",".join(str(i) for i in created_recharge_orders) or "0"
  166. db_exec(f"DELETE FROM yoshop_payment_trade WHERE trade_id IN ({tids})")
  167. db_exec(f"DELETE FROM yoshop_order_goods WHERE order_id IN ({ids})")
  168. db_exec(f"DELETE FROM yoshop_order WHERE order_id IN ({ids})")
  169. db_exec(f"DELETE FROM yoshop_recharge_order WHERE order_id IN ({rids})")
  170. pids = ",".join(str(i) for i in created_payments) or "0"
  171. db_exec(f"DELETE FROM yoshop_payment WHERE payment_id IN ({pids})")
  172. db_exec(f"DELETE FROM yoshop_user_balance_log WHERE user_id={USER_ID} AND "
  173. f"(`describe` LIKE '用户消费:TEST%' OR `describe` LIKE '用户充值:TEST%')")
  174. if backup:
  175. db_exec(f"UPDATE yoshop_user SET balance={backup['balance']}, pay_money={backup['pay_money']}, "
  176. f"points={backup['points']} WHERE user_id={USER_ID}")
  177. db_exec(f"UPDATE yoshop_goods SET sales_actual={backup['sales_actual']} WHERE goods_id={GOODS_ID}")
  178. db_exec(f"DELETE FROM yoshop_user_oauth WHERE user_id IN "
  179. f"(SELECT user_id FROM yoshop_user WHERE mobile='{TEST_MOBILE}')")
  180. db_exec(f"DELETE FROM yoshop_user WHERE mobile='{TEST_MOBILE}'")
  181. for key in ["yoshop:api:sms:" + TEST_MOBILE, "yoshop:api:sms:send:" + TEST_MOBILE,
  182. "yoshop:api:token:" + TOKEN]:
  183. redis_del(key)
  184. print(" CLEAN 测试数据已清理")
  185. except Exception as e: # noqa: BLE001
  186. print(" CLEAN 失败:", e)
  187. def main():
  188. print("== 客户端 api 第 4 批(验证码 / 登录 / 收银台 / 支付回调) ==")
  189. redis_cmd("SET", "yoshop:api:token:" + TOKEN, str(USER_ID))
  190. redis_cmd("EXPIRE", "yoshop:api:token:" + TOKEN, "3600")
  191. row = db(f"SELECT balance, pay_money, points FROM yoshop_user WHERE user_id={USER_ID}")[0]
  192. backup.update(balance=row[0], pay_money=row[1], points=row[2],
  193. sales_actual=db(f"SELECT sales_actual FROM yoshop_goods WHERE goods_id={GOODS_ID}")[0][0])
  194. for key in ["yoshop:api:sms:" + TEST_MOBILE, "yoshop:api:sms:send:" + TEST_MOBILE]:
  195. redis_del(key)
  196. # ---------------------------------------------------------------- 图形验证码
  197. print("\n[1] 图形验证码")
  198. r = get("/api/captcha/image", token=None)
  199. data = field(r, "data") or {}
  200. check("captcha/image 成功", r.get("status") == 200, r)
  201. check("出参仅 base64/key/md5", sorted(data.keys()) == ["base64", "key", "md5"], list(data.keys()))
  202. base64_text = str(data.get("base64"))
  203. check("base64 为 png data URI", base64_text.startswith("data:image/png;base64,") and len(base64_text) > 1000,
  204. base64_text[:40])
  205. check("key 为 bcrypt 摘要", str(data.get("key", "")).startswith("$2"), str(data.get("key"))[:10])
  206. cache = redis_get("yoshop:api:captcha:" + str(data.get("key")))
  207. cached = json.loads(cache) if cache else {}
  208. check("验证码写入 Redis", cached.get("code") and cached.get("times") == 5, cache)
  209. check("md5 为验证码摘要",
  210. data.get("md5") == hashlib.md5(str(cached.get("code")).encode()).hexdigest(), data.get("md5"))
  211. ttl = redis_cmd("TTL", "yoshop:api:captcha:" + str(data.get("key")))
  212. check("验证码 TTL 为 1800 秒内", isinstance(ttl, int) and 0 < ttl <= 1800, ttl)
  213. # ---------------------------------------------------------------- 发送短信验证码
  214. print("\n[2] 发送短信验证码")
  215. r = post("/api/captcha/sendSmsCaptcha", {"captchaCode": "abc", "captchaKey": "x"}, token=None)
  216. check("缺手机号报错", r.get("status") == 500 and "手机号不能为空" in (r.get("message") or ""), r)
  217. r = post("/api/captcha/sendSmsCaptcha", {"mobile": TEST_MOBILE}, token=None)
  218. check("缺图形验证码报错", r.get("status") == 500 and "图形验证码code不能为空" in (r.get("message") or ""), r)
  219. r = post("/api/captcha/sendSmsCaptcha",
  220. {"mobile": TEST_MOBILE, "captchaCode": "wrong", "captchaKey": data.get("key")}, token=None)
  221. check("图形验证码错误报错", r.get("status") == 500 and "图形验证码不正确" in (r.get("message") or ""), r)
  222. r = post("/api/captcha/sendSmsCaptcha",
  223. {"mobile": TEST_MOBILE, "captchaCode": "x"}, token=None)
  224. check("图形验证码 key 为空报错", r.get("status") == 500 and "图形验证码key不能为空" in (r.get("message") or ""), r)
  225. r, sms_code, used_captcha = send_sms(TEST_MOBILE)
  226. check("短信发送成功", r.get("status") == 200 and "发送成功" in (r.get("message") or ""), r)
  227. check("发送成功 data 为空数组", r.get("data") == [], r.get("data"))
  228. check("短信验证码为 6 位数字", bool(sms_code) and re.fullmatch(r"\d{6}", sms_code), sms_code)
  229. r = post("/api/captcha/sendSmsCaptcha",
  230. {"mobile": TEST_MOBILE, "captchaCode": "x", "captchaKey": used_captcha.get("key")}, token=None)
  231. check("图形验证码用后即焚", r.get("status") == 500 and "图形验证码不存在" in (r.get("message") or ""), r)
  232. # 每日发送次数限制:首次已用 1 次,再发 9 次后应为 0 次
  233. last = None
  234. for _ in range(9):
  235. last, _, _ = send_sms(TEST_MOBILE)
  236. check("第 10 次发送仍成功", last.get("status") == 200, last)
  237. last, _, _ = send_sms(TEST_MOBILE)
  238. check("超出每日发送次数被拒", last.get("status") == 500 and "已超出今日最大发送次数限制" in (last.get("message") or ""), last)
  239. left = redis_get("yoshop:api:sms:send:" + TEST_MOBILE)
  240. check("发送次数计为 0", str(left) == "0", left)
  241. # 重置发送次数窗口,避免影响后续登录用例
  242. redis_del("yoshop:api:sms:send:" + TEST_MOBILE)
  243. # ---------------------------------------------------------------- 登录
  244. print("\n[3] 手机号 + 短信验证码登录")
  245. r = post("/api/passport/login", {"smsCode": "123456"}, token=None)
  246. check("缺手机号报错", r.get("status") == 500 and "手机号不能为空" in (r.get("message") or ""), r)
  247. r = post("/api/passport/login", {"mobile": "12345", "smsCode": "123456"}, token=None)
  248. check("手机号格式校验", r.get("status") == 500 and "手机号格式不正确" in (r.get("message") or ""), r)
  249. if SMS_VERIFY:
  250. r = post("/api/passport/login", {"mobile": TEST_MOBILE}, token=None)
  251. check("缺短信验证码报错", r.get("status") == 500 and "短信验证码不能为空" in (r.get("message") or ""), r)
  252. redis_del("yoshop:api:sms:" + TEST_MOBILE)
  253. r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": "123456"}, token=None)
  254. check("验证码不存在报错", r.get("status") == 500 and "短信验证码不存在" in (r.get("message") or ""), r)
  255. _, sms_code, _ = send_sms(TEST_MOBILE)
  256. r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": "000000"}, token=None)
  257. check("验证码错误报错", r.get("status") == 500 and "短信验证码不正确" in (r.get("message") or ""), r)
  258. check("验证码错误后次数减一", json.loads(redis_get("yoshop:api:sms:" + TEST_MOBILE)).get("times") == 4,
  259. redis_get("yoshop:api:sms:" + TEST_MOBILE))
  260. r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": sms_code}, token=None)
  261. check("登录后验证码用后即焚", redis_get("yoshop:api:sms:" + TEST_MOBILE) is None)
  262. else:
  263. print(" (短信通道未接入,yoshop.sms.verify-code=false:验证码随便填)")
  264. r = post("/api/passport/login", {"mobile": TEST_MOBILE}, token=None)
  265. login_data = field(r, "data") or {}
  266. check("登录成功", r.get("status") == 200 and r.get("message") == "登录成功", r)
  267. if not SMS_VERIFY:
  268. check("空短信验证码也能登录(联调开关)", r.get("status") == 200, r)
  269. check("返回 userId 与 token", isinstance(login_data.get("userId"), int)
  270. and re.fullmatch(r"[0-9a-f]{32}", str(login_data.get("token"))) is not None, login_data)
  271. user_id = login_data.get("userId")
  272. check("新用户写入库", bool(user_id) and db(f"SELECT COUNT(*) FROM yoshop_user WHERE user_id={user_id}")[0][0] == 1)
  273. nick = db(f"SELECT nick_name, platform FROM yoshop_user WHERE user_id={user_id}")[0] if user_id else ("", "")
  274. check("默认昵称为打码手机号", nick[0] == "139****1111", nick)
  275. check("注册来源取 platform 头", nick[1] == "MP-WEIXIN", nick)
  276. login_token = str(login_data.get("token"))
  277. r = get("/api/user/info", token=login_token)
  278. info = field(field(r, "data"), "userInfo") or {}
  279. check("登录态可用", r.get("status") == 200 and info.get("userId") == user_id, r)
  280. check("用户信息手机号打码", info.get("mobile") == "139****1111", info.get("mobile"))
  281. if SMS_VERIFY:
  282. _, sms_code, _ = send_sms(TEST_MOBILE)
  283. r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": sms_code},
  284. token=None)
  285. else:
  286. r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": "000000"},
  287. token=None)
  288. check("重复登录复用同一账号", field(r, "data", {}).get("userId") == user_id, r)
  289. # ---------------------------------------------------------------- 微信小程序登录
  290. print("\n[4] 微信小程序登录")
  291. r = post("/api/passport/loginMpWx", {"partyData": {"oauth": "MP-WEIXIN", "userInfo": {}}}, token=None)
  292. check("缺 code 报错", r.get("status") == 500 and "code 不能为空" in (r.get("message") or ""), r)
  293. r = post("/api/passport/loginMpWx",
  294. {"partyData": {"oauth": "MP-WEIXIN", "code": "fake-code", "userInfo": {}}}, token=None)
  295. check("未配置小程序参数时报错",
  296. r.get("status") == 500 and "AppID和AppSecret" in (r.get("message") or ""), r)
  297. r = post("/api/passport/loginMpWxMobile",
  298. {"code": "fake-code", "encryptedData": "x", "iv": "y"}, token=None)
  299. check("授权手机号登录同样受小程序配置限制",
  300. r.get("status") == 500 and "AppID和AppSecret" in (r.get("message") or ""), r)
  301. r = post("/api/passport/isPersonalMpweixin", {"code": "fake-code"}, token=None)
  302. check("isPersonalMpweixin 关闭时直接返回 false",
  303. r.get("status") == 200 and field(r, "data", {}).get("isPersonalMpweixin") is False, r)
  304. check("微信登录接口零 snake key", not snake_keys(r), snake_keys(r))
  305. # ---------------------------------------------------------------- 收银台
  306. print("\n[5] 收银台")
  307. r = get("/api/cashier/orderInfo", {"orderId": 1, "client": "MP-WEIXIN"}, token=None)
  308. check("未登录访问收银台 401", r.get("status") == 401, r)
  309. order_id, order_no = make_order()
  310. cancelled_id, _ = make_order(order_status=20)
  311. r = get("/api/cashier/orderInfo", {"orderId": 999999, "client": "MP-WEIXIN"})
  312. check("订单不存在报错", r.get("status") == 500 and "订单不存在" in (r.get("message") or ""), r)
  313. r = get("/api/cashier/orderInfo", {"orderId": cancelled_id, "client": "MP-WEIXIN"})
  314. check("已取消订单不可支付", r.get("status") == 500 and "不允许支付" in (r.get("message") or ""), r)
  315. r = get("/api/cashier/orderInfo", {"orderId": order_id, "client": "MP-WEIXIN"})
  316. info = field(r, "data") or {}
  317. order_part = info.get("order") or {}
  318. check("收银台返回订单/用户/支付方式", r.get("status") == 200
  319. and sorted(info.keys()) == ["order", "paymentMethods", "personal"], list(info.keys()))
  320. check("订单金额两位小数字符串", order_part.get("payPrice") == "83.00", order_part)
  321. check("返回超时信息", order_part.get("showExpiration") is True
  322. and re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(order_part.get("expirationTime"))) is not None,
  323. order_part)
  324. check("订单字段齐全", {"orderId", "orderNo", "payStatus", "orderStatus", "createTime"} <= set(order_part.keys()),
  325. list(order_part.keys()))
  326. methods = [m.get("method") for m in (info.get("paymentMethods") or [])]
  327. check("支付方式含余额支付", "balance" in methods, methods)
  328. check("收银台零 snake key", not snake_keys(info), snake_keys(info))
  329. r = post("/api/cashier/orderPay", {"orderId": order_id, "client": "MP-WEIXIN"})
  330. check("缺支付方式报错", r.get("status") == 500 and "请选择支付方式" in (r.get("message") or ""), r)
  331. r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "unknown", "client": "MP-WEIXIN"})
  332. check("支付方式不可用报错", r.get("status") == 500 and "支付方式不可用" in (r.get("message") or ""), r)
  333. db_exec(f"UPDATE yoshop_user SET balance=0 WHERE user_id={USER_ID}")
  334. r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "balance", "client": "MP-WEIXIN"})
  335. check("余额不足报错", r.get("status") == 500 and "余额不足" in (r.get("message") or ""), r)
  336. wechat_payment_id = db_exec(
  337. "INSERT INTO yoshop_payment (client, method, is_must_template, template_id, is_enable, is_default, "
  338. "others, store_id, create_time, update_time) VALUES ('MP-WEIXIN', 'wechat', 0, 0, 1, 0, '{}', 10001, "
  339. "UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
  340. created_payments.append(wechat_payment_id)
  341. r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "wechat", "client": "MP-WEIXIN"})
  342. check("微信支付未接入有明确文案", r.get("status") == 500 and "尚未接入" in (r.get("message") or ""), r)
  343. pay_money_before = float(db(f"SELECT pay_money FROM yoshop_user WHERE user_id={USER_ID}")[0][0])
  344. db_exec(f"UPDATE yoshop_user SET balance=500 WHERE user_id={USER_ID}")
  345. r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "balance", "client": "MP-WEIXIN"})
  346. check("余额支付成功", r.get("status") == 200 and r.get("message") == "恭喜您,订单已付款成功", r)
  347. check("余额支付不返回第三方参数", field(r, "data", {}).get("payment") == [], r.get("data"))
  348. row = db(f"SELECT pay_status, pay_method, trade_id, pay_time FROM yoshop_order WHERE order_id={order_id}")[0]
  349. check("订单置为已付款", row[0] == 20 and row[1] == "balance" and row[3] > 0, row)
  350. balance = float(db(f"SELECT balance FROM yoshop_user WHERE user_id={USER_ID}")[0][0])
  351. check("余额扣减订单金额", abs(balance - 417.0) < 0.001, balance)
  352. pay_money = float(db(f"SELECT pay_money FROM yoshop_user WHERE user_id={USER_ID}")[0][0])
  353. check("累加用户总支付金额", abs(pay_money - pay_money_before - 83.0) < 0.001, pay_money)
  354. logs = db(f"SELECT scene, money, `describe` FROM yoshop_user_balance_log WHERE user_id={USER_ID} "
  355. f"AND `describe`='用户消费:{order_no}'")
  356. check("写余额消费明细", len(logs) == 1 and logs[0][0] == 20 and float(logs[0][1]) == -83.0, logs)
  357. sales = db(f"SELECT sales_actual FROM yoshop_goods WHERE goods_id={GOODS_ID}")[0][0]
  358. check("累计商品销量", sales == backup["sales_actual"] + 1, (sales, backup["sales_actual"]))
  359. r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "balance", "client": "MP-WEIXIN"})
  360. check("已支付订单不可重复支付", r.get("status") == 500 and "已支付" in (r.get("message") or ""), r)
  361. r = get("/api/cashier/tradeQuery", {"method": "wechat", "client": "MP-WEIXIN"})
  362. check("交易查询缺单号报错", r.get("status") == 500 and "商户订单号不能为空" in (r.get("message") or ""), r)
  363. r = get("/api/cashier/tradeQuery", {"outTradeNo": "X1", "method": "wechat", "client": "MP-WEIXIN"})
  364. check("交易查询失败仍返回成功响应",
  365. r.get("status") == 200 and field(r, "data", {}).get("isPay") is False, r)
  366. check("交易查询命中未接入文案", "尚未接入" in (r.get("message") or ""), r.get("message"))
  367. r = get("/api/cashier/tradeQuery", {"outTradeNo": "X1", "method": "balance", "client": "MP-WEIXIN"})
  368. check("非第三方交易查询返回未支付",
  369. r.get("status") == 200 and field(r, "data", {}).get("isPay") is False
  370. and "未支付" in (r.get("message") or ""), r)
  371. # ---------------------------------------------------------------- 支付回调
  372. print("\n[6] 支付回调")
  373. text = raw_post("/api/notify/wechatV2", text="<xml><out_trade_no>NOT-EXIST</out_trade_no></xml>",
  374. headers={"Content-Type": "text/xml"})
  375. check("微信 V2 未知单号返回 FAIL", text == "FAIL", text)
  376. text = raw_post("/api/notify/wechatV2", text="not-xml", headers={"Content-Type": "text/xml"})
  377. check("微信 V2 报文异常返回 FAIL", text == "FAIL", text)
  378. text = raw_post("/api/notify/wechatV3", text="{}", headers={"Content-Type": "application/json"})
  379. check("微信 V3 未接入返回失败 JSON", '"code": "FAIL"' in text, text)
  380. order2, order_no2 = make_order()
  381. _, out2 = make_trade(order2, order_no2, method="wechat")
  382. xml = (f"<xml><out_trade_no><![CDATA[{out2}]]></out_trade_no>"
  383. f"<transaction_id><![CDATA[WX{out2}]]></transaction_id>"
  384. f"<return_code><![CDATA[SUCCESS]]></return_code></xml>")
  385. text = raw_post("/api/notify/wechatV2", text=xml, headers={"Content-Type": "text/xml"})
  386. check("微信 V2 回调返回 SUCCESS", "SUCCESS" in text, text)
  387. row = db(f"SELECT pay_status, pay_method FROM yoshop_order WHERE order_id={order2}")[0]
  388. check("微信 V2 回调置订单已付款", row[0] == 20 and row[1] == "wechat", row)
  389. trade = db(f"SELECT trade_state, trade_no FROM yoshop_payment_trade WHERE out_trade_no='{out2}'")[0]
  390. check("交易记录置为已支付", trade[0] == 20 and trade[1] == "WX" + out2, trade)
  391. text = raw_post("/api/notify/wechatV2", text=xml, headers={"Content-Type": "text/xml"})
  392. check("重复回调幂等返回 SUCCESS", "SUCCESS" in text, text)
  393. order3, order_no3 = make_order()
  394. _, out3 = make_trade(order3, order_no3, method="alipay")
  395. text = raw_post("/api/notify/alipay", data={"out_trade_no": out3, "trade_no": "ALI" + out3})
  396. check("支付宝回调返回 success", text == "success", text)
  397. row = db(f"SELECT pay_status, pay_method FROM yoshop_order WHERE order_id={order3}")[0]
  398. check("支付宝回调置订单已付款", row[0] == 20 and row[1] == "alipay", row)
  399. text = raw_post("/api/notify/alipay", data={"out_trade_no": "NOT-EXIST"})
  400. check("支付宝未知单号返回 fail", text == "fail", text)
  401. # 充值订单回调
  402. recharge_no = "TESTR" + str(os.getpid())
  403. recharge_id = db_exec(
  404. "INSERT INTO yoshop_recharge_order (order_no, user_id, recharge_type, plan_id, pay_price, gift_money, "
  405. "actual_money, pay_method, pay_status, pay_time, trade_id, platform, store_id, create_time, update_time) "
  406. f"VALUES ('{recharge_no}', {USER_ID}, 10, 0, 100.00, 10.00, 110.00, '', 10, 0, 0, "
  407. f"'MP-WEIXIN', 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
  408. created_recharge_orders.append(recharge_id)
  409. _, out4 = make_trade(recharge_id, recharge_no, method="alipay", order_type=100)
  410. balance_before = float(db(f"SELECT balance FROM yoshop_user WHERE user_id={USER_ID}")[0][0])
  411. text = raw_post("/api/notify/alipay", data={"out_trade_no": out4, "trade_no": "ALI" + out4})
  412. check("充值订单回调返回 success", text == "success", text)
  413. row = db(f"SELECT pay_status FROM yoshop_recharge_order WHERE order_id={recharge_id}")[0]
  414. check("充值订单置为已支付", row[0] == 20, row)
  415. balance = float(db(f"SELECT balance FROM yoshop_user WHERE user_id={USER_ID}")[0][0])
  416. check("充值金额到账", abs(balance - balance_before - 110.0) < 0.001, (balance, balance_before))
  417. logs = db(f"SELECT COUNT(*) FROM yoshop_user_balance_log WHERE user_id={USER_ID} "
  418. f"AND `describe`='用户充值:{recharge_no}'")
  419. check("写充值明细", logs[0][0] == 1, logs)
  420. print(f"\n结果:PASS {passed} / FAIL {failed}")
  421. return 1 if failed else 0
  422. if __name__ == "__main__":
  423. try:
  424. code = main()
  425. finally:
  426. cleanup()
  427. sys.exit(code)