昨天已經把活動訂票管理系統做了初步的分析,與職責區分如下。
今天目標會是先規劃, UserModule 的部份。
UserModule 的部份主要是針對使用者身份驗證相關的處理。主要項目有以下:
因為昨天的系統發想有特別指定情境,希望使用者是使用手機來作為用戶端操作。所以這邊設計,不能使用原本網頁才能使用 cookie 來讓存放使用者狀態 session id 。
因此,這邊會採用 jwt 的技術,來做存取授權 token 與更新授權 token 。來做權限驗證。從 spec 來看,需要有兩種權限身份:活動管理者,一般參加者。活動管理者可以針對活動做管理,而一般參加者只能參加活動或是管理自己參加過的活動票券。
nest new ticket-booking-system
nest g mo auth
nest g controller auth
nest g mo users
nest g s users
nest g controller users
為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { isUUID } from 'class-validator';
import { UserStore } from './users.store';
describe('UsersService', () => {
let service: UsersService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService, UserStore],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
/** given a valid user info object
{
"email": "bda605@hotmail.com",
"password": "password"
}
===>
{
"id": uuid format string
}
*/
it('should return an created hashed', async () => {
const userInfo = {
email: "bda605@hotmail.com",
password: "password"
}
const result = await service.createUser(userInfo);
expect(result).toHaveProperty('id');
expect(isUUID(result['id'], 4)).toBeTruthy();
});
});
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { UsersService } from '../users/users.service';
describe('AuthController', () => {
let controller: AuthController;
let service: UsersService;
const mockUserService = {
createUser: jest.fn().mockResolvedValue({})
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [{
provide: UsersService,
useValue: mockUserService,
}]
}).compile();
controller = module.get<AuthController>(AuthController);
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
/**
given a userInfo Object
{
"email": "bda605@hotmail.com",
"password": "password"
}
*/
it('service.createUser should be call once', async () => {
const userInfo = {
email: "bda605@hotmail.com",
password: "password"
};
await controller.register(userInfo);
expect(service.createUser).toHaveBeenCalledTimes(1);
expect(service.createUser).toHaveBeenCalledWith(userInfo);
});
});
針對需要的行為,做最基礎的實踐。然後再逐步改進重構。
import { Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
import { UserStore } from './users.store';
import { UserRepository } from './users.repository';
import { UserEntity } from './schema/user.entity';
@Injectable()
export class UsersService {
constructor(
@Inject(UserStore)
private readonly userRepo: UserRepository,
) {}
async createUser(userInfo: CreateUserDto) {
const result = await this.userRepo.save({
email: userInfo.email,
password: await bcrypt.hash(userInfo.password, 10)
})
return {id: result.id }
}
async findUser(userInfo: Partial<UserEntity>) {
const result = await this.userRepo.findOne(userInfo);
return result;
}
}
對於建立使用者資料,預設需要 email, password 來建立資料,設定 DTO 如下
import { IsEmail, IsNotEmpty, IsString, IsStrongPassword } from 'class-validator';
export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@IsStrongPassword()
password: string;
}
預設需要針對,使用者存放有以下行為,建一個 Repository 作為行為介面如下
import { CreateUserDto } from './dto/create-user.dto';
import { UserEntity } from './schema/user.entity';
export abstract class UserRepository {
abstract save(userInfo: CreateUserDto): Promise<UserEntity>
abstract findOne(criteria: Partial<UserEntity>): Promise<UserEntity>
abstract find(criteria: Partial<UserEntity>): Promise<UserEntity[]>
}
而今天會先以 In Memory 方式來實作這個介面。如下:
import { NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UserEntity } from './schema/user.entity';
import { UserRepository } from './users.repository';
export class UserStore implements UserRepository {
users: UserEntity[] = [];
async save(userInfo: CreateUserDto): Promise<UserEntity> {
const id = crypto.randomUUID();
const newUser = new UserEntity();
newUser.id = id;
newUser.email = userInfo.email;
newUser.password = userInfo.password;
this.users.push(newUser);
return newUser;
}
async findOne(criteria: Partial<UserEntity>): Promise<UserEntity> {
const user = this.users.find((item) => {
return item.id == criteria.id || item.email == criteria.email
});
if (!user) {
throw new NotFoundException({ message: `user with ${criteria.id} not found`});
}
return user;
}
async find(criteria: Partial<UserEntity>): Promise<UserEntity[]> {
throw new Error('Method not implemented.');
}
}
而預計存放的資料行別如下:
import { IsEmail, IsString, IsUUID } from 'class-validator';
export class UserEntity {
@IsUUID()
id: string;
@IsEmail()
email: string;
@IsString()
password: string;
}
為了能檢核使用者註冊後資料有被寫入,於是加入了一個 getUser 的介面在 UserController 如下
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController', () => {
const mockUserService = {
createUser: jest.fn().mockResolvedValue({}),
findUser: jest.fn().mockResolvedValue({})
}
let controller: UsersController;
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [{
provide: UsersService,
useValue: mockUserService,
}]
}).compile();
controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
/**
given a userInfo Object
{
"id": uuid format
}
*/
it('service.findUser should be called once', async () => {
const userId = crypto.randomUUID();
await controller.getUser(userId);
expect(service.findUser).toHaveBeenCalledTimes(1);
expect(service.findUser).toHaveBeenCalledWith({id: userId});
})
});
加入從端點打入的驗證。如下
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';
describe('AppController (e2e)', () => {
let app: INestApplication;
let userId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}))
await app.init();
});
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()
});
});
it('/users/:userId (GET)', () => {
const agent = request(app.getHttpServer());
return agent
.get(`/users/${userId}`)
.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()}`)
.expect(404);
})
});
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(
private readonly usersService: UsersService
) {}
@Get(':id')
async getUser(@Param('id', ParseUUIDPipe) id: string ) {
return this.usersService.findUser({id: id});
}
}
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from '../users/dto/create-user.dto';
import { UsersService } from '../users/users.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly usersService: UsersService
) {}
@Post('register')
async register(@Body() userInfo: CreateUserDto) {
return await this.usersService.createUser(userInfo);
}
}
由於目前篇幅有點過長,所以 Jwt 驗證以及 Refresh 的設計實作部份挪移到下一篇來解說。
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// validation logic
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}))
await app.listen(3000);
}
bootstrap();
https://documenter.getpostman.com/view/2441389/2sAXjGducf
在 UsersModule 會需要給外部使用的 Provider 有二個。 UsersService 與 UserStore 。放到 exports 欄位,這樣在其他需要使用的 module 就可以直接 import 來使用。
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UserStore } from './users.store';
@Module({
providers: [UsersService, UserStore],
controllers: [UsersController],
exports: [UsersService, UserStore]
})
export class UsersModule {}
在 AuthModule 會需要引用 UsersModule 。使用到 UsersModule 內的 UsersService 。實作如下:
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { UsersService } from '../users/users.service';
@Module({
imports: [
UsersModule,
],
providers: [UsersService],
controllers: [AuthController]
})
export class AuthModule {}
本篇透過 nestjs 本身測試框架來實行類似於 BDD 開發模式。先定義好系統行為,然後再從驗證開始思考如何設計系統。這種方法,好處是能夠透過宣告式的方式,把系統逐步來拆解分析。降低了一開始就去思考太細節的問題,著重在重要的需求解析。
這種開發模式,需要開發針對需求有系統性的思考。先把系統規範建立起來。實作遵循著以下流程:測試紅燈 -> 測試綠燈 -> 重構。逐步把整個系統從底層服務建構起來。
開發之困難在於分析系統是把需求由大而小分析。但系統建構卻是反著來,需要由小而大,由具體實作搭組合出抽象邏輯。 nestjs 提供 Provider 的依賴住入,讓使用者可以先透過抽象介面定義出行為,然後再根據行為實踐出具體邏輯。比較接近於設計流程。
如果是先寫好實作再去補測試。那是不是反而是測試去屈就了實作,失去了測試的作用了呢?