import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { configureApp } from '../src/app.setup'; import { PrismaService } from '../src/prisma/prisma.service'; import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver'; import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants'; const { AppModule } = require('../src/app.module'); describe('App (e2e)', () => { let app: INestApplication; let jwtService: JwtService; let prisma: PrismaService; const upload = jest.fn(); async function signToken() { return jwtService.signAsync({ sub: 1, stylistId: '1', email: 'stylist@example.com', username: 'Ogawa', }); } beforeAll(async () => { upload.mockReset(); upload.mockResolvedValue(undefined); const moduleRef = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(STORAGE_DRIVER) .useValue({ upload }) .overrideProvider(GcsStorageDriver) .useValue({ upload }) .compile(); app = moduleRef.createNestApplication(); configureApp(app); await app.init(); jwtService = app.get(JwtService); prisma = app.get(PrismaService); }); afterAll(async () => { await prisma.aiHairSimulation.deleteMany({ where: { simulateRemark: { startsWith: 'app-e2e-customer-summary-', }, }, }); await prisma.customer.deleteMany({ where: { name: { startsWith: 'e2e-', }, }, }); await app.close(); }); it('/api/v1/health (GET)', async () => { const response = await request(app.getHttpServer()).get('/api/v1/health').expect(200); expect(response.body).toEqual({ ok: true, data: { status: 'ok', timestamp: expect.any(String), services: { database: expect.any(String), redis: expect.any(String), }, }, error: null, }); }); it('/api/v1/unknown (GET)', async () => { const response = await request(app.getHttpServer()).get('/api/v1/unknown').expect(404); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'HTTP_404', message: expect.any(String), }, }); }); it('/api/v1/auth/stylist/login (POST) validates request body', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/auth/stylist/login') .send({ email: 'bad-email', password: '' }) .expect(400); expect(response.body.ok).toBe(false); expect(response.body.data).toBeNull(); expect(response.body.error.code).toBe('HTTP_400'); }); it('/api/v1/auth/stylist/login (POST) returns invalid credentials when no matching account exists', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/auth/stylist/login') .send({ email: 'missing-app-e2e@example.com', password: 'Passw0rd!' }) .expect(401); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'AUTH_INVALID_CREDENTIALS', message: '邮箱或密码错误', }, }); }); it('/api/v1/customers (GET) requires authentication', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/customers') .expect(401); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效', }, }); }); it('/api/v1/customers (POST) creates a customer using the redesigned fields', async () => { const token = await signToken(); const uniqueSuffix = Date.now(); const customerName = `e2e-c-${uniqueSuffix}`; const response = await request(app.getHttpServer()) .post('/api/v1/customers') .set('Authorization', `Bearer ${token}`) .send({ name: customerName, phone: `090-${String(uniqueSuffix).slice(-4)}-0000`, namePronunciation: 'さとう みさき', expectContact: 'LINE', hairLength: 'MEDIUM', hairTexture: 'NORMAL', planDate: '2026-06-28', nextFollowDate: '2026-07-12', visitPurpose: 'CONSULT_SIM', trouble: '发量少', consultContent: '希望有轻盈感', expectLook: '自然层次感', followContent: '确认褪色情况并提醒护理', }) .expect(201); expect(response.body.ok).toBe(true); expect(response.body.data.name).toBe(customerName); expect(response.body.data.namePronunciation).toBe('さとう みさき'); expect(response.body.data.avatar).toBe( 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', ); expect(response.body.data.followContent).toBe('确认褪色情况并提醒护理'); }); it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => { const token = await signToken(); const uniqueSuffix = `${Date.now()}-list`; const customerName = `e2e-l-${uniqueSuffix}`; await prisma.customer.create({ data: { hairstylistId: BigInt(1), name: customerName, namePronunciation: '', phone: `090-${String(Date.now()).slice(-4)}-1111`, email: '', avatar: 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', expectContact: 'LINE', hairLength: 'MEDIUM', hairTexture: 'NORMAL', planDate: new Date(), nextFollowDate: new Date(), } as never, }); const response = await request(app.getHttpServer()) .get(`/api/v1/customers?page=1&pageSize=20&q=e2e-l-&visitStatus=TODAY&hairLength=MEDIUM`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.ok).toBe(true); expect(response.body.data).toEqual({ items: expect.any(Array), total: expect.any(Number), page: 1, pageSize: 20, }); }); it('/api/v1/customers/summary (GET) returns customer summary', async () => { const token = await signToken(); const uniqueSuffix = `${Date.now()}-summary`; const customerName = `e2e-s-${uniqueSuffix}`; const customer = await prisma.customer.create({ data: { hairstylistId: BigInt(1), name: customerName, namePronunciation: '', phone: `090-${String(Date.now()).slice(-4)}-2222`, email: '', avatar: 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', expectContact: 'LINE', planDate: new Date(), nextFollowDate: new Date(), } as never, }); await prisma.aiHairSimulation.create({ data: { customerId: customer.id, hairstylistId: BigInt(1), originImage: (customer as unknown as { avatar: string }).avatar, simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`, }, }); const response = await request(app.getHttpServer()) .get('/api/v1/customers/summary') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.ok).toBe(true); expect(response.body.data).toEqual({ total: expect.any(Number), today: expect.any(Number), followUp: expect.any(Number), simulated: expect.any(Number), }); }); it('/api/v1/customers/:id (GET) returns customer detail', async () => { const token = await signToken(); const uniqueSuffix = Date.now(); const customerName = `e2e-d-${uniqueSuffix}`; const created = await request(app.getHttpServer()) .post('/api/v1/customers') .set('Authorization', `Bearer ${token}`) .send({ name: customerName, phone: `091-${String(uniqueSuffix).slice(-4)}-3333`, recommendVisitDate: '2026-07-22', nextFollowDate: '2026-07-15', followContent: '请提前确认发色退色情况', }) .expect(201); const detail = await request(app.getHttpServer()) .get(`/api/v1/customers/${created.body.data.id}`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(detail.body.ok).toBe(true); expect(detail.body.data.name).toBe(customerName); expect(detail.body.data.visitFrequencyLabel).toBe('未记录'); expect(detail.body.data.followContent).toBe('请提前确认发色退色情况'); }); it('/api/v1/customers/:id (PATCH) updates customer detail', async () => { const token = await signToken(); const uniqueSuffix = Date.now(); const customerName = `e2e-u-${uniqueSuffix}`; const created = await request(app.getHttpServer()) .post('/api/v1/customers') .set('Authorization', `Bearer ${token}`) .send({ name: customerName, phone: `092-${String(uniqueSuffix).slice(-4)}-4444`, }) .expect(201); const updated = await request(app.getHttpServer()) .patch(`/api/v1/customers/${created.body.data.id}`) .set('Authorization', `Bearer ${token}`) .send({ name: `${customerName}-updated`, phone: `093-${String(uniqueSuffix).slice(-4)}-5555`, hairLength: 'LONG', hairTexture: 'CURLY', followContent: '更新后的跟进内容', }) .expect(200); expect(updated.body.ok).toBe(true); expect(updated.body.data.name).toBe(`${customerName}-updated`); expect(updated.body.data.phone).toBe(`093-${String(uniqueSuffix).slice(-4)}-5555`); expect(updated.body.data.hairLength).toBe('LONG'); expect(updated.body.data.followContent).toBe('更新后的跟进内容'); }); it('/api/v1/templates (GET) returns an empty list envelope', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/templates') .expect(200); expect(response.body).toEqual({ ok: true, data: { items: [], total: 0, }, error: null, }); }); it('/api/v1/simulations (POST) validates create payload', async () => { await request(app.getHttpServer()) .post('/api/v1/simulations') .send({}) .expect(400); }); it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/storage/upload') .field('type', 'template') .attach('file', Buffer.from('hello'), { filename: 'look.png', contentType: 'image/png', }) .expect(201); expect(upload).toHaveBeenCalledWith( expect.objectContaining({ objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/), contentType: 'image/png', }), ); expect(response.body.ok).toBe(true); expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/); expect(response.body.data.url).toBe( `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`, ); }); it('/api/v1/storage/upload (POST) rejects requests without a file', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/storage/upload') .field('type', 'template') .expect(400); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'HTTP_400', message: '请选择要上传的文件', }, }); }); it('/api/v1/storage/upload (POST) validates type', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/storage/upload') .field('type', '../template') .attach('file', Buffer.from('hello'), { filename: 'look.png', contentType: 'image/png', }) .expect(400); expect(response.body).toEqual({ ok: false, data: null, error: { code: 'HTTP_400', message: '上传类型不合法', }, }); }); });