test_api_batch1.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  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. setting = r.get("data") or {}
  85. check("setting/data 六个公共项", set(setting) == {
  86. "appTheme", "pageCategoryTemplate", "points", "recharge", "register", "customer"}, list(setting))
  87. check("setting/data register 有 registerMethod",
  88. "registerMethod" in (setting.get("register") or {}), setting.get("register"))
  89. # ---------- 地区 / 分类 / 内容
  90. r = get("/api/region/all")
  91. region_all = (r.get("data") or {}).get("list") or {}
  92. check("region/all 非空", len(region_all) > 3000, len(region_all))
  93. r = get("/api/region/tree")
  94. region_tree = (r.get("data") or {}).get("list") or {}
  95. check("region/tree 省级 34 条", len(region_tree) >= 30, len(region_tree))
  96. r = get("/api/category/list")
  97. cats = (r.get("data") or {}).get("list") or []
  98. check("category/list 是数组", isinstance(cats, list), type(cats))
  99. check("category/list 有子分类或至少 1 条", len(cats) >= 1, len(cats))
  100. if cats:
  101. keys = set(cats[0])
  102. check("分类隐藏 storeId/createTime/updateTime",
  103. not ({"storeId", "createTime", "updateTime"} & keys), keys)
  104. # 文章:库里现有文章已被软删除,临时插一条用于验证,跑完删除
  105. article_id = None
  106. try:
  107. db("INSERT INTO yoshop_article (title, show_type, category_id, image_id, content, sort, status, "
  108. "virtual_views, actual_views, is_delete, store_id, create_time, update_time) "
  109. "VALUES ('api自测文章', 10, 0, 0, '<p>正文</p>', 100, 1, 0, 0, 0, 10001, "
  110. "UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
  111. article_id = db("SELECT MAX(article_id) FROM yoshop_article")[0][0]
  112. r = get("/api/article.category/list")
  113. check("article.category/list status=200", r.get("status") == 200, r.get("message"))
  114. r = get("/api/article/list")
  115. page_list = (r.get("data") or {}).get("list") or {}
  116. check("article/list status=200 且分页结构", r.get("status") == 200
  117. and all(k in page_list for k in ("total", "perPage", "currentPage", "lastPage")),
  118. r.get("message"))
  119. check("article/list 命中临时文章", any(a.get("articleId") == article_id for a in (page_list.get("data") or [])))
  120. r = get("/api/article/detail", {"articleId": article_id})
  121. article = (r.get("data") or {}).get("detail") or {}
  122. check("article/detail 返回文章", article.get("articleId") == article_id,
  123. article.get("message") or r.get("message"))
  124. check("文章隐藏 virtualViews/actualViews",
  125. "actualViews" not in article and "virtualViews" not in article)
  126. check("文章 content 已反转义", "<" not in str(article.get("content")), article.get("content"))
  127. views = db(f"SELECT actual_views FROM yoshop_article WHERE article_id={article_id}")[0][0]
  128. check("文章阅读数 +1", views == 1, views)
  129. finally:
  130. if article_id:
  131. db(f"DELETE FROM yoshop_article WHERE article_id={article_id}")
  132. r = get("/api/help/list")
  133. check("help/list status=200", r.get("status") == 200, r.get("message"))
  134. # ---------- 页面
  135. r = get("/api/page/detail")
  136. page_data = (r.get("data") or {}).get("pageData") or {}
  137. check("page/detail 默认首页有 page/items", "page" in page_data and "items" in page_data, list(page_data))
  138. items = page_data.get("items") or []
  139. check("页面组件已去掉 defaultData/demo",
  140. all("defaultData" not in it and "demo" not in it for it in items))
  141. # ---------- 商品
  142. r = get("/api/goods/list")
  143. page = (r.get("data") or {}).get("list") or {}
  144. goods_list = page.get("data") or []
  145. check("goods/list 有数据", len(goods_list) > 0, r.get("message"))
  146. check("goods/list 分页 15", page.get("perPage") == 15, page.get("perPage"))
  147. if goods_list:
  148. g = goods_list[0]
  149. check("列表隐藏 content/goodsImages/images",
  150. not ({"content", "goodsImages", "images"} & set(g)), set(g) & {"content", "goodsImages", "images"})
  151. check("列表含 goodsImage/goodsSales/isUserGrade",
  152. all(k in g for k in ("goodsImage", "goodsSales", "isUserGrade")), list(g))
  153. goods_ids = [g["goodsId"] for g in goods_list[:3]]
  154. r = get("/api/goods/detail", {"goodsId": 10004})
  155. detail = (r.get("data") or {}).get("detail") or {}
  156. check("goods/detail 多规格含 skuList/specList",
  157. bool(detail.get("skuList")) and bool(detail.get("specList")), list(detail))
  158. check("详情隐藏 images", "images" not in detail, "images" in detail)
  159. check("详情含 goodsImages", "goodsImages" in detail, list(detail))
  160. check("详情 content 已反转义(不含 <)", "<" not in str(detail.get("content")))
  161. r = get("/api/goods/detail", {"goodsId": 999999})
  162. check("goods/detail 不存在时 status=500", r.get("status") == 500, r.get("status"))
  163. r = get("/api/goods/basic", {"goodsId": 10003})
  164. basic = (r.get("data") or {}).get("detail") or {}
  165. check("goods/basic 不含 skuList/video", "skuList" not in basic and "video" not in basic, list(basic))
  166. check("goods/basic 含 content", "content" in basic)
  167. r = get("/api/goods/spec_data", {"goodsId": 10004})
  168. spec = (r.get("data") or {}).get("specData") or {}
  169. check("goods/spec_data 含 skuList/specList", bool(spec.get("skuList")) and bool(spec.get("specList")))
  170. sku0 = (spec.get("skuList") or [{}])[0]
  171. check("skuList 隐藏 image/storeId/createTime", not ({"image", "storeId", "createTime"} & set(sku0)), set(sku0))
  172. check("skuList 含 imageUrl", "imageUrl" in sku0, list(sku0))
  173. sku_id = sku0.get("goodsSkuId")
  174. if sku_id:
  175. r = get("/api/goods/sku_info", {"goodsId": 10004, "goodsSkuId": sku_id})
  176. sku_info = (r.get("data") or {}).get("skuInfo") or {}
  177. check("goods/sku_info 命中 SKU", sku_info.get("goodsSkuId") == sku_id, sku_info)
  178. r = get("/api/goods.service/list", {"goodsId": 10004})
  179. check("goods.service/list status=200", r.get("status") == 200, r.get("message"))
  180. services = (r.get("data") or {}).get("list") or []
  181. if services:
  182. check("服务承诺隐藏 status/sort/isDefault",
  183. not ({"status", "sort", "isDefault"} & set(services[0])), set(services[0]))
  184. # ---------- 评价 / 优惠券 / 快递
  185. for path, key in [("/api/comment/list", "list"), ("/api/comment/list_rows", "list")]:
  186. r = get(path, {"goodsId": 10003})
  187. check(f"{path} status=200", r.get("status") == 200, r.get("message"))
  188. r = get("/api/comment/total", {"goodsId": 10003})
  189. total = r.get("data") or {}
  190. check("comment/total 四个统计项", set(total) == {"all", "praise", "review", "negative"}, total)
  191. r = get("/api/coupon/list")
  192. coupons = (r.get("data") or {}).get("list") or []
  193. check("coupon/list 有数据", len(coupons) >= 1, r.get("message"))
  194. if coupons:
  195. c = coupons[0]
  196. check("优惠券隐藏 totalNum/receiveNum/status",
  197. not ({"totalNum", "receiveNum", "status"} & set(c)), set(c))
  198. check("未登录不返回 isReceive", "isReceive" not in c, list(c))
  199. r = get("/api/express/list")
  200. express = (r.get("data") or {}).get("list") or []
  201. check("express/list 有数据", len(express) > 0, r.get("message"))
  202. if express:
  203. check("快递隐藏 kuaidi100Code/kdniaoCode",
  204. not ({"kuaidi100Code", "kdniaoCode"} & set(express[0])), set(express[0]))
  205. # ---------- 登录态相关(带 token 才有 isReceive / 会员价)
  206. if TOKEN:
  207. r = get("/api/coupon/list", token=TOKEN)
  208. coupons = (r.get("data") or {}).get("list") or []
  209. if coupons:
  210. check("登录后 coupon/list 返回 isReceive", "isReceive" in coupons[0], list(coupons[0]))
  211. else:
  212. print(" SKIP 带 token 的 isReceive 检查(未设置 YOSHOP_TOKEN)")
  213. # ---------- 驼峰检查
  214. for path, params in [("/api/goods/list", {}), ("/api/store/data", {}),
  215. ("/api/goods/detail", {"goodsId": 10004}),
  216. ("/api/category/list", {})]:
  217. r = get(path, params)
  218. bad = snake_keys(r.get("data"))
  219. check(f"{path} 无 snake_case 键", not bad, bad[:5])
  220. print(f"\n结果:{passed} passed, {failed} failed")
  221. return 0 if failed == 0 else 1
  222. if __name__ == "__main__":
  223. sys.exit(main())