app.e2e-spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  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/templates (GET) returns an empty list envelope', async () => {
  219. const response = await request(app.getHttpServer())
  220. .get('/api/v1/templates')
  221. .expect(200);
  222. expect(response.body).toEqual({
  223. ok: true,
  224. data: {
  225. items: [],
  226. total: 0,
  227. },
  228. error: null,
  229. });
  230. });
  231. it('/api/v1/simulations (POST) validates create payload', async () => {
  232. await request(app.getHttpServer())
  233. .post('/api/v1/simulations')
  234. .send({})
  235. .expect(400);
  236. });
  237. it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => {
  238. const response = await request(app.getHttpServer())
  239. .post('/api/v1/storage/upload')
  240. .field('type', 'template')
  241. .attach('file', Buffer.from('hello'), {
  242. filename: 'look.png',
  243. contentType: 'image/png',
  244. })
  245. .expect(201);
  246. expect(upload).toHaveBeenCalledWith(
  247. expect.objectContaining({
  248. objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
  249. contentType: 'image/png',
  250. }),
  251. );
  252. expect(response.body.ok).toBe(true);
  253. expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
  254. expect(response.body.data.url).toBe(
  255. `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
  256. );
  257. });
  258. it('/api/v1/storage/upload (POST) rejects requests without a file', async () => {
  259. const response = await request(app.getHttpServer())
  260. .post('/api/v1/storage/upload')
  261. .field('type', 'template')
  262. .expect(400);
  263. expect(response.body).toEqual({
  264. ok: false,
  265. data: null,
  266. error: {
  267. code: 'HTTP_400',
  268. message: '请选择要上传的文件',
  269. },
  270. });
  271. });
  272. it('/api/v1/storage/upload (POST) validates type', async () => {
  273. const response = await request(app.getHttpServer())
  274. .post('/api/v1/storage/upload')
  275. .field('type', '../template')
  276. .attach('file', Buffer.from('hello'), {
  277. filename: 'look.png',
  278. contentType: 'image/png',
  279. })
  280. .expect(400);
  281. expect(response.body).toEqual({
  282. ok: false,
  283. data: null,
  284. error: {
  285. code: 'HTTP_400',
  286. message: '上传类型不合法',
  287. },
  288. });
  289. });
  290. });