test-wechat-follow-reminder.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import assert from 'node:assert/strict'
  2. import { existsSync, readFileSync } from 'node:fs'
  3. import { createRequire } from 'node:module'
  4. import test from 'node:test'
  5. const utilUrl = new URL('../utils/wechatFollowReminder.js', import.meta.url)
  6. const apiUrl = new URL('../api/wechat-notices.js', import.meta.url)
  7. const load = async () => {
  8. assert.ok(existsSync(utilUrl), '缺少登录关注提醒控制器')
  9. return import(`data:text/javascript;base64,${Buffer.from(readFileSync(utilUrl, 'utf8')).toString('base64')}`)
  10. }
  11. const deferred = () => {
  12. let resolve, reject
  13. const promise = new Promise((yes, no) => { resolve = yes; reject = no })
  14. return { promise, resolve, reject }
  15. }
  16. async function setup(status = { enabled: true, bound: true, subscribed: false }, memory = {}) {
  17. const { createWechatFollowReminder } = await load()
  18. const state = { session: 'login-a', active: true }
  19. const calls = { queries: 0, prompts: [], opens: 0 }
  20. const deps = {
  21. getSessionKey: () => state.session,
  22. isActive: () => state.active,
  23. loadStatus: async () => { calls.queries++; return status },
  24. showPrompt: async (options) => { calls.prompts.push(options); return false },
  25. openGuide: () => { calls.opens++ },
  26. memory
  27. }
  28. return { state, calls, deps, create: () => createWechatFollowReminder(deps) }
  29. }
  30. test('confirmed unsubscribed recipient is reminded once per login and may defer', async () => {
  31. const p = await setup()
  32. const controller = p.create()
  33. await controller.check()
  34. await controller.check()
  35. assert.equal(p.calls.queries, 1)
  36. assert.equal(p.calls.prompts.length, 1)
  37. assert.equal(p.calls.prompts[0].title, '关注服务号')
  38. assert.equal(p.calls.prompts[0].cancelText, '稍后再说')
  39. assert.equal(p.calls.opens, 0)
  40. })
  41. test('already subscribed and bound users are not prompted', async () => {
  42. const p = await setup({ enabled: true, bound: true, subscribed: true })
  43. await p.create().check()
  44. assert.equal(p.calls.prompts.length, 0)
  45. })
  46. test('unbound users receive neutral setup wording, not an unverified subscription claim', async () => {
  47. const p = await setup({ enabled: true, bound: false, subscribed: false })
  48. await p.create().check()
  49. assert.match(p.calls.prompts[0].content, /如已关注/)
  50. assert.match(p.calls.prompts[0].content, /绑定/)
  51. assert.doesNotMatch(p.calls.prompts[0].content, /尚未关注|未关注服务号/)
  52. })
  53. test('disabled notification feature stays quiet', async () => {
  54. const p = await setup({ enabled: false, bound: false, subscribed: false })
  55. await p.create().check()
  56. assert.equal(p.calls.prompts.length, 0)
  57. })
  58. test('confirm opens guide and does not follow or bind automatically', async () => {
  59. const p = await setup()
  60. p.deps.showPrompt = async () => true
  61. await p.create().check()
  62. assert.equal(p.calls.opens, 1)
  63. })
  64. test('reminder dedup survives workbench recreation, next login is independent', async () => {
  65. const p = await setup()
  66. await p.create().check()
  67. await p.create().check()
  68. assert.equal(p.calls.prompts.length, 1)
  69. p.state.session = 'login-b'
  70. await p.create().check()
  71. assert.equal(p.calls.prompts.length, 2)
  72. })
  73. test('network failure neither blocks login nor claims unsubscribed, retry remains possible', async () => {
  74. const p = await setup()
  75. p.deps.loadStatus = async () => { throw new Error('network') }
  76. await p.create().check()
  77. assert.equal(p.calls.prompts.length, 0)
  78. p.deps.loadStatus = async () => ({ enabled: true, bound: true, subscribed: false })
  79. await p.create().check()
  80. assert.equal(p.calls.prompts.length, 1)
  81. })
  82. test('a concurrent show does not duplicate pending status lookup', async () => {
  83. const p = await setup()
  84. const response = deferred()
  85. p.deps.loadStatus = () => { p.calls.queries++; return response.promise }
  86. const controller = p.create()
  87. const pending = controller.check()
  88. await controller.check()
  89. response.resolve({ enabled: true, bound: true, subscribed: false })
  90. await pending
  91. assert.equal(p.calls.queries, 1)
  92. assert.equal(p.calls.prompts.length, 1)
  93. })
  94. test('hide cancels stale response and return can perform a fresh check', async () => {
  95. const p = await setup()
  96. const response = deferred()
  97. p.deps.loadStatus = () => response.promise
  98. const controller = p.create()
  99. const pending = controller.check()
  100. p.state.active = false
  101. controller.cancel()
  102. response.resolve({ enabled: true, bound: true, subscribed: false })
  103. await pending
  104. assert.equal(p.calls.prompts.length, 0)
  105. p.state.active = true
  106. await controller.check()
  107. assert.equal(p.calls.prompts.length, 1)
  108. })
  109. test('account switch during lookup ignores old user response', async () => {
  110. const p = await setup()
  111. const response = deferred()
  112. p.deps.loadStatus = () => response.promise
  113. const pending = p.create().check()
  114. p.state.session = 'new-user'
  115. response.resolve({ enabled: true, bound: true, subscribed: false })
  116. await pending
  117. assert.equal(p.calls.prompts.length, 0)
  118. })
  119. test('late modal confirm after logout does not navigate', async () => {
  120. const p = await setup()
  121. const answer = deferred()
  122. p.deps.showPrompt = () => answer.promise
  123. const pending = p.create().check()
  124. await Promise.resolve()
  125. p.state.session = ''
  126. answer.resolve(true)
  127. await pending
  128. assert.equal(p.calls.opens, 0)
  129. })
  130. test('inactive or logged out pages do not request status', async () => {
  131. const p = await setup()
  132. p.state.active = false
  133. await p.create().check()
  134. p.state.active = true
  135. p.state.session = ''
  136. await p.create().check()
  137. assert.equal(p.calls.queries, 0)
  138. })
  139. function api(response) {
  140. assert.ok(existsSync(apiUrl), '缺少本人关注状态API封装')
  141. const code = readFileSync(apiUrl, 'utf8')
  142. .replace(/^import[^\n]+\n/gm, '').replace(/export /g, '')
  143. const calls = []
  144. const fetchStatus = new Function('request', 'parseResponseJson', `${code}; return fetchWechatNoticeStatus`)(
  145. async (options) => { calls.push(options); return response }, (res) => res.data)
  146. return { fetchStatus, calls }
  147. }
  148. test('status API uses authenticated request and returns only validated status fields', async () => {
  149. const p = api({ statusCode: 200, data: { code: 200, data: { enabled: true, bound: true, subscribed: true } } })
  150. assert.deepEqual(await p.fetchStatus(), { enabled: true, bound: true, subscribed: true })
  151. assert.equal(p.calls[0].url, '/web/wechat-notices/binding')
  152. assert.equal(p.calls[0].method, 'GET')
  153. })
  154. test('HTTP, business and malformed failures cannot be read as unsubscribed', async () => {
  155. for (const response of [
  156. { statusCode: 502, data: { detail: '微信查询失败' } },
  157. { statusCode: 200, data: { code: 500 } },
  158. { statusCode: 200, data: { code: 200, data: { enabled: true, bound: 'true', subscribed: false } } }
  159. ]) {
  160. await assert.rejects(api(response).fetchStatus())
  161. }
  162. })
  163. async function workbench(statusPromise) {
  164. const require = createRequire(new URL('../../web-admin/package.json', import.meta.url))
  165. const { ref, computed } = require('vue')
  166. const { parse } = require('@vue/compiler-sfc')
  167. const { createWechatFollowReminder } = await load()
  168. const source = readFileSync(new URL('../pages/workbench/workbench.vue', import.meta.url), 'utf8')
  169. const body = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?from ['"][^'"]+['"]\s*\n/gm, '')
  170. const hooks = {}, calls = { prompts: [], urls: [], profiles: [] }
  171. const user = { isLoggedIn: true, token: 'page-login', checkLoginStatus() {},
  172. fetchMenuCodes: async () => {}, logout: async () => { user.token = ''; user.isLoggedIn = false } }
  173. const deps = {
  174. openWechatOfficialAccount: (options) => calls.profiles.push(options),
  175. ref, computed, onUnmounted: () => {}, onHide: (fn) => { hooks.hide = fn }, onShow: (fn) => { hooks.show = fn },
  176. useUserStore: () => user, WORKBENCH_MODULES: [], WORKBENCH_ROUTE_MAP: {},
  177. PAGE_PATHS: { login: '/pages/login/login', wechatOfficialAccount: '/pages/common/wechat-official-account' },
  178. filterWorkbenchModules: () => [], getSafeAreaStyle: () => ({}), useShareToLogin: () => {},
  179. checkMpUpdate: () => {}, formatMpUpdateLine: () => '', isMpUpdateUrgent: () => false,
  180. subscribeMpUpdate: () => () => {}, fetchWechatNoticeStatus: () => statusPromise,
  181. createWechatFollowReminder: (options) => createWechatFollowReminder({ ...options, memory: {} }),
  182. uni: { showModal: (options) => calls.prompts.push(options), navigateTo: ({ url }) => calls.urls.push(url),
  183. reLaunch: ({ url }) => calls.urls.push(url), showToast: () => {} }
  184. }
  185. const component = new Function(...Object.keys(deps), `${body}; return { handleLogout, openOfficialAccount }`)(...Object.values(deps))
  186. return { hooks, calls, component, user }
  187. }
  188. test('actual workbench show checks status and modal confirm opens existing guide', async () => {
  189. const p = await workbench(Promise.resolve({ enabled: true, bound: true, subscribed: false }))
  190. await p.hooks.show()
  191. assert.equal(p.calls.prompts.length, 1)
  192. p.calls.prompts[0].success({ confirm: true })
  193. await Promise.resolve()
  194. await Promise.resolve()
  195. assert.deepEqual(p.calls.urls, ['/pages/common/wechat-official-account'])
  196. p.hooks.hide()
  197. await p.hooks.show()
  198. assert.equal(p.calls.prompts.length, 1)
  199. })
  200. test('actual workbench hide invalidates pending lookup before response arrives', async () => {
  201. const response = deferred()
  202. const p = await workbench(response.promise)
  203. await p.hooks.show()
  204. p.hooks.hide()
  205. response.resolve({ enabled: true, bound: true, subscribed: false })
  206. await Promise.resolve()
  207. assert.equal(p.calls.prompts.length, 0)
  208. })
  209. test('workbench follow click opens local guide without native authorization prompt', async () => {
  210. const p = await workbench(Promise.resolve({ enabled: false, bound: false, subscribed: false }))
  211. await p.hooks.show()
  212. p.component.openOfficialAccount()
  213. assert.deepEqual(p.calls.urls, ['/pages/common/wechat-official-account'])
  214. assert.equal(p.calls.profiles.length, 0)
  215. })
  216. test('workbench follow entry does not navigate during logout', async () => {
  217. const p = await workbench(Promise.resolve({ enabled: false, bound: false, subscribed: false }))
  218. await p.hooks.show()
  219. const logout = p.component.handleLogout()
  220. p.component.openOfficialAccount()
  221. await logout
  222. assert.deepEqual(p.calls.urls, [])
  223. assert.equal(p.calls.profiles.length, 0)
  224. })