今天目標會是先規劃, EventModule 的部份。
這邊所講的 Event 是指舉辦的活動。以下統一用『活動』這個詞彙來說明。EventModule 的部份主要是針對活動相關的處理。主要項目有以下:
nest g mo events
nest g s events
nest g controller events
為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫
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)
})
});
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);
});
});
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);
});
});
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>;
}
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;
}
}
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});
}
}
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,
}
}
}
因為篇幅過長,明天在說明。使用 typeorm 引入,eventDBStore 的部份。
在開發過程中,可以發現最重要的就是確認需求,然後設計行為。 測試系統的行為,而不是測試實作。 這句話意思是,我們在寫程式時,一定要能掌握自己設計出來的系統行為。
想想如果去除掉程式語言以及框架的部份,我們實踐的部份是什麼。這樣框架之外的東西,才是真正開發者設計的邏輯。當然理解框架也很重要。但更重要的是,開發者透過框架設計了什麼行為,而不是每次都需要讓別人去看實作去理解設計。
把建立軟體系統當作蓋房子。使用框架的好處,是使用別人建立好的積木,來組合出開發者想要的設計。