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