Files
inkreach-official-website/apps/api/src/public/public.service.spec.ts
T
yeuimu f1e81872e7 fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path
问题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 夹具改为自包含(不依赖共享库既有数据)。
2026-09-02 18:39:35 +08:00

654 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 { 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 条在售 GoodHigh/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 条 GoodHigh/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 () => {
// 自包含 fixtureCUSTOM 光板成员(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);
}
});
});
});