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> = []; const hairTemplates: Array> = []; const hairTemplateMakeTasks: Array> = []; const aiHairSimulationTasks: Array> = []; let nextSystemConfigId = 1n; let nextTemplateId = 101n; let nextTaskId = 88n; function filterHairTemplates(params: { where?: Record; orderBy?: Record; 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> }) => { 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 }) => { 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) => { 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 }) => { 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) => { return filterHairTemplates({ where: where as Record | undefined, orderBy: orderBy as Record | undefined, skip: Number(skip), take: typeof take === 'number' ? Number(take) : undefined, }); }), count: jest.fn(async ({ where }: { where: Record }) => { return filterHairTemplates({ where }).length; }), update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record }) => { 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; }) => { 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 }) => { return ( hairTemplateMakeTasks.find((item) => { return item.id === where.id && item.isDeleted === where.isDeleted; }) ?? null ); }), findMany: jest.fn(async ({ where, orderBy }: Record) => { 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 }) => { 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; }) => { 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 }) => { 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 }) => Promise> }; hairTemplateMakeTask: { create: (args: { data: Record }) => Promise> }; }) => Promise) => 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', }), }), ); }); });