feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -1,103 +1,103 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PublicService } from './public.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;
|
||||
import { PublicService } from './public.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 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;
|
||||
|
||||
// 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,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: pos1.id,
|
||||
},
|
||||
});
|
||||
const g2 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
positionId: pos2.id,
|
||||
},
|
||||
});
|
||||
const g3 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
let originGoodId: 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;
|
||||
|
||||
// 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,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: pos1.id,
|
||||
},
|
||||
});
|
||||
const g2 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
positionId: pos2.id,
|
||||
},
|
||||
});
|
||||
const g3 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
goodIds = [g1.id, g2.id, g3.id];
|
||||
|
||||
const craftGroup = await prisma.tagGroup.create({
|
||||
@@ -143,102 +143,102 @@ describe('PublicService', () => {
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
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,
|
||||
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 ` } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
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,
|
||||
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 ` } },
|
||||
});
|
||||
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,
|
||||
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).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
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,
|
||||
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);
|
||||
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 () => {
|
||||
@@ -292,16 +292,16 @@ describe('PublicService', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
});
|
||||
@@ -335,15 +335,15 @@ describe('PublicService', () => {
|
||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
|
||||
it('getGood returns detail 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);
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
@@ -353,30 +353,30 @@ describe('PublicService', () => {
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user