app.e2e-spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. })
  140. .expect(201);
  141. expect(response.body.ok).toBe(true);
  142. expect(response.body.data.name).toBe(customerName);
  143. expect(response.body.data.namePronunciation).toBe('さとう みさき');
  144. expect(response.body.data.avatar).toBe(
  145. 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
  146. );
  147. });
  148. it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => {
  149. const token = await signToken();
  150. const uniqueSuffix = `${Date.now()}-list`;
  151. const customerName = `e2e-l-${uniqueSuffix}`;
  152. await prisma.customer.create({
  153. data: {
  154. hairstylistId: BigInt(1),
  155. name: customerName,
  156. namePronunciation: '',
  157. phone: `090-${String(Date.now()).slice(-4)}-1111`,
  158. email: '',
  159. avatar:
  160. 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
  161. expectContact: 'LINE',
  162. hairLength: 'MEDIUM',
  163. hairTexture: 'NORMAL',
  164. planDate: new Date(),
  165. nextFollowDate: new Date(),
  166. } as never,
  167. });
  168. const response = await request(app.getHttpServer())
  169. .get(`/api/v1/customers?page=1&pageSize=20&q=e2e-l-&visitStatus=TODAY&hairLength=MEDIUM`)
  170. .set('Authorization', `Bearer ${token}`)
  171. .expect(200);
  172. expect(response.body.ok).toBe(true);
  173. expect(response.body.data).toEqual({
  174. items: expect.any(Array),
  175. total: expect.any(Number),
  176. page: 1,
  177. pageSize: 20,
  178. });
  179. });
  180. it('/api/v1/customers/summary (GET) returns customer summary', async () => {
  181. const token = await signToken();
  182. const uniqueSuffix = `${Date.now()}-summary`;
  183. const customerName = `e2e-s-${uniqueSuffix}`;
  184. const customer = await prisma.customer.create({
  185. data: {
  186. hairstylistId: BigInt(1),
  187. name: customerName,
  188. namePronunciation: '',
  189. phone: `090-${String(Date.now()).slice(-4)}-2222`,
  190. email: '',
  191. avatar:
  192. 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
  193. expectContact: 'LINE',
  194. planDate: new Date(),
  195. nextFollowDate: new Date(),
  196. } as never,
  197. });
  198. await prisma.aiHairSimulation.create({
  199. data: {
  200. customerId: customer.id,
  201. hairstylistId: BigInt(1),
  202. originImage: (customer as unknown as { avatar: string }).avatar,
  203. simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`,
  204. },
  205. });
  206. const response = await request(app.getHttpServer())
  207. .get('/api/v1/customers/summary')
  208. .set('Authorization', `Bearer ${token}`)
  209. .expect(200);
  210. expect(response.body.ok).toBe(true);
  211. expect(response.body.data).toEqual({
  212. total: expect.any(Number),
  213. today: expect.any(Number),
  214. followUp: expect.any(Number),
  215. simulated: expect.any(Number),
  216. });
  217. });
  218. it('/api/v1/customers/:id (GET) returns customer detail', async () => {
  219. const token = await signToken();
  220. const uniqueSuffix = Date.now();
  221. const customerName = `e2e-d-${uniqueSuffix}`;
  222. const created = await request(app.getHttpServer())
  223. .post('/api/v1/customers')
  224. .set('Authorization', `Bearer ${token}`)
  225. .send({
  226. name: customerName,
  227. phone: `091-${String(uniqueSuffix).slice(-4)}-3333`,
  228. recommendVisitDate: '2026-07-22',
  229. nextFollowDate: '2026-07-15',
  230. })
  231. .expect(201);
  232. const detail = await request(app.getHttpServer())
  233. .get(`/api/v1/customers/${created.body.data.id}`)
  234. .set('Authorization', `Bearer ${token}`)
  235. .expect(200);
  236. expect(detail.body.ok).toBe(true);
  237. expect(detail.body.data.name).toBe(customerName);
  238. expect(detail.body.data.visitFrequencyLabel).toBe('未记录');
  239. });
  240. it('/api/v1/customers/:id (PATCH) updates customer detail', async () => {
  241. const token = await signToken();
  242. const uniqueSuffix = Date.now();
  243. const customerName = `e2e-u-${uniqueSuffix}`;
  244. const created = await request(app.getHttpServer())
  245. .post('/api/v1/customers')
  246. .set('Authorization', `Bearer ${token}`)
  247. .send({
  248. name: customerName,
  249. phone: `092-${String(uniqueSuffix).slice(-4)}-4444`,
  250. })
  251. .expect(201);
  252. const updated = await request(app.getHttpServer())
  253. .patch(`/api/v1/customers/${created.body.data.id}`)
  254. .set('Authorization', `Bearer ${token}`)
  255. .send({
  256. name: `${customerName}-updated`,
  257. phone: `093-${String(uniqueSuffix).slice(-4)}-5555`,
  258. hairLength: 'LONG',
  259. hairTexture: 'CURLY',
  260. })
  261. .expect(200);
  262. expect(updated.body.ok).toBe(true);
  263. expect(updated.body.data.name).toBe(`${customerName}-updated`);
  264. expect(updated.body.data.phone).toBe(`093-${String(uniqueSuffix).slice(-4)}-5555`);
  265. expect(updated.body.data.hairLength).toBe('LONG');
  266. });
  267. it('/api/v1/templates (GET) returns an empty list envelope', async () => {
  268. const response = await request(app.getHttpServer())
  269. .get('/api/v1/templates')
  270. .expect(200);
  271. expect(response.body).toEqual({
  272. ok: true,
  273. data: {
  274. items: [],
  275. total: 0,
  276. },
  277. error: null,
  278. });
  279. });
  280. it('/api/v1/simulations (POST) validates create payload', async () => {
  281. await request(app.getHttpServer())
  282. .post('/api/v1/simulations')
  283. .send({})
  284. .expect(400);
  285. });
  286. it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => {
  287. const response = await request(app.getHttpServer())
  288. .post('/api/v1/storage/upload')
  289. .field('type', 'template')
  290. .attach('file', Buffer.from('hello'), {
  291. filename: 'look.png',
  292. contentType: 'image/png',
  293. })
  294. .expect(201);
  295. expect(upload).toHaveBeenCalledWith(
  296. expect.objectContaining({
  297. objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
  298. contentType: 'image/png',
  299. }),
  300. );
  301. expect(response.body.ok).toBe(true);
  302. expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
  303. expect(response.body.data.url).toBe(
  304. `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
  305. );
  306. });
  307. it('/api/v1/storage/upload (POST) rejects requests without a file', async () => {
  308. const response = await request(app.getHttpServer())
  309. .post('/api/v1/storage/upload')
  310. .field('type', 'template')
  311. .expect(400);
  312. expect(response.body).toEqual({
  313. ok: false,
  314. data: null,
  315. error: {
  316. code: 'HTTP_400',
  317. message: '请选择要上传的文件',
  318. },
  319. });
  320. });
  321. it('/api/v1/storage/upload (POST) validates type', async () => {
  322. const response = await request(app.getHttpServer())
  323. .post('/api/v1/storage/upload')
  324. .field('type', '../template')
  325. .attach('file', Buffer.from('hello'), {
  326. filename: 'look.png',
  327. contentType: 'image/png',
  328. })
  329. .expect(400);
  330. expect(response.body).toEqual({
  331. ok: false,
  332. data: null,
  333. error: {
  334. code: 'HTTP_400',
  335. message: '上传类型不合法',
  336. },
  337. });
  338. });
  339. });