import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; 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'; describe('App (e2e)', () => { let app: INestApplication; let jwtService: JwtService; let prisma: PrismaService; const upload = jest.fn(); async function signToken() { return jwtService.signAsync({ sub: 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: 'app-e2e-customer-', }, }, }); 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 with default avatar', async () => { const token = await signToken(); const uniqueSuffix = Date.now(); const response = await request(app.getHttpServer()) .post('/api/v1/customers') .set('Authorization', token) .send({ name: `app-e2e-customer-${uniqueSuffix}`, phone: `090-${String(uniqueSuffix).slice(-4)}-0000`, hairLength: 'Medium', followStatus: 'NEED_FOLLOW_UP', nextVisit: '2026-06-28', }) .expect(201); expect(response.body.ok).toBe(true); expect(response.body.data.name).toBe(`app-e2e-customer-${uniqueSuffix}`); expect(response.body.data.image).toBe( 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', ); }); it('/api/v1/customers (GET) returns paginated customers', async () => { const token = await signToken(); const uniqueSuffix = `${Date.now()}-list`; await prisma.customer.create({ data: { hairstylistId: BigInt(1), name: `app-e2e-customer-${uniqueSuffix}`, kana: '', phone: `090-${String(Date.now()).slice(-4)}-1111`, email: '', avatarUrl: 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', contactType: 'LINE', hairLength: 'MEDIUM', followStatus: 'NEED_FOLLOW_UP', }, }); const response = await request(app.getHttpServer()) .get('/api/v1/customers?page=1&pageSize=20&q=app-e2e-customer-&hairLength=MEDIUM&followStatus=NEED_FOLLOW_UP') .set('Authorization', 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 customer = await prisma.customer.create({ data: { hairstylistId: BigInt(1), name: `app-e2e-customer-${uniqueSuffix}`, kana: '', phone: `090-${String(Date.now()).slice(-4)}-2222`, email: '', avatarUrl: 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', contactType: 'LINE', nextVisitDate: new Date(), followStatus: 'NEED_FOLLOW_UP', }, }); await prisma.aiHairSimulation.create({ data: { customerId: customer.id, hairstylistId: BigInt(1), originImage: customer.avatarUrl, simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`, }, }); const response = await request(app.getHttpServer()) .get('/api/v1/customers/summary') .set('Authorization', 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/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: '上传类型不合法', }, }); }); });