test_api_batch1.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. # -*- coding: utf-8 -*-
  2. """客户端 api 第 1 批接口冒烟:只读接口结构 + 关键字段断言。
  3. 用法:起 Java 18080 后执行
  4. python tools/test_api_batch1.py
  5. 环境变量:YOSHOP_API_BASE(默认 http://127.0.0.1:18080)、YOSHOP_TOKEN(已登录用户 token,可选)
  6. """
  7. import json
  8. import os
  9. import re
  10. import sys
  11. import pymysql
  12. import requests
  13. from _root import ROOT
  14. BASE = os.environ.get("YOSHOP_API_BASE", "http://127.0.0.1:18080")
  15. TOKEN = os.environ.get("YOSHOP_TOKEN", "").strip()
  16. YML = str(ROOT / "yoshop-java/src/main/resources/application.yml")
  17. PWD = re.findall(r"password:\s*(.+)", open(YML, encoding="utf-8").read())[0].strip().strip("\"'")
  18. S = requests.Session()
  19. S.trust_env = False
  20. passed = 0
  21. failed = 0
  22. def check(name, cond, detail=""):
  23. global passed, failed
  24. if cond:
  25. passed += 1
  26. print(f" PASS {name}")
  27. else:
  28. failed += 1
  29. print(f" FAIL {name} {detail}")
  30. def get(path, params=None, token=None):
  31. headers = {"platform": "MP-WEIXIN"}
  32. if token:
  33. headers["Access-Token"] = token
  34. resp = S.get(BASE + path, params=params or {}, headers=headers, timeout=30)
  35. try:
  36. return resp.json()
  37. except Exception:
  38. return {"status": resp.status_code, "raw": resp.text[:200]}
  39. def post(path, body=None, token=None):
  40. headers = {"platform": "MP-WEIXIN"}
  41. if token:
  42. headers["Access-Token"] = token
  43. resp = S.post(BASE + path, json=body or {}, headers=headers, timeout=30)
  44. try:
  45. return resp.json()
  46. except Exception:
  47. return {"status": resp.status_code, "raw": resp.text[:200]}
  48. def snake_keys(obj, prefix="$"):
  49. """收集所有 snake_case 键"""
  50. found = []
  51. if isinstance(obj, dict):
  52. for k, v in obj.items():
  53. if isinstance(k, str) and re.fullmatch(r"[a-z0-9]+(_[a-z0-9]+)+", k):
  54. found.append(f"{prefix}.{k}")
  55. found.extend(snake_keys(v, f"{prefix}.{k}"))
  56. elif isinstance(obj, list):
  57. for i, v in enumerate(obj[:3]):
  58. found.extend(snake_keys(v, f"{prefix}[{i}]"))
  59. return found
  60. def db(sql):
  61. conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=PWD,
  62. database="shop", charset="utf8mb4", autocommit=True)
  63. try:
  64. cur = conn.cursor()
  65. cur.execute(sql)
  66. return cur.fetchall()
  67. finally:
  68. conn.close()
  69. def main():
  70. print("== 客户端 api 第 1 批(基础 / 内容 / 商品 / 营销 只读) ==")
  71. # ---------- 基础信息
  72. r = get("/api/store/data")
  73. data = r.get("data") or {}
  74. check("store/data status=200", r.get("status") == 200, r.get("message"))
  75. check("store/data 含 storeInfo/client/setting/clientData",
  76. all(k in data for k in ("storeInfo", "client", "setting", "clientData")), list(data))
  77. check("store/data client=MP-WEIXIN", data.get("client") == "MP-WEIXIN", data.get("client"))
  78. store_info = data.get("storeInfo") or {}
  79. check("storeInfo 隐藏 sort/isRecycle/isDelete", not any(
  80. k in store_info for k in ("sort", "isRecycle", "isDelete")), list(store_info))
  81. check("storeInfo 含 imageUrl 键", "imageUrl" in store_info, list(store_info))
  82. check("clientData.h5.setting 存在", "setting" in ((data.get("clientData") or {}).get("h5") or {}))
  83. r = get("/api/setting/data")
  84. # PHP 控制器是 renderSuccess(compact('setting')),客户端读 result.data.setting
  85. setting = (r.get("data") or {}).get("setting") or {}
  86. check("setting/data 外层有 setting 包裹", isinstance((r.get("data") or {}).get("setting"), dict),
  87. list(r.get("data") or {}))
  88. check("setting/data 六个公共项", set(setting) == {
  89. "appTheme", "pageCategoryTemplate", "points", "recharge", "register", "customer"}, list(setting))
  90. check("setting/data register 有 registerMethod",
  91. "registerMethod" in (setting.get("register") or {}), setting.get("register"))
  92. # 库内只有 register/sms/trade,其余项必须按 PHP defaultData() 补齐,
  93. # 否则客户端读 pageCategoryTemplate.style 得到 undefined,分类页三个模板组件都不渲染
  94. check("setting/data 缺项按 PHP 默认值补齐",
  95. (setting.get("pageCategoryTemplate") or {}).get("style") == 30
  96. and (setting.get("appTheme") or {}).get("mainBg") == "#fa2209"
  97. and (setting.get("points") or {}).get("pointsName") == "积分"
  98. and (setting.get("recharge") or {}).get("isEntrance") == 1,
  99. {k: setting.get(k) for k in ("appTheme", "pageCategoryTemplate", "points", "recharge")})
  100. # ---------- 地区 / 分类 / 内容
  101. r = get("/api/region/all")
  102. region_all = (r.get("data") or {}).get("list") or {}
  103. check("region/all 非空", len(region_all) > 3000, len(region_all))
  104. r = get("/api/region/tree")
  105. region_tree = (r.get("data") or {}).get("list") or {}
  106. check("region/tree 省级 34 条", len(region_tree) >= 30, len(region_tree))
  107. r = get("/api/category/list")
  108. cats = (r.get("data") or {}).get("list") or []
  109. check("category/list 是数组", isinstance(cats, list), type(cats))
  110. check("category/list 有子分类或至少 1 条", len(cats) >= 1, len(cats))
  111. if cats:
  112. keys = set(cats[0])
  113. check("分类隐藏 storeId/createTime/updateTime",
  114. not ({"storeId", "createTime", "updateTime"} & keys), keys)
  115. # 文章:库里现有文章已被软删除,临时插一条用于验证,跑完删除
  116. article_id = None
  117. try:
  118. db("INSERT INTO yoshop_article (title, show_type, category_id, image_id, content, sort, status, "
  119. "virtual_views, actual_views, is_delete, store_id, create_time, update_time) "
  120. "VALUES ('api自测文章', 10, 0, 0, '<p>正文</p>', 100, 1, 0, 0, 0, 10001, "
  121. "UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
  122. article_id = db("SELECT MAX(article_id) FROM yoshop_article")[0][0]
  123. r = get("/api/article.category/list")
  124. check("article.category/list status=200", r.get("status") == 200, r.get("message"))
  125. r = get("/api/article/list")
  126. page_list = (r.get("data") or {}).get("list") or {}
  127. check("article/list status=200 且分页结构", r.get("status") == 200
  128. and all(k in page_list for k in ("total", "perPage", "currentPage", "lastPage")),
  129. r.get("message"))
  130. check("article/list 命中临时文章", any(a.get("articleId") == article_id for a in (page_list.get("data") or [])))
  131. r = get("/api/article/detail", {"articleId": article_id})
  132. article = (r.get("data") or {}).get("detail") or {}
  133. check("article/detail 返回文章", article.get("articleId") == article_id,
  134. article.get("message") or r.get("message"))
  135. check("文章隐藏 virtualViews/actualViews",
  136. "actualViews" not in article and "virtualViews" not in article)
  137. check("文章 content 已反转义", "<" not in str(article.get("content")), article.get("content"))
  138. views = db(f"SELECT actual_views FROM yoshop_article WHERE article_id={article_id}")[0][0]
  139. check("文章阅读数 +1", views == 1, views)
  140. finally:
  141. if article_id:
  142. db(f"DELETE FROM yoshop_article WHERE article_id={article_id}")
  143. r = get("/api/help/list")
  144. check("help/list status=200", r.get("status") == 200, r.get("message"))
  145. # ---------- 页面
  146. r = get("/api/page/detail")
  147. page_data = (r.get("data") or {}).get("pageData") or {}
  148. check("page/detail 默认首页有 page/items", "page" in page_data and "items" in page_data, list(page_data))
  149. items = page_data.get("items") or []
  150. check("页面组件已去掉 defaultData/demo",
  151. all("defaultData" not in it and "demo" not in it for it in items))
  152. # ---------- 商品
  153. r = get("/api/goods/list")
  154. page = (r.get("data") or {}).get("list") or {}
  155. goods_list = page.get("data") or []
  156. check("goods/list 有数据", len(goods_list) > 0, r.get("message"))
  157. check("goods/list 分页 15", page.get("perPage") == 15, page.get("perPage"))
  158. if goods_list:
  159. g = goods_list[0]
  160. check("列表隐藏 content/goodsImages/images",
  161. not ({"content", "goodsImages", "images"} & set(g)), set(g) & {"content", "goodsImages", "images"})
  162. check("列表含 goodsImage/goodsSales/isUserGrade",
  163. all(k in g for k in ("goodsImage", "goodsSales", "isUserGrade")), list(g))
  164. goods_ids = [g["goodsId"] for g in goods_list[:3]]
  165. r = get("/api/goods/detail", {"goodsId": 10004})
  166. detail = (r.get("data") or {}).get("detail") or {}
  167. check("goods/detail 多规格含 skuList/specList",
  168. bool(detail.get("skuList")) and bool(detail.get("specList")), list(detail))
  169. check("详情隐藏 images", "images" not in detail, "images" in detail)
  170. check("详情含 goodsImages", "goodsImages" in detail, list(detail))
  171. check("详情 content 已反转义(不含 <)", "<" not in str(detail.get("content")))
  172. r = get("/api/goods/detail", {"goodsId": 999999})
  173. check("goods/detail 不存在时 status=500", r.get("status") == 500, r.get("status"))
  174. r = get("/api/goods/basic", {"goodsId": 10003})
  175. basic = (r.get("data") or {}).get("detail") or {}
  176. check("goods/basic 不含 skuList/video", "skuList" not in basic and "video" not in basic, list(basic))
  177. check("goods/basic 含 content", "content" in basic)
  178. r = get("/api/goods/spec_data", {"goodsId": 10004})
  179. spec = (r.get("data") or {}).get("specData") or {}
  180. check("goods/spec_data 含 skuList/specList", bool(spec.get("skuList")) and bool(spec.get("specList")))
  181. sku0 = (spec.get("skuList") or [{}])[0]
  182. check("skuList 隐藏 image/storeId/createTime", not ({"image", "storeId", "createTime"} & set(sku0)), set(sku0))
  183. check("skuList 含 imageUrl", "imageUrl" in sku0, list(sku0))
  184. sku_id = sku0.get("goodsSkuId")
  185. if sku_id:
  186. r = get("/api/goods/sku_info", {"goodsId": 10004, "goodsSkuId": sku_id})
  187. sku_info = (r.get("data") or {}).get("skuInfo") or {}
  188. check("goods/sku_info 命中 SKU", sku_info.get("goodsSkuId") == sku_id, sku_info)
  189. r = get("/api/goods.service/list", {"goodsId": 10004})
  190. check("goods.service/list status=200", r.get("status") == 200, r.get("message"))
  191. services = (r.get("data") or {}).get("list") or []
  192. if services:
  193. check("服务承诺隐藏 status/sort/isDefault",
  194. not ({"status", "sort", "isDefault"} & set(services[0])), set(services[0]))
  195. # ---------- 评价 / 优惠券 / 快递
  196. for path, key in [("/api/comment/list", "list"), ("/api/comment/list_rows", "list")]:
  197. r = get(path, {"goodsId": 10003})
  198. check(f"{path} status=200", r.get("status") == 200, r.get("message"))
  199. r = get("/api/comment/total", {"goodsId": 10003})
  200. total = r.get("data") or {}
  201. check("comment/total 四个统计项", set(total) == {"all", "praise", "review", "negative"}, total)
  202. r = get("/api/coupon/list")
  203. coupons = (r.get("data") or {}).get("list") or []
  204. check("coupon/list 有数据", len(coupons) >= 1, r.get("message"))
  205. if coupons:
  206. c = coupons[0]
  207. check("优惠券隐藏 totalNum/receiveNum/status",
  208. not ({"totalNum", "receiveNum", "status"} & set(c)), set(c))
  209. check("未登录不返回 isReceive", "isReceive" not in c, list(c))
  210. r = get("/api/express/list")
  211. express = (r.get("data") or {}).get("list") or []
  212. check("express/list 有数据", len(express) > 0, r.get("message"))
  213. if express:
  214. check("快递隐藏 kuaidi100Code/kdniaoCode",
  215. not ({"kuaidi100Code", "kdniaoCode"} & set(express[0])), set(express[0]))
  216. # ---------- 登录态相关(带 token 才有 isReceive / 会员价)
  217. if TOKEN:
  218. r = get("/api/coupon/list", token=TOKEN)
  219. coupons = (r.get("data") or {}).get("list") or []
  220. if coupons:
  221. check("登录后 coupon/list 返回 isReceive", "isReceive" in coupons[0], list(coupons[0]))
  222. else:
  223. print(" SKIP 带 token 的 isReceive 检查(未设置 YOSHOP_TOKEN)")
  224. # ---------- 驼峰检查
  225. for path, params in [("/api/goods/list", {}), ("/api/store/data", {}),
  226. ("/api/goods/detail", {"goodsId": 10004}),
  227. ("/api/category/list", {})]:
  228. r = get(path, params)
  229. bad = snake_keys(r.get("data"))
  230. check(f"{path} 无 snake_case 键", not bad, bad[:5])
  231. print(f"\n结果:{passed} passed, {failed} failed")
  232. return 0 if failed == 0 else 1
  233. if __name__ == "__main__":
  234. sys.exit(main())