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:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+32 -32
View File
@@ -1,33 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import type { SyncLog } from '@prisma/client';
export class SyncLogDto {
@ApiProperty()
id!: string;
@ApiProperty()
import { ApiProperty } from '@nestjs/swagger';
import type { SyncLog } from '@prisma/client';
export class SyncLogDto {
@ApiProperty()
id!: string;
@ApiProperty()
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
@ApiProperty()
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
@ApiProperty({ nullable: true })
message!: string | null;
@ApiProperty()
startedAt!: string;
@ApiProperty({ nullable: true })
finishedAt!: string | null;
static from(log: SyncLog): SyncLogDto {
return {
id: log.id.toString(),
type: log.type,
status: log.status,
message: log.message,
startedAt: log.startedAt.toISOString(),
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
};
}
}
@ApiProperty()
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
@ApiProperty({ nullable: true })
message!: string | null;
@ApiProperty()
startedAt!: string;
@ApiProperty({ nullable: true })
finishedAt!: string | null;
static from(log: SyncLog): SyncLogDto {
return {
id: log.id.toString(),
type: log.type,
status: log.status,
message: log.message,
startedAt: log.startedAt.toISOString(),
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
};
}
}
+33 -33
View File
@@ -1,37 +1,37 @@
import { Test } from '@nestjs/testing';
import { of } from 'rxjs';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { SdsClientService } from './sds-client.service';
describe('SdsClientService', () => {
let service: SdsClientService;
let http: { post: jest.Mock; get: jest.Mock };
beforeEach(async () => {
http = { post: jest.fn(), get: jest.fn() };
const moduleRef = await Test.createTestingModule({
providers: [
SdsClientService,
{ provide: HttpService, useValue: http },
{ provide: ConfigService, useValue: { get: jest.fn(() => undefined) } },
],
}).compile();
service = moduleRef.get(SdsClientService);
});
import { Test } from '@nestjs/testing';
import { of } from 'rxjs';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { SdsClientService } from './sds-client.service';
describe('SdsClientService', () => {
let service: SdsClientService;
let http: { post: jest.Mock; get: jest.Mock };
beforeEach(async () => {
http = { post: jest.fn(), get: jest.fn() };
const moduleRef = await Test.createTestingModule({
providers: [
SdsClientService,
{ provide: HttpService, useValue: http },
{ provide: ConfigService, useValue: { get: jest.fn(() => undefined) } },
],
}).compile();
service = moduleRef.get(SdsClientService);
});
describe('fetchCategoryTree', () => {
it('throws when the upstream returns a degenerate small tree', async () => {
http.post.mockReturnValue(of({ data: [{ id: 1, name: 'Only' }] }));
await expect(service.fetchCategoryTree()).rejects.toThrow(/degenerate/i);
});
it('returns the tree when it is healthy', async () => {
const tree = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `C${i}` }));
http.post.mockReturnValue(of({ data: tree }));
const result = await service.fetchCategoryTree();
expect(result).toHaveLength(20);
});
it('throws when the upstream returns a degenerate small tree', async () => {
http.post.mockReturnValue(of({ data: [{ id: 1, name: 'Only' }] }));
await expect(service.fetchCategoryTree()).rejects.toThrow(/degenerate/i);
});
it('returns the tree when it is healthy', async () => {
const tree = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `C${i}` }));
http.post.mockReturnValue(of({ data: tree }));
const result = await service.fetchCategoryTree();
expect(result).toHaveLength(20);
});
});
describe('fetchProductDetail', () => {
+134 -134
View File
@@ -1,55 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
const POD_HEADERS = {
'Content-Type': 'application/json;charset=UTF-8',
Origin: 'https://inkpod.vip',
Referer: 'https://inkpod.vip/',
} as const;
/**
* Minimum number of category nodes a healthy `category/tree/3` response contains.
* Below this the response is treated as degenerate and rejected so the caller
* never runs a destructive sync against a partial tree.
*/
export const MIN_SDS_CATEGORY_NODES = 10;
export interface SdsCategoryTreeNode {
id: number | string | null;
name?: string;
icon?: string;
children?: SdsCategoryTreeNode[];
[key: string]: unknown;
}
export interface SdsProduct {
id: number | string;
name?: string;
title?: string;
pic?: string;
image?: string;
psd_img_url?: string;
thumbImgUrl?: string;
blankDesignUrl?: string;
img_url?: string;
show_img?: string;
price?: number | string;
currentPrice?: number | string;
categoryId?: number | string;
[key: string]: unknown;
}
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
const POD_HEADERS = {
'Content-Type': 'application/json;charset=UTF-8',
Origin: 'https://inkpod.vip',
Referer: 'https://inkpod.vip/',
} as const;
/**
* Minimum number of category nodes a healthy `category/tree/3` response contains.
* Below this the response is treated as degenerate and rejected so the caller
* never runs a destructive sync against a partial tree.
*/
export const MIN_SDS_CATEGORY_NODES = 10;
export interface SdsCategoryTreeNode {
id: number | string | null;
name?: string;
icon?: string;
children?: SdsCategoryTreeNode[];
[key: string]: unknown;
}
export interface SdsProduct {
id: number | string;
name?: string;
title?: string;
pic?: string;
image?: string;
psd_img_url?: string;
thumbImgUrl?: string;
blankDesignUrl?: string;
img_url?: string;
show_img?: string;
price?: number | string;
currentPrice?: number | string;
categoryId?: number | string;
[key: string]: unknown;
}
export interface SdsProductsPage {
items?: SdsProduct[];
content?: SdsProduct[];
totalCount?: number;
totalElements?: number;
total?: number;
page?: number;
size?: number;
[key: string]: unknown;
items?: SdsProduct[];
content?: SdsProduct[];
totalCount?: number;
totalElements?: number;
total?: number;
page?: number;
size?: number;
[key: string]: unknown;
}
export interface SdsProductVariant extends Record<string, unknown> {
@@ -131,90 +131,90 @@ export interface SdsProductDetail extends Record<string, unknown> {
items?: SdsProductVariant[];
};
}
/**
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
* `SyncService` consumes.
*
* Exposed as its own service so it can be mocked cleanly in unit tests.
*/
@Injectable()
export class SdsClientService {
private readonly logger = new Logger(SdsClientService.name);
private readonly baseUrl: string;
constructor(
private readonly http: HttpService,
config: ConfigService,
) {
this.baseUrl =
config.get<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
'https://mapi.sdspod.com';
}
private async request<T>(
method: 'post' | 'get',
url: string,
body?: unknown,
params?: Record<string, unknown>,
): Promise<T> {
const MAX_RETRIES = 3;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const config = {
headers: POD_HEADERS,
timeout: 30000,
...(params ? { params } : {}),
};
const obs =
method === 'post'
? this.http.post<T>(url, body, config)
: this.http.get<T>(url, config);
const { data } = await firstValueFrom(obs);
return data as T;
} catch (err) {
lastError = err;
const msg = err instanceof Error ? err.message : String(err);
if (attempt < MAX_RETRIES) {
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
}
throw lastError;
}
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
const url = `${this.baseUrl}/category/tree/3`;
const body = {
withActivityArea: true,
withPrivate: true,
onlyHaveProduct: true,
};
const data = await this.request<unknown>('post', url, body);
if (!Array.isArray(data)) {
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
}
if (data.length < MIN_SDS_CATEGORY_NODES) {
throw new Error(
`SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` +
`aborting to avoid destructive sync`,
);
}
return data as SdsCategoryTreeNode[];
}
/**
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
* `SyncService` consumes.
*
* Exposed as its own service so it can be mocked cleanly in unit tests.
*/
@Injectable()
export class SdsClientService {
private readonly logger = new Logger(SdsClientService.name);
private readonly baseUrl: string;
constructor(
private readonly http: HttpService,
config: ConfigService,
) {
this.baseUrl =
config.get<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
'https://mapi.sdspod.com';
}
private async request<T>(
method: 'post' | 'get',
url: string,
body?: unknown,
params?: Record<string, unknown>,
): Promise<T> {
const MAX_RETRIES = 3;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const config = {
headers: POD_HEADERS,
timeout: 30000,
...(params ? { params } : {}),
};
const obs =
method === 'post'
? this.http.post<T>(url, body, config)
: this.http.get<T>(url, config);
const { data } = await firstValueFrom(obs);
return data as T;
} catch (err) {
lastError = err;
const msg = err instanceof Error ? err.message : String(err);
if (attempt < MAX_RETRIES) {
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
}
throw lastError;
}
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
const url = `${this.baseUrl}/category/tree/3`;
const body = {
withActivityArea: true,
withPrivate: true,
onlyHaveProduct: true,
};
const data = await this.request<unknown>('post', url, body);
if (!Array.isArray(data)) {
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
}
if (data.length < MIN_SDS_CATEGORY_NODES) {
throw new Error(
`SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` +
`aborting to avoid destructive sync`,
);
}
return data as SdsCategoryTreeNode[];
}
async fetchProductsPage(
categoryId: string | number,
page = 1,
size = 50,
): Promise<SdsProductsPage> {
const url = `${this.baseUrl}/products/page`;
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
if (!data || typeof data !== 'object') {
throw new Error(`SDS products page returned ${typeof data}, expected object`);
}
categoryId: string | number,
page = 1,
size = 50,
): Promise<SdsProductsPage> {
const url = `${this.baseUrl}/products/page`;
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
if (!data || typeof data !== 'object') {
throw new Error(`SDS products page returned ${typeof data}, expected object`);
}
return data as SdsProductsPage;
}
+43 -43
View File
@@ -1,38 +1,38 @@
import {
Controller,
import {
Controller,
DefaultValuePipe,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { SyncService } from './sync.service';
import { SyncLogDto } from './dto/sync-log.dto';
@ApiTags('sync')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('sync')
export class SyncController {
constructor(private readonly service: SyncService) {}
@Post('categories')
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
async syncCategories() {
return this.service.startCategorySync();
}
@Post('products')
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { SyncService } from './sync.service';
import { SyncLogDto } from './dto/sync-log.dto';
@ApiTags('sync')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('sync')
export class SyncController {
constructor(private readonly service: SyncService) {}
@Post('categories')
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
async syncCategories() {
return this.service.startCategorySync();
}
@Post('products')
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
async syncProducts() {
return this.service.startProductSync();
}
@@ -48,14 +48,14 @@ export class SyncController {
async syncOneProductDetail(@Param('goodId') goodId: string) {
return this.service.syncOneProductDetail(goodId);
}
@Get('status')
@ApiOperation({ summary: 'Recent sync log entries' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async status(
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
): Promise<SyncLogDto[]> {
const logs = await this.service.getStatus(limit);
return logs.map((l) => SyncLogDto.from(l));
}
}
@Get('status')
@ApiOperation({ summary: 'Recent sync log entries' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async status(
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
): Promise<SyncLogDto[]> {
const logs = await this.service.getStatus(limit);
return logs.map((l) => SyncLogDto.from(l));
}
}
+14 -14
View File
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { HttpModule } from '@nestjs/axios';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
import { SdsClientService } from './sds-client.service';
@Module({
imports: [ScheduleModule.forRoot(), HttpModule],
controllers: [SyncController],
providers: [SyncService, SdsClientService],
exports: [SyncService, SdsClientService],
})
export class SyncModule {}
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { HttpModule } from '@nestjs/axios';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
import { SdsClientService } from './sds-client.service';
@Module({
imports: [ScheduleModule.forRoot(), HttpModule],
controllers: [SyncController],
providers: [SyncService, SdsClientService],
exports: [SyncService, SdsClientService],
})
export class SyncModule {}
+269 -269
View File
@@ -1,280 +1,280 @@
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import {
SyncService,
shouldRunDelistDetection,
shouldSkipStaleDeletion,
} from './sync.service';
import { SdsClientService } from './sds-client.service';
import { PrismaService } from '../prisma/prisma.service';
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import {
SyncService,
shouldRunDelistDetection,
shouldSkipStaleDeletion,
} from './sync.service';
import { SdsClientService } from './sds-client.service';
import { PrismaService } from '../prisma/prisma.service';
describe('SyncService', () => {
let service: SyncService;
let sds: jest.Mocked<SdsClientService>;
let prisma: PrismaService;
const createdSdsCategoryIds: string[] = [];
const createdSdsGoodIds: string[] = [];
beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = {
let service: SyncService;
let sds: jest.Mocked<SdsClientService>;
let prisma: PrismaService;
const createdSdsCategoryIds: string[] = [];
const createdSdsGoodIds: string[] = [];
beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = {
fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(),
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
};
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })],
providers: [
SyncService,
{ provide: SdsClientService, useValue: sdsMock },
PrismaService,
],
}).compile();
};
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })],
providers: [
SyncService,
{ provide: SdsClientService, useValue: sdsMock },
PrismaService,
],
}).compile();
service = moduleRef.get(SyncService);
jest
.spyOn(service, 'syncConfiguredProductDetails')
.mockResolvedValue({ synced: 0, failed: 0 });
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (createdSdsCategoryIds.length) {
await prisma.category.deleteMany({
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
});
}
if (createdSdsGoodIds.length) {
await prisma.originGood.deleteMany({
where: { sdsGoodId: { in: createdSdsGoodIds } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('flattenCategoryTree', () => {
it('flattens a nested SDS tree and preserves parent linkage', () => {
const tree = [
{
id: 1,
name: 'Root',
children: [
{ id: 11, name: 'Child A' },
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
],
},
];
const flat = service.flattenCategoryTree(tree);
expect(flat).toHaveLength(4);
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
expect(byId['1'].name).toBe('Root');
expect(byId['1'].parentSdsId).toBeUndefined();
expect(byId['11'].parentSdsId).toBe('1');
expect(byId['12'].parentSdsId).toBe('1');
expect(byId['121'].parentSdsId).toBe('12');
});
it('skips nodes without a usable id', () => {
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
expect(flat).toHaveLength(0);
});
});
describe('syncCategories', () => {
it('inserts + updates rows and links parents', async () => {
const stamp = Date.now();
sds.fetchCategoryTree.mockResolvedValueOnce([
{
id: `r-${stamp}`,
name: `Root ${stamp}`,
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
},
]);
const result = await service.syncCategories();
expect(result.total).toBe(2);
expect(result.inserted).toBe(2);
expect(result.updated).toBe(0);
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
const child = await prisma.category.findUnique({
where: { sdsCategoryId: `c-${stamp}` },
});
const root = await prisma.category.findUnique({
where: { sdsCategoryId: `r-${stamp}` },
});
expect(child?.parentCategoryId).toBe(root?.id);
});
it('marks SyncLog SUCCESS', async () => {
const logs = await prisma.syncLog.findMany({
where: { type: 'CATEGORIES' },
orderBy: { startedAt: 'desc' },
take: 1,
});
expect(logs[0]?.status).toBe('SUCCESS');
});
});
describe('syncProducts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
// Seed a leaf category with sdsCategoryId so the sync has work.
const stamp = Date.now();
const leaf = await prisma.category.create({
data: {
sdsCategoryId: `leaf-${stamp}`,
categoryName: `Leaf ${stamp}`,
},
});
createdSdsCategoryIds.push(`leaf-${stamp}`);
// Default mock returns a single page with 2 products, then
// breaks the loop because content.length < 50.
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leaf-${stamp}`) {
return {
content: [
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
],
};
}
// For any other (already-existing) category, return empty
// so the loop terminates immediately.
return { content: [] };
});
const result1 = await service.syncProducts();
expect(result1.inserted).toBeGreaterThanOrEqual(2);
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
// Re-run with updated name -> should be `updated`, not `inserted`.
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leaf-${stamp}`) {
return {
content: [
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
],
};
}
return { content: [] };
});
const result2 = await service.syncProducts();
expect(result2.updated).toBeGreaterThanOrEqual(1);
const row = await prisma.originGood.findUnique({
where: { sdsGoodId: `p-${stamp}-1` },
});
expect(row?.goodName).toBe('Product 1 renamed');
});
});
describe('sync guard thresholds', () => {
describe('shouldSkipStaleDeletion', () => {
it('skips stale deletion when the fetched count is below the hard floor', () => {
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
});
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
});
it('does NOT skip when fetched count is healthy', () => {
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
});
it('does NOT skip when there are no existing SDS categories', () => {
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
});
});
describe('shouldRunDelistDetection', () => {
it('skips delist detection when leaf categories are too few', () => {
expect(shouldRunDelistDetection(2, 500)).toBe(false);
expect(shouldRunDelistDetection(9, 500)).toBe(false);
});
it('skips delist detection when the seen product count is too small', () => {
expect(shouldRunDelistDetection(148, 2)).toBe(false);
expect(shouldRunDelistDetection(148, 49)).toBe(false);
});
it('runs delist detection only when both metrics are healthy', () => {
expect(shouldRunDelistDetection(148, 500)).toBe(true);
expect(shouldRunDelistDetection(10, 50)).toBe(true);
});
});
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
const stamp = Date.now();
const keep = await prisma.category.create({
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
});
createdSdsCategoryIds.push(`keep-${stamp}`);
sds.fetchCategoryTree.mockResolvedValueOnce([
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
]);
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
const result = await service.syncCategories();
expect(result.deletedStale).toBe(0);
const still = await prisma.category.findUnique({ where: { id: keep.id } });
expect(still).not.toBeNull();
});
it('product sync does NOT delist origin goods when it sees too few products', async () => {
const stamp = Date.now();
const leaf = await prisma.category.create({
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
});
createdSdsCategoryIds.push(`leafguard-${stamp}`);
const active = await prisma.originGood.create({
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
});
createdSdsGoodIds.push(`active-${stamp}`);
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leafguard-${stamp}`) {
return {
content: [
{ id: `guardp-${stamp}-1`, name: 'P1' },
{ id: `guardp-${stamp}-2`, name: 'P2' },
],
};
}
return { content: [] };
});
const result = await service.syncProducts();
expect(result.delisted).toBe(0);
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
expect(still?.delisted).toBe(false);
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
});
});
describe('getStatus', () => {
it('returns recent logs ordered by startedAt desc', async () => {
const logs = await service.getStatus(5);
expect(Array.isArray(logs)).toBe(true);
expect(logs.length).toBeGreaterThan(0);
});
});
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (createdSdsCategoryIds.length) {
await prisma.category.deleteMany({
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
});
}
if (createdSdsGoodIds.length) {
await prisma.originGood.deleteMany({
where: { sdsGoodId: { in: createdSdsGoodIds } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('flattenCategoryTree', () => {
it('flattens a nested SDS tree and preserves parent linkage', () => {
const tree = [
{
id: 1,
name: 'Root',
children: [
{ id: 11, name: 'Child A' },
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
],
},
];
const flat = service.flattenCategoryTree(tree);
expect(flat).toHaveLength(4);
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
expect(byId['1'].name).toBe('Root');
expect(byId['1'].parentSdsId).toBeUndefined();
expect(byId['11'].parentSdsId).toBe('1');
expect(byId['12'].parentSdsId).toBe('1');
expect(byId['121'].parentSdsId).toBe('12');
});
it('skips nodes without a usable id', () => {
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
expect(flat).toHaveLength(0);
});
});
describe('syncCategories', () => {
it('inserts + updates rows and links parents', async () => {
const stamp = Date.now();
sds.fetchCategoryTree.mockResolvedValueOnce([
{
id: `r-${stamp}`,
name: `Root ${stamp}`,
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
},
]);
const result = await service.syncCategories();
expect(result.total).toBe(2);
expect(result.inserted).toBe(2);
expect(result.updated).toBe(0);
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
const child = await prisma.category.findUnique({
where: { sdsCategoryId: `c-${stamp}` },
});
const root = await prisma.category.findUnique({
where: { sdsCategoryId: `r-${stamp}` },
});
expect(child?.parentCategoryId).toBe(root?.id);
});
it('marks SyncLog SUCCESS', async () => {
const logs = await prisma.syncLog.findMany({
where: { type: 'CATEGORIES' },
orderBy: { startedAt: 'desc' },
take: 1,
});
expect(logs[0]?.status).toBe('SUCCESS');
});
});
describe('syncProducts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
// Seed a leaf category with sdsCategoryId so the sync has work.
const stamp = Date.now();
const leaf = await prisma.category.create({
data: {
sdsCategoryId: `leaf-${stamp}`,
categoryName: `Leaf ${stamp}`,
},
});
createdSdsCategoryIds.push(`leaf-${stamp}`);
// Default mock returns a single page with 2 products, then
// breaks the loop because content.length < 50.
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leaf-${stamp}`) {
return {
content: [
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
],
};
}
// For any other (already-existing) category, return empty
// so the loop terminates immediately.
return { content: [] };
});
const result1 = await service.syncProducts();
expect(result1.inserted).toBeGreaterThanOrEqual(2);
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
// Re-run with updated name -> should be `updated`, not `inserted`.
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leaf-${stamp}`) {
return {
content: [
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
],
};
}
return { content: [] };
});
const result2 = await service.syncProducts();
expect(result2.updated).toBeGreaterThanOrEqual(1);
const row = await prisma.originGood.findUnique({
where: { sdsGoodId: `p-${stamp}-1` },
});
expect(row?.goodName).toBe('Product 1 renamed');
});
});
describe('sync guard thresholds', () => {
describe('shouldSkipStaleDeletion', () => {
it('skips stale deletion when the fetched count is below the hard floor', () => {
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
});
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
});
it('does NOT skip when fetched count is healthy', () => {
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
});
it('does NOT skip when there are no existing SDS categories', () => {
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
});
});
describe('shouldRunDelistDetection', () => {
it('skips delist detection when leaf categories are too few', () => {
expect(shouldRunDelistDetection(2, 500)).toBe(false);
expect(shouldRunDelistDetection(9, 500)).toBe(false);
});
it('skips delist detection when the seen product count is too small', () => {
expect(shouldRunDelistDetection(148, 2)).toBe(false);
expect(shouldRunDelistDetection(148, 49)).toBe(false);
});
it('runs delist detection only when both metrics are healthy', () => {
expect(shouldRunDelistDetection(148, 500)).toBe(true);
expect(shouldRunDelistDetection(10, 50)).toBe(true);
});
});
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
const stamp = Date.now();
const keep = await prisma.category.create({
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
});
createdSdsCategoryIds.push(`keep-${stamp}`);
sds.fetchCategoryTree.mockResolvedValueOnce([
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
]);
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
const result = await service.syncCategories();
expect(result.deletedStale).toBe(0);
const still = await prisma.category.findUnique({ where: { id: keep.id } });
expect(still).not.toBeNull();
});
it('product sync does NOT delist origin goods when it sees too few products', async () => {
const stamp = Date.now();
const leaf = await prisma.category.create({
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
});
createdSdsCategoryIds.push(`leafguard-${stamp}`);
const active = await prisma.originGood.create({
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
});
createdSdsGoodIds.push(`active-${stamp}`);
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
if (categoryId === `leafguard-${stamp}`) {
return {
content: [
{ id: `guardp-${stamp}-1`, name: 'P1' },
{ id: `guardp-${stamp}-2`, name: 'P2' },
],
};
}
return { content: [] };
});
const result = await service.syncProducts();
expect(result.delisted).toBe(0);
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
expect(still?.delisted).toBe(false);
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
});
});
describe('getStatus', () => {
it('returns recent logs ordered by startedAt desc', async () => {
const logs = await service.getStatus(5);
expect(Array.isArray(logs)).toBe(true);
expect(logs.length).toBeGreaterThan(0);
});
});
});
describe('SyncService product detail scopes', () => {
File diff suppressed because it is too large Load Diff