import { Test } from '@nestjs/testing'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PublicService } from './public.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { PrismaService } from '../prisma/prisma.service'; describe('PublicService', () => { let service: PublicService; let prisma: PrismaService; const stamp = Date.now(); let countryId: bigint; let categoryId: bigint; let childCategoryId: bigint; let otherCategoryId: bigint; let tagId: bigint; let filterGroupIds: bigint[] = []; let filterTagIds: bigint[] = []; let originGoodId: bigint; let familyId: bigint; let goodIds: bigint[] = []; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [PublicService, PrismaService], }).compile(); service = moduleRef.get(PublicService); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); const country = await prisma.country.create({ data: { countryName: `Pub Country ${stamp}` }, }); countryId = country.id; const cat = await prisma.category.create({ data: { categoryName: `Pub Cat ${stamp}` }, }); categoryId = cat.id; const child = await prisma.category.create({ data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id }, }); childCategoryId = child.id; const otherCat = await prisma.category.create({ data: { categoryName: `Pub Other ${stamp}` }, }); otherCategoryId = otherCat.id; const tag = await prisma.tag.create({ data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' }, }); tagId = tag.id; const og = await prisma.originGood.create({ data: { sdsGoodId: `pub-sds-${stamp}`, goodName: `Origin ${stamp}`, goodImage: 'http://img', }, }); originGoodId = og.id; // 公开契约族化:商品必须挂族才对外可见 const family = await prisma.productFamily.create({ data: { familyName: `Pub Family ${stamp}`, primaryOriginGoodId: og.id }, }); familyId = family.id; await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id }, }); // Seed 3 goods: // high priority + position.indexVal=1 // mid priority + position.indexVal=5 // no priority + no position (falls back to createdAt) const pos1 = await prisma.position.create({ data: { indexVal: 1, countryId, categoryId }, }); const pos2 = await prisma.position.create({ data: { indexVal: 5, countryId, categoryId }, }); const g1 = await prisma.good.create({ data: { goodName: `Pub High ${stamp}`, originGoodId, familyId: family.id, countryId, categoryId, goodPriority: 10, positionId: pos1.id, }, }); const g2 = await prisma.good.create({ data: { goodName: `Pub Mid ${stamp}`, originGoodId, familyId: family.id, countryId, categoryId, goodPriority: 5, positionId: pos2.id, }, }); const g3 = await prisma.good.create({ data: { goodName: `Pub NoPos ${stamp}`, originGoodId, familyId: family.id, countryId, categoryId, tagId, goodPriority: 1, }, }); // 自包含:链接级三组封闭词表标签 + 商品镜像 // (getTagGroups「物流渠道/印花数量/印刷工艺」断言依赖,不再依赖库内其他数据) const autoTagIds: bigint[] = []; for (const [groupName, tagName] of [ ['物流渠道', '包邮'], ['印花数量', '单面印花'], ['印刷工艺', '烫画'], ] as const) { const group = await prisma.tagGroup.upsert({ where: { groupName }, create: { groupName }, update: {}, }); const t = await prisma.tag.upsert({ where: { tagName }, create: { tagName, tagGroupId: group.id }, update: {}, }); autoTagIds.push(t.id); } await prisma.originGoodTag.createMany({ data: autoTagIds.map((tagId) => ({ originGoodId: og.id, tagId })), }); await prisma.goodTag.createMany({ data: [g1.id, g2.id, g3.id].flatMap((goodId) => autoTagIds.map((tagId) => ({ goodId, tagId })), ), }); goodIds = [g1.id, g2.id, g3.id]; const craftGroup = await prisma.tagGroup.create({ data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, }); const materialGroup = await prisma.tagGroup.create({ data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, }); filterGroupIds = [craftGroup.id, materialGroup.id]; const craftA = await prisma.tag.create({ data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, }); const craftB = await prisma.tag.create({ data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, }); const cotton = await prisma.tag.create({ data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, }); filterTagIds = [craftA.id, craftB.id, cotton.id]; await prisma.goodTag.createMany({ data: [ { goodId: g1.id, tagId: craftA.id }, { goodId: g1.id, tagId: cotton.id }, { goodId: g2.id, tagId: craftB.id }, ], }); await prisma.originGoodDetail.create({ data: { originGoodId, productCode: 'OZ10827003', productionProcess: '白墨烫画', sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, }, }); await prisma.originGoodVariant.create({ data: { originGoodId, sdsVariantId: `pub-variant-${stamp}`, sku: `OZ${stamp}`, sizeName: 'S', price: 38, }, }); // 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算) await new FamilyRecomputeService(prisma).recomputeFamily(family.id); // Seed a good in `otherCategory` so the "onlyHaveGoods" filter // returns more than one category. await prisma.good.create({ data: { goodName: `Pub Other ${stamp}`, originGoodId, familyId: family.id, countryId, categoryId: otherCategoryId, goodPriority: 1, }, }); // And seed a good in the *child* category, to verify categoryId // recursion. await prisma.good.create({ data: { goodName: `Pub ChildGood ${stamp}`, originGoodId, familyId: family.id, countryId, categoryId: childCategoryId, goodPriority: 0, }, }); }); afterAll(async () => { if (goodIds.length) { await prisma.good.deleteMany({ where: { id: { in: goodIds } } }); } await prisma.good.deleteMany({ where: { goodName: { contains: `Pub ` } }, }); // Good.familyId / OriginGood.familyId 均为 SetNull,先删商品再删族 await prisma.productFamily.deleteMany({ where: { id: familyId } }); await prisma.position.deleteMany({ where: { countryId }, }); await prisma.tag.delete({ where: { id: tagId } }); await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); await prisma.originGood.delete({ where: { id: originGoodId } }); // Delete children before parent (FK self-relation is RESTRICT). await prisma.category.delete({ where: { id: childCategoryId } }); await prisma.category.delete({ where: { id: otherCategoryId } }); await prisma.category.delete({ where: { id: categoryId } }); await prisma.country.delete({ where: { id: countryId } }); await prisma.onModuleDestroy(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('getCategoriesTree returns only categories that have goods', async () => { const tree = await service.getCategoriesTree(); const allIds = new Set(); const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => { for (const n of list) { allIds.add(n.id); walk(n.children as any); } }; walk(tree as any); // We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`. expect(allIds.has(categoryId.toString())).toBe(true); expect(allIds.has(childCategoryId.toString())).toBe(true); expect(allIds.has(otherCategoryId.toString())).toBe(true); }); it('getCountries returns only countries that have goods', async () => { const countries = await service.getCountries(); expect(countries.find((c) => c.id === countryId.toString())).toBeDefined(); }); it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => { const filtered = await service.getGoods({ page: 1, pageSize: 50, countryId: countryId.toString(), categoryId: categoryId.toString(), // includes child keyword: `Pub `, }); expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款 expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); }); it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { const result = await service.getGoods({ page: 1, pageSize: 50, countryId: countryId.toString(), keyword: `Pub `, }); const priorities = result.items.map((g) => g.goodPriority); // First verify primary descending priority. const sorted = [...priorities].sort((a, b) => b - a); expect(priorities).toEqual(sorted); }); it('uses OR within one tag group and AND across tag groups', async () => { const sameGroup = await service.getGoods({ page: 1, pageSize: 50, countryId: countryId.toString(), keyword: `Pub `, tags: [ { tagGroupId: filterGroupIds[0].toString(), tagIds: filterTagIds.slice(0, 2).map(String), }, ], }); // 族化后命中族内多条 Good 仍只出代表行(High 优先级最高) expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]); const acrossGroups = await service.getGoods({ page: 1, pageSize: 50, countryId: countryId.toString(), keyword: `Pub `, tags: [ { tagGroupId: filterGroupIds[0].toString(), tagIds: filterTagIds.slice(0, 2).map(String), }, { tagGroupId: filterGroupIds[1].toString(), tagIds: [filterTagIds[2].toString()], }, ], }); expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); }); it('rejects a tag paired with the wrong tag group', async () => { await expect( service.getGoods({ page: 1, pageSize: 20, tags: [ { tagGroupId: filterGroupIds[1].toString(), tagIds: [filterTagIds[0].toString()], }, ], }), ).rejects.toBeInstanceOf(BadRequestException); }); it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => { const result = await service.getGoods({ page: 1, pageSize: 50, countryId: countryId.toString(), keyword: `Pub `, }); // 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID expect(result.items).toHaveLength(1); expect(result.items[0].goodId).toBe(familyId.toString()); expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条 }); it('list price is the family price matrix minimum (SQL aggregate)', async () => { // 自包含 fixture:CUSTOM 光板成员(craftLabel/logisticsLabel 是矩阵归因来源) // + 单变体 38 元。SDS 成员无标签时矩阵为空(不解析名称,等整理补标签), // 共享 fixture 族 therefore 无矩阵,无法覆盖该路径。 const og = await prisma.originGood.create({ data: { source: 'CUSTOM', sdsGoodId: `pub-matrix-${stamp}`, goodName: `Pub Matrix ${stamp}`, craftLabel: '不打印', logisticsLabel: '包邮', goodPrice: 50, // 无矩阵时的回退链接价 }, }); const family = await prisma.productFamily.create({ data: { familyName: `Pub Matrix Family ${stamp}`, primaryOriginGoodId: og.id }, }); await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id }, }); await prisma.originGoodVariant.create({ data: { originGoodId: og.id, sdsVariantId: `pub-matrix-v-${stamp}`, sku: `PM${stamp}`, sizeName: 'S', price: 38, }, }); const good = await prisma.good.create({ data: { goodName: `Pub Matrix Good ${stamp}`, originGoodId: og.id, familyId: family.id, countryId, categoryId, }, }); try { // 重算前:族无矩阵 → 回退链接价 const before = await service.getGoods({ page: 1, pageSize: 50, keyword: `Pub Matrix Good`, }); expect(before.items).toHaveLength(1); expect(before.items[0].price).toBe('50'); await new FamilyRecomputeService(prisma).recomputeFamily(family.id); const after = await service.getGoods({ page: 1, pageSize: 50, keyword: `Pub Matrix Good`, }); expect(after.items).toHaveLength(1); // 重算后:矩阵最低价 38 生效,不再是回退链接价 expect(after.items[0].price).toBe('38'); } finally { await prisma.good.delete({ where: { id: good.id } }); await prisma.originGoodVariant.deleteMany({ where: { sdsVariantId: `pub-matrix-v-${stamp}` }, }); await prisma.productFamily.delete({ where: { id: family.id } }); await prisma.originGood.delete({ where: { id: og.id } }); } }); it('custom goods (无族) are not visible on public endpoints', async () => { const customPublicId = `custom-public-${stamp}`; const origin = await prisma.originGood.create({ data: { source: 'CUSTOM', sdsGoodId: customPublicId, goodName: `Pub Custom ${stamp}`, goodPrice: 42, detail: { create: { productCode: `CUSTOM-${stamp}` } }, }, }); const good = await prisma.good.create({ data: { originGoodId: origin.id, countryId, categoryId, goodName: `Pub Custom ${stamp}`, }, }); try { const list = await service.getGoods({ page: 1, pageSize: 50, countryId: countryId.toString(), keyword: `Pub Custom`, }); expect(list.items).toHaveLength(0); // sdsGoodId 不再是公开寻址键:非数字直接 404 await expect(service.getGood(customPublicId)).rejects.toBeInstanceOf(NotFoundException); } finally { await prisma.good.delete({ where: { id: good.id } }); await prisma.originGood.delete({ where: { id: origin.id } }); } }); it('getGood returns family detail by family id and 404 for unknown id', async () => { const first = await service.getGoods({ page: 1, pageSize: 1, countryId: countryId.toString(), keyword: `Pub `, }); expect(first.items.length).toBe(1); const detail = await service.getGood(familyId.toString()); expect(detail.goodId).toBe(first.items[0].goodId); expect(detail.productCode).toBe('OZ10827003'); expect(detail.details.productionProcess).toBe('白墨烫画'); expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); expect(detail.variants).toHaveLength(1); expect(detail.family?.familyId).toBe(familyId.toString()); // 旧 sdsGoodId 寻址不再可达(族 ID 是唯一公开键) await expect(service.getGood(`pub-sds-${stamp}`)).rejects.toBeInstanceOf(NotFoundException); await expect(service.getGood('99999999')).rejects.toBeInstanceOf( NotFoundException, ); }); it('home-goods dedupes by family (keeps the best-positioned good)', async () => { const home = await service.getHomeGoods({ limit: 50 }); // fixture 族的两条带位商品(High index=1 / Mid index=5)→ 只出一条代表行 const ours = home.filter((h) => h.goodId === familyId.toString()); expect(ours).toHaveLength(1); expect(ours[0].goodName).toBe(`Pub High ${stamp}`); }); it('getTags returns tags with their group info, sorted by group then order', async () => { const tags = await service.getTags(); expect(tags.length).toBeGreaterThan(0); // Each tag in our seed (包邮/不包邮/...) should have a group const grouped = tags.find((t) => t.tagName === '包邮'); if (grouped) { expect(grouped.group).not.toBeNull(); expect(grouped.group!.groupName).toBe('物流渠道'); } }); it('getTagGroups returns only groups that have goods', async () => { const groups = await service.getTagGroups(); expect(groups.length).toBeGreaterThan(0); const names = groups.map((g) => g.groupName); // 链接级派生标签落地后,商品挂在 物流渠道/印刷工艺/印花数量 三组 expect(names).toContain('物流渠道'); expect(names).toContain('印花数量'); expect(names).toContain('印刷工艺'); // Sorted by sortOrder const sortOrders = groups.map((g) => g.sortOrder); expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders); }); describe('merged secondary origin goods', () => { it('family members union variants and media (secondary link joins the family)', async () => { const secondary = await prisma.originGood.create({ data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` }, }); const secVariant = await prisma.originGoodVariant.create({ data: { originGoodId: secondary.id, sdsVariantId: `pub-var-sec-${stamp}`, sku: `PUB-SEC-${stamp}`, colorId: 'black', colorName: '黑色', imageUrl: 'http://img/black-sec', }, }); // 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联) await prisma.originGood.update({ where: { id: secondary.id }, data: { familyId }, }); try { const detail = await service.getGood(familyId.toString()); expect(detail.goodId).toBe(familyId.toString()); // 对外 goodId 恒为族ID expect(detail.variants.length).toBeGreaterThanOrEqual(2); const black = detail.mediaByColor.find((g) => g.colorName === '黑色'); expect(black).toBeTruthy(); expect(black!.images).toContain('http://img/black-sec'); // sdsGoodId 不是公开键:副链 ID 无法寻址 await expect(service.getGood(`pub-secondary-${stamp}`)).rejects.toBeInstanceOf( NotFoundException, ); } finally { await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined); await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); } }); it('dedupes variants by color+size (primary wins) and merges specs/options', async () => { const stamp2 = `${stamp}-merge2`; const primaryOg = await prisma.originGood.create({ data: { sdsGoodId: `pub-pri-${stamp2}`, goodName: `Pub Pri ${stamp2}` }, }); const secondaryOg = await prisma.originGood.create({ data: { sdsGoodId: `pub-sec-${stamp2}`, goodName: `Pub Sec ${stamp2}` }, }); // Primary: Black|S and Black|M, size chart S/M, package S, options S/M. await prisma.originGoodVariant.createMany({ data: [ { originGoodId: primaryOg.id, sdsVariantId: `v1-${stamp2}`, sku: `SK1-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 16 }, { originGoodId: primaryOg.id, sdsVariantId: `v2-${stamp2}`, sku: `SK2-${stamp2}`, colorName: 'black', sizeName: 'M', price: 16 }, ], }); await prisma.originGoodDetail.create({ data: { originGoodId: primaryOg.id, sizeChart: { rows: [{ sizeName: 'S', measurements: [{ key: 'chest', cm: '94' }] }, { sizeName: 'M', measurements: [{ key: 'chest', cm: '100' }] }] }, packageSpecs: { rows: [{ sizeName: 'S' }] }, options: { sizes: [{ name: 'S' }, { name: 'M' }] }, media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i2', url: 'http://img/pri-b', sortOrder: 1 }], primaryImageUrl: 'http://img/pri-a' }, }, }); // Secondary: duplicate Black|S with a DIFFERENT price (must be dropped, // primary wins), plus a unique color Kelly|S; specs add XXXL rows. await prisma.originGoodVariant.createMany({ data: [ { originGoodId: secondaryOg.id, sdsVariantId: `v3-${stamp2}`, sku: `SK3-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 20 }, { originGoodId: secondaryOg.id, sdsVariantId: `v4-${stamp2}`, sku: `SK4-${stamp2}`, colorName: 'Kelly', sizeName: 'S', price: 22 }, ], }); await prisma.originGoodDetail.create({ data: { originGoodId: secondaryOg.id, sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] }, packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] }, options: { sizes: [{ name: 'XXXL' }] }, media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i9', url: 'http://img/sec-x', sortOrder: 0 }], primaryImageUrl: 'http://img/pri-a' }, }, }); // 主副链同族 + 一条官网 Good(族化契约:Good 挂族才公开) const mergeFamily = await prisma.productFamily.create({ data: { familyName: `Pub Merge Family ${stamp2}`, primaryOriginGoodId: primaryOg.id }, }); await prisma.originGood.updateMany({ where: { id: { in: [primaryOg.id, secondaryOg.id] } }, data: { familyId: mergeFamily.id }, }); const mergedGood = await prisma.good.create({ data: { goodName: `Pub Merged ${stamp2}`, originGoodId: primaryOg.id, familyId: mergeFamily.id, countryId, categoryId, }, }); try { const detail = await service.getGood(mergeFamily.id.toString()); // Variants: 3 unique color+size combos (case-insensitive); duplicate // Black|S from the secondary deduped. expect( detail.variants.map((v) => `${v.colorName}/${v.sizeName}`.toLowerCase()).sort(), ).toEqual(['black/m', 'black/s', 'kelly/s']); const blackS = detail.variants.find((v) => v.colorName === 'Black' && v.sizeName === 'S'); expect(blackS!.price).toBe('16'); // primary price wins over secondary 20 // Size chart: S/M from primary, XXXL appended from secondary. const chartSizes = (detail.sizeChart as any).rows.map((r: any) => r.sizeName).sort(); expect(chartSizes).toEqual(['M', 'S', 'XXXL']); // Package specs: S from primary, M/XXXL appended from secondary. const pkgSizes = (detail.packageSpecs as any).rows.map((r: any) => r.sizeName).sort(); expect(pkgSizes).toEqual(['M', 'S', 'XXXL']); // Options: sizes unioned S/M + XXXL. const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort(); expect(optSizes).toEqual(['M', 'S', 'XXXL']); // Media gallery: primary images first, secondary-only URL appended, // duplicate URL (pri-a) kept once. Entries keep their object shape. const media = detail.media as any; expect(media.images.map((i: any) => i.url)).toEqual([ 'http://img/pri-a', 'http://img/pri-b', 'http://img/sec-x', ]); expect(media.primaryImageUrl).toBe('http://img/pri-a'); } finally { await prisma.good.delete({ where: { id: mergedGood.id } }); await prisma.productFamily.delete({ where: { id: mergeFamily.id } }); await prisma.originGoodVariant.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } }); await prisma.originGoodDetail.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } }); await prisma.originGood.delete({ where: { id: primaryOg.id } }); await prisma.originGood.delete({ where: { id: secondaryOg.id } }).catch(() => undefined); } }); }); });