import { Test } from '@nestjs/testing'; import { BadRequestException, NotFoundException, } from '@nestjs/common'; import { GoodsService } 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('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, ); }); });