/** * 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() }) }) })