api.test.js 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298
  1. /**
  2. * Tests for cfc-frontend utils/api.js
  3. *
  4. * Mocks uni.request to intercept and verify API calls.
  5. */
  6. import * as api from '@/utils/api'
  7. // --------------- helpers ---------------
  8. /** Helper: make uni.request call success(data) on next tick */
  9. function mockSuccess(data) {
  10. global.uni.request.mockImplementation((opts) => {
  11. process.nextTick(() => {
  12. if (opts.success) opts.success({ data })
  13. })
  14. })
  15. }
  16. /** Helper: make uni.request call fail(err) on next tick */
  17. function mockFail(err) {
  18. global.uni.request.mockImplementation((opts) => {
  19. process.nextTick(() => {
  20. if (opts.fail) opts.fail(err)
  21. })
  22. })
  23. }
  24. function mockSequence(responses) {
  25. var i = 0
  26. global.uni.request.mockImplementation((opts) => {
  27. var resp = responses[Math.min(i, responses.length - 1)]
  28. i++
  29. process.nextTick(() => {
  30. if (resp.err) {
  31. if (opts.fail) opts.fail(resp.err)
  32. } else {
  33. if (opts.success) opts.success({ data: resp.data })
  34. }
  35. })
  36. })
  37. }
  38. // --------------- global beforeEach ---------------
  39. beforeEach(() => {
  40. global.uni._storage = {}
  41. global.uni.request.mockReset()
  42. global.uni.showToast.mockReset()
  43. global.uni.reLaunch.mockReset()
  44. global.uni.login.mockReset()
  45. global.getCurrentPages.mockReset()
  46. global.getCurrentPages.mockReturnValue([{ route: 'pages/index-home/index' }])
  47. global.uni.getAccountInfoSync.mockReset()
  48. global.uni.getAccountInfoSync.mockReturnValue({
  49. miniProgram: { envVersion: 'develop' }
  50. })
  51. // api.js 的 3 秒请求去重缓存是模块级单例,跨用例残留会让后续用例拿到上一个用例缓存的
  52. // Promise(同 URL+method+body),表现为「本该 401 却是 200」「lastRequest() 为 undefined」。
  53. // 每个用例前显式清空,保证用例间隔离。
  54. api.clearApiDedup()
  55. })
  56. /** Capture the last uni.request call options */
  57. function lastRequest() {
  58. const calls = global.uni.request.mock.calls
  59. return calls[calls.length - 1][0]
  60. }
  61. // --------------- auth module ---------------
  62. describe('auth API', () => {
  63. beforeEach(() => {
  64. global.uni._storage.token = 'test-token'
  65. global.uni._storage.userId = '42'
  66. })
  67. test('wechatLogin sends POST /api/auth/wechat-login', async () => {
  68. mockSuccess({ code: 200, data: { token: 'jwt' } })
  69. const res = await api.wechatLogin('code123', { nickname: 'test' })
  70. const opts = lastRequest()
  71. expect(opts.method).toBe('POST')
  72. expect(opts.url).toMatch(/\/api\/auth\/wechat-login$/)
  73. expect(opts.data.code).toBe('code123')
  74. expect(res.data.token).toBe('jwt')
  75. })
  76. test('phoneLogin sends POST with phone and code', async () => {
  77. mockSuccess({ code: 200, data: { token: 'jwt' } })
  78. await api.phoneLogin({ phone: '13800138000', code: '1234' })
  79. const opts = lastRequest()
  80. expect(opts.method).toBe('POST')
  81. expect(opts.url).toMatch(/\/api\/auth\/phone-login$/)
  82. expect(opts.data.phone).toBe('13800138000')
  83. })
  84. test('sendCode sends POST with data', async () => {
  85. mockSuccess({ code: 200 })
  86. await api.sendCode({ phone: '13800138000' })
  87. const opts = lastRequest()
  88. expect(opts.method).toBe('POST')
  89. expect(opts.url).toMatch(/\/api\/auth\/send-code$/)
  90. })
  91. test('silentLogin sends POST with code', async () => {
  92. mockSuccess({ code: 200 })
  93. await api.silentLogin('wxcode')
  94. expect(lastRequest().data.code).toBe('wxcode')
  95. })
  96. // 已废弃:后端 /api/auth/auto-login 已改为 410 Gone(裸 openid 换 token 属越权漏洞)。
  97. // 导出仅为排查历史调用保留,不得在新代码中使用;续期请走 silentRelogin()。
  98. test('autoLogin (deprecated) still targets /api/auth/auto-login', async () => {
  99. mockSuccess({ code: 410, message: '该接口已废弃,请改用 /api/auth/silent-login' })
  100. await expect(api.autoLogin('openid_xxx')).rejects.toEqual(
  101. { code: 410, message: '该接口已废弃,请改用 /api/auth/silent-login' }
  102. )
  103. const opts = lastRequest()
  104. expect(opts.url).toMatch(/\/api\/auth\/auto-login$/)
  105. expect(opts.data.openid).toBe('openid_xxx')
  106. })
  107. test('setPassword and verifyPassword send correct endpoints', async () => {
  108. mockSuccess({ code: 200 })
  109. await api.setPassword('secret')
  110. expect(lastRequest().url).toMatch(/\/api\/auth\/set-password$/)
  111. expect(lastRequest().data.password).toBe('secret')
  112. await api.verifyPassword('secret')
  113. expect(lastRequest().url).toMatch(/\/api\/auth\/verify-password$/)
  114. })
  115. test('directRegister sends POST', async () => {
  116. mockSuccess({ code: 200 })
  117. await api.directRegister({ phone: '13800138000', nickname: 'tester' })
  118. expect(lastRequest().url).toMatch(/\/api\/auth\/direct-register$/)
  119. })
  120. test('checkPhone sends POST with phone', async () => {
  121. mockSuccess({ code: 200 })
  122. await api.checkPhone('13800138000')
  123. expect(lastRequest().data.phone).toBe('13800138000')
  124. })
  125. test('wechatPhoneLogin sends POST', async () => {
  126. mockSuccess({ code: 200 })
  127. await api.wechatPhoneLogin({ code: 'wxcode', iv: 'iv', encryptedData: 'enc' })
  128. expect(lastRequest().url).toMatch(/\/api\/auth\/wechat-phone-login$/)
  129. })
  130. test('verifyToken sends POST /api/auth/verify with bearer token', async () => {
  131. mockSuccess({ code: 200, data: { userId: 42, role: 'parent' } })
  132. const res = await api.verifyToken()
  133. const opts = lastRequest()
  134. expect(opts.method).toBe('POST')
  135. expect(opts.url).toMatch(/\/api\/auth\/verify$/)
  136. expect(opts.header.Authorization).toBe('Bearer test-token')
  137. expect(res.data.userId).toBe(42)
  138. })
  139. test('verifyToken with 401 (token expired) clears auth and redirects to login', async () => {
  140. jest.useFakeTimers()
  141. mockSuccess({ code: 401, message: 'Token无效或已过期', data: null })
  142. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
  143. expect(global.uni._storage.token).toBeUndefined()
  144. // 触发 _clearAuthAndRedirect 内 1.5s setTimeout(reLaunch 在此定时器回调中执行)
  145. jest.runAllTimers()
  146. expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
  147. expect(global.uni.showToast).toHaveBeenCalled()
  148. jest.useRealTimers()
  149. })
  150. test('verifyToken with 401 (user deleted) clears auth and redirects to login', async () => {
  151. jest.useFakeTimers()
  152. mockSuccess({ code: 401, message: '用户不存在', data: null })
  153. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: '用户不存在', data: null })
  154. expect(global.uni._storage.token).toBeUndefined()
  155. jest.runAllTimers()
  156. expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
  157. expect(global.uni.showToast).toHaveBeenCalled()
  158. jest.useRealTimers()
  159. })
  160. // 静默续期 = uni.login() 取微信签发的一次性 code → /api/auth/silent-login(服务端 code2Session)。
  161. // 本地 openid 只作为「本设备曾登录过」的触发条件,不作为凭证上送。
  162. function mockUniLoginCode(code) {
  163. global.uni.login.mockImplementation((opts) => {
  164. process.nextTick(() => {
  165. if (opts.success) opts.success({ code: code || 'wxcode' })
  166. })
  167. })
  168. }
  169. test('401 silently re-logins via wx.login + code2Session and retries original request', async () => {
  170. global.uni.reLaunch.mockReset()
  171. global.uni.showToast.mockReset()
  172. global.uni._storage.openid = 'openid_abc'
  173. global.uni._storage.token = 'expired-token'
  174. mockUniLoginCode('fresh-wxcode')
  175. mockSequence([
  176. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  177. { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
  178. { data: { code: 200, data: { userId: 42, role: 'parent' } } }
  179. ])
  180. const res = await api.verifyToken()
  181. expect(res.data.userId).toBe(42)
  182. expect(global.uni._storage.token).toBe('new-jwt')
  183. // 续期必须走 code2Session,且不得把 openid 当凭证发送
  184. const calls = global.uni.request.mock.calls.map((c) => c[0])
  185. const renewReq = calls.find((c) => /\/api\/auth\/silent-login/.test(c.url))
  186. expect(renewReq).toBeDefined()
  187. expect(renewReq.data.code).toBe('fresh-wxcode')
  188. expect(renewReq.data.openid).toBeUndefined()
  189. // 已废弃的裸 openid 换 token 接口不得被调用
  190. expect(calls.find((c) => /\/api\/auth\/auto-login/.test(c.url))).toBeUndefined()
  191. expect(global.uni.reLaunch).not.toHaveBeenCalled()
  192. expect(global.uni.showToast).not.toHaveBeenCalled()
  193. })
  194. // 回归:首页冷启动会并发打出多个 401(family/member/list、membership/my、onboarding/progress…)。
  195. // 修复前 _handle401 在 _reloginLock 为真时直接调 _clearAuthAndRedirect():
  196. // 第 1 个 401 刚启动 silentRelogin()(uni.login 异步),第 2、3 个 401 立刻把 token 从
  197. // storage 抹掉并预约 1500ms 后 reLaunch 登录页 → 在途续期被打断,
  198. // 线上表现为「控制台只有 401、没有任何续期日志、用户被弹回登录页」。
  199. test('concurrent 401s queue behind one renewal and all get replayed', async () => {
  200. global.uni._storage.openid = 'openid_abc'
  201. global.uni._storage.token = 'expired-token'
  202. mockUniLoginCode('fresh-wxcode')
  203. // 3 个业务请求同时 401 → 续期 1 次成功 → 3 个重放全部 200
  204. mockSequence([
  205. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  206. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  207. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  208. { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
  209. { data: { code: 200, data: { ok: 'verify' } } },
  210. { data: { code: 200, data: { ok: 'members' } } },
  211. { data: { code: 200, data: { ok: 'membership' } } }
  212. ])
  213. const [verifyRes, memberRes, membershipRes] = await Promise.all([
  214. api.verifyToken(),
  215. api.getFamilyMemberList({}),
  216. api.getMyMembership()
  217. ])
  218. // 三个请求都必须拿到成功结果(修复前后两个会被 reject)
  219. expect(verifyRes.data.ok).toBe('verify')
  220. expect(memberRes.data.ok).toBe('members')
  221. expect(membershipRes.data.ok).toBe('membership')
  222. const calls = global.uni.request.mock.calls.map((c) => c[0])
  223. // 续期只发生一次,绝不能因并发 401 引发续期风暴
  224. expect(calls.filter((c) => /\/api\/auth\/silent-login/.test(c.url)).length).toBe(1)
  225. // token 必须是被续期后的新值,而不是被并发 401 抹掉
  226. expect(global.uni._storage.token).toBe('new-jwt')
  227. // 关键回归点:并发 401 不得触发「登录已过期」提示,也不得跳登录页
  228. expect(global.uni.showToast).not.toHaveBeenCalled()
  229. expect(global.uni.reLaunch).not.toHaveBeenCalled()
  230. })
  231. // 回归:重放请求若仍 401(刚签发的 token 依旧不可用),必须直接失败而不是重新入队,
  232. // 否则「续期 → 重放 → 401 → 续期 → 重放」会 ping-pong 死循环。
  233. test('replayed request that still 401 fails without triggering another renewal', async () => {
  234. global.uni._storage.openid = 'openid_abc'
  235. global.uni._storage.token = 'expired-token'
  236. mockUniLoginCode('fresh-wxcode')
  237. mockSequence([
  238. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  239. { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
  240. { data: { code: 401, message: 'Token无效或已过期', data: null } }
  241. ])
  242. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
  243. const calls = global.uni.request.mock.calls.map((c) => c[0])
  244. // 只续期过一次,重放 401 不再触发第二次续期
  245. expect(calls.filter((c) => /\/api\/auth\/silent-login/.test(c.url)).length).toBe(1)
  246. expect(global.uni._storage.token).toBe('new-jwt')
  247. })
  248. test('401 retry does not duplicate query params in the replayed request URL', async () => {
  249. global.uni._storage.openid = 'openid_abc'
  250. global.uni._storage.token = 'expired-token'
  251. mockUniLoginCode('fresh-wxcode')
  252. mockSequence([
  253. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  254. { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
  255. { data: { code: 200, data: { ok: true } } }
  256. ])
  257. // publishPackage 通过 options 传 query(?publish=true),是最容易暴露重复拼接的调用形态
  258. await api.publishPackage(7, true)
  259. const calls = global.uni.request.mock.calls.map((c) => c[0])
  260. const hits = calls.filter((c) => /\/api\/packages\/7\/publish/.test(c.url))
  261. expect(hits.length).toBe(2) // 原始请求 1 次 + 401 续期后重放 1 次
  262. // 修复前:_handle401 收到的是已拼过 query 的 url,重放时 options 又拼一次 → ?publish=true&publish=true
  263. hits.forEach((c) => {
  264. expect(c.url).toBe(c.url.split('?')[0] + '?publish=true')
  265. expect((c.url.match(/publish=/g) || []).length).toBe(1)
  266. })
  267. })
  268. test('401 during app launch window clears auth but does NOT navigate to login subpackage', async () => {
  269. jest.useFakeTimers()
  270. global.uni._storage.openid = 'openid_abc'
  271. global.uni._storage.token = 'expired-token'
  272. // 仍停在 splash:pages/login 是分包,此刻尚未下载,
  273. // reLaunch 过去会报 'Page "pages/login/login" has not been registered yet'
  274. global.getCurrentPages.mockReturnValue([{ route: 'pages/splash/index' }])
  275. mockUniLoginCode()
  276. mockSequence([
  277. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  278. { data: { code: 500, message: '用户不存在,请先登录', data: null } }
  279. ])
  280. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
  281. jest.runAllTimers()
  282. // 登录态必须清掉
  283. expect(global.uni._storage.token).toBeUndefined()
  284. // 但启动窗口内绝不跳登录页,交由 splash 落地首页(主包)
  285. expect(global.uni.reLaunch).not.toHaveBeenCalled()
  286. jest.useRealTimers()
  287. })
  288. test('401 during app launch window (no page registered yet) also skips navigation', async () => {
  289. jest.useFakeTimers()
  290. global.uni._storage.openid = 'openid_abc'
  291. global.uni._storage.token = 'expired-token'
  292. // 首屏尚未注册,getCurrentPages() 为空
  293. global.getCurrentPages.mockReturnValue([])
  294. mockUniLoginCode()
  295. mockSequence([
  296. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  297. { data: { code: 500, message: '用户不存在,请先登录', data: null } }
  298. ])
  299. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
  300. jest.runAllTimers()
  301. expect(global.uni._storage.token).toBeUndefined()
  302. expect(global.uni.reLaunch).not.toHaveBeenCalled()
  303. jest.useRealTimers()
  304. })
  305. test('401 falls back to login page when silent re-login is refused', async () => {
  306. jest.useFakeTimers()
  307. global.uni._storage.openid = 'openid_abc'
  308. global.uni._storage.token = 'expired-token'
  309. mockUniLoginCode()
  310. mockSequence([
  311. { data: { code: 401, message: 'Token无效或已过期', data: null } },
  312. { data: { code: 500, message: '用户不存在,请先登录', data: null } }
  313. ])
  314. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
  315. jest.runAllTimers()
  316. expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
  317. expect(global.uni.showToast).toHaveBeenCalled()
  318. jest.useRealTimers()
  319. })
  320. test('401 with no cached openid (never logged in on this device) goes straight to login', async () => {
  321. jest.useFakeTimers()
  322. global.uni._storage.token = 'expired-token'
  323. mockSequence([
  324. { data: { code: 401, message: 'Token无效或已过期', data: null } }
  325. ])
  326. await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
  327. // 本设备从未登录过 → 不应调用 uni.login 浪费一次 code2Session
  328. expect(global.uni.login).not.toHaveBeenCalled()
  329. jest.runAllTimers()
  330. expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
  331. jest.useRealTimers()
  332. })
  333. test('silentRelogin returns null when wx.login yields no code', async () => {
  334. global.uni._storage.openid = 'openid_abc'
  335. global.uni.login.mockImplementation((opts) => {
  336. process.nextTick(() => { if (opts.success) opts.success({}) })
  337. })
  338. await expect(api.silentRelogin()).resolves.toBeNull()
  339. expect(global.uni.request).not.toHaveBeenCalled()
  340. })
  341. test('silentRelogin returns null when wx.login fails', async () => {
  342. global.uni._storage.openid = 'openid_abc'
  343. global.uni.login.mockImplementation((opts) => {
  344. process.nextTick(() => { if (opts.fail) opts.fail({ errMsg: 'login:fail' }) })
  345. })
  346. await expect(api.silentRelogin()).resolves.toBeNull()
  347. expect(global.uni.request).not.toHaveBeenCalled()
  348. })
  349. test('silentRelogin does not issue a renew request on 401 (no recursive renewal)', async () => {
  350. global.uni._storage.openid = 'openid_abc'
  351. global.uni._storage.token = 'expired-token'
  352. mockUniLoginCode()
  353. // 续期接口自身返回 401:必须直接失败,不能再触发一次 silentRelogin,否则会递归续期/误清登录态
  354. mockSuccess({ code: 401, message: 'Token无效或已过期', data: null })
  355. await expect(api.silentRelogin()).resolves.toBeNull()
  356. expect(global.uni.request.mock.calls.length).toBe(1)
  357. // 续期失败不得清空登录态(那是「主动登出」才做的事)
  358. expect(global.uni._storage.token).toBe('expired-token')
  359. expect(global.uni.reLaunch).not.toHaveBeenCalled()
  360. })
  361. test('Authorization header includes Bearer token', async () => {
  362. mockSuccess({ code: 200 })
  363. global.uni._storage.token = 'my-jwt-token'
  364. await api.getUserInfo()
  365. const opts = lastRequest()
  366. expect(opts.header.Authorization).toBe('Bearer my-jwt-token')
  367. })
  368. test('X-User-Id header is NOT sent (removed for security)', async () => {
  369. mockSuccess({ code: 200 })
  370. global.uni._storage.userId = '99'
  371. await api.getUserInfo()
  372. expect(lastRequest().header['X-User-Id']).toBeUndefined()
  373. })
  374. test('rejects on non-200 code', async () => {
  375. mockSuccess({ code: 400, message: 'Bad Request' })
  376. await expect(api.getUserInfo()).rejects.toEqual({ code: 400, message: 'Bad Request' })
  377. })
  378. test('rejects on network failure', async () => {
  379. mockFail(new Error('Network error'))
  380. await expect(api.getUserInfo()).rejects.toThrow('Network error')
  381. })
  382. })
  383. // --------------- streets module ---------------
  384. describe('street address API', () => {
  385. test('getProvinces sends POST to /api/streets/provinces', async () => {
  386. mockSuccess({ code: 200, data: [] })
  387. await api.getProvinces()
  388. expect(lastRequest().url).toMatch(/\/api\/streets\/provinces$/)
  389. })
  390. test('getStreetChildren sends POST with parentId', async () => {
  391. mockSuccess({ code: 200 })
  392. await api.getStreetChildren(110000)
  393. expect(lastRequest().url).toMatch(/\/api\/streets\/children\/110000$/)
  394. })
  395. test('getStreetById sends POST to correct URL', async () => {
  396. mockSuccess({ code: 200 })
  397. await api.getStreetById(110101)
  398. expect(lastRequest().url).toMatch(/\/api\/streets\/110101$/)
  399. })
  400. test('getStreetPath sends POST', async () => {
  401. mockSuccess({ code: 200 })
  402. await api.getStreetPath(110101)
  403. expect(lastRequest().url).toMatch(/\/api\/streets\/110101\/path$/)
  404. })
  405. test('searchStreet sends POST with keyword', async () => {
  406. mockSuccess({ code: 200 })
  407. await api.searchStreet('北京')
  408. expect(lastRequest().data.keyword).toBe('北京')
  409. })
  410. test('matchByStreet sends POST with streetId', async () => {
  411. mockSuccess({ code: 200 })
  412. await api.matchByStreet(110101)
  413. expect(lastRequest().url).toMatch(/\/110101\/match$/)
  414. })
  415. })
  416. // --------------- user / family module ---------------
  417. describe('user & family API', () => {
  418. test('getUserInfo sends POST', async () => {
  419. mockSuccess({ code: 200 })
  420. await api.getUserInfo()
  421. expect(lastRequest().url).toMatch(/\/api\/user\/info$/)
  422. })
  423. test('updateUserInfo sends POST with data', async () => {
  424. mockSuccess({ code: 200 })
  425. await api.updateUserInfo({ nickname: 'new-name' })
  426. expect(lastRequest().data.nickname).toBe('new-name')
  427. })
  428. test('joinFamily sends POST with inviteCode', async () => {
  429. mockSuccess({ code: 200 })
  430. await api.joinFamily('ABC123')
  431. expect(lastRequest().data.inviteCode).toBe('ABC123')
  432. })
  433. test('createFamily sends POST with name', async () => {
  434. mockSuccess({ code: 200 })
  435. await api.createFamily('幸福之家')
  436. expect(lastRequest().data.name).toBe('幸福之家')
  437. })
  438. test('getFamilyMemberList sends POST to unified endpoint', async () => {
  439. mockSuccess({ code: 200 })
  440. await api.getFamilyMemberList()
  441. expect(lastRequest().url).toMatch(/\/api\/family\/member\/list$/)
  442. })
  443. test('getFamilyMemberList sends visibleOnly param', async () => {
  444. mockSuccess({ code: 200 })
  445. await api.getFamilyMemberList({ visibleOnly: true })
  446. expect(lastRequest().url).toMatch(/\/api\/family\/member\/list$/)
  447. expect(lastRequest().data.visibleOnly).toBe(true)
  448. })
  449. test('getFamilyMemberList sends includeFamily param', async () => {
  450. mockSuccess({ code: 200 })
  451. await api.getFamilyMemberList({ includeFamily: true })
  452. expect(lastRequest().url).toMatch(/\/api\/family\/member\/list$/)
  453. expect(lastRequest().data.includeFamily).toBe(true)
  454. })
  455. test('updateMemberVisibility sends POST with params', async () => {
  456. mockSuccess({ code: 200 })
  457. await api.updateMemberVisibility(1, 'child', true)
  458. expect(lastRequest().data.memberId).toBe(1)
  459. expect(lastRequest().data.memberType).toBe('child')
  460. expect(lastRequest().data.showToFamily).toBe(true)
  461. })
  462. test('switchMode sends POST with mode', async () => {
  463. mockSuccess({ code: 200 })
  464. await api.switchMode('child')
  465. expect(lastRequest().data.mode).toBe('child')
  466. })
  467. test('switchToChild sends POST with memberId', async () => {
  468. mockSuccess({ code: 200 })
  469. await api.switchToChild(5)
  470. expect(lastRequest().data.memberId).toBe(5)
  471. })
  472. test('switchBackToParent sends POST', async () => {
  473. mockSuccess({ code: 200 })
  474. await api.switchBackToParent()
  475. expect(lastRequest().url).toMatch(/\/api\/user\/switch-back-to-parent$/)
  476. })
  477. test('getChildren sends POST to unified member list', async () => {
  478. mockSuccess({ code: 200 })
  479. await api.getChildren()
  480. expect(lastRequest().url).toMatch(/\/api\/family\/member\/list$/)
  481. })
  482. test('createChild sends POST with data', async () => {
  483. mockSuccess({ code: 200 })
  484. await api.createChild({ name: '小宝', gender: 'male' })
  485. expect(lastRequest().url).toMatch(/\/api\/family\/user\/children$/)
  486. expect(lastRequest().data.name).toBe('小宝')
  487. })
  488. test('updateChild sends POST merged with memberId', async () => {
  489. mockSuccess({ code: 200 })
  490. await api.updateChild(5, { nickname: '大宝' })
  491. expect(lastRequest().data.memberId).toBe(5)
  492. expect(lastRequest().data.nickname).toBe('大宝')
  493. })
  494. test('kickFamilyMember sends POST with targetUserId', async () => {
  495. mockSuccess({ code: 200 })
  496. await api.kickFamilyMember(10)
  497. expect(lastRequest().data.targetUserId).toBe(10)
  498. })
  499. })
  500. // --------------- membership module ---------------
  501. describe('membership API', () => {
  502. test('getMembershipLevels sends POST', async () => {
  503. mockSuccess({ code: 200 })
  504. await api.getMembershipLevels()
  505. expect(lastRequest().url).toMatch(/\/api\/membership\/levels$/)
  506. })
  507. test('getMyMembership sends POST', async () => {
  508. mockSuccess({ code: 200 })
  509. await api.getMyMembership()
  510. expect(lastRequest().url).toMatch(/\/api\/membership\/my$/)
  511. })
  512. test('createOrder sends POST with levelCode and paymentType', async () => {
  513. mockSuccess({ code: 200 })
  514. await api.createOrder('vip', 'wechat')
  515. expect(lastRequest().data.levelCode).toBe('vip')
  516. expect(lastRequest().data.paymentType).toBe('wechat')
  517. })
  518. })
  519. // --------------- tasks module ---------------
  520. describe('tasks API', () => {
  521. test('createTask sends POST with data', async () => {
  522. mockSuccess({ code: 200 })
  523. await api.createTask({ title: '晨读', memberId: 5 })
  524. // 后端 TaskController: @RequestMapping("/api/tasks") + @PostMapping("/create")
  525. expect(lastRequest().url).toMatch(/\/api\/tasks\/create$/)
  526. expect(lastRequest().data.title).toBe('晨读')
  527. })
  528. test('getTodayTasks sends POST with memberId', async () => {
  529. mockSuccess({ code: 200 })
  530. await api.getTodayTasks(5)
  531. expect(lastRequest().data.memberId).toBe(5)
  532. })
  533. test('getPendingReviewTasks sends POST', async () => {
  534. mockSuccess({ code: 200 })
  535. await api.getPendingReviewTasks()
  536. expect(lastRequest().url).toMatch(/\/api\/tasks\/pending-review$/)
  537. })
  538. test('approveTask sends POST with approved:true', async () => {
  539. mockSuccess({ code: 200 })
  540. await api.approveTask(10)
  541. expect(lastRequest().url).toMatch(/\/api\/tasks\/10\/review$/)
  542. expect(lastRequest().data.approved).toBe(true)
  543. })
  544. test('rejectTask sends POST with approved:false', async () => {
  545. mockSuccess({ code: 200 })
  546. await api.rejectTask(10)
  547. expect(lastRequest().data.approved).toBe(false)
  548. })
  549. test('completeTask sends POST with memberId and photoUrl', async () => {
  550. mockSuccess({ code: 200 })
  551. await api.completeTask(10, 5, 'http://photo.url')
  552. expect(lastRequest().data.memberId).toBe(5)
  553. expect(lastRequest().data.photoUrl).toBe('http://photo.url')
  554. })
  555. test('reviewTask sends POST with data', async () => {
  556. mockSuccess({ code: 200 })
  557. await api.reviewTask(10, { approved: true, comment: '很好' })
  558. expect(lastRequest().url).toMatch(/\/api\/tasks\/10\/review$/)
  559. expect(lastRequest().data.comment).toBe('很好')
  560. })
  561. test('getTaskHistory sends POST with pagination', async () => {
  562. mockSuccess({ code: 200 })
  563. await api.getTaskHistory(5, 1, 20)
  564. expect(lastRequest().data.memberId).toBe(5)
  565. expect(lastRequest().data.page).toBe(1)
  566. expect(lastRequest().data.size).toBe(20)
  567. })
  568. test('deleteTask sends POST with _method DELETE', async () => {
  569. mockSuccess({ code: 200 })
  570. await api.deleteTask(10)
  571. expect(lastRequest().method).toBe('POST')
  572. expect(lastRequest().url).toMatch(/\/api\/tasks\/10$/)
  573. expect(lastRequest().data._method).toBe('DELETE')
  574. })
  575. test('batchCompleteTasks sends POST with array', async () => {
  576. mockSuccess({ code: 200 })
  577. await api.batchCompleteTasks([1, 2, 3])
  578. expect(lastRequest().data.taskIds).toEqual([1, 2, 3])
  579. })
  580. test('getTodayParentTasks sends POST', async () => {
  581. mockSuccess({ code: 200 })
  582. await api.getTodayParentTasks()
  583. expect(lastRequest().url).toMatch(/\/api\/tasks\/today-parent$/)
  584. })
  585. })
  586. // --------------- points module ---------------
  587. describe('points API', () => {
  588. test('getPointsBalance sends POST with memberId', async () => {
  589. mockSuccess({ code: 200 })
  590. await api.getPointsBalance(5)
  591. expect(lastRequest().data.memberId).toBe(5)
  592. })
  593. test('getPointsLogs sends POST with pagination', async () => {
  594. mockSuccess({ code: 200 })
  595. await api.getPointsLogs(5, 2, 20)
  596. expect(lastRequest().data.memberId).toBe(5)
  597. expect(lastRequest().data.page).toBe(2)
  598. expect(lastRequest().data.size).toBe(20)
  599. })
  600. })
  601. // --------------- rewards / wishes module ---------------
  602. describe('rewards & wishes API', () => {
  603. test('createReward sends POST', async () => {
  604. mockSuccess({ code: 200 })
  605. await api.createReward({ name: '玩具', points: 100 })
  606. expect(lastRequest().data.name).toBe('玩具')
  607. })
  608. test('getWishlist sends POST with memberId', async () => {
  609. mockSuccess({ code: 200 })
  610. await api.getWishlist(5)
  611. expect(lastRequest().data.memberId).toBe(5)
  612. })
  613. test('exchangeReward sends POST', async () => {
  614. mockSuccess({ code: 200 })
  615. await api.exchangeReward(1, 5)
  616. expect(lastRequest().data.memberId).toBe(5)
  617. })
  618. test('getPendingWishes sends POST with empty body', async () => {
  619. mockSuccess({ code: 200 })
  620. await api.getPendingWishes()
  621. expect(lastRequest().data).toEqual({})
  622. })
  623. test('getWishes sends POST with params', async () => {
  624. mockSuccess({ code: 200 })
  625. await api.getWishes({ memberId: 5, status: 'pending' })
  626. expect(lastRequest().data.memberId).toBe(5)
  627. })
  628. test('setWishPrice sends POST with pointsRequired', async () => {
  629. mockSuccess({ code: 200 })
  630. await api.setWishPrice(1, 200)
  631. expect(lastRequest().data.pointsRequired).toBe(200)
  632. })
  633. test('approveWishExchange sends POST', async () => {
  634. mockSuccess({ code: 200 })
  635. await api.approveWishExchange(1, true)
  636. expect(lastRequest().data.approved).toBe(true)
  637. })
  638. test('createWish sends POST with data', async () => {
  639. mockSuccess({ code: 200 })
  640. await api.createWish({ title: '新书包', memberId: 5 })
  641. expect(lastRequest().data.title).toBe('新书包')
  642. })
  643. })
  644. // --------------- identity parameter hardening ---------------
  645. // 前端不再向后端发送可伪造的 userId / familyId / childId(仅 memberId),
  646. // 身份一律由后端从 JWT 推导。以下断言锁定这一不变量。
  647. describe('identity parameter hardening', () => {
  648. beforeEach(() => {
  649. global.uni._storage.token = 'test-token'
  650. })
  651. test('request() does NOT send X-User-Id header', async () => {
  652. mockSuccess({ code: 200 })
  653. await api.getPendingWishes()
  654. const opts = lastRequest()
  655. expect(opts.header['X-User-Id']).toBeUndefined()
  656. })
  657. test('getMyAssessmentResults sends empty body, no userId', async () => {
  658. mockSuccess({ code: 200 })
  659. await api.getMyAssessmentResults()
  660. const opts = lastRequest()
  661. expect(opts.data).toEqual({})
  662. expect(opts.data.userId).toBeUndefined()
  663. })
  664. test('getChildAssessmentResults sends empty body, no userId', async () => {
  665. mockSuccess({ code: 200 })
  666. await api.getChildAssessmentResults()
  667. const opts = lastRequest()
  668. expect(opts.data).toEqual({})
  669. expect(opts.data.userId).toBeUndefined()
  670. })
  671. test('getTodayTasksByCategory sends only memberId and category', async () => {
  672. mockSuccess({ code: 200 })
  673. await api.getTodayTasksByCategory(7, 'wisdom')
  674. const opts = lastRequest()
  675. expect(opts.data.memberId).toBe(7)
  676. expect(opts.data.category).toBe('wisdom')
  677. expect(opts.data.childId).toBeUndefined()
  678. expect(opts.data.userId).toBeUndefined()
  679. })
  680. })
  681. // --------------- guide / packages module ---------------
  682. describe('guide & packages API', () => {
  683. test('getGuidePackages sends POST to my list', async () => {
  684. mockSuccess({ code: 200 })
  685. await api.getGuidePackages(1)
  686. expect(lastRequest().url).toMatch(/\/api\/guide\/packages\/my$/)
  687. expect(lastRequest().data.guideId).toBe(1)
  688. })
  689. test('registerGuide sends POST', async () => {
  690. mockSuccess({ code: 200 })
  691. await api.registerGuide({ name: '张老师', level: 'senior' })
  692. expect(lastRequest().url).toMatch(/\/api\/guide\/register$/)
  693. })
  694. test('generateBindInvite sends POST', async () => {
  695. mockSuccess({ code: 200 })
  696. await api.generateBindInvite()
  697. expect(lastRequest().url).toMatch(/\/api\/guide\/bind\/invite$/)
  698. })
  699. test('getBoundFamilies sends POST', async () => {
  700. mockSuccess({ code: 200 })
  701. await api.getBoundFamilies()
  702. expect(lastRequest().url).toMatch(/\/api\/guide\/families$/)
  703. })
  704. test('getTeamMembers sends POST', async () => {
  705. mockSuccess({ code: 200 })
  706. await api.getTeamMembers()
  707. expect(lastRequest().url).toMatch(/\/api\/guide\/team\/members$/)
  708. })
  709. test('getGuideOrders sends POST', async () => {
  710. mockSuccess({ code: 200 })
  711. await api.getGuideOrders()
  712. expect(lastRequest().url).toMatch(/\/api\/guide\/orders\/list$/)
  713. })
  714. test('getGuideMessages sends POST', async () => {
  715. mockSuccess({ code: 200 })
  716. await api.getGuideMessages()
  717. expect(lastRequest().url).toMatch(/\/api\/guide\/messages\/list$/)
  718. })
  719. })
  720. // --------------- payment module ---------------
  721. describe('payment API', () => {
  722. test('createWechatPayOrder sends POST', async () => {
  723. mockSuccess({ code: 200 })
  724. await api.createWechatPayOrder('NO001', '测试订单', 100)
  725. expect(lastRequest().data.orderNo).toBe('NO001')
  726. expect(lastRequest().data.amount).toBe(100)
  727. })
  728. })
  729. // --------------- assessment module ---------------
  730. describe('assessment API', () => {
  731. test('createAssessmentAppointment sends POST', async () => {
  732. mockSuccess({ code: 200 })
  733. await api.createAssessmentAppointment({ memberId: 5, date: '2026-06-20' })
  734. expect(lastRequest().url).toMatch(/\/api\/dan-assessment\/appointment\/create$/)
  735. })
  736. test('payAssessmentOrder sends POST', async () => {
  737. mockSuccess({ code: 200 })
  738. await api.payAssessmentOrder('ORDER001', 'wechat')
  739. expect(lastRequest().data.orderNo).toBe('ORDER001')
  740. })
  741. test('recordAssessmentResult sends POST', async () => {
  742. mockSuccess({ code: 200 })
  743. await api.recordAssessmentResult({ orderId: 1, score: 85 })
  744. expect(lastRequest().url).toMatch(/\/api\/dan-assessment\/result\/record$/)
  745. })
  746. })
  747. // --------------- mini-games module ---------------
  748. describe('mini-games API', () => {
  749. test('getMiniGameList sends POST', async () => {
  750. mockSuccess({ code: 200 })
  751. await api.getMiniGameList()
  752. expect(lastRequest().url).toMatch(/\/api\/mini-game\/list$/)
  753. })
  754. test('getMiniGameByCode sends POST', async () => {
  755. mockSuccess({ code: 200 })
  756. await api.getMiniGameByCode('sudoku')
  757. expect(lastRequest().url).toMatch(/\/api\/mini-game\/sudoku$/)
  758. })
  759. test('completeMiniGame sends POST with game data', async () => {
  760. mockSuccess({ code: 200 })
  761. await api.completeMiniGame(5, 'sudoku', 120, 85)
  762. expect(lastRequest().data.memberId).toBe(5)
  763. expect(lastRequest().data.gameCode).toBe('sudoku')
  764. expect(lastRequest().data.completionTime).toBe(120)
  765. expect(lastRequest().data.score).toBe(85)
  766. })
  767. test('getGameHistory sends POST', async () => {
  768. mockSuccess({ code: 200 })
  769. await api.getGameHistory({ memberId: 5 })
  770. expect(lastRequest().url).toMatch(/\/api\/game\/history$/)
  771. })
  772. test('getGameLeaderboard sends POST', async () => {
  773. mockSuccess({ code: 200 })
  774. await api.getGameLeaderboard({ gameCode: 'sudoku' })
  775. expect(lastRequest().url).toMatch(/\/api\/game\/leaderboard$/)
  776. })
  777. })
  778. // --------------- growth module ---------------
  779. describe('growth records API', () => {
  780. test('createGrowthRecord sends POST', async () => {
  781. mockSuccess({ code: 200 })
  782. await api.createGrowthRecord({ memberId: 5, content: '长高了2cm' })
  783. expect(lastRequest().url).toMatch(/\/api\/growth\/record\/create$/)
  784. })
  785. test('getGrowthRecordList sends POST', async () => {
  786. mockSuccess({ code: 200 })
  787. await api.getGrowthRecordList()
  788. expect(lastRequest().url).toMatch(/\/api\/growth\/record\/list$/)
  789. })
  790. test('createGrowthRecord with alsoPurchaseAssessment uses merge endpoint', async () => {
  791. mockSuccess({ code: 200 })
  792. await api.createGrowthRecord({ memberId: 5, alsoPurchaseAssessment: true })
  793. expect(lastRequest().url).toMatch(/\/api\/growth\/record\/create-with-order$/)
  794. })
  795. })
  796. // --------------- energy module ---------------
  797. describe('energy API', () => {
  798. test('getEnergyOverview sends POST with memberId', async () => {
  799. mockSuccess({ code: 200 })
  800. await api.getEnergyOverview(5)
  801. expect(lastRequest().data.memberId).toBe(5)
  802. })
  803. test('getEnergyLogs sends POST', async () => {
  804. mockSuccess({ code: 200 })
  805. await api.getEnergyLogs(5, 'body', 1, 20)
  806. expect(lastRequest().data.dimensionCode).toBe('body')
  807. })
  808. test('getFamilyEnergySandbox sends POST', async () => {
  809. mockSuccess({ code: 200 })
  810. await api.getFamilyEnergySandbox()
  811. expect(lastRequest().url).toMatch(/\/api\/energy\/sandbox$/)
  812. })
  813. })
  814. // --------------- badges module ---------------
  815. describe('badges API', () => {
  816. test('getActiveBadges sends POST', async () => {
  817. mockSuccess({ code: 200 })
  818. await api.getActiveBadges()
  819. expect(lastRequest().url).toMatch(/\/api\/badges\/list$/)
  820. })
  821. test('getMemberBadges sends POST with memberId', async () => {
  822. mockSuccess({ code: 200 })
  823. await api.getMemberBadges(5)
  824. expect(lastRequest().data.memberId).toBe(5)
  825. })
  826. test('toggleBadgeFavorite sends POST', async () => {
  827. mockSuccess({ code: 200 })
  828. await api.toggleBadgeFavorite(5, 3)
  829. expect(lastRequest().data.memberId).toBe(5)
  830. expect(lastRequest().data.badgeId).toBe(3)
  831. })
  832. })
  833. // --------------- articles module ---------------
  834. describe('articles API', () => {
  835. test('getArticleCategories sends POST', async () => {
  836. mockSuccess({ code: 200 })
  837. await api.getArticleCategories()
  838. expect(lastRequest().url).toMatch(/\/api\/articles\/categories$/)
  839. })
  840. test('getArticleList sends POST with data', async () => {
  841. mockSuccess({ code: 200 })
  842. await api.getArticleList({ categoryId: 1, page: 1 })
  843. expect(lastRequest().url).toMatch(/\/api\/articles\/list$/)
  844. })
  845. test('getArticleDetail sends POST', async () => {
  846. mockSuccess({ code: 200 })
  847. await api.getArticleDetail({ id: 42 })
  848. expect(lastRequest().url).toMatch(/\/api\/articles\/detail$/)
  849. })
  850. test('getDailyTip sends POST', async () => {
  851. mockSuccess({ code: 200 })
  852. await api.getDailyTip()
  853. expect(lastRequest().url).toMatch(/\/api\/articles\/daily-tip$/)
  854. })
  855. })
  856. // --------------- contact module ---------------
  857. describe('contact API', () => {
  858. test('getContactList sends POST', async () => {
  859. mockSuccess({ code: 200 })
  860. await api.getContactList({ memberId: 5 })
  861. expect(lastRequest().url).toMatch(/\/api\/contact\/list$/)
  862. })
  863. test('createContact sends POST with data', async () => {
  864. mockSuccess({ code: 200 })
  865. await api.createContact({ name: '爷爷', relation: 'grandfather' })
  866. expect(lastRequest().url).toMatch(/\/api\/contact\/create$/)
  867. })
  868. test('deleteContact sends POST with id', async () => {
  869. mockSuccess({ code: 200 })
  870. await api.deleteContact(3)
  871. expect(lastRequest().url).toMatch(/\/api\/contact\/delete$/)
  872. expect(lastRequest().data.id).toBe(3)
  873. })
  874. test('importPhoneContact sends POST', async () => {
  875. mockSuccess({ code: 200 })
  876. await api.importPhoneContact({ contacts: [{ name: '张三', phone: '13800138000' }] })
  877. expect(lastRequest().url).toMatch(/\/api\/contact\/import-phone$/)
  878. })
  879. })
  880. // --------------- vendor module ---------------
  881. describe('vendor API', () => {
  882. test('vendorApply sends POST with data', async () => {
  883. mockSuccess({ code: 200 })
  884. await api.vendorApply({ companyName: '某某公司', type: 'planner' })
  885. expect(lastRequest().url).toMatch(/\/api\/vendor\/apply$/)
  886. })
  887. test('vendorStatus sends POST', async () => {
  888. mockSuccess({ code: 200 })
  889. await api.vendorStatus()
  890. expect(lastRequest().url).toMatch(/\/api\/vendor\/status$/)
  891. })
  892. test('vendorInfo sends POST', async () => {
  893. mockSuccess({ code: 200 })
  894. await api.vendorInfo()
  895. expect(lastRequest().url).toMatch(/\/api\/vendor\/info$/)
  896. })
  897. })
  898. // --------------- product module ---------------
  899. describe('product API', () => {
  900. test('productList sends POST', () => {
  901. // productList uses new Promise without success/fail callbacks,
  902. // so we check the request synchronously.
  903. api.productList({ page: 1, size: 10 })
  904. const opts = lastRequest()
  905. expect(opts.method).toBe('POST')
  906. expect(opts.url).toMatch(/\/api\/product\/list$/)
  907. })
  908. test('productDetail sends POST', async () => {
  909. mockSuccess({ code: 200 })
  910. await api.productDetail({ id: 1 })
  911. expect(lastRequest().url).toMatch(/\/api\/product\/detail$/)
  912. })
  913. test('productCreate sends POST', async () => {
  914. mockSuccess({ code: 200 })
  915. await api.productCreate({ name: '测试商品', price: 100 })
  916. expect(lastRequest().url).toMatch(/\/api\/product\/create$/)
  917. })
  918. test('productOrderCreate sends POST', async () => {
  919. mockSuccess({ code: 200 })
  920. await api.productOrderCreate({ productId: 1, quantity: 2 })
  921. expect(lastRequest().url).toMatch(/\/api\/product\/order\/create$/)
  922. })
  923. })
  924. // --------------- commission / invite ---------------
  925. describe('commission & invite API', () => {
  926. test('getReferralCode sends POST', async () => {
  927. mockSuccess({ code: 200 })
  928. await api.getReferralCode()
  929. expect(lastRequest().url).toMatch(/\/api\/invite\/code$/)
  930. })
  931. test('bindReferral sends POST with code', async () => {
  932. mockSuccess({ code: 200 })
  933. await api.bindReferral('ABC123')
  934. expect(lastRequest().data.referralCode).toBe('ABC123')
  935. })
  936. test('getCommissionSummary sends POST', async () => {
  937. mockSuccess({ code: 200 })
  938. await api.getCommissionSummary()
  939. expect(lastRequest().url).toMatch(/\/api\/commission\/summary$/)
  940. })
  941. test('applyWithdrawal sends POST', async () => {
  942. mockSuccess({ code: 200 })
  943. await api.applyWithdrawal(500, { bank: 'ICBC', account: '6222****' })
  944. expect(lastRequest().data.amount).toBe(500)
  945. })
  946. })
  947. // --------------- stats module ---------------
  948. describe('stats API', () => {
  949. test('getChildCompletionStats sends POST', async () => {
  950. mockSuccess({ code: 200 })
  951. await api.getChildCompletionStats({ memberId: 5 })
  952. expect(lastRequest().url).toMatch(/\/api\/stats\/child-completion$/)
  953. })
  954. test('getChildOverview sends POST with memberId', async () => {
  955. mockSuccess({ code: 200 })
  956. await api.getChildOverview(5)
  957. expect(lastRequest().data.memberId).toBe(5)
  958. })
  959. })
  960. // --------------- AI chat module ---------------
  961. describe('AI chat API', () => {
  962. test('aiSendMessage sends POST', async () => {
  963. mockSuccess({ code: 200 })
  964. await api.aiSendMessage({ conversationId: 1, content: '你好' })
  965. expect(lastRequest().url).toMatch(/\/api\/ai\/chat\/send$/)
  966. })
  967. test('aiGetConversations sends POST', async () => {
  968. mockSuccess({ code: 200 })
  969. await api.aiGetConversations()
  970. expect(lastRequest().url).toMatch(/\/api\/ai\/chat\/conversations$/)
  971. })
  972. })
  973. // --------------- health / wealth module ---------------
  974. // --------------- health report module (new) ---------------
  975. describe('health report API', () => {
  976. test('createHealthReport sends POST', async () => {
  977. mockSuccess({ code: 200 })
  978. await api.createHealthReport({ memberId: 5, reportData: { height: 120, weight: 22 } })
  979. expect(lastRequest().url).toMatch(/\/api\/health\/report\/create$/)
  980. expect(lastRequest().data.memberId).toBe(5)
  981. })
  982. test('getReportList sends POST with memberId', async () => {
  983. mockSuccess({ code: 200 })
  984. await api.getReportList(5)
  985. expect(lastRequest().url).toMatch(/\/api\/health\/report\/list$/)
  986. expect(lastRequest().data.memberId).toBe(5)
  987. })
  988. test('getReportDetail sends POST with reportId', async () => {
  989. mockSuccess({ code: 200 })
  990. await api.getReportDetail(42)
  991. expect(lastRequest().url).toMatch(/\/api\/health\/report\/detail$/)
  992. expect(lastRequest().data.reportId).toBe(42)
  993. })
  994. test('getLatestHealthReport sends POST with memberId', async () => {
  995. mockSuccess({ code: 200 })
  996. await api.getLatestHealthReport(5)
  997. expect(lastRequest().url).toMatch(/\/api\/health\/report\/latest$/)
  998. expect(lastRequest().data.memberId).toBe(5)
  999. })
  1000. test('getHealthIndicatorList sends POST with memberId', async () => {
  1001. mockSuccess({ code: 200 })
  1002. await api.getHealthIndicatorList(5)
  1003. expect(lastRequest().url).toMatch(/\/api\/health\/indicator\/list$/)
  1004. expect(lastRequest().data.memberId).toBe(5)
  1005. })
  1006. test('createReportSurvey sends POST with reportId and memberId', async () => {
  1007. mockSuccess({ code: 200 })
  1008. await api.createReportSurvey(10, 5)
  1009. expect(lastRequest().url).toMatch(/\/api\/health\/survey\/create$/)
  1010. expect(lastRequest().data.reportId).toBe(10)
  1011. expect(lastRequest().data.memberId).toBe(5)
  1012. })
  1013. test('submitReportSurvey sends POST with surveyId and data', async () => {
  1014. mockSuccess({ code: 200 })
  1015. await api.submitReportSurvey(1, { q1: 'yes', q2: 'no' })
  1016. expect(lastRequest().url).toMatch(/\/api\/health\/survey\/submit$/)
  1017. expect(lastRequest().data.surveyId).toBe(1)
  1018. expect(lastRequest().data.surveyData.q1).toBe('yes')
  1019. })
  1020. test('getSurveyStatus sends POST with reportId', async () => {
  1021. mockSuccess({ code: 200 })
  1022. await api.getSurveyStatus({ reportId: 10 })
  1023. expect(lastRequest().url).toMatch(/\/api\/survey\/status$/)
  1024. expect(lastRequest().data.reportId).toBe(10)
  1025. })
  1026. })
  1027. describe('health & wealth API', () => {
  1028. test('healthCheckinList sends POST', async () => {
  1029. mockSuccess({ code: 200 })
  1030. await api.healthCheckinList({ memberId: 5 })
  1031. expect(lastRequest().url).toMatch(/\/api\/health\/checkin\/list$/)
  1032. })
  1033. test('getCheckinList sends POST', async () => {
  1034. mockSuccess({ code: 200 })
  1035. await api.getCheckinList({ memberId: 5 })
  1036. expect(lastRequest().url).toMatch(/\/api\/wealth\/checkin\/list$/)
  1037. })
  1038. test('getInsuranceList sends POST', async () => {
  1039. mockSuccess({ code: 200 })
  1040. await api.getInsuranceList({ memberId: 5 })
  1041. expect(lastRequest().url).toMatch(/\/api\/wealth\/insurance\/list$/)
  1042. })
  1043. })
  1044. // --------------- invite card module ---------------
  1045. describe('invite card API', () => {
  1046. test('generateInviteCard sends POST', async () => {
  1047. mockSuccess({ code: 200 })
  1048. await api.generateInviteCard('guide', 1)
  1049. expect(lastRequest().data.type).toBe('guide')
  1050. })
  1051. test('verifyInviteCard sends POST with code', async () => {
  1052. mockSuccess({ code: 200 })
  1053. await api.verifyInviteCard('CARD123')
  1054. expect(lastRequest().data.code).toBe('CARD123')
  1055. })
  1056. })
  1057. describe('article related API', () => {
  1058. test('getRelatedArticles sends POST /api/articles/related', async () => {
  1059. let captured = null
  1060. global.uni.request.mockImplementation((opts) => {
  1061. captured = opts
  1062. process.nextTick(() => { if (opts.success) opts.success({ data: { code: 200, data: [] } }) })
  1063. })
  1064. await api.getRelatedArticles({ articleId: 15, size: 3 })
  1065. expect(captured.url).toMatch(/\/api\/articles\/related$/)
  1066. expect(captured.method).toBe('POST')
  1067. expect(captured.data.articleId).toBe(15)
  1068. expect(captured.data.size).toBe(3)
  1069. })
  1070. test('getRelatedArticles passes data through request', async () => {
  1071. mockSuccess({ code: 200, data: [{ id: 2, title: 't' }] })
  1072. const res = await api.getRelatedArticles({ articleId: 15 })
  1073. expect(res.code).toBe(200)
  1074. })
  1075. })
  1076. // --------------- AI 会员门禁(T-F0:aiGateStatus + _noToast) ---------------
  1077. describe('AI member gate API', () => {
  1078. beforeEach(() => {
  1079. global.uni._storage.token = 'test-token'
  1080. })
  1081. test('aiGateStatus sends POST /api/ai/gate 且不把 _noToast 拼进 URL', async () => {
  1082. mockSuccess({ code: 200, data: { status: 'ALLOW', canUse: true } })
  1083. const res = await api.aiGateStatus()
  1084. const opts = lastRequest()
  1085. expect(opts.url).toMatch(/\/api\/ai\/gate$/)
  1086. expect(opts.url).not.toMatch(/_noToast/)
  1087. expect(opts.method).toBe('POST')
  1088. expect(res.data.status).toBe('ALLOW')
  1089. })
  1090. test('_noToast:非 200 不弹 toast,reject 仍带 code/message', async () => {
  1091. mockSuccess({ code: 46003, message: '该功能为会员专享,请先开通会员' })
  1092. await expect(api.aiGateStatus()).rejects.toMatchObject({
  1093. code: 46003,
  1094. message: '该功能为会员专享,请先开通会员'
  1095. })
  1096. expect(global.uni.showToast).not.toHaveBeenCalled()
  1097. })
  1098. test('无 _noToast 时非 200 仍弹 toast(回归保护)', async () => {
  1099. mockSuccess({ code: 500, message: '服务异常' })
  1100. await expect(api.getMyMembership()).rejects.toMatchObject({ code: 500 })
  1101. expect(global.uni.showToast).toHaveBeenCalledTimes(1)
  1102. expect(global.uni.showToast.mock.calls[0][0].title).toBe('服务异常')
  1103. })
  1104. test('aiSendMessage 门禁错误不弹 toast(避免 toast + 弹窗叠加)', async () => {
  1105. mockSuccess({ code: 46004, message: '会员已过期,请续费后继续使用' })
  1106. await expect(api.aiSendMessage({ query: 'hi', conversationId: '' })).rejects.toMatchObject({
  1107. code: 46004
  1108. })
  1109. expect(global.uni.showToast).not.toHaveBeenCalled()
  1110. })
  1111. })