|
|
@@ -0,0 +1,953 @@
|
|
|
+import { INestApplication } from '@nestjs/common';
|
|
|
+import { JwtService } from '@nestjs/jwt';
|
|
|
+import { Test } from '@nestjs/testing';
|
|
|
+import * as request from 'supertest';
|
|
|
+import { configureApp } from '../src/app.setup';
|
|
|
+import { TemplateAiClient } from '../src/modules/templates/template-ai.client';
|
|
|
+import { TemplateTaskFiles } from '../src/modules/templates/template-task-files';
|
|
|
+import { PrismaService } from '../src/prisma/prisma.service';
|
|
|
+
|
|
|
+process.env.GCS_BUCKET ??= 'test-bucket';
|
|
|
+process.env.GCS_PROJECT_ID ??= 'test-project';
|
|
|
+process.env.GCS_CLIENT_EMAIL ??= 'test@example.com';
|
|
|
+process.env.GCS_PRIVATE_KEY ??= 'test-key';
|
|
|
+
|
|
|
+describe('Templates (e2e)', () => {
|
|
|
+ let app: INestApplication;
|
|
|
+ let jwtService: JwtService;
|
|
|
+ const executeTemplate = jest.fn();
|
|
|
+ const getResult = jest.fn();
|
|
|
+ const templateTaskFiles = {
|
|
|
+ downloadToTempFile: jest.fn(),
|
|
|
+ prepareStep2Bundle: jest.fn(),
|
|
|
+ cleanup: jest.fn(),
|
|
|
+ };
|
|
|
+ const systemConfigs: Array<Record<string, unknown>> = [];
|
|
|
+ const hairTemplates: Array<Record<string, unknown>> = [];
|
|
|
+ const hairTemplateMakeTasks: Array<Record<string, unknown>> = [];
|
|
|
+ const aiHairSimulationTasks: Array<Record<string, unknown>> = [];
|
|
|
+ let nextSystemConfigId = 1n;
|
|
|
+ let nextTemplateId = 101n;
|
|
|
+ let nextTaskId = 88n;
|
|
|
+
|
|
|
+ function filterHairTemplates(params: {
|
|
|
+ where?: Record<string, unknown>;
|
|
|
+ orderBy?: Record<string, unknown>;
|
|
|
+ skip?: number;
|
|
|
+ take?: number;
|
|
|
+ }) {
|
|
|
+ let items = [...hairTemplates];
|
|
|
+ const typedWhere = (params.where ?? {}) as {
|
|
|
+ hairstylistId?: bigint;
|
|
|
+ isDeleted?: number;
|
|
|
+ category?: string;
|
|
|
+ status?: string;
|
|
|
+ OR?: Array<{
|
|
|
+ templateName?: { contains: string };
|
|
|
+ tags?: { contains: string };
|
|
|
+ description?: { contains: string };
|
|
|
+ }>;
|
|
|
+ };
|
|
|
+
|
|
|
+ if (typeof typedWhere.isDeleted === 'number') {
|
|
|
+ items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof typedWhere.hairstylistId === 'bigint') {
|
|
|
+ items = items.filter((item) => item.hairstylistId === typedWhere.hairstylistId);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typedWhere.category) {
|
|
|
+ items = items.filter((item) => item.category === typedWhere.category);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typedWhere.status) {
|
|
|
+ items = items.filter((item) => item.status === typedWhere.status);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Array.isArray(typedWhere.OR) && typedWhere.OR.length > 0) {
|
|
|
+ const keyword =
|
|
|
+ typedWhere.OR[0]?.templateName?.contains ??
|
|
|
+ typedWhere.OR[1]?.tags?.contains ??
|
|
|
+ typedWhere.OR[2]?.description?.contains ??
|
|
|
+ '';
|
|
|
+
|
|
|
+ items = items.filter((item) => {
|
|
|
+ return (
|
|
|
+ String(item.templateName ?? '').includes(keyword) ||
|
|
|
+ String(item.tags ?? '').includes(keyword) ||
|
|
|
+ String(item.description ?? '').includes(keyword)
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
+ Array.isArray(params.orderBy) &&
|
|
|
+ params.orderBy[0] &&
|
|
|
+ 'id' in params.orderBy[0] &&
|
|
|
+ params.orderBy[0].id === 'desc'
|
|
|
+ ) {
|
|
|
+ items.sort((a, b) => Number(b.id) - Number(a.id));
|
|
|
+ }
|
|
|
+
|
|
|
+ return typeof params.take === 'number'
|
|
|
+ ? items.slice(Number(params.skip ?? 0), Number(params.skip ?? 0) + params.take)
|
|
|
+ : items;
|
|
|
+ }
|
|
|
+
|
|
|
+ const prisma = {
|
|
|
+ systemConfig: {
|
|
|
+ createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
|
|
|
+ for (const item of data) {
|
|
|
+ systemConfigs.push({
|
|
|
+ id: nextSystemConfigId,
|
|
|
+ isDeleted: 0,
|
|
|
+ createTime: new Date(),
|
|
|
+ updateTime: new Date(),
|
|
|
+ remark: '',
|
|
|
+ ...item,
|
|
|
+ });
|
|
|
+ nextSystemConfigId += 1n;
|
|
|
+ }
|
|
|
+
|
|
|
+ return { count: data.length };
|
|
|
+ }),
|
|
|
+ create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
|
+ const created = {
|
|
|
+ id: nextSystemConfigId,
|
|
|
+ isDeleted: 0,
|
|
|
+ createTime: new Date(),
|
|
|
+ updateTime: new Date(),
|
|
|
+ remark: '',
|
|
|
+ ...data,
|
|
|
+ };
|
|
|
+ systemConfigs.push(created);
|
|
|
+ nextSystemConfigId += 1n;
|
|
|
+ return created;
|
|
|
+ }),
|
|
|
+ findMany: jest.fn(async ({ where, orderBy, take }: Record<string, unknown>) => {
|
|
|
+ let items = [...systemConfigs];
|
|
|
+
|
|
|
+ if (where && typeof where === 'object') {
|
|
|
+ const typedWhere = where as {
|
|
|
+ isDeleted?: number;
|
|
|
+ configGroup?: string | { in: string[] };
|
|
|
+ };
|
|
|
+
|
|
|
+ if (typeof typedWhere.isDeleted === 'number') {
|
|
|
+ items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof typedWhere.configGroup === 'string') {
|
|
|
+ items = items.filter((item) => item.configGroup === typedWhere.configGroup);
|
|
|
+ }
|
|
|
+
|
|
|
+ const configGroupFilter = typedWhere.configGroup;
|
|
|
+ if (
|
|
|
+ configGroupFilter &&
|
|
|
+ typeof configGroupFilter === 'object' &&
|
|
|
+ 'in' in configGroupFilter &&
|
|
|
+ Array.isArray(configGroupFilter.in)
|
|
|
+ ) {
|
|
|
+ items = items.filter((item) =>
|
|
|
+ configGroupFilter.in.includes(String(item.configGroup)),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Array.isArray(orderBy)) {
|
|
|
+ items.sort((a, b) => {
|
|
|
+ const sortA = Number(a.sort ?? 0);
|
|
|
+ const sortB = Number(b.sort ?? 0);
|
|
|
+ if (sortA !== sortB) {
|
|
|
+ return sortA - sortB;
|
|
|
+ }
|
|
|
+
|
|
|
+ return Number(a.id) - Number(b.id);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof take === 'number') {
|
|
|
+ items = items.slice(0, take);
|
|
|
+ }
|
|
|
+
|
|
|
+ return items;
|
|
|
+ }),
|
|
|
+ deleteMany: jest.fn(async () => ({ count: 0 })),
|
|
|
+ },
|
|
|
+ hairTemplate: {
|
|
|
+ findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
|
+ return (
|
|
|
+ hairTemplates.find((item) => {
|
|
|
+ return (
|
|
|
+ item.id === where.id &&
|
|
|
+ item.isDeleted === where.isDeleted &&
|
|
|
+ item.hairstylistId === where.hairstylistId
|
|
|
+ );
|
|
|
+ }) ?? null
|
|
|
+ );
|
|
|
+ }),
|
|
|
+ findMany: jest.fn(async ({ where, orderBy, skip = 0, take }: Record<string, unknown>) => {
|
|
|
+ return filterHairTemplates({
|
|
|
+ where: where as Record<string, unknown> | undefined,
|
|
|
+ orderBy: orderBy as Record<string, unknown> | undefined,
|
|
|
+ skip: Number(skip),
|
|
|
+ take: typeof take === 'number' ? Number(take) : undefined,
|
|
|
+ });
|
|
|
+ }),
|
|
|
+ count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
|
+ return filterHairTemplates({ where }).length;
|
|
|
+ }),
|
|
|
+ update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
|
|
|
+ const target = hairTemplates.find((item) => item.id === where.id);
|
|
|
+ if (!target) {
|
|
|
+ throw new Error('template not found');
|
|
|
+ }
|
|
|
+ Object.assign(target, data);
|
|
|
+ return target;
|
|
|
+ }),
|
|
|
+ updateMany: jest.fn(async ({
|
|
|
+ where,
|
|
|
+ data,
|
|
|
+ }: {
|
|
|
+ where: {
|
|
|
+ id?: bigint | { in?: bigint[] };
|
|
|
+ hairstylistId?: bigint;
|
|
|
+ isDeleted?: number;
|
|
|
+ };
|
|
|
+ data: Record<string, unknown>;
|
|
|
+ }) => {
|
|
|
+ let items = [...hairTemplates];
|
|
|
+ if (typeof where.isDeleted === 'number') {
|
|
|
+ items = items.filter((item) => item.isDeleted === where.isDeleted);
|
|
|
+ }
|
|
|
+ if (typeof where.hairstylistId === 'bigint') {
|
|
|
+ items = items.filter((item) => item.hairstylistId === where.hairstylistId);
|
|
|
+ }
|
|
|
+ if (typeof where.id === 'bigint') {
|
|
|
+ items = items.filter((item) => item.id === where.id);
|
|
|
+ } else {
|
|
|
+ const idFilter = where.id;
|
|
|
+ if (
|
|
|
+ idFilter &&
|
|
|
+ typeof idFilter === 'object' &&
|
|
|
+ 'in' in idFilter &&
|
|
|
+ Array.isArray(idFilter.in)
|
|
|
+ ) {
|
|
|
+ const ids = idFilter.in;
|
|
|
+ items = items.filter((item) => ids.includes(item.id as bigint));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ items.forEach((item) => Object.assign(item, data));
|
|
|
+ return { count: items.length };
|
|
|
+ }),
|
|
|
+ deleteMany: jest.fn(async () => ({ count: 0 })),
|
|
|
+ },
|
|
|
+ hairTemplateMakeTask: {
|
|
|
+ findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
|
+ return (
|
|
|
+ hairTemplateMakeTasks.find((item) => {
|
|
|
+ return item.id === where.id && item.isDeleted === where.isDeleted;
|
|
|
+ }) ?? null
|
|
|
+ );
|
|
|
+ }),
|
|
|
+ findMany: jest.fn(async ({ where, orderBy }: Record<string, unknown>) => {
|
|
|
+ const typedWhere = (where ?? {}) as {
|
|
|
+ isDeleted?: number;
|
|
|
+ templateId?: { in?: bigint[] };
|
|
|
+ };
|
|
|
+ let items = [...hairTemplateMakeTasks];
|
|
|
+
|
|
|
+ if (typeof typedWhere.isDeleted === 'number') {
|
|
|
+ items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typedWhere.templateId?.in) {
|
|
|
+ items = items.filter((item) => typedWhere.templateId?.in?.includes(item.templateId as bigint));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Array.isArray(orderBy) && orderBy[0] && 'id' in orderBy[0] && orderBy[0].id === 'desc') {
|
|
|
+ items.sort((a, b) => Number(b.id) - Number(a.id));
|
|
|
+ }
|
|
|
+
|
|
|
+ return items;
|
|
|
+ }),
|
|
|
+ update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
|
|
|
+ const target = hairTemplateMakeTasks.find((item) => item.id === where.id);
|
|
|
+ if (!target) {
|
|
|
+ throw new Error('task not found');
|
|
|
+ }
|
|
|
+ Object.assign(target, data);
|
|
|
+ return target;
|
|
|
+ }),
|
|
|
+ updateMany: jest.fn(async ({
|
|
|
+ where,
|
|
|
+ data,
|
|
|
+ }: {
|
|
|
+ where: {
|
|
|
+ templateId?: bigint | { in?: bigint[] };
|
|
|
+ isDeleted?: number;
|
|
|
+ };
|
|
|
+ data: Record<string, unknown>;
|
|
|
+ }) => {
|
|
|
+ let items = [...hairTemplateMakeTasks];
|
|
|
+ if (typeof where.isDeleted === 'number') {
|
|
|
+ items = items.filter((item) => item.isDeleted === where.isDeleted);
|
|
|
+ }
|
|
|
+ if (typeof where.templateId === 'bigint') {
|
|
|
+ items = items.filter((item) => item.templateId === where.templateId);
|
|
|
+ } else {
|
|
|
+ const templateIdFilter = where.templateId;
|
|
|
+ if (
|
|
|
+ templateIdFilter &&
|
|
|
+ typeof templateIdFilter === 'object' &&
|
|
|
+ 'in' in templateIdFilter &&
|
|
|
+ Array.isArray(templateIdFilter.in)
|
|
|
+ ) {
|
|
|
+ const templateIds = templateIdFilter.in;
|
|
|
+ items = items.filter((item) =>
|
|
|
+ templateIds.includes(item.templateId as bigint),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ items.forEach((item) => Object.assign(item, data));
|
|
|
+ return { count: items.length };
|
|
|
+ }),
|
|
|
+ deleteMany: jest.fn(async () => ({ count: 0 })),
|
|
|
+ },
|
|
|
+ aiHairSimulationTask: {
|
|
|
+ count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
|
+ const typedWhere = where as {
|
|
|
+ taskStatus?: string;
|
|
|
+ isDeleted?: number;
|
|
|
+ templateId?: { in?: string[] };
|
|
|
+ taskFinishTime?: { gte?: Date; lt?: Date };
|
|
|
+ };
|
|
|
+ let items = [...aiHairSimulationTasks];
|
|
|
+
|
|
|
+ if (typeof typedWhere.taskStatus === 'string') {
|
|
|
+ items = items.filter((item) => item.taskStatus === typedWhere.taskStatus);
|
|
|
+ }
|
|
|
+ if (typeof typedWhere.isDeleted === 'number') {
|
|
|
+ items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
|
|
|
+ }
|
|
|
+ if (typedWhere.templateId?.in) {
|
|
|
+ items = items.filter((item) =>
|
|
|
+ typedWhere.templateId?.in?.includes(String(item.templateId)),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (typedWhere.taskFinishTime?.gte instanceof Date) {
|
|
|
+ items = items.filter(
|
|
|
+ (item) =>
|
|
|
+ item.taskFinishTime instanceof Date &&
|
|
|
+ item.taskFinishTime >= typedWhere.taskFinishTime!.gte!,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (typedWhere.taskFinishTime?.lt instanceof Date) {
|
|
|
+ items = items.filter(
|
|
|
+ (item) =>
|
|
|
+ item.taskFinishTime instanceof Date &&
|
|
|
+ item.taskFinishTime < typedWhere.taskFinishTime!.lt!,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return items.length;
|
|
|
+ }),
|
|
|
+ },
|
|
|
+ $transaction: jest.fn(async (callback: (tx: {
|
|
|
+ hairTemplate: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
|
|
|
+ hairTemplateMakeTask: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
|
|
|
+ }) => Promise<unknown>) =>
|
|
|
+ callback({
|
|
|
+ hairTemplate: {
|
|
|
+ create: async ({ data }) => {
|
|
|
+ const created = {
|
|
|
+ id: nextTemplateId,
|
|
|
+ createTime: new Date(),
|
|
|
+ updateTime: new Date(),
|
|
|
+ isDeleted: 0,
|
|
|
+ ...data,
|
|
|
+ };
|
|
|
+ hairTemplates.push(created);
|
|
|
+ nextTemplateId += 1n;
|
|
|
+ return created;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ hairTemplateMakeTask: {
|
|
|
+ create: async ({ data }) => {
|
|
|
+ const created = {
|
|
|
+ id: nextTaskId,
|
|
|
+ createTime: new Date(),
|
|
|
+ updateTime: new Date(),
|
|
|
+ isDeleted: 0,
|
|
|
+ ...data,
|
|
|
+ };
|
|
|
+ hairTemplateMakeTasks.push(created);
|
|
|
+ nextTaskId += 1n;
|
|
|
+ return created;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ })),
|
|
|
+ };
|
|
|
+
|
|
|
+ async function signToken() {
|
|
|
+ return jwtService.signAsync({
|
|
|
+ sub: 1,
|
|
|
+ stylistId: '1',
|
|
|
+ email: 'stylist@example.com',
|
|
|
+ username: 'Ogawa',
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ const { AppModule } = await import('../src/app.module');
|
|
|
+ const moduleRef = await Test.createTestingModule({
|
|
|
+ imports: [AppModule],
|
|
|
+ })
|
|
|
+ .overrideProvider(TemplateAiClient)
|
|
|
+ .useValue({ executeTemplate, getResult })
|
|
|
+ .overrideProvider(TemplateTaskFiles)
|
|
|
+ .useValue(templateTaskFiles)
|
|
|
+ .overrideProvider(PrismaService)
|
|
|
+ .useValue(prisma)
|
|
|
+ .compile();
|
|
|
+
|
|
|
+ app = moduleRef.createNestApplication();
|
|
|
+ configureApp(app);
|
|
|
+ await app.init();
|
|
|
+ jwtService = app.get(JwtService);
|
|
|
+ });
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ executeTemplate.mockReset();
|
|
|
+ getResult.mockReset();
|
|
|
+ templateTaskFiles.downloadToTempFile.mockReset();
|
|
|
+ templateTaskFiles.prepareStep2Bundle.mockReset();
|
|
|
+ templateTaskFiles.cleanup.mockReset();
|
|
|
+ systemConfigs.length = 0;
|
|
|
+ hairTemplates.length = 0;
|
|
|
+ hairTemplateMakeTasks.length = 0;
|
|
|
+ aiHairSimulationTasks.length = 0;
|
|
|
+ nextSystemConfigId = 1n;
|
|
|
+ nextTemplateId = 101n;
|
|
|
+ nextTaskId = 88n;
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await app.close();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/assets (GET) returns grouped asset options', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+ const uniqueSuffix = `${Date.now()}-assets`;
|
|
|
+
|
|
|
+ await prisma.systemConfig.createMany({
|
|
|
+ data: [
|
|
|
+ {
|
|
|
+ configKey: `e2e-template-model-${uniqueSuffix}`,
|
|
|
+ configName: '模特',
|
|
|
+ configGroup: 'ai-model',
|
|
|
+ configValue: 'https://cdn.example.com/model.jpg',
|
|
|
+ valueType: 'STRING',
|
|
|
+ sort: 1,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ configKey: `e2e-template-clothes-${uniqueSuffix}`,
|
|
|
+ configName: '服装',
|
|
|
+ configGroup: 'clothing',
|
|
|
+ configValue: 'https://cdn.example.com/clothes.jpg',
|
|
|
+ valueType: 'STRING',
|
|
|
+ sort: 2,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ configKey: `e2e-template-background-${uniqueSuffix}`,
|
|
|
+ configName: '背景',
|
|
|
+ configGroup: 'background',
|
|
|
+ configValue: 'https://cdn.example.com/background.jpg',
|
|
|
+ valueType: 'STRING',
|
|
|
+ sort: 3,
|
|
|
+ },
|
|
|
+ ] as never,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .get('/api/v1/templates/assets')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(response.body.data.models).toEqual(
|
|
|
+ expect.arrayContaining([
|
|
|
+ expect.objectContaining({
|
|
|
+ name: '模特',
|
|
|
+ url: 'https://cdn.example.com/model.jpg',
|
|
|
+ }),
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ expect(response.body.data.clothes).toEqual(
|
|
|
+ expect.arrayContaining([
|
|
|
+ expect.objectContaining({
|
|
|
+ name: '服装',
|
|
|
+ url: 'https://cdn.example.com/clothes.jpg',
|
|
|
+ }),
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ expect(response.body.data.backgrounds).toEqual(
|
|
|
+ expect.arrayContaining([
|
|
|
+ expect.objectContaining({
|
|
|
+ name: '背景',
|
|
|
+ url: 'https://cdn.example.com/background.jpg',
|
|
|
+ }),
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+ const uniqueSuffix = `${Date.now()}-create`;
|
|
|
+
|
|
|
+ await prisma.systemConfig.create({
|
|
|
+ data: {
|
|
|
+ configKey: `e2e-template-step1-${uniqueSuffix}`,
|
|
|
+ configName: 'step1',
|
|
|
+ configGroup: 'template-step1',
|
|
|
+ configValue: 'https://cdn.example.com/template-step1.json',
|
|
|
+ valueType: 'STRING',
|
|
|
+ sort: 1,
|
|
|
+ } as never,
|
|
|
+ });
|
|
|
+
|
|
|
+ executeTemplate.mockResolvedValueOnce({
|
|
|
+ taskId: `e2e-template-step1-task-${uniqueSuffix}`,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .post('/api/v1/templates/make-tasks')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .send({
|
|
|
+ templateName: `e2e-template-${uniqueSuffix}`,
|
|
|
+ category: 'LONG',
|
|
|
+ status: 'DRAFT',
|
|
|
+ description: 'e2e description',
|
|
|
+ tags: ['首次建议', '层次'],
|
|
|
+ refHairUrl: 'https://cdn.example.com/ref.jpg',
|
|
|
+ modelUrl: 'https://cdn.example.com/model.jpg',
|
|
|
+ clothesUrl: 'https://cdn.example.com/clothes.jpg',
|
|
|
+ backgroundUrl: 'https://cdn.example.com/background.jpg',
|
|
|
+ })
|
|
|
+ .expect(201);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({
|
|
|
+ templateId: expect.any(String),
|
|
|
+ taskId: expect.any(String),
|
|
|
+ totalTaskId: expect.stringContaining('tmpl_'),
|
|
|
+ taskStatus: 'PROCESSING',
|
|
|
+ });
|
|
|
+ expect(executeTemplate).toHaveBeenCalledWith(
|
|
|
+ 'https://cdn.example.com/template-step1.json',
|
|
|
+ {
|
|
|
+ images: ['https://cdn.example.com/ref.jpg'],
|
|
|
+ },
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/:templateId (GET) returns template basic info', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push({
|
|
|
+ id: 43n,
|
|
|
+ templateName: '柔和层次长发',
|
|
|
+ coverUrl: 'https://cdn.example.com/cover.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/template.zip',
|
|
|
+ status: 'DRAFT',
|
|
|
+ category: 'LONG',
|
|
|
+ tags: '层次,首次建议',
|
|
|
+ description: '适合轻盈感修饰',
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .get('/api/v1/templates/43')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({
|
|
|
+ id: '43',
|
|
|
+ templateName: '柔和层次长发',
|
|
|
+ status: 'DRAFT',
|
|
|
+ category: 'LONG',
|
|
|
+ tags: ['层次', '首次建议'],
|
|
|
+ description: '适合轻盈感修饰',
|
|
|
+ coverUrl: 'https://cdn.example.com/cover.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/template.zip',
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/:templateId (PATCH) updates only template basic info', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push({
|
|
|
+ id: 43n,
|
|
|
+ templateName: '旧模板名',
|
|
|
+ coverUrl: 'https://cdn.example.com/cover.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/template.zip',
|
|
|
+ status: 'DRAFT',
|
|
|
+ category: 'LONG',
|
|
|
+ tags: '旧标签',
|
|
|
+ description: '旧描述',
|
|
|
+ modelUrl: 'https://cdn.example.com/model.jpg',
|
|
|
+ clothesUrl: 'https://cdn.example.com/clothes.jpg',
|
|
|
+ backgroundUrl: 'https://cdn.example.com/background.jpg',
|
|
|
+ refHairUrl: 'https://cdn.example.com/ref.jpg',
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .patch('/api/v1/templates/43')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .send({
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ tags: ['空气感', '层次'],
|
|
|
+ description: '新描述',
|
|
|
+ })
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({
|
|
|
+ id: '43',
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ tags: ['空气感', '层次'],
|
|
|
+ description: '新描述',
|
|
|
+ coverUrl: 'https://cdn.example.com/cover.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/template.zip',
|
|
|
+ });
|
|
|
+ expect(hairTemplates[0]).toEqual(
|
|
|
+ expect.objectContaining({
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ tags: '空气感,层次',
|
|
|
+ description: '新描述',
|
|
|
+ modelUrl: 'https://cdn.example.com/model.jpg',
|
|
|
+ clothesUrl: 'https://cdn.example.com/clothes.jpg',
|
|
|
+ backgroundUrl: 'https://cdn.example.com/background.jpg',
|
|
|
+ refHairUrl: 'https://cdn.example.com/ref.jpg',
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates (GET) filters and paginates stylist templates', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push(
|
|
|
+ {
|
|
|
+ id: 101n,
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '空气感提案',
|
|
|
+ tags: '空气感,脸周',
|
|
|
+ coverUrl: 'https://cdn.example.com/101.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/101.zip',
|
|
|
+ usedCount: 5,
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 102n,
|
|
|
+ templateName: '厚重短发',
|
|
|
+ category: 'SHORT',
|
|
|
+ status: 'DRAFT',
|
|
|
+ description: '短发提案',
|
|
|
+ tags: '短发',
|
|
|
+ coverUrl: 'https://cdn.example.com/102.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/102.zip',
|
|
|
+ usedCount: 1,
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 103n,
|
|
|
+ templateName: '其他美发师模板',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '不应出现',
|
|
|
+ tags: '空气感',
|
|
|
+ coverUrl: 'https://cdn.example.com/103.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/103.zip',
|
|
|
+ usedCount: 3,
|
|
|
+ hairstylistId: 2n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+ hairTemplateMakeTasks.push({
|
|
|
+ id: 88n,
|
|
|
+ templateId: 101n,
|
|
|
+ taskStatus: 'STEP2_SUCCESS',
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .get('/api/v1/templates?q=空气感&category=MEDIUM&status=PUBLIC&page=1&pageSize=1')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({
|
|
|
+ items: [
|
|
|
+ {
|
|
|
+ id: '101',
|
|
|
+ name: '空气感中长发',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '空气感提案',
|
|
|
+ tags: ['空气感', '脸周'],
|
|
|
+ image: 'https://cdn.example.com/101.jpg',
|
|
|
+ makeTaskId: '88',
|
|
|
+ makeTaskStatus: 'STEP2_SUCCESS',
|
|
|
+ usedCount: 5,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ total: 1,
|
|
|
+ page: 1,
|
|
|
+ pageSize: 1,
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/summary (GET) returns real template dashboard summary', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push(
|
|
|
+ {
|
|
|
+ id: 101n,
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '空气感提案',
|
|
|
+ tags: '空气感,脸周',
|
|
|
+ coverUrl: 'https://cdn.example.com/101.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/101.zip',
|
|
|
+ usedCount: 5,
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 102n,
|
|
|
+ templateName: '厚重短发',
|
|
|
+ category: 'SHORT',
|
|
|
+ status: 'DRAFT',
|
|
|
+ description: '短发提案',
|
|
|
+ tags: '短发',
|
|
|
+ coverUrl: 'https://cdn.example.com/102.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/102.zip',
|
|
|
+ usedCount: 1,
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+ aiHairSimulationTasks.push(
|
|
|
+ {
|
|
|
+ id: 501n,
|
|
|
+ templateId: '101',
|
|
|
+ taskStatus: 'SUCCESSES',
|
|
|
+ taskFinishTime: new Date(),
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 502n,
|
|
|
+ templateId: '999',
|
|
|
+ taskStatus: 'SUCCESSES',
|
|
|
+ taskFinishTime: new Date(),
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .get('/api/v1/templates/summary')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({
|
|
|
+ total: 2,
|
|
|
+ published: 1,
|
|
|
+ weeklyUsed: 1,
|
|
|
+ draft: 1,
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/:templateId (DELETE) soft deletes owned template', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push({
|
|
|
+ id: 101n,
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '空气感提案',
|
|
|
+ tags: '空气感,脸周',
|
|
|
+ coverUrl: 'https://cdn.example.com/101.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/101.zip',
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+ hairTemplateMakeTasks.push({
|
|
|
+ id: 88n,
|
|
|
+ templateId: 101n,
|
|
|
+ taskStatus: 'STEP2_SUCCESS',
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .delete('/api/v1/templates/101')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({ success: true });
|
|
|
+ expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
|
|
|
+ expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/bulk-delete (POST) soft deletes current stylist templates only', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push(
|
|
|
+ {
|
|
|
+ id: 101n,
|
|
|
+ templateName: '空气感中长发',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '空气感提案',
|
|
|
+ tags: '空气感,脸周',
|
|
|
+ coverUrl: 'https://cdn.example.com/101.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/101.zip',
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 102n,
|
|
|
+ templateName: '厚重短发',
|
|
|
+ category: 'SHORT',
|
|
|
+ status: 'DRAFT',
|
|
|
+ description: '短发提案',
|
|
|
+ tags: '短发',
|
|
|
+ coverUrl: 'https://cdn.example.com/102.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/102.zip',
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 103n,
|
|
|
+ templateName: '其他美发师模板',
|
|
|
+ category: 'MEDIUM',
|
|
|
+ status: 'PUBLIC',
|
|
|
+ description: '不应删除',
|
|
|
+ tags: '空气感',
|
|
|
+ coverUrl: 'https://cdn.example.com/103.jpg',
|
|
|
+ templateUrl: 'https://cdn.example.com/103.zip',
|
|
|
+ hairstylistId: 2n,
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+ hairTemplateMakeTasks.push(
|
|
|
+ {
|
|
|
+ id: 88n,
|
|
|
+ templateId: 101n,
|
|
|
+ taskStatus: 'STEP2_SUCCESS',
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 89n,
|
|
|
+ templateId: 102n,
|
|
|
+ taskStatus: 'PROCESSING',
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 90n,
|
|
|
+ templateId: 103n,
|
|
|
+ taskStatus: 'STEP2_SUCCESS',
|
|
|
+ isDeleted: 0,
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .post('/api/v1/templates/bulk-delete')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .send({
|
|
|
+ ids: ['101', '102', '103'],
|
|
|
+ })
|
|
|
+ .expect(201);
|
|
|
+
|
|
|
+ expect(response.body.data).toEqual({ deletedCount: 2 });
|
|
|
+ expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
|
|
|
+ expect(hairTemplates.find((item) => item.id === 102n)?.isDeleted).toBe(1);
|
|
|
+ expect(hairTemplates.find((item) => item.id === 103n)?.isDeleted).toBe(0);
|
|
|
+ expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
|
|
|
+ expect(hairTemplateMakeTasks.find((item) => item.id === 89n)?.isDeleted).toBe(1);
|
|
|
+ expect(hairTemplateMakeTasks.find((item) => item.id === 90n)?.isDeleted).toBe(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 success and returns detail', async () => {
|
|
|
+ const token = await signToken();
|
|
|
+
|
|
|
+ hairTemplates.push({
|
|
|
+ id: 101n,
|
|
|
+ templateName: '柔和层次长发',
|
|
|
+ coverUrl: '',
|
|
|
+ templateUrl: '',
|
|
|
+ status: 'DRAFT',
|
|
|
+ hairstylistId: 1n,
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+ hairTemplateMakeTasks.push({
|
|
|
+ id: 88n,
|
|
|
+ totalTaskId: 'tmpl_123',
|
|
|
+ taskStatus: 'PROCESSING',
|
|
|
+ templateId: 101n,
|
|
|
+ step1TaskId: 'step1-task-1',
|
|
|
+ step2TaskId: '',
|
|
|
+ refHairUrl: 'https://cdn.example.com/ref.jpg',
|
|
|
+ modelUrl: 'https://cdn.example.com/model.jpg',
|
|
|
+ clothesUrl: 'https://cdn.example.com/clothes.jpg',
|
|
|
+ backgroundUrl: 'https://cdn.example.com/background.jpg',
|
|
|
+ step1ResultImg: '',
|
|
|
+ step2ResultImg: '',
|
|
|
+ step1Resource: JSON.stringify({
|
|
|
+ templateUrl: 'https://cdn.example.com/template-step1.json',
|
|
|
+ }),
|
|
|
+ step2Resource: JSON.stringify({}),
|
|
|
+ createTime: new Date('2026-06-28T12:00:00.000Z'),
|
|
|
+ updateTime: new Date(Date.now() - 20_000),
|
|
|
+ isDeleted: 0,
|
|
|
+ });
|
|
|
+ getResult.mockResolvedValueOnce({
|
|
|
+ status: 'successes',
|
|
|
+ message: '',
|
|
|
+ images: ['https://cdn.example.com/step1-result.jpg'],
|
|
|
+ });
|
|
|
+ templateTaskFiles.downloadToTempFile.mockResolvedValue(
|
|
|
+ '/tmp/template-make/tmpl_123/hairstyle.jpg',
|
|
|
+ );
|
|
|
+
|
|
|
+ const response = await request(app.getHttpServer())
|
|
|
+ .get('/api/v1/templates/make-tasks/88')
|
|
|
+ .set('Authorization', `Bearer ${token}`)
|
|
|
+ .expect(200);
|
|
|
+
|
|
|
+ expect(getResult).toHaveBeenCalledWith('step1-task-1');
|
|
|
+ expect(response.body.data).toEqual(
|
|
|
+ expect.objectContaining({
|
|
|
+ id: '88',
|
|
|
+ templateId: '101',
|
|
|
+ taskStatus: 'STEP1_SUCCESS',
|
|
|
+ step1: expect.objectContaining({
|
|
|
+ taskId: 'step1-task-1',
|
|
|
+ status: 'STEP1_SUCCESS',
|
|
|
+ resultImage: 'https://cdn.example.com/step1-result.jpg',
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ });
|
|
|
+});
|