app.e2e-spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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 { PrismaService } from '../src/prisma/prisma.service';
  7. import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
  8. import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
  9. const { AppModule } = require('../src/app.module');
  10. describe('App (e2e)', () => {
  11. let app: INestApplication;
  12. let jwtService: JwtService;
  13. let prisma: PrismaService;
  14. const upload = jest.fn();
  15. async function signToken() {
  16. return jwtService.signAsync({
  17. sub: 1,
  18. stylistId: '1',
  19. email: 'stylist@example.com',
  20. username: 'Ogawa',
  21. });
  22. }
  23. beforeAll(async () => {
  24. upload.mockReset();
  25. upload.mockResolvedValue(undefined);
  26. const moduleRef = await Test.createTestingModule({
  27. imports: [AppModule],
  28. })
  29. .overrideProvider(STORAGE_DRIVER)
  30. .useValue({ upload })
  31. .overrideProvider(GcsStorageDriver)
  32. .useValue({ upload })
  33. .compile();
  34. app = moduleRef.createNestApplication();
  35. configureApp(app);
  36. await app.init();
  37. jwtService = app.get(JwtService);
  38. prisma = app.get(PrismaService);
  39. });
  40. afterAll(async () => {
  41. await prisma.aiHairSimulation.deleteMany({
  42. where: {
  43. simulateRemark: {
  44. startsWith: 'app-e2e-customer-summary-',
  45. },
  46. },
  47. });
  48. await prisma.customer.deleteMany({
  49. where: {
  50. name: {
  51. startsWith: 'e2e-',
  52. },
  53. },
  54. });
  55. await app.close();
  56. });
  57. it('/api/v1/health (GET)', async () => {
  58. const response = await request(app.getHttpServer()).get('/api/v1/health').expect(200);
  59. expect(response.body).toEqual({
  60. ok: true,
  61. data: {
  62. status: 'ok',
  63. timestamp: expect.any(String),
  64. services: {
  65. database: expect.any(String),
  66. redis: expect.any(String),
  67. },
  68. },
  69. error: null,
  70. });
  71. });
  72. it('/api/v1/unknown (GET)', async () => {
  73. const response = await request(app.getHttpServer()).get('/api/v1/unknown').expect(404);
  74. expect(response.body).toEqual({
  75. ok: false,
  76. data: null,
  77. error: {
  78. code: 'HTTP_404',
  79. message: expect.any(String),
  80. },
  81. });
  82. });
  83. it('/api/v1/auth/stylist/login (POST) validates request body', async () => {
  84. const response = await request(app.getHttpServer())
  85. .post('/api/v1/auth/stylist/login')
  86. .send({ email: 'bad-email', password: '' })
  87. .expect(400);
  88. expect(response.body.ok).toBe(false);
  89. expect(response.body.data).toBeNull();
  90. expect(response.body.error.code).toBe('HTTP_400');
  91. });
  92. it('/api/v1/auth/stylist/login (POST) returns invalid credentials when no matching account exists', async () => {
  93. const response = await request(app.getHttpServer())
  94. .post('/api/v1/auth/stylist/login')
  95. .send({ email: 'missing-app-e2e@example.com', password: 'Passw0rd!' })
  96. .expect(401);
  97. expect(response.body).toEqual({
  98. ok: false,
  99. data: null,
  100. error: {
  101. code: 'AUTH_INVALID_CREDENTIALS',
  102. message: '邮箱或密码错误',
  103. },
  104. });
  105. });
  106. it('/api/v1/customers (GET) requires authentication', async () => {
  107. const response = await request(app.getHttpServer())
  108. .get('/api/v1/customers')
  109. .expect(401);
  110. expect(response.body).toEqual({
  111. ok: false,
  112. data: null,
  113. error: {
  114. code: 'AUTH_UNAUTHORIZED',
  115. message: '未登录或登录已失效',
  116. },
  117. });
  118. });
  119. it('/api/v1/customers (POST) creates a customer using the redesigned fields', async () => {
  120. const token = await signToken();
  121. const uniqueSuffix = Date.now();
  122. const customerName = `e2e-c-${uniqueSuffix}`;
  123. const response = await request(app.getHttpServer())
  124. .post('/api/v1/customers')
  125. .set('Authorization', `Bearer ${token}`)
  126. .send({
  127. name: customerName,
  128. phone: `090-${String(uniqueSuffix).slice(-4)}-0000`,
  129. namePronunciation: 'さとう みさき',
  130. expectContact: 'LINE',
  131. hairLength: 'MEDIUM',
  132. hairTexture: 'NORMAL',
  133. planDate: '2026-06-28',
  134. nextFollowDate: '2026-07-12',
  135. visitPurpose: 'CONSULT_SIM',
  136. trouble: '发量少',
  137. consultContent: '希望有轻盈感',
  138. expectLook: '自然层次感',
  139. followContent: '确认褪色情况并提醒护理',
  140. })
  141. .expect(201);
  142. expect(response.body.ok).toBe(true);
  143. expect(response.body.data.name).toBe(customerName);
  144. expect(response.body.data.namePronunciation).toBe('さとう みさき');
  145. expect(response.body.data.avatar).toBe(
  146. 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
  147. );
  148. expect(response.body.data.followContent).toBe('确认褪色情况并提醒护理');
  149. });
  150. it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => {
  151. const token = await signToken();
  152. const uniqueSuffix = `${Date.now()}-list`;
  153. const customerName = `e2e-l-${uniqueSuffix}`;
  154. await prisma.customer.create({
  155. data: {
  156. hairstylistId: BigInt(1),
  157. name: customerName,
  158. namePronunciation: '',
  159. phone: `090-${String(Date.now()).slice(-4)}-1111`,
  160. email: '',
  161. avatar:
  162. 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
  163. expectContact: 'LINE',
  164. hairLength: 'MEDIUM',
  165. hairTexture: 'NORMAL',
  166. planDate: new Date(),
  167. nextFollowDate: new Date(),
  168. } as never,
  169. });
  170. const response = await request(app.getHttpServer())
  171. .get(`/api/v1/customers?page=1&pageSize=20&q=e2e-l-&visitStatus=TODAY&hairLength=MEDIUM`)
  172. .set('Authorization', `Bearer ${token}`)
  173. .expect(200);
  174. expect(response.body.ok).toBe(true);
  175. expect(response.body.data).toEqual({
  176. items: expect.any(Array),
  177. total: expect.any(Number),
  178. page: 1,
  179. pageSize: 20,
  180. });
  181. });
  182. it('/api/v1/customers/summary (GET) returns customer summary', async () => {
  183. const token = await signToken();
  184. const uniqueSuffix = `${Date.now()}-summary`;
  185. const customerName = `e2e-s-${uniqueSuffix}`;
  186. const customer = await prisma.customer.create({
  187. data: {
  188. hairstylistId: BigInt(1),
  189. name: customerName,
  190. namePronunciation: '',
  191. phone: `090-${String(Date.now()).slice(-4)}-2222`,
  192. email: '',
  193. avatar:
  194. 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
  195. expectContact: 'LINE',
  196. planDate: new Date(),
  197. nextFollowDate: new Date(),
  198. } as never,
  199. });
  200. await prisma.aiHairSimulation.create({
  201. data: {
  202. customerId: customer.id,
  203. hairstylistId: BigInt(1),
  204. originImage: (customer as unknown as { avatar: string }).avatar,
  205. simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`,
  206. },
  207. });
  208. const response = await request(app.getHttpServer())
  209. .get('/api/v1/customers/summary')
  210. .set('Authorization', `Bearer ${token}`)
  211. .expect(200);
  212. expect(response.body.ok).toBe(true);
  213. expect(response.body.data).toEqual({
  214. total: expect.any(Number),
  215. today: expect.any(Number),
  216. followUp: expect.any(Number),
  217. simulated: expect.any(Number),
  218. });
  219. });
  220. it('/api/v1/customers/:id (GET) returns customer detail', async () => {
  221. const token = await signToken();
  222. const uniqueSuffix = Date.now();
  223. const customerName = `e2e-d-${uniqueSuffix}`;
  224. const created = await request(app.getHttpServer())
  225. .post('/api/v1/customers')
  226. .set('Authorization', `Bearer ${token}`)
  227. .send({
  228. name: customerName,
  229. phone: `091-${String(uniqueSuffix).slice(-4)}-3333`,
  230. recommendVisitDate: '2026-07-22',
  231. nextFollowDate: '2026-07-15',
  232. followContent: '请提前确认发色退色情况',
  233. })
  234. .expect(201);
  235. const detail = await request(app.getHttpServer())
  236. .get(`/api/v1/customers/${created.body.data.id}`)
  237. .set('Authorization', `Bearer ${token}`)
  238. .expect(200);
  239. expect(detail.body.ok).toBe(true);
  240. expect(detail.body.data.name).toBe(customerName);
  241. expect(detail.body.data.visitFrequencyLabel).toBe('未记录');
  242. expect(detail.body.data.followContent).toBe('请提前确认发色退色情况');
  243. });
  244. it('/api/v1/customers/:id (PATCH) updates customer detail', async () => {
  245. const token = await signToken();
  246. const uniqueSuffix = Date.now();
  247. const customerName = `e2e-u-${uniqueSuffix}`;
  248. const created = await request(app.getHttpServer())
  249. .post('/api/v1/customers')
  250. .set('Authorization', `Bearer ${token}`)
  251. .send({
  252. name: customerName,
  253. phone: `092-${String(uniqueSuffix).slice(-4)}-4444`,
  254. })
  255. .expect(201);
  256. const updated = await request(app.getHttpServer())
  257. .patch(`/api/v1/customers/${created.body.data.id}`)
  258. .set('Authorization', `Bearer ${token}`)
  259. .send({
  260. name: `${customerName}-updated`,
  261. phone: `093-${String(uniqueSuffix).slice(-4)}-5555`,
  262. hairLength: 'LONG',
  263. hairTexture: 'CURLY',
  264. followContent: '更新后的跟进内容',
  265. })
  266. .expect(200);
  267. expect(updated.body.ok).toBe(true);
  268. expect(updated.body.data.name).toBe(`${customerName}-updated`);
  269. expect(updated.body.data.phone).toBe(`093-${String(uniqueSuffix).slice(-4)}-5555`);
  270. expect(updated.body.data.hairLength).toBe('LONG');
  271. expect(updated.body.data.followContent).toBe('更新后的跟进内容');
  272. });
  273. it('/api/v1/templates (GET) returns an empty list envelope', async () => {
  274. const response = await request(app.getHttpServer())
  275. .get('/api/v1/templates')
  276. .expect(200);
  277. expect(response.body).toEqual({
  278. ok: true,
  279. data: {
  280. items: [],
  281. total: 0,
  282. },
  283. error: null,
  284. });
  285. });
  286. it('/api/v1/simulations (POST) validates create payload', async () => {
  287. await request(app.getHttpServer())
  288. .post('/api/v1/simulations')
  289. .send({})
  290. .expect(400);
  291. });
  292. it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => {
  293. const response = await request(app.getHttpServer())
  294. .post('/api/v1/storage/upload')
  295. .field('type', 'template')
  296. .attach('file', Buffer.from('hello'), {
  297. filename: 'look.png',
  298. contentType: 'image/png',
  299. })
  300. .expect(201);
  301. expect(upload).toHaveBeenCalledWith(
  302. expect.objectContaining({
  303. objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
  304. contentType: 'image/png',
  305. }),
  306. );
  307. expect(response.body.ok).toBe(true);
  308. expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
  309. expect(response.body.data.url).toBe(
  310. `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
  311. );
  312. });
  313. it('/api/v1/storage/upload (POST) rejects requests without a file', async () => {
  314. const response = await request(app.getHttpServer())
  315. .post('/api/v1/storage/upload')
  316. .field('type', 'template')
  317. .expect(400);
  318. expect(response.body).toEqual({
  319. ok: false,
  320. data: null,
  321. error: {
  322. code: 'HTTP_400',
  323. message: '请选择要上传的文件',
  324. },
  325. });
  326. });
  327. it('/api/v1/storage/upload (POST) validates type', async () => {
  328. const response = await request(app.getHttpServer())
  329. .post('/api/v1/storage/upload')
  330. .field('type', '../template')
  331. .attach('file', Buffer.from('hello'), {
  332. filename: 'look.png',
  333. contentType: 'image/png',
  334. })
  335. .expect(400);
  336. expect(response.body).toEqual({
  337. ok: false,
  338. data: null,
  339. error: {
  340. code: 'HTTP_400',
  341. message: '上传类型不合法',
  342. },
  343. });
  344. });
  345. });