问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复: - 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES 别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效) - updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 → 缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags) - autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有 autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告 - 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键 familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复 附加: - updateTags 后未归族链接自动并入匹配族(attachToMatchingFamily,响应带 attachedFamilyId) - 整理新增碎片族合并 consolidateFragments:纯碎片族并入带商品族并删除 (保公开 goodId=族ID 稳定),带商品/覆盖价/人工锁定进人工复审报告 - admin:保存标签提示补齐明细,整理完成消息含并入/碎片合并/待人工数 问题1(后台频繁 Unauthorized 掉线): - refresh cookie path '/auth' 与代理前缀(/api、/v2-api)不匹配导致浏览器 永远带不上 refresh cookie → 两 cookie path 统一为 '/' - v2 构建基址带尾斜杠 + 手工拼接产生 /v2-api//auth/refresh 双斜杠 404 → request.ts 规范拼接 测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误; public.service 夹具改为自包含(不依赖共享库既有数据)。
654 lines
25 KiB
TypeScript
654 lines
25 KiB
TypeScript
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<string>();
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
});
|