simulations.service.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import { ConfigService } from '@nestjs/config';
  2. import { Test } from '@nestjs/testing';
  3. import { PrismaService } from '../../prisma/prisma.service';
  4. import { AiSimulationClient } from './ai-simulation.client';
  5. import { SimulationsService } from './simulations.service';
  6. describe('SimulationsService', () => {
  7. let service: SimulationsService;
  8. const aiClient = {
  9. execute: jest.fn(),
  10. getResult: jest.fn(),
  11. };
  12. const configService = {
  13. getOrThrow: jest.fn((key: string) => {
  14. if (key === 'aiSimulation.refreshIntervalMs') {
  15. return 10_000;
  16. }
  17. throw new Error(`Unexpected config key: ${key}`);
  18. }),
  19. };
  20. const prisma = {
  21. customer: {
  22. findFirst: jest.fn(),
  23. },
  24. aiHairSimulation: {
  25. findFirst: jest.fn(),
  26. findMany: jest.fn(),
  27. },
  28. aiHairSimulationTask: {
  29. findMany: jest.fn(),
  30. update: jest.fn(),
  31. },
  32. hairTemplate: {
  33. findMany: jest.fn(),
  34. count: jest.fn(),
  35. },
  36. $transaction: jest.fn(),
  37. };
  38. beforeEach(async () => {
  39. jest.resetAllMocks();
  40. configService.getOrThrow.mockImplementation((key: string) => {
  41. if (key === 'aiSimulation.refreshIntervalMs') {
  42. return 10_000;
  43. }
  44. throw new Error(`Unexpected config key: ${key}`);
  45. });
  46. const moduleRef = await Test.createTestingModule({
  47. providers: [
  48. SimulationsService,
  49. { provide: PrismaService, useValue: prisma },
  50. { provide: AiSimulationClient, useValue: aiClient },
  51. { provide: ConfigService, useValue: configService },
  52. ],
  53. }).compile();
  54. service = moduleRef.get(SimulationsService);
  55. });
  56. it('returns only public stylist templates for the current stylist', async () => {
  57. prisma.hairTemplate.findMany.mockResolvedValue([
  58. {
  59. id: BigInt(11),
  60. templateName: 'Layer Bob',
  61. coverUrl: 'https://cdn.example.com/bob.png',
  62. category: 'SHORT',
  63. tags: 'airy,bob',
  64. description: 'short bob',
  65. templateUrl: 'https://cdn.example.com/bob.zip',
  66. isSystemTemplate: 'NO',
  67. },
  68. ]);
  69. prisma.hairTemplate.count.mockResolvedValue(1);
  70. const result = await service.listTemplateOptions(BigInt(7), {
  71. source: 'STYLIST',
  72. page: 1,
  73. pageSize: 12,
  74. });
  75. expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
  76. expect.objectContaining({
  77. where: expect.objectContaining({
  78. isDeleted: 0,
  79. status: 'PUBLIC',
  80. isSystemTemplate: 'NO',
  81. hairstylistId: BigInt(7),
  82. }),
  83. }),
  84. );
  85. expect(result.items[0]).toMatchObject({
  86. id: '11',
  87. templateName: 'Layer Bob',
  88. tags: ['airy', 'bob'],
  89. isSystemTemplate: false,
  90. });
  91. });
  92. it('returns only public system templates for the system tab', async () => {
  93. prisma.hairTemplate.findMany.mockResolvedValue([
  94. {
  95. id: BigInt(21),
  96. templateName: 'Classic Medium',
  97. coverUrl: '',
  98. category: 'MEDIUM',
  99. tags: '',
  100. description: null,
  101. templateUrl: 'https://cdn.example.com/medium.zip',
  102. isSystemTemplate: 'YES',
  103. },
  104. ]);
  105. prisma.hairTemplate.count.mockResolvedValue(1);
  106. const result = await service.listTemplateOptions(BigInt(7), {
  107. source: 'SYSTEM',
  108. page: 1,
  109. pageSize: 12,
  110. });
  111. expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
  112. expect.objectContaining({
  113. where: expect.objectContaining({
  114. isDeleted: 0,
  115. status: 'PUBLIC',
  116. isSystemTemplate: 'YES',
  117. }),
  118. }),
  119. );
  120. expect(result.items[0].id).toBe('21');
  121. });
  122. it('creates one simulation and one task row per selected template', async () => {
  123. prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
  124. prisma.hairTemplate.findMany.mockResolvedValue([
  125. {
  126. id: BigInt(11),
  127. templateUrl: 'https://cdn.example.com/11.zip',
  128. isSystemTemplate: 'NO',
  129. hairstylistId: BigInt(7),
  130. status: 'PUBLIC',
  131. isDeleted: 0,
  132. },
  133. {
  134. id: BigInt(12),
  135. templateUrl: 'https://cdn.example.com/12.zip',
  136. isSystemTemplate: 'YES',
  137. hairstylistId: null,
  138. status: 'PUBLIC',
  139. isDeleted: 0,
  140. },
  141. ]);
  142. aiClient.execute
  143. .mockResolvedValueOnce({ taskId: 'task-11' })
  144. .mockResolvedValueOnce({ taskId: 'task-12' });
  145. const createSimulationRecord = jest.fn().mockResolvedValue({ id: BigInt(91) });
  146. const createTaskRecords = jest.fn().mockResolvedValue({ count: 2 });
  147. prisma.$transaction.mockImplementation(async (callback: (tx: {
  148. aiHairSimulation: { create: jest.Mock };
  149. aiHairSimulationTask: { createMany: jest.Mock };
  150. }) => Promise<{ simulationId: string }>) =>
  151. callback({
  152. aiHairSimulation: {
  153. create: createSimulationRecord,
  154. },
  155. aiHairSimulationTask: {
  156. createMany: createTaskRecords,
  157. },
  158. }),
  159. );
  160. const result = await service.createSimulation(BigInt(7), {
  161. customerId: '5',
  162. originImage: 'https://cdn.example.com/origin.jpg',
  163. templateIds: ['11', '12'],
  164. simulateRemark: 'soft volume',
  165. });
  166. expect(aiClient.execute).toHaveBeenNthCalledWith(
  167. 1,
  168. 'https://cdn.example.com/11.zip',
  169. 'https://cdn.example.com/origin.jpg',
  170. );
  171. expect(createSimulationRecord).toHaveBeenCalledWith({
  172. data: expect.objectContaining({
  173. templateIds: JSON.stringify(['11', '12']),
  174. }),
  175. });
  176. expect(result).toEqual({ simulationId: '91' });
  177. });
  178. it('rejects customer ids outside the current stylist scope', async () => {
  179. prisma.customer.findFirst.mockResolvedValue(null);
  180. await expect(
  181. service.createSimulation(BigInt(7), {
  182. customerId: '999',
  183. originImage: 'https://cdn.example.com/origin.jpg',
  184. templateIds: ['11'],
  185. }),
  186. ).rejects.toThrow('顾客不存在');
  187. });
  188. it('refreshes stale processing tasks and returns PARTIAL_SUCCESS when mixed', async () => {
  189. prisma.aiHairSimulation.findFirst.mockResolvedValue({
  190. id: BigInt(91),
  191. customerId: BigInt(5),
  192. hairstylistId: BigInt(7),
  193. originImage: 'https://cdn.example.com/origin.jpg',
  194. simulateRemark: 'airy',
  195. createTime: new Date('2026-06-28T09:00:00Z'),
  196. });
  197. prisma.customer.findFirst.mockResolvedValue({
  198. id: BigInt(5),
  199. name: 'Mika',
  200. avatar: 'https://cdn.example.com/mika.png',
  201. phone: '090',
  202. expectContact: 'LINE',
  203. });
  204. prisma.aiHairSimulationTask.findMany
  205. .mockResolvedValueOnce([
  206. {
  207. id: BigInt(1),
  208. templateId: '11',
  209. customerId: BigInt(5),
  210. taskId: 'task-11',
  211. taskStatus: 'PROCESSING',
  212. resultImages: null,
  213. errorMsg: null,
  214. taskStartTime: new Date('2026-06-28T10:00:00Z'),
  215. taskFinishTime: null,
  216. updateTime: new Date(Date.now() - 20_000),
  217. isDeleted: 0,
  218. },
  219. {
  220. id: BigInt(2),
  221. templateId: '12',
  222. customerId: BigInt(5),
  223. taskId: 'task-12',
  224. taskStatus: 'FAILED',
  225. resultImages: null,
  226. errorMsg: 'template invalid',
  227. taskStartTime: new Date('2026-06-28T10:00:00Z'),
  228. taskFinishTime: new Date('2026-06-28T10:02:00Z'),
  229. updateTime: new Date('2026-06-28T10:02:00Z'),
  230. isDeleted: 0,
  231. },
  232. ])
  233. .mockResolvedValueOnce([
  234. {
  235. id: BigInt(1),
  236. templateId: '11',
  237. customerId: BigInt(5),
  238. taskId: 'task-11',
  239. taskStatus: 'SUCCESSES',
  240. resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
  241. errorMsg: null,
  242. taskStartTime: new Date('2026-06-28T10:00:00Z'),
  243. taskFinishTime: new Date('2026-06-28T10:01:00Z'),
  244. updateTime: new Date('2026-06-28T10:01:00Z'),
  245. isDeleted: 0,
  246. },
  247. {
  248. id: BigInt(2),
  249. templateId: '12',
  250. customerId: BigInt(5),
  251. taskId: 'task-12',
  252. taskStatus: 'FAILED',
  253. resultImages: null,
  254. errorMsg: 'template invalid',
  255. taskStartTime: new Date('2026-06-28T10:00:00Z'),
  256. taskFinishTime: new Date('2026-06-28T10:02:00Z'),
  257. updateTime: new Date('2026-06-28T10:02:00Z'),
  258. isDeleted: 0,
  259. },
  260. ]);
  261. prisma.hairTemplate.findMany.mockResolvedValue([
  262. {
  263. id: BigInt(11),
  264. templateName: 'Layer Bob',
  265. coverUrl: '',
  266. category: 'SHORT',
  267. isSystemTemplate: 'NO',
  268. },
  269. {
  270. id: BigInt(12),
  271. templateName: 'Classic Medium',
  272. coverUrl: '',
  273. category: 'MEDIUM',
  274. isSystemTemplate: 'YES',
  275. },
  276. ]);
  277. aiClient.getResult.mockResolvedValue({
  278. status: 'successes',
  279. message: '',
  280. images: ['https://cdn.example.com/result.png'],
  281. });
  282. const result = await service.getSimulationDetail(BigInt(7), '91');
  283. expect(aiClient.getResult).toHaveBeenCalledWith('task-11');
  284. expect(prisma.aiHairSimulationTask.update).toHaveBeenCalledWith(
  285. expect.objectContaining({
  286. data: expect.objectContaining({
  287. taskStatus: 'SUCCESSES',
  288. resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
  289. }),
  290. }),
  291. );
  292. expect(result.createdAt).toBe('2026-06-28T09:00:00.000Z');
  293. expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
  294. });
  295. it('lists flattened customer gallery cards in descending simulation time order', async () => {
  296. prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
  297. prisma.aiHairSimulation.findMany.mockResolvedValue([
  298. {
  299. id: BigInt(91),
  300. customerId: BigInt(5),
  301. hairstylistId: BigInt(7),
  302. createTime: new Date('2026-06-28T09:00:00Z'),
  303. isDeleted: 0,
  304. },
  305. {
  306. id: BigInt(88),
  307. customerId: BigInt(5),
  308. hairstylistId: BigInt(7),
  309. createTime: new Date('2026-06-27T09:00:00Z'),
  310. isDeleted: 0,
  311. },
  312. ]);
  313. prisma.aiHairSimulationTask.findMany.mockResolvedValue([
  314. {
  315. id: BigInt(1),
  316. simulationId: BigInt(91),
  317. templateId: '11',
  318. taskId: 'task-11',
  319. taskStatus: 'SUCCESSES',
  320. resultImages: JSON.stringify([
  321. 'https://cdn.example.com/result-2.png',
  322. 'https://cdn.example.com/result-1.png',
  323. ]),
  324. isDeleted: 0,
  325. },
  326. {
  327. id: BigInt(2),
  328. simulationId: BigInt(88),
  329. templateId: '12',
  330. taskId: 'task-12',
  331. taskStatus: 'FAILED',
  332. resultImages: null,
  333. isDeleted: 0,
  334. },
  335. ]);
  336. prisma.hairTemplate.findMany.mockResolvedValue([
  337. {
  338. id: BigInt(11),
  339. templateName: 'Layer Bob',
  340. },
  341. ]);
  342. const result = await service.listCustomerSimulationGallery(BigInt(7), '5');
  343. expect(result.items).toEqual([
  344. {
  345. simulationId: '91',
  346. taskId: 'task-11',
  347. imageUrl: 'https://cdn.example.com/result-2.png',
  348. templateName: 'Layer Bob',
  349. createdAt: '2026-06-28T09:00:00.000Z',
  350. taskStatus: 'SUCCESSES',
  351. },
  352. {
  353. simulationId: '91',
  354. taskId: 'task-11',
  355. imageUrl: 'https://cdn.example.com/result-1.png',
  356. templateName: 'Layer Bob',
  357. createdAt: '2026-06-28T09:00:00.000Z',
  358. taskStatus: 'SUCCESSES',
  359. },
  360. ]);
  361. });
  362. });