左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按 首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名 误显示成 PLTK016。 - admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫, cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留) - api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/ batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述 - 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库) - 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0 Tests: api 22 suites/199 passed, admin 27 passed, tsc clean
672 lines
25 KiB
TypeScript
672 lines
25 KiB
TypeScript
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,
|
||
);
|
||
});
|
||
});
|