templates.e2e-spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953
  1. import { INestApplication } from '@nestjs/common';
  2. import { JwtService } from '@nestjs/jwt';
  3. import { Test } from '@nestjs/testing';
  4. import * as request from 'supertest';
  5. import { configureApp } from '../src/app.setup';
  6. import { TemplateAiClient } from '../src/modules/templates/template-ai.client';
  7. import { TemplateTaskFiles } from '../src/modules/templates/template-task-files';
  8. import { PrismaService } from '../src/prisma/prisma.service';
  9. process.env.GCS_BUCKET ??= 'test-bucket';
  10. process.env.GCS_PROJECT_ID ??= 'test-project';
  11. process.env.GCS_CLIENT_EMAIL ??= 'test@example.com';
  12. process.env.GCS_PRIVATE_KEY ??= 'test-key';
  13. describe('Templates (e2e)', () => {
  14. let app: INestApplication;
  15. let jwtService: JwtService;
  16. const executeTemplate = jest.fn();
  17. const getResult = jest.fn();
  18. const templateTaskFiles = {
  19. downloadToTempFile: jest.fn(),
  20. prepareStep2Bundle: jest.fn(),
  21. cleanup: jest.fn(),
  22. };
  23. const systemConfigs: Array<Record<string, unknown>> = [];
  24. const hairTemplates: Array<Record<string, unknown>> = [];
  25. const hairTemplateMakeTasks: Array<Record<string, unknown>> = [];
  26. const aiHairSimulationTasks: Array<Record<string, unknown>> = [];
  27. let nextSystemConfigId = 1n;
  28. let nextTemplateId = 101n;
  29. let nextTaskId = 88n;
  30. function filterHairTemplates(params: {
  31. where?: Record<string, unknown>;
  32. orderBy?: Record<string, unknown>;
  33. skip?: number;
  34. take?: number;
  35. }) {
  36. let items = [...hairTemplates];
  37. const typedWhere = (params.where ?? {}) as {
  38. hairstylistId?: bigint;
  39. isDeleted?: number;
  40. category?: string;
  41. status?: string;
  42. OR?: Array<{
  43. templateName?: { contains: string };
  44. tags?: { contains: string };
  45. description?: { contains: string };
  46. }>;
  47. };
  48. if (typeof typedWhere.isDeleted === 'number') {
  49. items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
  50. }
  51. if (typeof typedWhere.hairstylistId === 'bigint') {
  52. items = items.filter((item) => item.hairstylistId === typedWhere.hairstylistId);
  53. }
  54. if (typedWhere.category) {
  55. items = items.filter((item) => item.category === typedWhere.category);
  56. }
  57. if (typedWhere.status) {
  58. items = items.filter((item) => item.status === typedWhere.status);
  59. }
  60. if (Array.isArray(typedWhere.OR) && typedWhere.OR.length > 0) {
  61. const keyword =
  62. typedWhere.OR[0]?.templateName?.contains ??
  63. typedWhere.OR[1]?.tags?.contains ??
  64. typedWhere.OR[2]?.description?.contains ??
  65. '';
  66. items = items.filter((item) => {
  67. return (
  68. String(item.templateName ?? '').includes(keyword) ||
  69. String(item.tags ?? '').includes(keyword) ||
  70. String(item.description ?? '').includes(keyword)
  71. );
  72. });
  73. }
  74. if (
  75. Array.isArray(params.orderBy) &&
  76. params.orderBy[0] &&
  77. 'id' in params.orderBy[0] &&
  78. params.orderBy[0].id === 'desc'
  79. ) {
  80. items.sort((a, b) => Number(b.id) - Number(a.id));
  81. }
  82. return typeof params.take === 'number'
  83. ? items.slice(Number(params.skip ?? 0), Number(params.skip ?? 0) + params.take)
  84. : items;
  85. }
  86. const prisma = {
  87. systemConfig: {
  88. createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
  89. for (const item of data) {
  90. systemConfigs.push({
  91. id: nextSystemConfigId,
  92. isDeleted: 0,
  93. createTime: new Date(),
  94. updateTime: new Date(),
  95. remark: '',
  96. ...item,
  97. });
  98. nextSystemConfigId += 1n;
  99. }
  100. return { count: data.length };
  101. }),
  102. create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
  103. const created = {
  104. id: nextSystemConfigId,
  105. isDeleted: 0,
  106. createTime: new Date(),
  107. updateTime: new Date(),
  108. remark: '',
  109. ...data,
  110. };
  111. systemConfigs.push(created);
  112. nextSystemConfigId += 1n;
  113. return created;
  114. }),
  115. findMany: jest.fn(async ({ where, orderBy, take }: Record<string, unknown>) => {
  116. let items = [...systemConfigs];
  117. if (where && typeof where === 'object') {
  118. const typedWhere = where as {
  119. isDeleted?: number;
  120. configGroup?: string | { in: string[] };
  121. };
  122. if (typeof typedWhere.isDeleted === 'number') {
  123. items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
  124. }
  125. if (typeof typedWhere.configGroup === 'string') {
  126. items = items.filter((item) => item.configGroup === typedWhere.configGroup);
  127. }
  128. const configGroupFilter = typedWhere.configGroup;
  129. if (
  130. configGroupFilter &&
  131. typeof configGroupFilter === 'object' &&
  132. 'in' in configGroupFilter &&
  133. Array.isArray(configGroupFilter.in)
  134. ) {
  135. items = items.filter((item) =>
  136. configGroupFilter.in.includes(String(item.configGroup)),
  137. );
  138. }
  139. }
  140. if (Array.isArray(orderBy)) {
  141. items.sort((a, b) => {
  142. const sortA = Number(a.sort ?? 0);
  143. const sortB = Number(b.sort ?? 0);
  144. if (sortA !== sortB) {
  145. return sortA - sortB;
  146. }
  147. return Number(a.id) - Number(b.id);
  148. });
  149. }
  150. if (typeof take === 'number') {
  151. items = items.slice(0, take);
  152. }
  153. return items;
  154. }),
  155. deleteMany: jest.fn(async () => ({ count: 0 })),
  156. },
  157. hairTemplate: {
  158. findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
  159. return (
  160. hairTemplates.find((item) => {
  161. return (
  162. item.id === where.id &&
  163. item.isDeleted === where.isDeleted &&
  164. item.hairstylistId === where.hairstylistId
  165. );
  166. }) ?? null
  167. );
  168. }),
  169. findMany: jest.fn(async ({ where, orderBy, skip = 0, take }: Record<string, unknown>) => {
  170. return filterHairTemplates({
  171. where: where as Record<string, unknown> | undefined,
  172. orderBy: orderBy as Record<string, unknown> | undefined,
  173. skip: Number(skip),
  174. take: typeof take === 'number' ? Number(take) : undefined,
  175. });
  176. }),
  177. count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
  178. return filterHairTemplates({ where }).length;
  179. }),
  180. update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
  181. const target = hairTemplates.find((item) => item.id === where.id);
  182. if (!target) {
  183. throw new Error('template not found');
  184. }
  185. Object.assign(target, data);
  186. return target;
  187. }),
  188. updateMany: jest.fn(async ({
  189. where,
  190. data,
  191. }: {
  192. where: {
  193. id?: bigint | { in?: bigint[] };
  194. hairstylistId?: bigint;
  195. isDeleted?: number;
  196. };
  197. data: Record<string, unknown>;
  198. }) => {
  199. let items = [...hairTemplates];
  200. if (typeof where.isDeleted === 'number') {
  201. items = items.filter((item) => item.isDeleted === where.isDeleted);
  202. }
  203. if (typeof where.hairstylistId === 'bigint') {
  204. items = items.filter((item) => item.hairstylistId === where.hairstylistId);
  205. }
  206. if (typeof where.id === 'bigint') {
  207. items = items.filter((item) => item.id === where.id);
  208. } else {
  209. const idFilter = where.id;
  210. if (
  211. idFilter &&
  212. typeof idFilter === 'object' &&
  213. 'in' in idFilter &&
  214. Array.isArray(idFilter.in)
  215. ) {
  216. const ids = idFilter.in;
  217. items = items.filter((item) => ids.includes(item.id as bigint));
  218. }
  219. }
  220. items.forEach((item) => Object.assign(item, data));
  221. return { count: items.length };
  222. }),
  223. deleteMany: jest.fn(async () => ({ count: 0 })),
  224. },
  225. hairTemplateMakeTask: {
  226. findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
  227. return (
  228. hairTemplateMakeTasks.find((item) => {
  229. return item.id === where.id && item.isDeleted === where.isDeleted;
  230. }) ?? null
  231. );
  232. }),
  233. findMany: jest.fn(async ({ where, orderBy }: Record<string, unknown>) => {
  234. const typedWhere = (where ?? {}) as {
  235. isDeleted?: number;
  236. templateId?: { in?: bigint[] };
  237. };
  238. let items = [...hairTemplateMakeTasks];
  239. if (typeof typedWhere.isDeleted === 'number') {
  240. items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
  241. }
  242. if (typedWhere.templateId?.in) {
  243. items = items.filter((item) => typedWhere.templateId?.in?.includes(item.templateId as bigint));
  244. }
  245. if (Array.isArray(orderBy) && orderBy[0] && 'id' in orderBy[0] && orderBy[0].id === 'desc') {
  246. items.sort((a, b) => Number(b.id) - Number(a.id));
  247. }
  248. return items;
  249. }),
  250. update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
  251. const target = hairTemplateMakeTasks.find((item) => item.id === where.id);
  252. if (!target) {
  253. throw new Error('task not found');
  254. }
  255. Object.assign(target, data);
  256. return target;
  257. }),
  258. updateMany: jest.fn(async ({
  259. where,
  260. data,
  261. }: {
  262. where: {
  263. templateId?: bigint | { in?: bigint[] };
  264. isDeleted?: number;
  265. };
  266. data: Record<string, unknown>;
  267. }) => {
  268. let items = [...hairTemplateMakeTasks];
  269. if (typeof where.isDeleted === 'number') {
  270. items = items.filter((item) => item.isDeleted === where.isDeleted);
  271. }
  272. if (typeof where.templateId === 'bigint') {
  273. items = items.filter((item) => item.templateId === where.templateId);
  274. } else {
  275. const templateIdFilter = where.templateId;
  276. if (
  277. templateIdFilter &&
  278. typeof templateIdFilter === 'object' &&
  279. 'in' in templateIdFilter &&
  280. Array.isArray(templateIdFilter.in)
  281. ) {
  282. const templateIds = templateIdFilter.in;
  283. items = items.filter((item) =>
  284. templateIds.includes(item.templateId as bigint),
  285. );
  286. }
  287. }
  288. items.forEach((item) => Object.assign(item, data));
  289. return { count: items.length };
  290. }),
  291. deleteMany: jest.fn(async () => ({ count: 0 })),
  292. },
  293. aiHairSimulationTask: {
  294. count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
  295. const typedWhere = where as {
  296. taskStatus?: string;
  297. isDeleted?: number;
  298. templateId?: { in?: string[] };
  299. taskFinishTime?: { gte?: Date; lt?: Date };
  300. };
  301. let items = [...aiHairSimulationTasks];
  302. if (typeof typedWhere.taskStatus === 'string') {
  303. items = items.filter((item) => item.taskStatus === typedWhere.taskStatus);
  304. }
  305. if (typeof typedWhere.isDeleted === 'number') {
  306. items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
  307. }
  308. if (typedWhere.templateId?.in) {
  309. items = items.filter((item) =>
  310. typedWhere.templateId?.in?.includes(String(item.templateId)),
  311. );
  312. }
  313. if (typedWhere.taskFinishTime?.gte instanceof Date) {
  314. items = items.filter(
  315. (item) =>
  316. item.taskFinishTime instanceof Date &&
  317. item.taskFinishTime >= typedWhere.taskFinishTime!.gte!,
  318. );
  319. }
  320. if (typedWhere.taskFinishTime?.lt instanceof Date) {
  321. items = items.filter(
  322. (item) =>
  323. item.taskFinishTime instanceof Date &&
  324. item.taskFinishTime < typedWhere.taskFinishTime!.lt!,
  325. );
  326. }
  327. return items.length;
  328. }),
  329. },
  330. $transaction: jest.fn(async (callback: (tx: {
  331. hairTemplate: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
  332. hairTemplateMakeTask: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
  333. }) => Promise<unknown>) =>
  334. callback({
  335. hairTemplate: {
  336. create: async ({ data }) => {
  337. const created = {
  338. id: nextTemplateId,
  339. createTime: new Date(),
  340. updateTime: new Date(),
  341. isDeleted: 0,
  342. ...data,
  343. };
  344. hairTemplates.push(created);
  345. nextTemplateId += 1n;
  346. return created;
  347. },
  348. },
  349. hairTemplateMakeTask: {
  350. create: async ({ data }) => {
  351. const created = {
  352. id: nextTaskId,
  353. createTime: new Date(),
  354. updateTime: new Date(),
  355. isDeleted: 0,
  356. ...data,
  357. };
  358. hairTemplateMakeTasks.push(created);
  359. nextTaskId += 1n;
  360. return created;
  361. },
  362. },
  363. })),
  364. };
  365. async function signToken() {
  366. return jwtService.signAsync({
  367. sub: 1,
  368. stylistId: '1',
  369. email: 'stylist@example.com',
  370. username: 'Ogawa',
  371. });
  372. }
  373. beforeAll(async () => {
  374. const { AppModule } = await import('../src/app.module');
  375. const moduleRef = await Test.createTestingModule({
  376. imports: [AppModule],
  377. })
  378. .overrideProvider(TemplateAiClient)
  379. .useValue({ executeTemplate, getResult })
  380. .overrideProvider(TemplateTaskFiles)
  381. .useValue(templateTaskFiles)
  382. .overrideProvider(PrismaService)
  383. .useValue(prisma)
  384. .compile();
  385. app = moduleRef.createNestApplication();
  386. configureApp(app);
  387. await app.init();
  388. jwtService = app.get(JwtService);
  389. });
  390. beforeEach(() => {
  391. executeTemplate.mockReset();
  392. getResult.mockReset();
  393. templateTaskFiles.downloadToTempFile.mockReset();
  394. templateTaskFiles.prepareStep2Bundle.mockReset();
  395. templateTaskFiles.cleanup.mockReset();
  396. systemConfigs.length = 0;
  397. hairTemplates.length = 0;
  398. hairTemplateMakeTasks.length = 0;
  399. aiHairSimulationTasks.length = 0;
  400. nextSystemConfigId = 1n;
  401. nextTemplateId = 101n;
  402. nextTaskId = 88n;
  403. });
  404. afterAll(async () => {
  405. await app.close();
  406. });
  407. it('/api/v1/templates/assets (GET) returns grouped asset options', async () => {
  408. const token = await signToken();
  409. const uniqueSuffix = `${Date.now()}-assets`;
  410. await prisma.systemConfig.createMany({
  411. data: [
  412. {
  413. configKey: `e2e-template-model-${uniqueSuffix}`,
  414. configName: '模特',
  415. configGroup: 'ai-model',
  416. configValue: 'https://cdn.example.com/model.jpg',
  417. valueType: 'STRING',
  418. sort: 1,
  419. },
  420. {
  421. configKey: `e2e-template-clothes-${uniqueSuffix}`,
  422. configName: '服装',
  423. configGroup: 'clothing',
  424. configValue: 'https://cdn.example.com/clothes.jpg',
  425. valueType: 'STRING',
  426. sort: 2,
  427. },
  428. {
  429. configKey: `e2e-template-background-${uniqueSuffix}`,
  430. configName: '背景',
  431. configGroup: 'background',
  432. configValue: 'https://cdn.example.com/background.jpg',
  433. valueType: 'STRING',
  434. sort: 3,
  435. },
  436. ] as never,
  437. });
  438. const response = await request(app.getHttpServer())
  439. .get('/api/v1/templates/assets')
  440. .set('Authorization', `Bearer ${token}`)
  441. .expect(200);
  442. expect(response.body.data.models).toEqual(
  443. expect.arrayContaining([
  444. expect.objectContaining({
  445. name: '模特',
  446. url: 'https://cdn.example.com/model.jpg',
  447. }),
  448. ]),
  449. );
  450. expect(response.body.data.clothes).toEqual(
  451. expect.arrayContaining([
  452. expect.objectContaining({
  453. name: '服装',
  454. url: 'https://cdn.example.com/clothes.jpg',
  455. }),
  456. ]),
  457. );
  458. expect(response.body.data.backgrounds).toEqual(
  459. expect.arrayContaining([
  460. expect.objectContaining({
  461. name: '背景',
  462. url: 'https://cdn.example.com/background.jpg',
  463. }),
  464. ]),
  465. );
  466. });
  467. it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => {
  468. const token = await signToken();
  469. const uniqueSuffix = `${Date.now()}-create`;
  470. await prisma.systemConfig.create({
  471. data: {
  472. configKey: `e2e-template-step1-${uniqueSuffix}`,
  473. configName: 'step1',
  474. configGroup: 'template-step1',
  475. configValue: 'https://cdn.example.com/template-step1.json',
  476. valueType: 'STRING',
  477. sort: 1,
  478. } as never,
  479. });
  480. executeTemplate.mockResolvedValueOnce({
  481. taskId: `e2e-template-step1-task-${uniqueSuffix}`,
  482. });
  483. const response = await request(app.getHttpServer())
  484. .post('/api/v1/templates/make-tasks')
  485. .set('Authorization', `Bearer ${token}`)
  486. .send({
  487. templateName: `e2e-template-${uniqueSuffix}`,
  488. category: 'LONG',
  489. status: 'DRAFT',
  490. description: 'e2e description',
  491. tags: ['首次建议', '层次'],
  492. refHairUrl: 'https://cdn.example.com/ref.jpg',
  493. modelUrl: 'https://cdn.example.com/model.jpg',
  494. clothesUrl: 'https://cdn.example.com/clothes.jpg',
  495. backgroundUrl: 'https://cdn.example.com/background.jpg',
  496. })
  497. .expect(201);
  498. expect(response.body.data).toEqual({
  499. templateId: expect.any(String),
  500. taskId: expect.any(String),
  501. totalTaskId: expect.stringContaining('tmpl_'),
  502. taskStatus: 'PROCESSING',
  503. });
  504. expect(executeTemplate).toHaveBeenCalledWith(
  505. 'https://cdn.example.com/template-step1.json',
  506. {
  507. images: ['https://cdn.example.com/ref.jpg'],
  508. },
  509. );
  510. });
  511. it('/api/v1/templates/:templateId (GET) returns template basic info', async () => {
  512. const token = await signToken();
  513. hairTemplates.push({
  514. id: 43n,
  515. templateName: '柔和层次长发',
  516. coverUrl: 'https://cdn.example.com/cover.jpg',
  517. templateUrl: 'https://cdn.example.com/template.zip',
  518. status: 'DRAFT',
  519. category: 'LONG',
  520. tags: '层次,首次建议',
  521. description: '适合轻盈感修饰',
  522. hairstylistId: 1n,
  523. isDeleted: 0,
  524. });
  525. const response = await request(app.getHttpServer())
  526. .get('/api/v1/templates/43')
  527. .set('Authorization', `Bearer ${token}`)
  528. .expect(200);
  529. expect(response.body.data).toEqual({
  530. id: '43',
  531. templateName: '柔和层次长发',
  532. status: 'DRAFT',
  533. category: 'LONG',
  534. tags: ['层次', '首次建议'],
  535. description: '适合轻盈感修饰',
  536. coverUrl: 'https://cdn.example.com/cover.jpg',
  537. templateUrl: 'https://cdn.example.com/template.zip',
  538. });
  539. });
  540. it('/api/v1/templates/:templateId (PATCH) updates only template basic info', async () => {
  541. const token = await signToken();
  542. hairTemplates.push({
  543. id: 43n,
  544. templateName: '旧模板名',
  545. coverUrl: 'https://cdn.example.com/cover.jpg',
  546. templateUrl: 'https://cdn.example.com/template.zip',
  547. status: 'DRAFT',
  548. category: 'LONG',
  549. tags: '旧标签',
  550. description: '旧描述',
  551. modelUrl: 'https://cdn.example.com/model.jpg',
  552. clothesUrl: 'https://cdn.example.com/clothes.jpg',
  553. backgroundUrl: 'https://cdn.example.com/background.jpg',
  554. refHairUrl: 'https://cdn.example.com/ref.jpg',
  555. hairstylistId: 1n,
  556. isDeleted: 0,
  557. });
  558. const response = await request(app.getHttpServer())
  559. .patch('/api/v1/templates/43')
  560. .set('Authorization', `Bearer ${token}`)
  561. .send({
  562. templateName: '空气感中长发',
  563. status: 'PUBLIC',
  564. category: 'MEDIUM',
  565. tags: ['空气感', '层次'],
  566. description: '新描述',
  567. })
  568. .expect(200);
  569. expect(response.body.data).toEqual({
  570. id: '43',
  571. templateName: '空气感中长发',
  572. status: 'PUBLIC',
  573. category: 'MEDIUM',
  574. tags: ['空气感', '层次'],
  575. description: '新描述',
  576. coverUrl: 'https://cdn.example.com/cover.jpg',
  577. templateUrl: 'https://cdn.example.com/template.zip',
  578. });
  579. expect(hairTemplates[0]).toEqual(
  580. expect.objectContaining({
  581. templateName: '空气感中长发',
  582. status: 'PUBLIC',
  583. category: 'MEDIUM',
  584. tags: '空气感,层次',
  585. description: '新描述',
  586. modelUrl: 'https://cdn.example.com/model.jpg',
  587. clothesUrl: 'https://cdn.example.com/clothes.jpg',
  588. backgroundUrl: 'https://cdn.example.com/background.jpg',
  589. refHairUrl: 'https://cdn.example.com/ref.jpg',
  590. }),
  591. );
  592. });
  593. it('/api/v1/templates (GET) filters and paginates stylist templates', async () => {
  594. const token = await signToken();
  595. hairTemplates.push(
  596. {
  597. id: 101n,
  598. templateName: '空气感中长发',
  599. category: 'MEDIUM',
  600. status: 'PUBLIC',
  601. description: '空气感提案',
  602. tags: '空气感,脸周',
  603. coverUrl: 'https://cdn.example.com/101.jpg',
  604. templateUrl: 'https://cdn.example.com/101.zip',
  605. usedCount: 5,
  606. hairstylistId: 1n,
  607. isDeleted: 0,
  608. },
  609. {
  610. id: 102n,
  611. templateName: '厚重短发',
  612. category: 'SHORT',
  613. status: 'DRAFT',
  614. description: '短发提案',
  615. tags: '短发',
  616. coverUrl: 'https://cdn.example.com/102.jpg',
  617. templateUrl: 'https://cdn.example.com/102.zip',
  618. usedCount: 1,
  619. hairstylistId: 1n,
  620. isDeleted: 0,
  621. },
  622. {
  623. id: 103n,
  624. templateName: '其他美发师模板',
  625. category: 'MEDIUM',
  626. status: 'PUBLIC',
  627. description: '不应出现',
  628. tags: '空气感',
  629. coverUrl: 'https://cdn.example.com/103.jpg',
  630. templateUrl: 'https://cdn.example.com/103.zip',
  631. usedCount: 3,
  632. hairstylistId: 2n,
  633. isDeleted: 0,
  634. },
  635. );
  636. hairTemplateMakeTasks.push({
  637. id: 88n,
  638. templateId: 101n,
  639. taskStatus: 'STEP2_SUCCESS',
  640. isDeleted: 0,
  641. });
  642. const response = await request(app.getHttpServer())
  643. .get('/api/v1/templates?q=空气感&category=MEDIUM&status=PUBLIC&page=1&pageSize=1')
  644. .set('Authorization', `Bearer ${token}`)
  645. .expect(200);
  646. expect(response.body.data).toEqual({
  647. items: [
  648. {
  649. id: '101',
  650. name: '空气感中长发',
  651. category: 'MEDIUM',
  652. status: 'PUBLIC',
  653. description: '空气感提案',
  654. tags: ['空气感', '脸周'],
  655. image: 'https://cdn.example.com/101.jpg',
  656. makeTaskId: '88',
  657. makeTaskStatus: 'STEP2_SUCCESS',
  658. usedCount: 5,
  659. },
  660. ],
  661. total: 1,
  662. page: 1,
  663. pageSize: 1,
  664. });
  665. });
  666. it('/api/v1/templates/summary (GET) returns real template dashboard summary', async () => {
  667. const token = await signToken();
  668. hairTemplates.push(
  669. {
  670. id: 101n,
  671. templateName: '空气感中长发',
  672. category: 'MEDIUM',
  673. status: 'PUBLIC',
  674. description: '空气感提案',
  675. tags: '空气感,脸周',
  676. coverUrl: 'https://cdn.example.com/101.jpg',
  677. templateUrl: 'https://cdn.example.com/101.zip',
  678. usedCount: 5,
  679. hairstylistId: 1n,
  680. isDeleted: 0,
  681. },
  682. {
  683. id: 102n,
  684. templateName: '厚重短发',
  685. category: 'SHORT',
  686. status: 'DRAFT',
  687. description: '短发提案',
  688. tags: '短发',
  689. coverUrl: 'https://cdn.example.com/102.jpg',
  690. templateUrl: 'https://cdn.example.com/102.zip',
  691. usedCount: 1,
  692. hairstylistId: 1n,
  693. isDeleted: 0,
  694. },
  695. );
  696. aiHairSimulationTasks.push(
  697. {
  698. id: 501n,
  699. templateId: '101',
  700. taskStatus: 'SUCCESSES',
  701. taskFinishTime: new Date(),
  702. isDeleted: 0,
  703. },
  704. {
  705. id: 502n,
  706. templateId: '999',
  707. taskStatus: 'SUCCESSES',
  708. taskFinishTime: new Date(),
  709. isDeleted: 0,
  710. },
  711. );
  712. const response = await request(app.getHttpServer())
  713. .get('/api/v1/templates/summary')
  714. .set('Authorization', `Bearer ${token}`)
  715. .expect(200);
  716. expect(response.body.data).toEqual({
  717. total: 2,
  718. published: 1,
  719. weeklyUsed: 1,
  720. draft: 1,
  721. });
  722. });
  723. it('/api/v1/templates/:templateId (DELETE) soft deletes owned template', async () => {
  724. const token = await signToken();
  725. hairTemplates.push({
  726. id: 101n,
  727. templateName: '空气感中长发',
  728. category: 'MEDIUM',
  729. status: 'PUBLIC',
  730. description: '空气感提案',
  731. tags: '空气感,脸周',
  732. coverUrl: 'https://cdn.example.com/101.jpg',
  733. templateUrl: 'https://cdn.example.com/101.zip',
  734. hairstylistId: 1n,
  735. isDeleted: 0,
  736. });
  737. hairTemplateMakeTasks.push({
  738. id: 88n,
  739. templateId: 101n,
  740. taskStatus: 'STEP2_SUCCESS',
  741. isDeleted: 0,
  742. });
  743. const response = await request(app.getHttpServer())
  744. .delete('/api/v1/templates/101')
  745. .set('Authorization', `Bearer ${token}`)
  746. .expect(200);
  747. expect(response.body.data).toEqual({ success: true });
  748. expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
  749. expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
  750. });
  751. it('/api/v1/templates/bulk-delete (POST) soft deletes current stylist templates only', async () => {
  752. const token = await signToken();
  753. hairTemplates.push(
  754. {
  755. id: 101n,
  756. templateName: '空气感中长发',
  757. category: 'MEDIUM',
  758. status: 'PUBLIC',
  759. description: '空气感提案',
  760. tags: '空气感,脸周',
  761. coverUrl: 'https://cdn.example.com/101.jpg',
  762. templateUrl: 'https://cdn.example.com/101.zip',
  763. hairstylistId: 1n,
  764. isDeleted: 0,
  765. },
  766. {
  767. id: 102n,
  768. templateName: '厚重短发',
  769. category: 'SHORT',
  770. status: 'DRAFT',
  771. description: '短发提案',
  772. tags: '短发',
  773. coverUrl: 'https://cdn.example.com/102.jpg',
  774. templateUrl: 'https://cdn.example.com/102.zip',
  775. hairstylistId: 1n,
  776. isDeleted: 0,
  777. },
  778. {
  779. id: 103n,
  780. templateName: '其他美发师模板',
  781. category: 'MEDIUM',
  782. status: 'PUBLIC',
  783. description: '不应删除',
  784. tags: '空气感',
  785. coverUrl: 'https://cdn.example.com/103.jpg',
  786. templateUrl: 'https://cdn.example.com/103.zip',
  787. hairstylistId: 2n,
  788. isDeleted: 0,
  789. },
  790. );
  791. hairTemplateMakeTasks.push(
  792. {
  793. id: 88n,
  794. templateId: 101n,
  795. taskStatus: 'STEP2_SUCCESS',
  796. isDeleted: 0,
  797. },
  798. {
  799. id: 89n,
  800. templateId: 102n,
  801. taskStatus: 'PROCESSING',
  802. isDeleted: 0,
  803. },
  804. {
  805. id: 90n,
  806. templateId: 103n,
  807. taskStatus: 'STEP2_SUCCESS',
  808. isDeleted: 0,
  809. },
  810. );
  811. const response = await request(app.getHttpServer())
  812. .post('/api/v1/templates/bulk-delete')
  813. .set('Authorization', `Bearer ${token}`)
  814. .send({
  815. ids: ['101', '102', '103'],
  816. })
  817. .expect(201);
  818. expect(response.body.data).toEqual({ deletedCount: 2 });
  819. expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
  820. expect(hairTemplates.find((item) => item.id === 102n)?.isDeleted).toBe(1);
  821. expect(hairTemplates.find((item) => item.id === 103n)?.isDeleted).toBe(0);
  822. expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
  823. expect(hairTemplateMakeTasks.find((item) => item.id === 89n)?.isDeleted).toBe(1);
  824. expect(hairTemplateMakeTasks.find((item) => item.id === 90n)?.isDeleted).toBe(0);
  825. });
  826. it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 success and returns detail', async () => {
  827. const token = await signToken();
  828. hairTemplates.push({
  829. id: 101n,
  830. templateName: '柔和层次长发',
  831. coverUrl: '',
  832. templateUrl: '',
  833. status: 'DRAFT',
  834. hairstylistId: 1n,
  835. isDeleted: 0,
  836. });
  837. hairTemplateMakeTasks.push({
  838. id: 88n,
  839. totalTaskId: 'tmpl_123',
  840. taskStatus: 'PROCESSING',
  841. templateId: 101n,
  842. step1TaskId: 'step1-task-1',
  843. step2TaskId: '',
  844. refHairUrl: 'https://cdn.example.com/ref.jpg',
  845. modelUrl: 'https://cdn.example.com/model.jpg',
  846. clothesUrl: 'https://cdn.example.com/clothes.jpg',
  847. backgroundUrl: 'https://cdn.example.com/background.jpg',
  848. step1ResultImg: '',
  849. step2ResultImg: '',
  850. step1Resource: JSON.stringify({
  851. templateUrl: 'https://cdn.example.com/template-step1.json',
  852. }),
  853. step2Resource: JSON.stringify({}),
  854. createTime: new Date('2026-06-28T12:00:00.000Z'),
  855. updateTime: new Date(Date.now() - 20_000),
  856. isDeleted: 0,
  857. });
  858. getResult.mockResolvedValueOnce({
  859. status: 'successes',
  860. message: '',
  861. images: ['https://cdn.example.com/step1-result.jpg'],
  862. });
  863. templateTaskFiles.downloadToTempFile.mockResolvedValue(
  864. '/tmp/template-make/tmpl_123/hairstyle.jpg',
  865. );
  866. const response = await request(app.getHttpServer())
  867. .get('/api/v1/templates/make-tasks/88')
  868. .set('Authorization', `Bearer ${token}`)
  869. .expect(200);
  870. expect(getResult).toHaveBeenCalledWith('step1-task-1');
  871. expect(response.body.data).toEqual(
  872. expect.objectContaining({
  873. id: '88',
  874. templateId: '101',
  875. taskStatus: 'STEP1_SUCCESS',
  876. step1: expect.objectContaining({
  877. taskId: 'step1-task-1',
  878. status: 'STEP1_SUCCESS',
  879. resultImage: 'https://cdn.example.com/step1-result.jpg',
  880. }),
  881. }),
  882. );
  883. });
  884. });