|
|
@@ -0,0 +1,247 @@
|
|
|
+/**
|
|
|
+ * utils/indicator-favorite 关注指标收藏单测(T04 方案A:后端持久化 + 本地降级)
|
|
|
+ * 规则来源:design §8.1 / §8.3(F4)、§11.3 T04
|
|
|
+ * 隔离维度:userId × familyId × memberId × sourceType → 不同 key,防串号
|
|
|
+ * 后端契约:/api/indicator/favorite/{toggle,list,remove},见
|
|
|
+ * specs/2026-10-30-indicator-favorite-backend-design.md §4
|
|
|
+ */
|
|
|
+jest.mock('@/utils/api.js', () => ({
|
|
|
+ listIndicatorFavorites: jest.fn(),
|
|
|
+ toggleIndicatorFavorite: jest.fn(),
|
|
|
+ removeIndicatorFavorite: jest.fn()
|
|
|
+}))
|
|
|
+
|
|
|
+import { buildFavoriteKey, readFavorites, writeFavorites, toggleFavorite, removeFavorite } from '@/utils/indicator-favorite'
|
|
|
+import { listIndicatorFavorites, toggleIndicatorFavorite, removeIndicatorFavorite } from '@/utils/api'
|
|
|
+
|
|
|
+/** 后端成功响应包装(api.js 的 request() resolve 的是整个 Result) */
|
|
|
+function ok(data) {
|
|
|
+ return Promise.resolve({ code: 200, message: 'success', data: data })
|
|
|
+}
|
|
|
+
|
|
|
+const SCOPE = { userId: 1, familyId: 1, memberId: 1, sourceType: 'gut_flora' }
|
|
|
+
|
|
|
+describe('indicator-favorite 关注指标收藏(方案A:后端持久化 + 本地降级)', () => {
|
|
|
+ beforeEach(function() {
|
|
|
+ jest.clearAllMocks()
|
|
|
+ // 每个用例前清空 storage,避免用例间污染
|
|
|
+ if (global.uni && global.uni._storage) {
|
|
|
+ Object.keys(global.uni._storage).filter(function(k) { return k.indexOf('cfc:indicator:fav:') === 0 }).forEach(function(k) { delete global.uni._storage[k] })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('buildFavoriteKey', () => {
|
|
|
+ test('默认维度全部填充占位符', () => {
|
|
|
+ var key = buildFavoriteKey({})
|
|
|
+ expect(key).toMatch(/cfc:indicator:fav:anon:nofamily:nomember:all$/)
|
|
|
+ })
|
|
|
+ test('全维度隔离', () => {
|
|
|
+ var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'gut_flora' })
|
|
|
+ var k2 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 4, sourceType: 'gut_flora' })
|
|
|
+ expect(k2).not.toBe(k1)
|
|
|
+ expect(k2).toContain(':4:')
|
|
|
+ })
|
|
|
+ test('sourceType 隔离', () => {
|
|
|
+ var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'gut_flora' })
|
|
|
+ var k2 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'food_recommend' })
|
|
|
+ expect(k2).not.toBe(k1)
|
|
|
+ })
|
|
|
+ test('不同 familyId 隔离', () => {
|
|
|
+ var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3 })
|
|
|
+ var k2 = buildFavoriteKey({ userId: 1, familyId: 5, memberId: 3 })
|
|
|
+ expect(k2).not.toBe(k1)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('readFavorites(后端优先)', () => {
|
|
|
+ test('后端返回 definitionIds → 原样返回并回写本地缓存', async () => {
|
|
|
+ listIndicatorFavorites.mockReturnValue(ok({ memberId: 1, sourceType: 'gut_flora', definitionIds: [456, 789] }))
|
|
|
+
|
|
|
+ var ids = await readFavorites(SCOPE)
|
|
|
+
|
|
|
+ expect(ids).toEqual([456, 789])
|
|
|
+ expect(listIndicatorFavorites).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora' })
|
|
|
+ // 回写本地:下次离线可降级读
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([456, 789])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端返回空集合 → []', async () => {
|
|
|
+ listIndicatorFavorites.mockReturnValue(ok({ memberId: 1, sourceType: 'gut_flora', definitionIds: [] }))
|
|
|
+
|
|
|
+ expect(await readFavorites(SCOPE)).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端 reject → 降级读本地 Storage', async () => {
|
|
|
+ writeFavorites(SCOPE, [101, 102])
|
|
|
+ listIndicatorFavorites.mockReturnValue(Promise.reject({ code: 500, message: 'boom' }))
|
|
|
+
|
|
|
+ expect(await readFavorites(SCOPE)).toEqual([101, 102])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端返回无 data(如 401 未登录)→ 降级读本地', async () => {
|
|
|
+ writeFavorites(SCOPE, [101])
|
|
|
+ listIndicatorFavorites.mockReturnValue(Promise.resolve({ code: 401, message: '请先登录', data: null }))
|
|
|
+
|
|
|
+ expect(await readFavorites(SCOPE)).toEqual([101])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('本地无缓存且后端不可用 → []', async () => {
|
|
|
+ listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
|
|
|
+
|
|
|
+ expect(await readFavorites(SCOPE)).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('缺 memberId/sourceType(未登录/无视角)→ 不调后端,直接读本地', async () => {
|
|
|
+ writeFavorites({}, [101])
|
|
|
+
|
|
|
+ expect(await readFavorites({})).toEqual([101])
|
|
|
+ expect(listIndicatorFavorites).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('非法本地数据容错返回 []', async () => {
|
|
|
+ var key = buildFavoriteKey(SCOPE)
|
|
|
+ listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
|
|
|
+ global.uni._storage[key] = 'not-a-json'
|
|
|
+ expect(await readFavorites(SCOPE)).toEqual([])
|
|
|
+ global.uni._storage[key] = 123
|
|
|
+ expect(await readFavorites(SCOPE)).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('跨 scope 隔离:不同 memberId 读到各自缓存', async () => {
|
|
|
+ var s2 = { userId: 1, familyId: 1, memberId: 2, sourceType: 'gut_flora' }
|
|
|
+ var s3 = { userId: 1, familyId: 1, memberId: 3, sourceType: 'gut_flora' }
|
|
|
+ writeFavorites(s2, [101])
|
|
|
+ listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
|
|
|
+
|
|
|
+ expect(await readFavorites(s2)).toEqual([101])
|
|
|
+ expect(await readFavorites(s3)).toEqual([])
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('toggleFavorite(异步 + 乐观更新 + 失败回滚)', () => {
|
|
|
+ test('首次收藏 → 调后端,added=true,本地写入', async () => {
|
|
|
+ toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: true }))
|
|
|
+
|
|
|
+ var res = await toggleFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(res.added).toBe(true)
|
|
|
+ expect(res.ids).toEqual([101])
|
|
|
+ expect(toggleIndicatorFavorite).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora', definitionId: 101 })
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('已收藏再点 → added=false,从本地移除', async () => {
|
|
|
+ writeFavorites(SCOPE, [101])
|
|
|
+ toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: false }))
|
|
|
+
|
|
|
+ var res = await toggleFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(res.added).toBe(false)
|
|
|
+ expect(res.ids).toEqual([])
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端失败 → 回滚本地,added=false', async () => {
|
|
|
+ writeFavorites(SCOPE, [101])
|
|
|
+ toggleIndicatorFavorite.mockReturnValue(Promise.reject({ code: 500, message: 'boom' }))
|
|
|
+
|
|
|
+ var res = await toggleFavorite(SCOPE, 102)
|
|
|
+
|
|
|
+ expect(res.added).toBe(false)
|
|
|
+ expect(res.ids).toEqual([101]) // 回滚到调用前
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端失败且原本为空 → 回滚为空', async () => {
|
|
|
+ toggleIndicatorFavorite.mockReturnValue(Promise.reject(new Error('network')))
|
|
|
+
|
|
|
+ var res = await toggleFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(res.ids).toEqual([])
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端返回 added=false(服务端已有该收藏)→ 本地缓存同步为未收藏', async () => {
|
|
|
+ // 本地缓存过期(空),但服务端判定「已存在→删除」
|
|
|
+ toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: false }))
|
|
|
+
|
|
|
+ var res = await toggleFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(res.added).toBe(false)
|
|
|
+ expect(res.ids).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('定义 id 非法(NaN)→ 不改变存储、不调后端', async () => {
|
|
|
+ writeFavorites(SCOPE, [101])
|
|
|
+
|
|
|
+ var res = await toggleFavorite(SCOPE, 'not-a-number')
|
|
|
+
|
|
|
+ expect(res.ids).toEqual([101])
|
|
|
+ expect(res.added).toBe(false)
|
|
|
+ expect(toggleIndicatorFavorite).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('缺 memberId(未登录)→ 本地模式,不调后端', async () => {
|
|
|
+ var res = await toggleFavorite({}, 101)
|
|
|
+
|
|
|
+ expect(res.added).toBe(true)
|
|
|
+ expect(res.ids).toEqual([101])
|
|
|
+ expect(toggleIndicatorFavorite).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('跨 sourceType 收藏隔离', async () => {
|
|
|
+ var s2 = { userId: 1, familyId: 1, memberId: 1, sourceType: 'food_recommend' }
|
|
|
+ toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: true }))
|
|
|
+
|
|
|
+ await toggleFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(s2)]).toBeUndefined()
|
|
|
+ expect(await readFavorites(s2)).toEqual([])
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('removeFavorite(显式删除,幂等)', () => {
|
|
|
+ test('移除已收藏 → removed=true,本地删除', async () => {
|
|
|
+ writeFavorites(SCOPE, [101, 102])
|
|
|
+ removeIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, removed: true }))
|
|
|
+
|
|
|
+ var res = await removeFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(res.removed).toBe(true)
|
|
|
+ expect(res.ids).toEqual([102])
|
|
|
+ expect(removeIndicatorFavorite).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora', definitionId: 101 })
|
|
|
+ })
|
|
|
+
|
|
|
+ test('移除不存在的项(后端幂等)→ removed=true,本地不变', async () => {
|
|
|
+ writeFavorites(SCOPE, [102])
|
|
|
+ removeIndicatorFavorite.mockReturnValue(ok({ definitionId: 999, removed: true }))
|
|
|
+
|
|
|
+ var res = await removeFavorite(SCOPE, 999)
|
|
|
+
|
|
|
+ expect(res.removed).toBe(true)
|
|
|
+ expect(res.ids).toEqual([102])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('后端失败 → 回滚本地,removed=false', async () => {
|
|
|
+ writeFavorites(SCOPE, [101])
|
|
|
+ removeIndicatorFavorite.mockReturnValue(Promise.reject(new Error('network')))
|
|
|
+
|
|
|
+ var res = await removeFavorite(SCOPE, 101)
|
|
|
+
|
|
|
+ expect(res.removed).toBe(false)
|
|
|
+ expect(res.ids).toEqual([101])
|
|
|
+ expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('定义 id 非法 → 不调后端', async () => {
|
|
|
+ writeFavorites(SCOPE, [101])
|
|
|
+
|
|
|
+ var res = await removeFavorite(SCOPE, 'x')
|
|
|
+
|
|
|
+ expect(res.removed).toBe(false)
|
|
|
+ expect(res.ids).toEqual([101])
|
|
|
+ expect(removeIndicatorFavorite).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|