| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- 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: '自然层次感',
- })
- .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',
- );
- });
- 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',
- })
- .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('未记录');
- });
- 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',
- })
- .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');
- });
- 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: '上传类型不合法',
- },
- });
- });
- });
|