app.e2e-spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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 { AppModule } from '../src/app.module';
  7. import { PrismaService } from '../src/prisma/prisma.service';
  8. import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
  9. import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
  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. templateIds: '[]',
  206. simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`,
  207. },
  208. });
  209. const response = await request(app.getHttpServer())
  210. .get('/api/v1/customers/summary')
  211. .set('Authorization', `Bearer ${token}`)
  212. .expect(200);
  213. expect(response.body.ok).toBe(true);
  214. expect(response.body.data).toEqual({
  215. total: expect.any(Number),
  216. today: expect.any(Number),
  217. followUp: expect.any(Number),
  218. simulated: expect.any(Number),
  219. });
  220. });
  221. it('/api/v1/customers/:id (GET) returns customer detail', async () => {
  222. const token = await signToken();
  223. const uniqueSuffix = Date.now();
  224. const customerName = `e2e-d-${uniqueSuffix}`;
  225. const created = await request(app.getHttpServer())
  226. .post('/api/v1/customers')
  227. .set('Authorization', `Bearer ${token}`)
  228. .send({
  229. name: customerName,
  230. phone: `091-${String(uniqueSuffix).slice(-4)}-3333`,
  231. recommendVisitDate: '2026-07-22',
  232. nextFollowDate: '2026-07-15',
  233. followContent: '请提前确认发色退色情况',
  234. })
  235. .expect(201);
  236. const detail = await request(app.getHttpServer())
  237. .get(`/api/v1/customers/${created.body.data.id}`)
  238. .set('Authorization', `Bearer ${token}`)
  239. .expect(200);
  240. expect(detail.body.ok).toBe(true);
  241. expect(detail.body.data.name).toBe(customerName);
  242. expect(detail.body.data.visitFrequencyLabel).toBe('未记录');
  243. expect(detail.body.data.followContent).toBe('请提前确认发色退色情况');
  244. });
  245. it('/api/v1/customers/:id (PATCH) updates customer detail', async () => {
  246. const token = await signToken();
  247. const uniqueSuffix = Date.now();
  248. const customerName = `e2e-u-${uniqueSuffix}`;
  249. const created = await request(app.getHttpServer())
  250. .post('/api/v1/customers')
  251. .set('Authorization', `Bearer ${token}`)
  252. .send({
  253. name: customerName,
  254. phone: `092-${String(uniqueSuffix).slice(-4)}-4444`,
  255. })
  256. .expect(201);
  257. const updated = await request(app.getHttpServer())
  258. .patch(`/api/v1/customers/${created.body.data.id}`)
  259. .set('Authorization', `Bearer ${token}`)
  260. .send({
  261. name: `${customerName}-updated`,
  262. phone: `093-${String(uniqueSuffix).slice(-4)}-5555`,
  263. hairLength: 'LONG',
  264. hairTexture: 'CURLY',
  265. followContent: '更新后的跟进内容',
  266. })
  267. .expect(200);
  268. expect(updated.body.ok).toBe(true);
  269. expect(updated.body.data.name).toBe(`${customerName}-updated`);
  270. expect(updated.body.data.phone).toBe(`093-${String(uniqueSuffix).slice(-4)}-5555`);
  271. expect(updated.body.data.hairLength).toBe('LONG');
  272. expect(updated.body.data.followContent).toBe('更新后的跟进内容');
  273. });
  274. it('/api/v1/templates (GET) returns an empty list envelope', async () => {
  275. const response = await request(app.getHttpServer())
  276. .get('/api/v1/templates')
  277. .expect(200);
  278. expect(response.body).toEqual({
  279. ok: true,
  280. data: {
  281. items: [],
  282. total: 0,
  283. },
  284. error: null,
  285. });
  286. });
  287. it('/api/v1/simulations (POST) validates create payload', async () => {
  288. const token = await signToken();
  289. await request(app.getHttpServer())
  290. .post('/api/v1/simulations')
  291. .set('Authorization', `Bearer ${token}`)
  292. .send({})
  293. .expect(400);
  294. });
  295. it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => {
  296. const response = await request(app.getHttpServer())
  297. .post('/api/v1/storage/upload')
  298. .field('type', 'template')
  299. .attach('file', Buffer.from('hello'), {
  300. filename: 'look.png',
  301. contentType: 'image/png',
  302. })
  303. .expect(201);
  304. expect(upload).toHaveBeenCalledWith(
  305. expect.objectContaining({
  306. objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
  307. contentType: 'image/png',
  308. }),
  309. );
  310. expect(response.body.ok).toBe(true);
  311. expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
  312. expect(response.body.data.url).toBe(
  313. `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
  314. );
  315. });
  316. it('/api/v1/storage/upload (POST) rejects requests without a file', async () => {
  317. const response = await request(app.getHttpServer())
  318. .post('/api/v1/storage/upload')
  319. .field('type', 'template')
  320. .expect(400);
  321. expect(response.body).toEqual({
  322. ok: false,
  323. data: null,
  324. error: {
  325. code: 'HTTP_400',
  326. message: '请选择要上传的文件',
  327. },
  328. });
  329. });
  330. it('/api/v1/storage/upload (POST) validates type', async () => {
  331. const response = await request(app.getHttpServer())
  332. .post('/api/v1/storage/upload')
  333. .field('type', '../template')
  334. .attach('file', Buffer.from('hello'), {
  335. filename: 'look.png',
  336. contentType: 'image/png',
  337. })
  338. .expect(400);
  339. expect(response.body).toEqual({
  340. ok: false,
  341. data: null,
  342. error: {
  343. code: 'HTTP_400',
  344. message: '上传类型不合法',
  345. },
  346. });
  347. });
  348. });