indicator-favorite.test.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. /**
  2. * utils/indicator-favorite 关注指标收藏单测(T04 方案A:后端持久化 + 本地降级)
  3. * 规则来源:design §8.1 / §8.3(F4)、§11.3 T04
  4. * 隔离维度:userId × familyId × memberId × sourceType → 不同 key,防串号
  5. * 后端契约:/api/indicator/favorite/{toggle,list,remove},见
  6. * specs/2026-10-30-indicator-favorite-backend-design.md §4
  7. */
  8. jest.mock('@/utils/api.js', () => ({
  9. listIndicatorFavorites: jest.fn(),
  10. toggleIndicatorFavorite: jest.fn(),
  11. removeIndicatorFavorite: jest.fn()
  12. }))
  13. import { buildFavoriteKey, readFavorites, writeFavorites, toggleFavorite, removeFavorite } from '@/utils/indicator-favorite'
  14. import { listIndicatorFavorites, toggleIndicatorFavorite, removeIndicatorFavorite } from '@/utils/api'
  15. /** 后端成功响应包装(api.js 的 request() resolve 的是整个 Result) */
  16. function ok(data) {
  17. return Promise.resolve({ code: 200, message: 'success', data: data })
  18. }
  19. const SCOPE = { userId: 1, familyId: 1, memberId: 1, sourceType: 'gut_flora' }
  20. describe('indicator-favorite 关注指标收藏(方案A:后端持久化 + 本地降级)', () => {
  21. beforeEach(function() {
  22. jest.clearAllMocks()
  23. // 每个用例前清空 storage,避免用例间污染
  24. if (global.uni && global.uni._storage) {
  25. Object.keys(global.uni._storage).filter(function(k) { return k.indexOf('cfc:indicator:fav:') === 0 }).forEach(function(k) { delete global.uni._storage[k] })
  26. }
  27. })
  28. describe('buildFavoriteKey', () => {
  29. test('默认维度全部填充占位符', () => {
  30. var key = buildFavoriteKey({})
  31. expect(key).toMatch(/cfc:indicator:fav:anon:nofamily:nomember:all$/)
  32. })
  33. test('全维度隔离', () => {
  34. var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'gut_flora' })
  35. var k2 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 4, sourceType: 'gut_flora' })
  36. expect(k2).not.toBe(k1)
  37. expect(k2).toContain(':4:')
  38. })
  39. test('sourceType 隔离', () => {
  40. var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'gut_flora' })
  41. var k2 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'food_recommend' })
  42. expect(k2).not.toBe(k1)
  43. })
  44. test('不同 familyId 隔离', () => {
  45. var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3 })
  46. var k2 = buildFavoriteKey({ userId: 1, familyId: 5, memberId: 3 })
  47. expect(k2).not.toBe(k1)
  48. })
  49. })
  50. describe('readFavorites(后端优先)', () => {
  51. test('后端返回 definitionIds → 原样返回并回写本地缓存', async () => {
  52. listIndicatorFavorites.mockReturnValue(ok({ memberId: 1, sourceType: 'gut_flora', definitionIds: [456, 789] }))
  53. var ids = await readFavorites(SCOPE)
  54. expect(ids).toEqual([456, 789])
  55. expect(listIndicatorFavorites).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora' })
  56. // 回写本地:下次离线可降级读
  57. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([456, 789])
  58. })
  59. test('后端返回空集合 → []', async () => {
  60. listIndicatorFavorites.mockReturnValue(ok({ memberId: 1, sourceType: 'gut_flora', definitionIds: [] }))
  61. expect(await readFavorites(SCOPE)).toEqual([])
  62. })
  63. test('后端 reject → 降级读本地 Storage', async () => {
  64. writeFavorites(SCOPE, [101, 102])
  65. listIndicatorFavorites.mockReturnValue(Promise.reject({ code: 500, message: 'boom' }))
  66. expect(await readFavorites(SCOPE)).toEqual([101, 102])
  67. })
  68. test('后端返回无 data(如 401 未登录)→ 降级读本地', async () => {
  69. writeFavorites(SCOPE, [101])
  70. listIndicatorFavorites.mockReturnValue(Promise.resolve({ code: 401, message: '请先登录', data: null }))
  71. expect(await readFavorites(SCOPE)).toEqual([101])
  72. })
  73. test('本地无缓存且后端不可用 → []', async () => {
  74. listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
  75. expect(await readFavorites(SCOPE)).toEqual([])
  76. })
  77. test('缺 memberId/sourceType(未登录/无视角)→ 不调后端,直接读本地', async () => {
  78. writeFavorites({}, [101])
  79. expect(await readFavorites({})).toEqual([101])
  80. expect(listIndicatorFavorites).not.toHaveBeenCalled()
  81. })
  82. test('非法本地数据容错返回 []', async () => {
  83. var key = buildFavoriteKey(SCOPE)
  84. listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
  85. global.uni._storage[key] = 'not-a-json'
  86. expect(await readFavorites(SCOPE)).toEqual([])
  87. global.uni._storage[key] = 123
  88. expect(await readFavorites(SCOPE)).toEqual([])
  89. })
  90. test('跨 scope 隔离:不同 memberId 读到各自缓存', async () => {
  91. var s2 = { userId: 1, familyId: 1, memberId: 2, sourceType: 'gut_flora' }
  92. var s3 = { userId: 1, familyId: 1, memberId: 3, sourceType: 'gut_flora' }
  93. writeFavorites(s2, [101])
  94. listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
  95. expect(await readFavorites(s2)).toEqual([101])
  96. expect(await readFavorites(s3)).toEqual([])
  97. })
  98. })
  99. describe('toggleFavorite(异步 + 乐观更新 + 失败回滚)', () => {
  100. test('首次收藏 → 调后端,added=true,本地写入', async () => {
  101. toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: true }))
  102. var res = await toggleFavorite(SCOPE, 101)
  103. expect(res.added).toBe(true)
  104. expect(res.ids).toEqual([101])
  105. expect(toggleIndicatorFavorite).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora', definitionId: 101 })
  106. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
  107. })
  108. test('已收藏再点 → added=false,从本地移除', async () => {
  109. writeFavorites(SCOPE, [101])
  110. toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: false }))
  111. var res = await toggleFavorite(SCOPE, 101)
  112. expect(res.added).toBe(false)
  113. expect(res.ids).toEqual([])
  114. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([])
  115. })
  116. test('后端失败 → 回滚本地,added=false', async () => {
  117. writeFavorites(SCOPE, [101])
  118. toggleIndicatorFavorite.mockReturnValue(Promise.reject({ code: 500, message: 'boom' }))
  119. var res = await toggleFavorite(SCOPE, 102)
  120. expect(res.added).toBe(false)
  121. expect(res.ids).toEqual([101]) // 回滚到调用前
  122. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
  123. })
  124. test('后端失败且原本为空 → 回滚为空', async () => {
  125. toggleIndicatorFavorite.mockReturnValue(Promise.reject(new Error('network')))
  126. var res = await toggleFavorite(SCOPE, 101)
  127. expect(res.ids).toEqual([])
  128. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([])
  129. })
  130. test('后端返回 added=false(服务端已有该收藏)→ 本地缓存同步为未收藏', async () => {
  131. // 本地缓存过期(空),但服务端判定「已存在→删除」
  132. toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: false }))
  133. var res = await toggleFavorite(SCOPE, 101)
  134. expect(res.added).toBe(false)
  135. expect(res.ids).toEqual([])
  136. })
  137. test('定义 id 非法(NaN)→ 不改变存储、不调后端', async () => {
  138. writeFavorites(SCOPE, [101])
  139. var res = await toggleFavorite(SCOPE, 'not-a-number')
  140. expect(res.ids).toEqual([101])
  141. expect(res.added).toBe(false)
  142. expect(toggleIndicatorFavorite).not.toHaveBeenCalled()
  143. })
  144. test('缺 memberId(未登录)→ 本地模式,不调后端', async () => {
  145. var res = await toggleFavorite({}, 101)
  146. expect(res.added).toBe(true)
  147. expect(res.ids).toEqual([101])
  148. expect(toggleIndicatorFavorite).not.toHaveBeenCalled()
  149. })
  150. test('跨 sourceType 收藏隔离', async () => {
  151. var s2 = { userId: 1, familyId: 1, memberId: 1, sourceType: 'food_recommend' }
  152. toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: true }))
  153. await toggleFavorite(SCOPE, 101)
  154. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
  155. expect(global.uni._storage[buildFavoriteKey(s2)]).toBeUndefined()
  156. expect(await readFavorites(s2)).toEqual([])
  157. })
  158. })
  159. describe('removeFavorite(显式删除,幂等)', () => {
  160. test('移除已收藏 → removed=true,本地删除', async () => {
  161. writeFavorites(SCOPE, [101, 102])
  162. removeIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, removed: true }))
  163. var res = await removeFavorite(SCOPE, 101)
  164. expect(res.removed).toBe(true)
  165. expect(res.ids).toEqual([102])
  166. expect(removeIndicatorFavorite).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora', definitionId: 101 })
  167. })
  168. test('移除不存在的项(后端幂等)→ removed=true,本地不变', async () => {
  169. writeFavorites(SCOPE, [102])
  170. removeIndicatorFavorite.mockReturnValue(ok({ definitionId: 999, removed: true }))
  171. var res = await removeFavorite(SCOPE, 999)
  172. expect(res.removed).toBe(true)
  173. expect(res.ids).toEqual([102])
  174. })
  175. test('后端失败 → 回滚本地,removed=false', async () => {
  176. writeFavorites(SCOPE, [101])
  177. removeIndicatorFavorite.mockReturnValue(Promise.reject(new Error('network')))
  178. var res = await removeFavorite(SCOPE, 101)
  179. expect(res.removed).toBe(false)
  180. expect(res.ids).toEqual([101])
  181. expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
  182. })
  183. test('定义 id 非法 → 不调后端', async () => {
  184. writeFavorites(SCOPE, [101])
  185. var res = await removeFavorite(SCOPE, 'x')
  186. expect(res.removed).toBe(false)
  187. expect(res.ids).toEqual([101])
  188. expect(removeIndicatorFavorite).not.toHaveBeenCalled()
  189. })
  190. })
  191. })