Files
inkreach-official-website/apps/api/src/goods/goods.service.spec.ts
T
yeuimu c3be8ab979 fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述
左栏商品名显示成光款号(如 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
2026-09-03 01:04:22 +08:00

672 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('backfillPureSkuGoodNamesdry-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,
);
});
});