import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { PrismaService } from '../../prisma/prisma.service'; import { AiSimulationClient } from './ai-simulation.client'; import { SimulationsService } from './simulations.service'; describe('SimulationsService', () => { let service: SimulationsService; const aiClient = { execute: jest.fn(), getResult: jest.fn(), }; const configService = { getOrThrow: jest.fn((key: string) => { if (key === 'aiSimulation.refreshIntervalMs') { return 10_000; } throw new Error(`Unexpected config key: ${key}`); }), }; const prisma = { customer: { findFirst: jest.fn(), }, aiHairSimulation: { findFirst: jest.fn(), findMany: jest.fn(), }, aiHairSimulationTask: { findMany: jest.fn(), update: jest.fn(), }, hairTemplate: { findMany: jest.fn(), count: jest.fn(), }, $transaction: jest.fn(), }; beforeEach(async () => { jest.resetAllMocks(); configService.getOrThrow.mockImplementation((key: string) => { if (key === 'aiSimulation.refreshIntervalMs') { return 10_000; } throw new Error(`Unexpected config key: ${key}`); }); const moduleRef = await Test.createTestingModule({ providers: [ SimulationsService, { provide: PrismaService, useValue: prisma }, { provide: AiSimulationClient, useValue: aiClient }, { provide: ConfigService, useValue: configService }, ], }).compile(); service = moduleRef.get(SimulationsService); }); it('returns only public stylist templates for the current stylist', async () => { prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateName: 'Layer Bob', coverUrl: 'https://cdn.example.com/bob.png', category: 'SHORT', tags: 'airy,bob', description: 'short bob', templateUrl: 'https://cdn.example.com/bob.zip', isSystemTemplate: 'NO', }, ]); prisma.hairTemplate.count.mockResolvedValue(1); const result = await service.listTemplateOptions(BigInt(7), { source: 'STYLIST', page: 1, pageSize: 12, }); expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ isDeleted: 0, status: 'PUBLIC', isSystemTemplate: 'NO', hairstylistId: BigInt(7), }), }), ); expect(result.items[0]).toMatchObject({ id: '11', templateName: 'Layer Bob', tags: ['airy', 'bob'], isSystemTemplate: false, }); }); it('returns only public system templates for the system tab', async () => { prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(21), templateName: 'Classic Medium', coverUrl: '', category: 'MEDIUM', tags: '', description: null, templateUrl: 'https://cdn.example.com/medium.zip', isSystemTemplate: 'YES', }, ]); prisma.hairTemplate.count.mockResolvedValue(1); const result = await service.listTemplateOptions(BigInt(7), { source: 'SYSTEM', page: 1, pageSize: 12, }); expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ isDeleted: 0, status: 'PUBLIC', isSystemTemplate: 'YES', }), }), ); expect(result.items[0].id).toBe('21'); }); it('creates one simulation and one task row per selected template', async () => { prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) }); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateUrl: 'https://cdn.example.com/11.zip', isSystemTemplate: 'NO', hairstylistId: BigInt(7), status: 'PUBLIC', isDeleted: 0, }, { id: BigInt(12), templateUrl: 'https://cdn.example.com/12.zip', isSystemTemplate: 'YES', hairstylistId: null, status: 'PUBLIC', isDeleted: 0, }, ]); aiClient.execute .mockResolvedValueOnce({ taskId: 'task-11' }) .mockResolvedValueOnce({ taskId: 'task-12' }); const createSimulationRecord = jest.fn().mockResolvedValue({ id: BigInt(91) }); const createTaskRecords = jest.fn().mockResolvedValue({ count: 2 }); prisma.$transaction.mockImplementation(async (callback: (tx: { aiHairSimulation: { create: jest.Mock }; aiHairSimulationTask: { createMany: jest.Mock }; }) => Promise<{ simulationId: string }>) => callback({ aiHairSimulation: { create: createSimulationRecord, }, aiHairSimulationTask: { createMany: createTaskRecords, }, }), ); const result = await service.createSimulation(BigInt(7), { customerId: '5', originImage: 'https://cdn.example.com/origin.jpg', templateIds: ['11', '12'], simulateRemark: 'soft volume', }); expect(aiClient.execute).toHaveBeenNthCalledWith( 1, 'https://cdn.example.com/11.zip', 'https://cdn.example.com/origin.jpg', ); expect(createSimulationRecord).toHaveBeenCalledWith({ data: expect.objectContaining({ templateIds: JSON.stringify(['11', '12']), }), }); expect(result).toEqual({ simulationId: '91' }); }); it('rejects customer ids outside the current stylist scope', async () => { prisma.customer.findFirst.mockResolvedValue(null); await expect( service.createSimulation(BigInt(7), { customerId: '999', originImage: 'https://cdn.example.com/origin.jpg', templateIds: ['11'], }), ).rejects.toThrow('顾客不存在'); }); it('refreshes stale processing tasks and returns PARTIAL_SUCCESS when mixed', async () => { prisma.aiHairSimulation.findFirst.mockResolvedValue({ id: BigInt(91), customerId: BigInt(5), hairstylistId: BigInt(7), originImage: 'https://cdn.example.com/origin.jpg', simulateRemark: 'airy', createTime: new Date('2026-06-28T09:00:00Z'), }); prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5), name: 'Mika', avatar: 'https://cdn.example.com/mika.png', phone: '090', expectContact: 'LINE', }); prisma.aiHairSimulationTask.findMany .mockResolvedValueOnce([ { id: BigInt(1), templateId: '11', customerId: BigInt(5), taskId: 'task-11', taskStatus: 'PROCESSING', resultImages: null, errorMsg: null, taskStartTime: new Date('2026-06-28T10:00:00Z'), taskFinishTime: null, updateTime: new Date(Date.now() - 20_000), isDeleted: 0, }, { id: BigInt(2), templateId: '12', customerId: BigInt(5), taskId: 'task-12', taskStatus: 'FAILED', resultImages: null, errorMsg: 'template invalid', taskStartTime: new Date('2026-06-28T10:00:00Z'), taskFinishTime: new Date('2026-06-28T10:02:00Z'), updateTime: new Date('2026-06-28T10:02:00Z'), isDeleted: 0, }, ]) .mockResolvedValueOnce([ { id: BigInt(1), templateId: '11', customerId: BigInt(5), taskId: 'task-11', taskStatus: 'SUCCESSES', resultImages: JSON.stringify(['https://cdn.example.com/result.png']), errorMsg: null, taskStartTime: new Date('2026-06-28T10:00:00Z'), taskFinishTime: new Date('2026-06-28T10:01:00Z'), updateTime: new Date('2026-06-28T10:01:00Z'), isDeleted: 0, }, { id: BigInt(2), templateId: '12', customerId: BigInt(5), taskId: 'task-12', taskStatus: 'FAILED', resultImages: null, errorMsg: 'template invalid', taskStartTime: new Date('2026-06-28T10:00:00Z'), taskFinishTime: new Date('2026-06-28T10:02:00Z'), updateTime: new Date('2026-06-28T10:02:00Z'), isDeleted: 0, }, ]); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateName: 'Layer Bob', coverUrl: '', category: 'SHORT', isSystemTemplate: 'NO', }, { id: BigInt(12), templateName: 'Classic Medium', coverUrl: '', category: 'MEDIUM', isSystemTemplate: 'YES', }, ]); aiClient.getResult.mockResolvedValue({ status: 'successes', message: '', images: ['https://cdn.example.com/result.png'], }); const result = await service.getSimulationDetail(BigInt(7), '91'); expect(aiClient.getResult).toHaveBeenCalledWith('task-11'); expect(prisma.aiHairSimulationTask.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ taskStatus: 'SUCCESSES', resultImages: JSON.stringify(['https://cdn.example.com/result.png']), }), }), ); expect(result.createdAt).toBe('2026-06-28T09:00:00.000Z'); expect(result.overallStatus).toBe('PARTIAL_SUCCESS'); }); it('lists flattened customer gallery cards in descending simulation time order', async () => { prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) }); prisma.aiHairSimulation.findMany.mockResolvedValue([ { id: BigInt(91), customerId: BigInt(5), hairstylistId: BigInt(7), createTime: new Date('2026-06-28T09:00:00Z'), isDeleted: 0, }, { id: BigInt(88), customerId: BigInt(5), hairstylistId: BigInt(7), createTime: new Date('2026-06-27T09:00:00Z'), isDeleted: 0, }, ]); prisma.aiHairSimulationTask.findMany.mockResolvedValue([ { id: BigInt(1), simulationId: BigInt(91), templateId: '11', taskId: 'task-11', taskStatus: 'SUCCESSES', resultImages: JSON.stringify([ 'https://cdn.example.com/result-2.png', 'https://cdn.example.com/result-1.png', ]), isDeleted: 0, }, { id: BigInt(2), simulationId: BigInt(88), templateId: '12', taskId: 'task-12', taskStatus: 'FAILED', resultImages: null, isDeleted: 0, }, ]); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateName: 'Layer Bob', }, ]); const result = await service.listCustomerSimulationGallery(BigInt(7), '5'); expect(result.items).toEqual([ { simulationId: '91', taskId: 'task-11', imageUrl: 'https://cdn.example.com/result-2.png', templateName: 'Layer Bob', createdAt: '2026-06-28T09:00:00.000Z', taskStatus: 'SUCCESSES', }, { simulationId: '91', taskId: 'task-11', imageUrl: 'https://cdn.example.com/result-1.png', templateName: 'Layer Bob', createdAt: '2026-06-28T09:00:00.000Z', taskStatus: 'SUCCESSES', }, ]); }); });