Просмотр исходного кода

test(frontend): B-63 资产入库 - 4个验收守卫测试文件(29例全绿,coupon-claimed-actions 10 / report-upload-linked-products 6 / shop-price-sort 6 / team-invite-share 7,对应 11-06 优惠券/报告卡片/价格排序/团队分享需求)

E2E Test Bot 4 дней назад
Родитель
Сommit
9cee622417

+ 99 - 0
cfc-frontend/__tests__/specs/coupon-claimed-actions.test.js

@@ -0,0 +1,99 @@
+/**
+ * 验收逻辑守卫:我的优惠券「已领取券」按钮修复 + 使用按券类型跳转
+ * 对应需求文档:docs/superpowers/specs/2026-11-06-coupon-claimed-actions.md
+ *
+ * 说明(与仓库既有约定一致,见 FamilyRelationGraph.test.js):
+ *   cfc-frontend jest 配置 testEnvironment=node,且仅 babel-jest 转换 .js,
+ *   无 vue-jest,故 .vue SFC 方法无法直接挂载测试。仓库惯例是把组件中
+ *   的「纯决策逻辑」抽取为本地函数在此测试。以下函数镜像 coupons.vue
+ *   useCoupon()/giftCoupon() 的跳转决策表(spec §4.1 / §验收标准 2-5)。
+ *
+ *   建议(非本闭环范围,禁止改生产源码):将 resolveCouponUseRoute 抽到
+ *   utils/couponRoute.js,届时本测试直接 import 真实实现即可升级为真正单测。
+ */
+
+// ---- 镜像 coupons.vue useCoupon(coupon) 的路由决策 ----
+function resolveCouponUseRoute(coupon) {
+  const applicableTo = coupon && coupon.applicableTo
+  if (applicableTo === 'PRODUCT') {
+    // 指定商品券:跳确认订单页,自动选券(用券实例 id 作为 couponId)
+    const productId = coupon.productId
+    if (productId) {
+      return `/pages/shop/checkout/checkout?from=coupon&id=${productId}&couponId=${coupon.id}`
+    }
+    // productId 缺失兜底跳商城,不报错(spec §风险)
+    return '/pages/shop/index/index'
+  }
+  if (applicableTo === 'ALL') {
+    return '/pages/shop/index/index' // 全品类 → 商城
+  }
+  if (applicableTo === 'MEMBERSHIP') {
+    return '/pages/membership/upgrade' // 会员券 → 开通会员
+  }
+  // 兜底(ASSESSMENT/其他/null)→ 商城
+  return '/pages/shop/index/index'
+}
+
+// 转赠统一跳赠送会员券页(spec §4.1.3)
+function resolveCouponGiftRoute() {
+  return '/pages/membership/gift-coupon'
+}
+
+// 已领取券应显示「使用 + 转赠」,不再显示「立即领取」(spec §验收 1/6)
+function canClaim(coupon) {
+  // 已领取券 status 为 AVAILABLE/USED/EXPIRED,均非可领取态
+  const claimable = !coupon || !coupon.status || coupon.status === 'AVAILABLE'
+  // spec 根因:canClaim 对“已领取(有券实例)”应返回 false
+  const isClaimedInstance = !!(coupon && coupon.id && coupon.couponId)
+  return claimable && !isClaimedInstance
+}
+
+describe('resolveCouponUseRoute - 使用按钮按券类型跳转', () => {
+  it('PRODUCT 券带 productId → 跳确认订单并自动选券(实例id)', () => {
+    const route = resolveCouponUseRoute({ applicableTo: 'PRODUCT', productId: 9527, id: 7001, couponId: 2001 })
+    expect(route).toBe('/pages/shop/checkout/checkout?from=coupon&id=9527&couponId=7001')
+  })
+
+  it('PRODUCT 券 couponId 必须用实例id(7001)而非模板id(2001)', () => {
+    const route = resolveCouponUseRoute({ applicableTo: 'PRODUCT', productId: 9527, id: 7001, couponId: 2001 })
+    expect(route).toContain('couponId=7001')
+    expect(route).not.toContain('couponId=2001')
+  })
+
+  it('PRODUCT 券 productId 缺失 → 兜底跳商城(不报错)', () => {
+    const route = resolveCouponUseRoute({ applicableTo: 'PRODUCT', id: 7001 })
+    expect(route).toBe('/pages/shop/index/index')
+  })
+
+  it('ALL 全品类券 → 跳商城', () => {
+    expect(resolveCouponUseRoute({ applicableTo: 'ALL', id: 7001 })).toBe('/pages/shop/index/index')
+  })
+
+  it('MEMBERSHIP 会员券 → 跳开通会员', () => {
+    expect(resolveCouponUseRoute({ applicableTo: 'MEMBERSHIP', id: 7001 })).toBe('/pages/membership/upgrade')
+  })
+
+  it('兜底(ASSESSMENT/null) → 跳商城', () => {
+    expect(resolveCouponUseRoute({ applicableTo: 'ASSESSMENT', id: 7001 })).toBe('/pages/shop/index/index')
+    expect(resolveCouponUseRoute({ id: 7001 })).toBe('/pages/shop/index/index')
+  })
+})
+
+describe('resolveCouponGiftRoute - 转赠按钮跳转', () => {
+  it('转赠 → 跳赠送会员券页', () => {
+    expect(resolveCouponGiftRoute()).toBe('/pages/membership/gift-coupon')
+  })
+})
+
+describe('canClaim - 已领取券不再显示「立即领取」', () => {
+  it('已领取券实例(有 id+couponId) → 不可领取', () => {
+    expect(canClaim({ id: 7001, couponId: 2001, status: 'AVAILABLE' })).toBe(false)
+  })
+  it('已使用/已过期券 → 不可领取', () => {
+    expect(canClaim({ id: 7001, couponId: 2001, status: 'USED' })).toBe(false)
+    expect(canClaim({ id: 7001, couponId: 2001, status: 'EXPIRED' })).toBe(false)
+  })
+  it('无券实例(纯模板) → 可领取', () => {
+    expect(canClaim({ status: 'AVAILABLE' })).toBe(true)
+  })
+})

+ 84 - 0
cfc-frontend/__tests__/specs/report-upload-linked-products.test.js

@@ -0,0 +1,84 @@
+/**
+ * 验收逻辑守卫:报告上传页「关联商品」卡片区按商品 id 去重
+ * 对应需求文档:docs/需求分析/2026-11-06-报告上传页关联商品卡片需求变更.md(变更 R1)
+ * 实现任务书:docs/superpowers/specs/2026-11-06-report-upload-product-cards.md(已合入 cfclub 0bdc78def)
+ *
+ * 说明(与仓库既有约定一致,见 coupon-claimed-actions.test.js):
+ *   cfc-frontend jest 配置 testEnvironment=node,无 vue-jest,.vue SFC 方法无法直接挂载。
+ *   约定是把组件中「纯决策逻辑」抽取为本地函数在此测试。以下函数镜像
+ *   report-upload.vue 的 `linkedProducts` computed(spec §变更 R1):
+ *     遍历 reportTypeLinks,用 Set 按商品 id(linkedProductId) 去重
+ *     (parseInt 后过滤 null/0/NaN),返回 [{productId, productName}];
+ *     同商品多个报告类型只保留一条(productName 取首个匹配);接口失败/未配置返回 []。
+ *
+ *   建议(非本闭环范围,禁止改生产源码):将 dedupLinkedProducts 抽到
+ *   utils/reportUpload.js,届时本测试直接 import 真实实现即可升级为真正单测。
+ */
+
+// ---- 镜像 report-upload.vue linkedProducts computed 的去重逻辑 ----
+function dedupLinkedProducts(links) {
+  if (!Array.isArray(links)) return []
+  const seen = new Set()
+  const out = []
+  for (const link of links) {
+    if (!link || !link.linkedProductId) continue
+    const pid = parseInt(link.linkedProductId, 10)
+    if (!pid || Number.isNaN(pid)) continue // 过滤 null/0/NaN
+    if (seen.has(pid)) continue // 同商品多个报告类型只保留一条
+    seen.add(pid)
+    out.push({ productId: pid, productName: link.productName })
+  }
+  return out
+}
+
+describe('dedupLinkedProducts - 关联商品按商品id去重', () => {
+  it('接口失败/未配置(非数组) → 返回 []', () => {
+    expect(dedupLinkedProducts(null)).toEqual([])
+    expect(dedupLinkedProducts(undefined)).toEqual([])
+    expect(dedupLinkedProducts('not-array')).toEqual([])
+  })
+
+  it('空数组(未配置任何关联商品) → 返回 [](静默隐藏)', () => {
+    expect(dedupLinkedProducts([])).toEqual([])
+  })
+
+  it('两个报告类型挂同一商品 → 只保留一条(productName 取首个匹配)', () => {
+    const links = [
+      { typeId: 1, linkedProductId: 100, productName: '益生菌' },
+      { typeId: 2, linkedProductId: 100, productName: '益生菌(重复)' }
+    ]
+    expect(dedupLinkedProducts(links)).toEqual([{ productId: 100, productName: '益生菌' }])
+  })
+
+  it('linkedProductId 为字符串数字 → parseInt 后正确去重', () => {
+    const links = [
+      { typeId: 1, linkedProductId: '200', productName: 'A' },
+      { typeId: 2, linkedProductId: 200, productName: 'B' }
+    ]
+    // '200' 与 200 视为同一商品 → 只保留一条
+    expect(dedupLinkedProducts(links)).toEqual([{ productId: 200, productName: 'A' }])
+  })
+
+  it('过滤无效 linkedProductId(null/0/NaN)', () => {
+    const links = [
+      { typeId: 1, linkedProductId: null, productName: 'X' },
+      { typeId: 2, linkedProductId: 0, productName: 'Y' },
+      { typeId: 3, linkedProductId: 'abc', productName: 'Z' },
+      { typeId: 4, linkedProductId: 300, productName: '有效' }
+    ]
+    expect(dedupLinkedProducts(links)).toEqual([{ productId: 300, productName: '有效' }])
+  })
+
+  it('不同商品 → 多条互不干扰', () => {
+    const links = [
+      { typeId: 1, linkedProductId: 100, productName: 'A' },
+      { typeId: 2, linkedProductId: 200, productName: 'B' },
+      { typeId: 3, linkedProductId: 300, productName: 'C' }
+    ]
+    expect(dedupLinkedProducts(links)).toEqual([
+      { productId: 100, productName: 'A' },
+      { productId: 200, productName: 'B' },
+      { productId: 300, productName: 'C' }
+    ])
+  })
+})

+ 59 - 0
cfc-frontend/__tests__/specs/shop-price-sort.test.js

@@ -0,0 +1,59 @@
+/**
+ * 验收逻辑守卫:商城商品列表「按价格排序」参数构造
+ * 对应需求文档:docs/需求分析/2026-11-07-商城价格排序需求变更.md(变更 S1)
+ * 实现任务书:docs/superpowers/specs/2026-11-07-shop-price-sort.md(已派发 lan-204 dev中)
+ *
+ * 说明(与仓库既有约定一致,见 coupon-claimed-actions.test.js):
+ *   cfc-frontend jest 配置 testEnvironment=node,无 vue-jest,.vue SFC 方法无法直接挂载。
+ *   约定是把组件中「纯决策逻辑」抽取为本地函数在此测试。以下函数镜像
+ *   shop/index/index.vue 的 `buildSort(sortType)`(spec §变更 S1):
+ *     price_asc  → [{column:'price',order:'asc'}]
+ *     price_desc → [{column:'price',order:'desc'}]
+ *     default/其他 → [](不传,走后端默认 sort_order asc + created_at desc)
+ *
+ *   关键风险(spec §风险 1):排序参数必须是数组 [{column,order}],字符串会被
+ *   后端 SortInterceptor 忽略(这是 loadRecommended 现有 sort:'sales' 无效的根因)。
+ *   本守卫显式断言返回值为数组,防前端复现该 bug。
+ *
+ *   注意:S1 当前在 dev 分支(lan-204)开发,cfclub 尚未合入;本文件为「验收逻辑守卫」,
+ *   守卫 spec 契约,而非已合入实现。建议合入后将 buildSort 抽到 utils 后改为 import 真实实现。
+ */
+
+// ---- 镜像 shop/index/index.vue buildSort(sortType) ----
+function buildSort(sortType) {
+  if (sortType === 'price_asc') return [{ column: 'price', order: 'asc' }]
+  if (sortType === 'price_desc') return [{ column: 'price', order: 'desc' }]
+  return [] // default / 未知类型 → 不传 sort,走后端默认排序
+}
+
+describe('buildSort - 商城按价格排序参数构造', () => {
+  it('price_asc → 数组 [{column:price,order:asc}]', () => {
+    expect(buildSort('price_asc')).toEqual([{ column: 'price', order: 'asc' }])
+  })
+
+  it('price_desc → 数组 [{column:price,order:desc}]', () => {
+    expect(buildSort('price_desc')).toEqual([{ column: 'price', order: 'desc' }])
+  })
+
+  it('default → 空数组(不传 sort,走后端默认排序)', () => {
+    expect(buildSort('default')).toEqual([])
+  })
+
+  it('未知/空 sortType → 空数组(不污染后端默认排序)', () => {
+    expect(buildSort('')).toEqual([])
+    expect(buildSort('sales')).toEqual([]) // 字符串 'sales' 旧 bug 形态,必须被忽略而非透传
+    expect(buildSort(undefined)).toEqual([])
+  })
+
+  it('守卫:返回值必须是数组(防 SortInterceptor 忽略字符串 bug)', () => {
+    for (const t of ['price_asc', 'price_desc', 'default', 'sales', '']) {
+      expect(Array.isArray(buildSort(t))).toBe(true)
+    }
+  })
+
+  it('守卫:price 列名拼写正确(与 SortUtil 驼峰→下划线映射 product.price 对应)', () => {
+    const asc = buildSort('price_asc')[0]
+    expect(asc && asc.column).toBe('price')
+    expect(asc.order).toBe('asc')
+  })
+})

+ 52 - 0
cfc-frontend/__tests__/specs/team-invite-share.test.js

@@ -0,0 +1,52 @@
+/**
+ * 验收逻辑守卫:我的团队「推广收益内外口径对齐」+「邀请成员直接分享」
+ * 对应需求文档:docs/superpowers/specs/2026-11-06-team-invite-share.md
+ *
+ * 说明(与仓库既有约定一致):cfc-frontend jest 无 vue-jest,SFC 方法不可挂载。
+ * 以下本地函数镜像 profile.vue loadReferralEarnings() 的口径换算逻辑
+ * (spec §3.1 / §验收 1):profile 推广收益改取 getCfSummary().totalEarned,
+ * 并 ÷100(分→元),与推广中心 promotion/index.vue 的 cfVal 显示一致。
+ *
+ * 建议(非本闭环范围,禁止改生产源码):将 toYuan 抽到 utils/money.js,
+ * 届时本测试 import 真实实现即升级为真正单测。
+ */
+
+// ---- 镜像 profile.vue loadReferralEarnings() 的换算 ----
+function toYuan(cfVal) {
+  // spec: self.referralEarnings = (res.data.totalEarned || 0) / 100
+  return (cfVal || 0) / 100
+}
+
+// 内外口径一致性:profile 展示值应与 promotion 收益总览(cfVal(totalEarned))相等
+function isEarningsAligned(profileCfVal, promotionCfVal) {
+  return toYuan(profileCfVal) === toYuan(promotionCfVal)
+}
+
+describe('toYuan - 推广收益 分→元 口径', () => {
+  it('正常分值为元(100分=1元)', () => {
+    expect(toYuan(100)).toBe(1)
+  })
+  it('非零分值正确换算', () => {
+    expect(toYuan(12345)).toBe(123.45)
+  })
+  it('0 → 0', () => {
+    expect(toYuan(0)).toBe(0)
+  })
+  it('null → 0(spec: totalEarned || 0)', () => {
+    expect(toYuan(null)).toBe(0)
+  })
+  it('undefined → 0', () => {
+    expect(toYuan(undefined)).toBe(0)
+  })
+})
+
+describe('isEarningsAligned - 内外口径一致(验收标准 1)', () => {
+  it('profile 与 promotion 取同一 CF 总览值 → 数字一致', () => {
+    // 同一用户、无新佣金变动时,两者 totalEarned 相同
+    expect(isEarningsAligned(8800, 8800)).toBe(true)
+  })
+  it('不同口径(旧佣金求和 vs 新 CF 总览)会不一致 → 需统一为 CF 口径', () => {
+    // 仅当两者都已改为 getCfSummary().totalEarned 才一致
+    expect(isEarningsAligned(8800, 8800)).toBe(true)
+  })
+})