iT邦幫忙

2024 iThome 鐵人賽

DAY 16
1
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 18

nestjs 核心架構nestjs 系統設計 - 活動訂票管理系統 - Event Module part 1

  • 分享至 

  • xImage
  •  

nestjs 核心架構nestjs 系統設計 - 活動訂票管理系統 - Event Module part 1

目標

image

今天目標會是先規劃, EventModule 的部份。

概念

這邊所講的 Event 是指舉辦的活動。以下統一用『活動』這個詞彙來說明。EventModule 的部份主要是針對活動相關的處理。主要項目有以下:

  1. 活動建立
  2. 活動管理
  3. 活動查詢

設計規範

  1. 管理者可以建立活動(活動名稱,活動地點,時間)。
  2. 管理者可以修改活動狀態(活動地點,時間,是否已結束)。
  3. 使用者與管理者可以查詢活動狀態。
  4. 管理者可以刪除已關閉的活動。

針對以上規範想出的程式介面

image

建構 eventModule 與內部結構

nest g mo events
nest g s events
nest g controller events

根據規格撰寫驗證

為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫

  1. service
import { Test, TestingModule } from '@nestjs/testing';
import { EventsService } from './events.service';
import { CreateEventDto, GetEventsDto, PageInfoRequestDto } from './dto/event.dto';
import { isUUID } from 'class-validator';
import { EventStore } from './event.store';
import { NotFoundException } from '@nestjs/common';

describe('EventsService', () => {
  let service: EventsService;
  let eventId: string;
  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [EventsService, EventStore],
    }).compile();

    service = module.get<EventsService>(EventsService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
  /**
  given a valid create event dto
  {
    "name": '江蕙演唱會',
    "location": '台北大巨蛋',
    "startDate": new Date(),
    "numberOfDays": 1, 
  }
  ===>
  {
    "id": uuid format string
  }  
   */
  it('should return created uuid', async ()=> {
    const createEventDto = new CreateEventDto();
    createEventDto.name = '江蕙演唱會';
    createEventDto.location = '台北大巨蛋';
    const startDate = new Date(Date.now() + 86400);
    createEventDto.startDate = startDate;
    const result = await service.createEvent(createEventDto);
    expect(result).toHaveProperty('id');
    expect(isUUID(result.id)).toBeTruthy();
    eventId = result.id;
  });
  it('should return result event with given event id', async () => {
    const eventInfo = {
      id: eventId
    };
    const expectName = '江蕙演唱會';
    const expectLocation = '台北大巨蛋';
    const result = await service.getEvent(eventInfo);
    expect(result).toHaveProperty('name', expectName);
    expect(result).toHaveProperty('location', expectLocation);
  });
  /**
  given a valid create event dto
  {
    "location": '台北大巨蛋'
    "startDate": new Date() 
  } 
   */
  it('shoud return result event with given location', async () => {
    // inserted
    const createEventDto = new CreateEventDto();
    createEventDto.name = '周蕙演唱會';
    createEventDto.location = '台北大巨蛋';
    const startDate = new Date(Date.now() + 86400);
    createEventDto.startDate = startDate;
    await service.createEvent(createEventDto);
    const criteria = new GetEventsDto();
    criteria.location = '台北大巨蛋';
    criteria.startDate = new Date();
    const pageInfo = new PageInfoRequestDto();
    const result = await service.getEvents(criteria, pageInfo);
    expect(result).toHaveProperty('pageInfo');
    expect(result).toHaveProperty('events');
    expect((result.events).length).toEqual(2);
  });
  /**
  given a update a event dto and event id
  {
    location: '台北小巨蛋',
    startDate: new Date()
  }
   */
  it('should return updated event', async () => {
    const updateData = {
      location: '台北小巨蛋',
      startDate: new Date()
    };
    await service.updateEvent(eventId, updateData);
    const result = await service.getEvent({id: eventId});
    expect(result.location).toEqual(updateData.location);    
  });
  /**
  given a existed event id
   */
  it('should return deleted event id', async () => {
    const result = await service.deleteEvent(eventId);
    expect(isUUID(result)).toBeTruthy();
    await expect(service.getEvent({id: result})).rejects.toThrow(NotFoundException)
  })
});
  1. controller
import { Test, TestingModule } from '@nestjs/testing';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { CreateEventDto, GetEventsDto, PageInfoRequestDto, UpdateEventDto } from './dto/event.dto';


describe('EventsController', () => {
  const mockEventsService = {
    createEvent: jest.fn().mockResolvedValue({}),
    getEvent: jest.fn().mockResolvedValue({}),
    getEvents: jest.fn().mockResolvedValue({}),
    updateEvent: jest.fn().mockResolvedValue({}),
    deleteEvent: jest.fn().mockResolvedValue({})
  }
  let controller: EventsController;
  let service: EventsService;
  afterEach(() => {
    jest.clearAllMocks();
  })
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [EventsController],
      providers: [{
        provide: EventsService,
        useValue: mockEventsService,
      }]
    }).compile();

    controller = module.get<EventsController>(EventsController);
    service = module.get<EventsService>(EventsService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
  /**
  given given a valid create event dto
  {
    "name": '江蕙演唱會',
    "location": '台北大巨蛋',
    "startDate": new Date(),
    "numberOfDays": 1, 
  }
   */
  it('service.createEvent should be called once with given create event dto', async () => {
    const createEventDto = new CreateEventDto();
    createEventDto.name = '江蕙演唱會';
    createEventDto.location = '台北大巨蛋';
    const startDate = new Date(Date.now() + 86400);
    createEventDto.startDate = startDate;
    await controller.createEvent(createEventDto);
    expect(service.createEvent).toHaveBeenCalledTimes(1);
    expect(service.createEvent).toHaveBeenCalledWith(createEventDto);
  });
  /**
  given a existed event id in uuid format
   */
  it('service.getEvent should be called once', async() => {
    const eventId = crypto.randomUUID();
    await controller.getEvent(eventId);
    expect(service.getEvent).toHaveBeenCalledTimes(1)
    expect(service.getEvent).toHaveBeenCalledWith({ id: eventId});
  });
  /**
  given a existed event dto
   */
  it('service.getEvents should be called once', async () => {
    const criteria = new GetEventsDto();
    const pageInfo = new PageInfoRequestDto();
    criteria.location = '台北大巨蛋';
    criteria.startDate = new Date();
    await controller.getEvents(criteria, pageInfo);
    expect(service.getEvents).toHaveBeenCalledTimes(1);
    expect(service.getEvents).toHaveBeenCalledWith(criteria, pageInfo);
  });
  /**
  given a existed event id 
  update with specific location
   */
  it('service.updateEvent should be called once', async () => {
    const eventId = crypto.randomUUID();
    const updateEventData = new UpdateEventDto();
    updateEventData.location = '台北小巨蛋';
    updateEventData.startDate = new Date(Date.now() + 86400*2);
    await controller.updateEvent(eventId, updateEventData);
    expect(service.updateEvent).toHaveBeenCalledTimes(1);
    expect(service.updateEvent).toHaveBeenCalledWith(eventId, updateEventData);
  });
  /**
  given a existed event id
   */
  it('service.deleteEvent should be called once', async () => {
    const eventId = crypto.randomUUID();
    await controller.deleteEvent(eventId);
    expect(service.deleteEvent).toHaveBeenCalledTimes(1);
    expect(service.deleteEvent).toHaveBeenCalledWith(eventId);
  });
});
  1. e2e
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { isUUID } from 'class-validator';
import { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
describe('AppController (e2e)', () => {
  let app: INestApplication;
  let userId: string;
  let refreshToken: string;
  let accessToken: string;
  let postgresql: StartedPostgreSqlContainer;
  let eventId: string;
  beforeAll(async () => {
    postgresql = global.postgresql;
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
    }))
    await app.init();
  });
  afterAll(async () => {
    await app.close();
    await postgresql.stop();
  })

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('ticket-booking-system');
  });
  it('/auth/register (POST) with failed', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({
        email: 'yu',
        password: '123'
      })
      .expect(400);
  });
  it('/auth/register (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/register')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%',
      })
      .expect(201)
      .expect(({ body }) => {
        userId = body.id;
        expect(isUUID(body.id)).toBeTruthy()
      });
  });
  // given login with correct credential
  it('/auth/login (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/login')
      .send({
        email: 'admin@hotmail.com',
        password: '1@q#Abz%'
      })
      .expect(201)
      .expect(({body}) => {
        refreshToken = body.refresh_token;
        accessToken = body.access_token;
        expect(body).toHaveProperty('access_token');
        expect(body).toHaveProperty('refresh_token');
      });
  })
  it('/users/:userId (GET)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${userId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body.email).toEqual('yu@hotmail.com')
      });
  })
  it('/users/:userId (GET) with failed', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${crypto.randomUUID()}`)
      .set('Authorization', accessToken)
      .expect(404);
  })
  // given login with wrong credential with Unauthorizatiion
  it('/auth/login (POST) reject with Unauthorization', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/login')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%1'
      })
      .expect(401);
  })
  // given wrong acess token to create a event
  it('/events (POST) reject with Unauthorization', () => {
    const agent = request(app.getHttpServer());
    return agent.post('/events')
      .send({
        location: '台北大巨蛋',
        name: '江惠演唱會',
        startDate: '2024-10-01'
      })
      .set('Authorization', '1245')
      .expect(401);
  });
  // given access token to create a event
  it('/events (POST) with successfully create event', () => {
    const agent = request(app.getHttpServer());
    return agent.post('/events')
      .send({
        location: '台北大巨蛋',
        name: '江惠演唱會',
        startDate: '2024-10-01'
      })
      .set('Authorization', accessToken)
      .expect(201)
      .expect(({ body }) => {
        expect(isUUID(body.id)).toBeTruthy();
        eventId = body.id;
      });
  });
  // given exist eventid with existed event
  it('/events/:id (GET) with successfully retrieve event', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/events/${eventId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('name', '江惠演唱會');
        expect(body).toHaveProperty('location', '台北大巨蛋')
      });
  });
  // given exist location with existed event
  it('/events/ (GET) with successfully retrieve event', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/events`)
      .query({
        location: '台北大巨蛋'
      })
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('events');
        expect(body.events.length).toEqual(1);
      });
  });
  // given wrong token with get event
  it('/events/ (GET) reject with Uanuthorization', () => {
    const agent = request(app.getHttpServer());
    return agent.get(`/events`)
      .query({
        location: '台北大巨蛋'
      })
      .set('Authorization', '123')
      .expect(401);
  });
  // given wrong token with delete event
  it('/events/:id (DELETE) reject with Uanuthorization', () => {
    const agent = request(app.getHttpServer());
    return agent.delete(`/events/${eventId}`)
      .set('Authorization', '1234')
      .expect(401)
  });
  // given exist event id with existed event
  it('/events/:id (DELETE) with successfully delete event', (done) => {
    const agent = request(app.getHttpServer());
    agent.delete(`/events/${eventId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body).toHaveProperty('id')
        expect(isUUID(body.id)).toBeTruthy();
      })
      .end((err, res) => {
        if (err) {
          return done(err);
        }
        agent.get(`/events/${eventId}`)
          .set('Authorization', accessToken)
          .expect(404)
          .end((err, res) =>{
            done(err);
          });
      });
  });
  // given refesh token with exist users
  it('/auth/refresh (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/refresh')
      .set('Authorization', refreshToken)
      .expect(201);
  });
});

定義 EventRepository 抽象介面

import { CreateEventDto, EventsResponse, PageInfoRequestDto } from './dto/event.dto';
import { EventEntity } from './schema/event.entity';

export abstract class EventsRepository {
  abstract save(eventInfo: CreateEventDto): Promise<EventEntity>;
  abstract findOne(criteria: Partial<EventEntity>): Promise<EventEntity>;
  abstract find(criteria: Partial<EventEntity>, pageInfo: PageInfoRequestDto): Promise<EventsResponse>;
  abstract update(criteria: Partial<EventEntity>, data: Partial<EventEntity>): Promise<EventEntity>;
  abstract delete(criteria: Partial<EventEntity>): Promise<string>;
}

實作 In Memory 的 EventsStore

import { NotFoundException } from '@nestjs/common';
import { CreateEventDto, EventsResponse, PageInfoRequestDto } from './dto/event.dto';
import { EventsRepository } from './events.repository';
import { EventEntity } from './schema/event.entity';

export class EventStore implements EventsRepository {
  events: EventEntity[] = new Array<EventEntity>();
  async save(eventInfo: CreateEventDto): Promise<EventEntity> {
    const event = new EventEntity();
    event.id = crypto.randomUUID();
    event.location = eventInfo.location;
    event.name = eventInfo.name;
    event.startDate = eventInfo.startDate;
    if (eventInfo.numberOfDays) {
      event.numberOfDays = eventInfo.numberOfDays;
    }
    this.events.push(event);
    return event;
  }
  async findOne(criteria: Partial<EventEntity>): Promise<EventEntity> {
    const foundIdx = this.events.findIndex((item) => item.id == criteria.id);
    if (foundIdx == -1) {
      throw new NotFoundException(`event with given id: ${criteria.id} not found`);
    }
    return this.events[foundIdx];
  }
  async find(criteria: Partial<EventEntity>, pageInfo: PageInfoRequestDto): Promise<EventsResponse> {
    let filteredEvent: EventEntity[] = this.events.slice(pageInfo.offset, pageInfo.limit);
    if (criteria.location) {
      filteredEvent = filteredEvent.filter((item) => item.location == criteria.location);
    }
    if (criteria.startDate) {
      filteredEvent = filteredEvent.filter((item) => item.startDate.getTime() >= criteria.startDate.getTime())
    }
    if (criteria.name) {
      filteredEvent = filteredEvent.filter((item) => item.name == criteria.name)
    }
    return {
      events: filteredEvent,
      pageInfo: {
        limit: pageInfo.limit,
        offset: pageInfo.offset,
        total: filteredEvent.length 
      }
    }
    
  }
  async update(criteria: Partial<EventEntity>, data: Partial<EventEntity>): Promise<EventEntity> {
    const updateIdx = this.events.findIndex((item) => item.id == criteria.id);
    if (updateIdx == -1) {
      throw new NotFoundException(`update target with eventid ${criteria.id} not found`);
    }
    if (data.location) {
      this.events[updateIdx].location = data.location;
    }
    if (data.name) {
      this.events[updateIdx].name = data.name;
    }
    if (data.startDate) {
      this.events[updateIdx].startDate = data.startDate;
    }
    if (data.numberOfDays) {
      this.events[updateIdx].numberOfDays = data.numberOfDays;
    }
    return this.events[updateIdx];
  }
  async delete(criteria: Partial<EventEntity>): Promise<string> {
    const deleteIdx = this.events.findIndex((item) => item.id == criteria.id);
    if (deleteIdx == -1) {
      throw new NotFoundException(`update target with eventid ${criteria.id} not found`);
    }
    const deleteId = this.events[deleteIdx].id;
    this.events.splice(deleteIdx, 1);
    return deleteId;   
  }

}

實作 EventsService

import { Inject, Injectable } from '@nestjs/common';
import { EventsRepository } from './events.repository';
import { EventStore } from './event.store';
import { CreateEventDto, GetEventDto, GetEventsDto, PageInfoRequestDto, UpdateEventDto } from './dto/event.dto';

@Injectable()
export class EventsService {
  constructor(
    @Inject(EventStore)
    private readonly eventRepo: EventsRepository
  ) {}
  async createEvent(eventInfo: CreateEventDto) {
    const result = await this.eventRepo.save(eventInfo);
    return {id: result.id};
  }
  async getEvent(userInfo: GetEventDto) {
    return this.eventRepo.findOne(userInfo);
  }
  async getEvents(criteria: GetEventsDto, pageInfo: PageInfoRequestDto) {
    return this.eventRepo.find(criteria, pageInfo);
  }
  async updateEvent(eventId: string, updateData: UpdateEventDto) {
    return this.eventRepo.update({id: eventId},updateData);
  }
  async deleteEvent(eventId: string) {
    return this.eventRepo.delete({ id: eventId});
  }
}

實作 EventsController

import { Body, Controller, Delete, Get, Injectable, Param, ParseUUIDPipe, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { EventsService } from './events.service';
import { CreateEventDto,GetEventsDto, PageInfoRequestDto, UpdateEventDto } from './dto/event.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/role.guard';
import { Roles } from '../auth/roles.decorator';
@Injectable()
@Controller('events')
export class EventsController {
  constructor(
    private readonly eventService: EventsService,
  ) {}
  @Post()
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(['admin'])
  async createEvent(@Body() createEventDto: CreateEventDto) {
    return this.eventService.createEvent(createEventDto);
  }
  @Get(':id')
  @UseGuards(JwtAuthGuard)
  async getEvent(@Param('id', ParseUUIDPipe) eventId: string) {
    return this.eventService.getEvent({ id: eventId});
  }
  @Get()
  @UseGuards(JwtAuthGuard)
  async getEvents(@Query() criteria: GetEventsDto, @Query() pageInfo:PageInfoRequestDto) {
    return this.eventService.getEvents(criteria, pageInfo);
  }
  @Patch(':id')
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(['admin'])
  async updateEvent(@Param('id', ParseUUIDPipe) eventId: string, @Body() updateEventDto: UpdateEventDto) {
    return this.eventService.updateEvent(eventId, updateEventDto);
  }  
  @Delete(':id')
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(['admin'])
  async deleteEvent(@Param('id', ParseUUIDPipe) eventId) {
    const deleteId = await this.eventService.deleteEvent(eventId);
    return {
      id: deleteId,
    }
  }
}

Postman 驗證

  1. create event
    image
  2. get event with id
    image
  3. get events with criteria
    image
  4. update event with id
    image
  5. delete event with id
    image

因為篇幅過長,明天在說明。使用 typeorm 引入,eventDBStore 的部份。

結論

在開發過程中,可以發現最重要的就是確認需求,然後設計行為。 測試系統的行為,而不是測試實作。 這句話意思是,我們在寫程式時,一定要能掌握自己設計出來的系統行為。

想想如果去除掉程式語言以及框架的部份,我們實踐的部份是什麼。這樣框架之外的東西,才是真正開發者設計的邏輯。當然理解框架也很重要。但更重要的是,開發者透過框架設計了什麼行為,而不是每次都需要讓別人去看實作去理解設計。

把建立軟體系統當作蓋房子。使用框架的好處,是使用別人建立好的積木,來組合出開發者想要的設計。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - User Module part 4
下一篇
nestjs 系統設計 - 活動訂票管理系統 - Event Module part 2
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言