import { Test } from '@nestjs/testing'; import { BadRequestException, NotFoundException, } from '@nestjs/common'; import { cleanGoodDisplayName, describePureSkuGoodName, GoodsService, isPureSkuGoodName, } from './goods.service'; import { PrismaService } from '../prisma/prisma.service'; import { SyncService } from '../sync/sync.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; describe('GoodsService', () => { let service: GoodsService; let prisma: PrismaService; const stamp = Date.now(); // Fixtures let countryId: bigint; let country2Id: bigint; let categoryId: bigint; let childCategoryId: bigint; let tagId: bigint; let positionId: bigint; let originGoodIds: bigint[] = []; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ GoodsService, PrismaService, { provide: SyncService, useValue: { queueProductDetailSync: jest.fn() }, }, { provide: FamilyRecomputeService, // 全量并行跑时其他套件的扫名归族可能把本套件夹具链接收进族, // create/update 会调用 syncFamilyTags —— mock 必须覆盖全部被调方法 useValue: { enqueue: jest.fn(), recomputeFamily: jest.fn().mockResolvedValue(undefined), syncFamilyTags: jest.fn().mockResolvedValue({ goodsUpdated: 0, linksUpdated: 0 }), refreshLinkTags: jest.fn().mockResolvedValue(undefined), mirrorLinkTagsToGoods: jest.fn().mockResolvedValue(undefined), }, }, ], }).compile(); service = moduleRef.get(GoodsService); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); const country = await prisma.country.create({ data: { countryName: `Goods Country ${stamp}` }, }); countryId = country.id; const country2 = await prisma.country.create({ data: { countryName: `Goods Country 2 ${stamp}` }, }); country2Id = country2.id; const cat = await prisma.category.create({ data: { categoryName: `Goods Cat ${stamp}` }, }); categoryId = cat.id; const child = await prisma.category.create({ data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id }, }); childCategoryId = child.id; const tag = await prisma.tag.create({ data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' }, }); tagId = tag.id; const pos = await prisma.position.create({ data: { indexVal: 1, countryId, categoryId }, }); positionId = pos.id; const originGoods = await Promise.all( Array.from({ length: 5 }).map((_, i) => prisma.originGood.create({ data: { sdsGoodId: `sds-goods-${stamp}-${i}`, goodName: `Goods Origin ${stamp} ${i}`, }, }), ), ); originGoodIds = originGoods.map((og) => og.id); }); afterAll(async () => { // Wipe all goods first so origin_goods/category can be removed. await prisma.good.deleteMany({ where: { goodName: { contains: `Goods Test ${stamp}` } }, }); await prisma.good.deleteMany({ where: { goodName: { contains: `Origin ${stamp}` } }, }); await prisma.originGood.deleteMany({ where: { id: { in: originGoodIds } }, }); await prisma.position.delete({ where: { id: positionId } }); await prisma.tag.delete({ where: { id: tagId } }); await prisma.category.delete({ where: { id: childCategoryId } }); await prisma.category.delete({ where: { id: categoryId } }); await prisma.country.delete({ where: { id: countryId } }); await prisma.country.delete({ where: { id: country2Id } }); await prisma.onModuleDestroy(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('creates and reads back a good', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} basic`, originGoodId: Number(originGoodIds[0]), countryId: Number(countryId), categoryId: Number(categoryId), tagIds: [Number(tagId)], positionId: Number(positionId), goodPriority: 3, }); expect(created.id).toBeTruthy(); expect(created.country?.countryName).toBeTruthy(); expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true); const fetched = await service.findOne(BigInt(created.id)); expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`); }); it('creates, edits, and removes a fully editable custom good', async () => { const created = await service.createCustom({ goodName: `Goods Test ${stamp} custom`, goodImage: 'https://example.com/custom.png', goodPrice: '29.90', countryId: Number(countryId), categoryId: Number(categoryId), tagIds: [Number(tagId)], detail: { productCode: `CUSTOM-${stamp}`, materialDescription: 'Cotton', sizeChart: { columns: [], rows: [] }, packageSpecs: { rows: [] }, }, variants: [ { sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' }, ], }); expect(created.originGood?.source).toBe('CUSTOM'); expect(created.originGood?.isCustom).toBe(true); expect(created.originGood?.goodPrice).toBe('29.9'); expect(created.variants).toHaveLength(1); const updated = await service.updateCustomContent(BigInt(created.id), { goodName: `Goods Test ${stamp} custom edited`, goodPrice: '39.90', detail: { materialDescription: 'Organic cotton' }, variants: [ { sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' }, ], }); expect(updated.goodName).toContain('custom edited'); expect(updated.originGood?.goodPrice).toBe('39.9'); expect(updated.originDetail?.materialDescription).toBe('Organic cotton'); expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`); expect(updated.variants[0]?.sizeName).toBe('M'); const customOriginId = BigInt(updated.originGoodId); await service.remove(BigInt(updated.id)); await expect( prisma.originGood.findUnique({ where: { id: customOriginId } }), ).resolves.toBeNull(); }); it('filters by countryId, tagId, positionId and keyword', async () => { const result = await service.findAll({ page: 1, pageSize: 20, countryId: Number(countryId), tagId: Number(tagId), keyword: `Goods Test ${stamp}`, }); expect(result.items.length).toBeGreaterThan(0); expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true); expect( result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())), ).toBe(true); }); it('categoryId filter includes descendants recursively', async () => { const inChild = await service.create({ goodName: `Goods Test ${stamp} child`, originGoodId: Number(originGoodIds[1]), countryId: Number(countryId), categoryId: Number(childCategoryId), }); const result = await service.findAll({ page: 1, pageSize: 20, categoryId: Number(categoryId), keyword: `Goods Test ${stamp}`, }); const ids = result.items.map((g) => g.id); expect(ids).toContain(inChild.id); }); it('batch update priority is atomic', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} prio`, originGoodId: Number(originGoodIds[2]), countryId: Number(countryId), categoryId: Number(categoryId), }); const result = await service.batchUpdatePriority({ items: [{ id: Number(created.id), priority: 42 }], }); expect(result.count).toBe(1); const after = await service.findOne(BigInt(created.id)); expect(after.goodPriority).toBe(42); }); it('batch create creates all rows or none', async () => { // Count of goods whose originGoodId is one of the two fixture ids, // so we are independent of goodName (which batch derives from origin). const before = await service.findAll({ page: 1, pageSize: 100, keyword: `Goods Origin ${stamp}`, }); const created = await service.batchCreate({ countryId: Number(countryId), categoryId: Number(categoryId), defaultPriority: 1, items: [ { originGoodId: Number(originGoodIds[3]) }, { originGoodId: Number(originGoodIds[4]) }, ], }); expect(created.length).toBe(2); const after = await service.findAll({ page: 1, pageSize: 100, keyword: `Goods Origin ${stamp}`, }); expect(after.total).toBe(before.total + 2); }); it('batch create rolls back on failure', async () => { const before = await service.findAll({ page: 1, pageSize: 100, keyword: `Goods Origin ${stamp}`, }); await expect( service.batchCreate({ countryId: Number(countryId), categoryId: Number(categoryId), items: [ { originGoodId: Number(originGoodIds[0]) }, { originGoodId: 99999999 }, // missing -> failure ], }), ).rejects.toBeInstanceOf(BadRequestException); const after = await service.findAll({ page: 1, pageSize: 100, keyword: `Goods Origin ${stamp}`, }); expect(after.total).toBe(before.total); }); describe('good name normalization', () => { it('create/update 时把结构完整的原始链接名规范化为 品名+型号', async () => { const created = await service.create({ goodName: '德国(不包邮)230g水洗T恤-DETM002-双面印花', originGoodId: Number(originGoodIds[0]), countryId: Number(countryId), categoryId: Number(categoryId), }); try { expect(created.goodName).toBe('230g水洗T恤 DETM002'); const updated = await service.update(BigInt(created.id), { goodName: '加拿大(不包邮)180g纯棉T恤-CATM001-单面印花', }); expect(updated.goodName).toBe('180g纯棉T恤 CATM001'); } finally { await prisma.good.delete({ where: { id: BigInt(created.id) } }); } }); it('非链接结构的名称原样保留(已解析名/自定义名)', async () => { const created = await service.create({ goodName: '230g水洗T恤 DETM002', originGoodId: Number(originGoodIds[0]), countryId: Number(countryId), categoryId: Number(categoryId), }); try { expect(created.goodName).toBe('230g水洗T恤 DETM002'); const updated = await service.update(BigInt(created.id), { goodName: '自定义商品 ABC', }); expect(updated.goodName).toBe('自定义商品 ABC'); } finally { await prisma.good.delete({ where: { id: BigInt(created.id) } }); } }); }); describe('pure sku good name(纯款号补描述)', () => { const pskuCode = `PSKU${stamp}`; const pskuDesc = `童装纯色测试插肩T恤${stamp}`; let pskuOriginGoodId: bigint; let bareOriginGoodId: bigint; beforeAll(async () => { await prisma.category.create({ data: { categoryName: `${pskuCode} ${pskuDesc}`, sdsCategoryId: `psku-cat-${stamp}`, }, }); const pskuOg = await prisma.originGood.create({ data: { sdsGoodId: `sds-psku-${stamp}`, goodName: pskuCode, sdsCategoryId: `psku-cat-${stamp}`, }, }); pskuOriginGoodId = pskuOg.id; const bareOg = await prisma.originGood.create({ data: { sdsGoodId: `sds-psku-bare-${stamp}`, goodName: `PSKB${stamp}`, }, }); bareOriginGoodId = bareOg.id; }); afterAll(async () => { await prisma.good.deleteMany({ where: { OR: [{ goodName: { contains: pskuCode } }, { goodName: { contains: `PSKB${stamp}` } }] }, }); await prisma.originGood.deleteMany({ where: { id: { in: [pskuOriginGoodId, bareOriginGoodId] } }, }); await prisma.category.deleteMany({ where: { sdsCategoryId: `psku-cat-${stamp}` }, }); }); it('isPureSkuGoodName:仅纯字母数字为真', () => { expect(isPureSkuGoodName('PLTK016')).toBe(true); expect(isPureSkuGoodName(' pltk016 ')).toBe(true); expect(isPureSkuGoodName('PSKU1')).toBe(true); expect(isPureSkuGoodName('童装纯色插肩短袖T恤 PLTK016')).toBe(false); expect(isPureSkuGoodName('德国(不包邮)T恤-DG001-烫画')).toBe(false); expect(isPureSkuGoodName('230g水洗T恤 DETM002')).toBe(false); expect(isPureSkuGoodName('P')).toBe(false); expect(isPureSkuGoodName('')).toBe(false); }); it('cleanGoodDisplayName:品名内的 ASCII 型号限定词剥离,中文括号内容保留', () => { expect( cleanGoodDisplayName('波兰(包邮)童装纯色插肩短袖T恤(DTG180)-PLTK016-双面印花'), ).toBe('童装纯色插肩短袖T恤 PLTK016'); expect(cleanGoodDisplayName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBe( '童装纯色插肩短袖T恤 PLTK016', ); expect(cleanGoodDisplayName('180G纯棉T恤 (JSA002) DG001')).toBe('180G纯棉T恤 DG001'); expect(cleanGoodDisplayName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601'); expect(cleanGoodDisplayName('230g水洗T恤 DETM002')).toBe('230g水洗T恤 DETM002'); }); it('describePureSkuGoodName:标准分类名剥款号补描述', () => { expect( describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤'), ).toBe('童装纯色插肩短袖T恤 PLTK016'); }); it('describePureSkuGoodName:分类名无款号前缀时用全名', () => { expect( describePureSkuGoodName('PLTK016', '童装纯色插肩短袖T恤'), ).toBe('童装纯色插肩短袖T恤 PLTK016'); }); it('describePureSkuGoodName:分类名即款号/空分类名/非纯款号名原样返回', () => { expect(describePureSkuGoodName('PLTK016', 'PLTK016')).toBe('PLTK016'); expect(describePureSkuGoodName('PLTK016', null)).toBe('PLTK016'); expect(describePureSkuGoodName('PLTK016', undefined)).toBe('PLTK016'); expect( describePureSkuGoodName('童装T恤 PLTK016', 'PLTK016 童装T恤'), ).toBe('童装T恤 PLTK016'); expect(describePureSkuGoodName('230g水洗T恤 DETM002', 'PLTK016 童装T恤')).toBe( '230g水洗T恤 DETM002', ); }); it('describePureSkuGoodName:分类名中的 ASCII 型号限定词一并剥离', () => { expect( describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤(DTG180)'), ).toBe('童装纯色插肩短袖T恤 PLTK016'); }); it('create:纯款号名按主链接 SDS 分类名补描述', async () => { const created = await service.create({ goodName: pskuCode, originGoodId: Number(pskuOriginGoodId), countryId: Number(countryId), categoryId: Number(categoryId), }); try { expect(created.goodName).toBe(`${pskuDesc} ${pskuCode}`); } finally { await prisma.good.delete({ where: { id: BigInt(created.id) } }); } }); it('create:主链接无 sdsCategoryId 映射时纯款号原样保留', async () => { const created = await service.create({ goodName: `PSKB${stamp}`, originGoodId: Number(bareOriginGoodId), countryId: Number(countryId), categoryId: Number(categoryId), }); try { expect(created.goodName).toBe(`PSKB${stamp}`); } finally { await prisma.good.delete({ where: { id: BigInt(created.id) } }); } }); it('update:改成纯款号名按商品主链接分类名补描述', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} psku-rename`, originGoodId: Number(pskuOriginGoodId), countryId: Number(countryId), categoryId: Number(categoryId), }); try { const updated = await service.update(BigInt(created.id), { goodName: pskuCode, }); expect(updated.goodName).toBe(`${pskuDesc} ${pskuCode}`); } finally { await prisma.good.delete({ where: { id: BigInt(created.id) } }); } }); it('batchCreate:纯款号链接名补描述、完整链接名规范化', async () => { const created = await service.batchCreate({ countryId: Number(countryId), categoryId: Number(categoryId), items: [{ originGoodId: Number(pskuOriginGoodId) }], }); try { expect(created[0]?.goodName).toBe(`${pskuDesc} ${pskuCode}`); } finally { await prisma.good.deleteMany({ where: { id: { in: created.map((g) => BigInt(g.id)) } }, }); } }); it('backfillPureSkuGoodNames:dry-run 只列不改,apply 改名且幂等', async () => { // 绕过写路径直写库,模拟存量纯款号名 const row = await prisma.good.create({ data: { goodName: pskuCode, originGoodId: pskuOriginGoodId, countryId, categoryId, }, }); try { // dry-run / apply 均用 goodId 限定在本夹具,避免测试副作用波及共享库真实数据 const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); expect(dry.renames).toEqual([ { id: row.id.toString(), from: pskuCode, to: `${pskuDesc} ${pskuCode}` }, ]); expect( (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, ).toBe(pskuCode); const applied = await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id }); expect(applied.renamed).toBe(1); expect( (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, ).toBe(`${pskuDesc} ${pskuCode}`); // 幂等:已改名的行不再出现在后续清单 const again = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); expect(again.renames).toEqual([]); } finally { await prisma.good.delete({ where: { id: row.id } }); } }); it('backfillPureSkuGoodNames:剥离「品名(型号限定词) SKU」存量名中的限定词', async () => { const row = await prisma.good.create({ data: { goodName: `${pskuDesc}(DTG180) ${pskuCode}`, originGoodId: pskuOriginGoodId, countryId, categoryId, }, }); try { const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); expect(dry.renames).toEqual([ { id: row.id.toString(), from: `${pskuDesc}(DTG180) ${pskuCode}`, to: `${pskuDesc} ${pskuCode}`, }, ]); await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id }); expect( (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, ).toBe(`${pskuDesc} ${pskuCode}`); } finally { await prisma.good.delete({ where: { id: row.id } }); } }); }); describe('merged origin goods', () => { it('creates a good with merged origin goods and reads them back', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} merged`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])], countryId: Number(countryId), categoryId: Number(categoryId), }); expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual( [originGoodIds[2].toString(), originGoodIds[3].toString()].sort(), ); const fetched = await service.findOne(BigInt(created.id)); expect(fetched.mergedOriginGoods.length).toBe(2); }); it('rejects mergedOriginGoodIds containing the primary', async () => { await expect( service.create({ goodName: `Goods Test ${stamp} bad-primary`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[1])], countryId: Number(countryId), categoryId: Number(categoryId), }), ).rejects.toThrow(BadRequestException); }); it('rejects mergedOriginGoodIds that do not exist', async () => { await expect( service.create({ goodName: `Goods Test ${stamp} bad-missing`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [999999999], countryId: Number(countryId), categoryId: Number(categoryId), }), ).rejects.toThrow(BadRequestException); }); it('replaces merged origin goods on update', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} replace`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); const updated = await service.update(BigInt(created.id), { mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])], }); expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( [originGoodIds[3].toString(), originGoodIds[4].toString()].sort(), ); }); it('moves old primary into merged list when switching primary', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} switch`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); const updated = await service.update(BigInt(created.id), { originGoodId: Number(originGoodIds[2]), mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])], }); expect(updated.originGoodId).toBe(originGoodIds[2].toString()); expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( [originGoodIds[1].toString(), originGoodIds[3].toString()].sort(), ); }); it('cascades merged rows on good removal', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} cascade`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); await service.remove(BigInt(created.id)); const rows = await prisma.goodOriginGood.count({ where: { goodId: BigInt(created.id) }, }); expect(rows).toBe(0); }); it('returns merged variants with source annotation in detail', async () => { const v1 = await prisma.originGoodVariant.create({ data: { originGoodId: originGoodIds[1], sdsVariantId: `mv-pri-${stamp}`, sku: `MV-PRI-${stamp}`, colorName: '黑色', }, }); const v2 = await prisma.originGoodVariant.create({ data: { originGoodId: originGoodIds[2], sdsVariantId: `mv-sec-${stamp}`, sku: `MV-SEC-${stamp}`, colorName: '白色', }, }); try { const created = await service.create({ goodName: `Goods Test ${stamp} variants`, originGoodId: Number(originGoodIds[1]), mergedOriginGoodIds: [Number(originGoodIds[2])], countryId: Number(countryId), categoryId: Number(categoryId), }); const detail = await service.findOne(BigInt(created.id)); const sources = new Set( detail.variants.map((v) => v['originGoodId'] as string), ); expect(sources.has(originGoodIds[1].toString())).toBe(true); expect(sources.has(originGoodIds[2].toString())).toBe(true); expect(detail.variants).toHaveLength(2); expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1); } finally { await prisma.originGoodVariant.delete({ where: { id: v1.id } }); await prisma.originGoodVariant.delete({ where: { id: v2.id } }); } }); }); it('throws NotFoundException for unknown id', async () => { await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf( NotFoundException, ); }); });