feat(config): add config module, and more testing codes

This commit is contained in:
hantsy 2020-06-30 22:36:08 +08:00 committed by Hantsy Bai
parent 5bb4d4300a
commit 04d9ef758b
40 changed files with 701 additions and 135 deletions

5
codecov.yml Normal file
View File

@ -0,0 +1,5 @@
ignore:
- "**/*stub.ts"
- "**/*dto.ts"
- "**/*interface.ts"
- "**/*model.ts"

38
package-lock.json generated
View File

@ -1189,6 +1189,12 @@
"integrity": "sha512-wo2rHprtDzTHf4tiSxavktJ52ntiwmg7eHNGFLH38G1of8OfGVwOc1sVbpM4jN/HK/rCMhYOi6xzoPqsv0537A==",
"dev": true
},
"@golevelup/nestjs-testing": {
"version": "0.1.2",
"resolved": "https://registry.npm.taobao.org/@golevelup/nestjs-testing/download/@golevelup/nestjs-testing-0.1.2.tgz",
"integrity": "sha1-MU6H5jTVgXl9CGyfX0Xc6AKtkZo=",
"dev": true
},
"@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@ -2192,6 +2198,18 @@
"uuid": "8.2.0"
}
},
"@nestjs/config": {
"version": "0.5.0",
"resolved": "https://registry.npm.taobao.org/@nestjs/config/download/@nestjs/config-0.5.0.tgz",
"integrity": "sha1-rREQ2TfsJpQbj85fV1hZBG+Ne0s=",
"requires": {
"dotenv": "8.2.0",
"dotenv-expand": "5.1.0",
"lodash.get": "4.4.2",
"lodash.set": "4.3.2",
"uuid": "8.1.0"
}
},
"@nestjs/core": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-7.3.1.tgz",
@ -5182,6 +5200,16 @@
"is-obj": "^1.0.0"
}
},
"dotenv": {
"version": "8.2.0",
"resolved": "https://registry.npm.taobao.org/dotenv/download/dotenv-8.2.0.tgz",
"integrity": "sha1-l+YZJZradQ7qPk6j4mvO6lQksWo="
},
"dotenv-expand": {
"version": "5.1.0",
"resolved": "https://registry.npm.taobao.org/dotenv-expand/download/dotenv-expand-5.1.0.tgz",
"integrity": "sha1-P7rwIL/XlIhAcuomsel5HUWmKfA="
},
"duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@ -10370,6 +10398,11 @@
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
"dev": true
},
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npm.taobao.org/lodash.get/download/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npm.taobao.org/lodash.includes/download/lodash.includes-4.3.0.tgz",
@ -10411,6 +10444,11 @@
"resolved": "https://registry.npm.taobao.org/lodash.once/download/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
"lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npm.taobao.org/lodash.set/download/lodash.set-4.3.2.tgz",
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM="
},
"lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",

View File

@ -22,6 +22,7 @@
},
"dependencies": {
"@nestjs/common": "^7.2.0",
"@nestjs/config": "^0.5.0",
"@nestjs/core": "^7.2.0",
"@nestjs/jwt": "^7.0.0",
"@nestjs/passport": "^7.1.0",
@ -37,6 +38,7 @@
"devDependencies": {
"@commitlint/cli": "^9.0.1",
"@commitlint/config-conventional": "^9.0.1",
"@golevelup/nestjs-testing": "^0.1.2",
"@nestjs/cli": "^7.4.1",
"@nestjs/schematics": "^7.0.0",
"@nestjs/testing": "^7.2.0",
@ -46,8 +48,8 @@
"@types/node": "^14.0.14",
"@types/passport-jwt": "^3.0.3",
"@types/passport-local": "^1.0.33",
"@typescript-eslint/eslint-plugin": "3.5.0",
"@types/supertest": "^2.0.10",
"@typescript-eslint/eslint-plugin": "3.5.0",
"@typescript-eslint/parser": "3.5.0",
"eslint": "7.3.1",
"eslint-config-prettier": "^6.10.0",

View File

@ -1,37 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AuthService } from './auth/auth.service';
import { of } from 'rxjs';
describe('AppController', () => {
let appController: AppController;
let authService: AuthService;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [
{
provide: AuthService,
useValue: {
constructor: jest.fn(),
login: jest.fn(),
},
},
],
}).compile();
appController = app.get<AppController>(AppController);
authService = app.get<AuthService>(AuthService);
});
describe('root', () => {
it('should return access_token', async() => {
jest.spyOn(authService, "login").mockImplementation((user:any)=>{
return of({ access_token: 'jwttoken' } );
});
const token = await appController.login({} as any).toPromise();
expect(token.access_token).toEqual('jwttoken');
expect(authService.login).toBeCalled();
});
it('should be defined', () => {
expect(appController).toBeDefined();
});
});

View File

@ -1,24 +1,6 @@
import { Controller, Post, UseGuards, Get, Req } from '@nestjs/common';
import { Request } from 'express';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { Observable } from 'rxjs';
import { AuthService } from './auth/auth.service';
import { Controller } from '@nestjs/common';
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
login(@Req() req: Request): Observable<any> {
return this.authService.login(req.user);
}
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() req: Request): any {
//console.log(req.user);
return req.user;
}
}

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
@ -8,6 +9,7 @@ import { UserModule } from './user/user.module';
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true }),
DatabaseModule,
PostModule,
AuthModule,

View File

@ -0,0 +1,3 @@
export interface AccessToken{
readonly access_token:string
}

View File

@ -1,5 +1 @@
export const jwtConstants = {
secret: 'secretKey',
};
export const HAS_ROLES_KEY = 'has-roles';

View File

@ -0,0 +1,37 @@
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
describe('AuthController', () => {
let controller: AuthController;
let authService: AuthService;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: {
constructor: jest.fn(),
login: jest.fn(),
},
},
],
}).compile();
controller = app.get<AuthController>(AuthController);
authService = app.get<AuthService>(AuthService);
});
describe('login', () => {
it('should return access_token', async () => {
jest.spyOn(authService, 'login').mockImplementation((user: any) => {
return of({ access_token: 'jwttoken' });
});
const token = await controller.login({} as any).toPromise();
expect(token.access_token).toEqual('jwttoken');
expect(authService.login).toBeCalled();
});
});
});

View File

@ -0,0 +1,17 @@
import { Controller, UseGuards, Post, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local-auth.guard';
import { Observable } from 'rxjs';
import { Request } from 'express';
import { AuthenticatedRequest } from './authenticated-request.interface';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('login')
login(@Req() req: AuthenticatedRequest): Observable<any> {
return this.authService.login(req.user);
}
}

View File

@ -3,20 +3,30 @@ import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './auth.constants';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';
import { ConfigModule, ConfigType } from '@nestjs/config';
import jwtConfig from '../config/jwt.config';
import { AuthController } from './auth.controller';
@Module({
imports: [
ConfigModule.forFeature(jwtConfig),
UserModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '3600s' },
JwtModule.registerAsync({
imports: [ConfigModule.forFeature(jwtConfig)],
useFactory: (config: ConfigType<typeof jwtConfig>) => {
return {
secret: config.secretKey,
signOptions: { expiresIn: config.expiresIn },
} as JwtModuleOptions;
},
inject: [jwtConfig.KEY],
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}

View File

@ -1,10 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { toArray } from 'rxjs/operators';
import { RoleType } from '../database/role-type.enum';
import { User } from '../database/user.model';
import { UserService } from '../user/user.service';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
let userService: UserService;
let jwtService: JwtService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -28,9 +34,108 @@ describe('AuthService', () => {
}).compile();
service = module.get<AuthService>(AuthService);
userService = module.get<UserService>(UserService);
jwtService = module.get<JwtService>(JwtService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateUser', () => {
it('if user is found', async () => {
jest
.spyOn(userService, 'findByUsername')
.mockImplementation((username: string) => {
return of({
username,
password: 'password',
email: 'hantsy@example.com',
roles: [RoleType.USER],
} as User);
});
service.validateUser('test', 'password').subscribe({
next: (data) => {
expect(data.username).toBe('test');
// expect(data.password).toBeUndefined();
expect(data.email).toBe('hantsy@example.com');
expect(data.roles).toEqual([RoleType.USER]);
//verify
expect(userService.findByUsername).toBeCalledTimes(1);
expect(userService.findByUsername).toBeCalledWith('test');
},
});
});
it('if user is found but pass is mismatched', async () => {
jest
.spyOn(userService, 'findByUsername')
.mockImplementation((username: string) => {
return of({
username,
password: 'password',
email: 'hantsy@example.com',
roles: [RoleType.USER],
} as User);
});
service
.validateUser('test', 'password001')
.pipe(toArray())
.subscribe({
next: (data) => {
expect(data.length).toBe(0);
expect(userService.findByUsername).toBeCalledTimes(1);
expect(userService.findByUsername).toBeCalledWith('test');
},
});
});
it('if user is not found', async () => {
jest
.spyOn(userService, 'findByUsername')
.mockImplementation((username: string) => {
return of(null as User);
});
service
.validateUser('test', 'password001')
.pipe(toArray())
.subscribe({
next: (data) => {
expect(data.length).toBe(0);
expect(userService.findByUsername).toBeCalledTimes(1);
expect(userService.findByUsername).toBeCalledWith('test');
},
});
});
});
describe('login', () => {
it('should return signed jwt token', async () => {
jest.spyOn(jwtService, 'signAsync').mockResolvedValue('test');
service
.login({
username: 'test',
id: '_id',
email: 'hantsy@example.com',
roles: [RoleType.USER],
})
.subscribe({
next: (data) => {
expect(data.access_token).toBe('test');
expect(jwtService.signAsync).toBeCalledTimes(1);
expect(jwtService.signAsync).toBeCalledWith({
upn: 'test',
sub: '_id',
email: 'hantsy@example.com',
roles: [RoleType.USER],
});
},
});
});
});
});

View File

@ -3,6 +3,9 @@ import { JwtService } from '@nestjs/jwt';
import { from, Observable, EMPTY, of } from 'rxjs';
import { map, flatMap } from 'rxjs/operators';
import { UserService } from '../user/user.service';
import { UserPrincipal } from './user-principal.interface';
import { JwtPayload } from './jwt-payload.interface';
import { AccessToken } from './access-token.interface';
@Injectable()
export class AuthService {
@ -11,13 +14,13 @@ export class AuthService {
private jwtService: JwtService,
) {}
validateUser(username: string, pass: string): Observable<any> {
validateUser(username: string, pass: string): Observable<UserPrincipal> {
return this.userService.findByUsername(username).pipe(
flatMap(user => {
flatMap((user) => {
//console.log('userService.findByUsername::' + JSON.stringify(user));
if (user && user.password === pass) {
const { _id, username, email, roles } = user;
return of({ _id, username, email, roles });
return of({ id: _id, username, email, roles });
}
return EMPTY;
}),
@ -25,20 +28,21 @@ export class AuthService {
}
// If `LocalStrateg#validateUser` return a `Observable`, the `request.user` is
// bound to a `Observable<User>`, not a `User`.
// bound to a `Observable<UserPrincipal>`, not a `UserPrincipal`.
//
// I would like use the current `Promise` for this case.
// I would like use the current `Promise` for this case, thus it will get
// a `UserPrincipal` here directly.
//
login(user: any): Observable<any> {
console.log(user);
const payload = {
login(user: UserPrincipal): Observable<AccessToken> {
//console.log(user);
const payload: JwtPayload = {
upn: user.username, //upn is defined in Microprofile JWT spec, a human readable principal name.
sub: user._id,
sub: user.id,
email: user.email,
roles: user.roles,
};
return from(this.jwtService.signAsync(payload)).pipe(
map(access_token => {
map((access_token) => {
return { access_token };
}),
);

View File

@ -1,6 +1,6 @@
import { User } from 'src/database/user.model';
import { Request } from 'express';
import { UserPrincipal } from './user-principal.interface';
export interface AuthenticatedRequest extends Request {
readonly user: User;
readonly user: UserPrincipal;
}

View File

@ -1,7 +1,51 @@
import { createMock } from '@golevelup/nestjs-testing';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtAuthGuard } from './jwt-auth.guard';
describe('JwtAuthGuard', () => {
describe('LocalAuthGuard', () => {
let guard: JwtAuthGuard;
beforeEach(() => {
guard = new JwtAuthGuard();
});
it('should be defined', () => {
expect(new JwtAuthGuard()).toBeDefined();
expect(guard).toBeDefined();
});
it('should return true for `canActivate`', async () => {
AuthGuard('jwt').prototype.canActivate = jest.fn(() =>
Promise.resolve(true),
);
AuthGuard('jwt').prototype.logIn = jest.fn(() => Promise.resolve());
expect(
await guard.canActivate(createMock<ExecutionContext>()),
).toBeTruthy();
});
it('handleRequest: error', async () => {
const error = { name: 'test', message: 'error' } as Error;
try {
guard.handleRequest(error, {}, {});
} catch (e) {
//console.log(e);
expect(e).toEqual(error);
}
});
it('handleRequest', async () => {
expect(
await guard.handleRequest(undefined, { username: 'hantsy' }, undefined),
).toEqual({ username: 'hantsy' });
});
it('handleRequest: Unauthorized', async () => {
try {
guard.handleRequest(undefined, undefined, undefined);
} catch (e) {
// console.log(e);
expect(e).toBeDefined();
}
});
});

View File

@ -0,0 +1,8 @@
import { RoleType } from "../database/role-type.enum";
export interface JwtPayload {
readonly upn: string;
readonly sub: string;
readonly email: string;
readonly roles: RoleType[];
}

View File

@ -1,27 +1,29 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtConstants } from './auth.constants';
import jwtConfig from '../config/jwt.config';
import { ConfigType } from '@nestjs/config';
import { JwtPayload } from './jwt-payload.interface';
import { UserPrincipal } from './user-principal.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
constructor(@Inject(jwtConfig.KEY) config: ConfigType<typeof jwtConfig>) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
secretOrKey: config.secretKey,
});
}
//payload is the decoded jwt clmais.
validate(payload: any): any {
validate(payload: JwtPayload): UserPrincipal {
//console.log('jwt payload:' + JSON.stringify(payload));
return {
username: payload.upn,
email: payload.email,
_id: payload.sub,
id: payload.sub,
roles: payload.roles,
};
}
}

View File

@ -1,7 +1,21 @@
import { ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { LocalAuthGuard } from './local-auth.guard';
describe('LocalAuthGuard', () => {
it('should be defined', () => {
expect(new LocalAuthGuard()).toBeDefined();
let guard: LocalAuthGuard;
beforeEach(() => {
guard = new LocalAuthGuard();
});
it('should be defined', () => {
expect(guard).toBeDefined();
});
it('should return true for `canActivate`', async () => {
AuthGuard('local').prototype.canActivate = jest.fn(() =>
Promise.resolve(true),
);
AuthGuard('local').prototype.logIn = jest.fn(() => Promise.resolve());
expect(await guard.canActivate({} as ExecutionContext)).toBe(true);
});
});

View File

@ -0,0 +1,46 @@
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { RoleType } from '../database/role-type.enum';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
describe('LocalStrategy', () => {
let strategy: LocalStrategy;
let authService: AuthService;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
providers: [
LocalStrategy,
{
provide: AuthService,
useValue: {
constructor: jest.fn(),
login: jest.fn(),
validateUser:jest.fn()
},
},
],
}).compile();
strategy = app.get<LocalStrategy>(LocalStrategy);
authService = app.get<AuthService>(AuthService);
});
describe('validate', () => {
it('should return user principal if user and password is provided ', async () => {
jest
.spyOn(authService, 'validateUser')
.mockImplementation((user: any, pass: any) => {
return of({
username: 'test',
id: '_id',
email: 'hantsy@example.com',
roles: [RoleType.USER],
});
});
const user = await strategy.validate('test', 'pass');
expect(user.username).toEqual('test');
expect(authService.validateUser).toBeCalledWith('test', 'pass');
});
});
});

View File

@ -1,9 +1,8 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { Observable } from 'rxjs';
import { map, throwIfEmpty } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { UserPrincipal } from './user-principal.interface';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
@ -30,8 +29,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
// .pipe(throwIfEmpty(() => new UnauthorizedException()));
// }
async validate(username: string, password: string): Promise<any> {
const user = await this.authService
async validate(username: string, password: string): Promise<UserPrincipal> {
const user: UserPrincipal = await this.authService
.validateUser(username, password)
.toPromise();

View File

@ -1,7 +1,85 @@
import { RolesGuard } from './roles.guard';
import { Reflector } from '@nestjs/core';
import { TestingModule, Test } from '@nestjs/testing';
import { ExecutionContext } from '@nestjs/common';
import { createMock } from '@golevelup/nestjs-testing';
import { RoleType } from '../database/role-type.enum';
import { AuthenticatedRequest } from './authenticated-request.interface';
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RolesGuard,
{
provide: Reflector,
useValue: {
constructor: jest.fn(),
get: jest.fn(),
},
},
],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
reflector = module.get<Reflector>(Reflector);
});
it('should be defined', () => {
//expect(new RolesGuard()).toBeDefined();
expect(guard).toBeDefined();
});
it('should skip(return true) if the `HasRoles` decorator is not set', async () => {
jest.spyOn(reflector, 'get').mockImplementation((a: any, b: any) => {
return undefined;
});
expect(
guard.canActivate(
createMock<ExecutionContext>({
getHandler: jest.fn(),
}),
),
).toBe(true);
expect(reflector.get).toBeCalled();
});
it('should return true if the `HasRoles` decorator is set', async () => {
jest.spyOn(reflector, 'get').mockImplementation((a: any, b: any) => {
return [RoleType.USER];
});
expect(
guard.canActivate(
createMock<ExecutionContext>({
getHandler: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
user: { roles: [RoleType.USER]},
} as AuthenticatedRequest),
}),
}),
),
).toBe(true);
expect(reflector.get).toBeCalled();
});
it('should return false if the `HasRoles` decorator is set but role is not allowed', async () => {
jest.spyOn(reflector, 'get').mockImplementation((a: any, b: any) => {
return [RoleType.ADMIN];
});
expect(
guard.canActivate(
createMock<ExecutionContext>({
getHandler: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
user: { roles: [RoleType.USER]},
} as AuthenticatedRequest),
}),
}),
),
).toBe(false);
expect(reflector.get).toBeCalled();
});
});

View File

@ -0,0 +1,8 @@
import { RoleType } from '../database/role-type.enum';
export interface UserPrincipal {
readonly username: string;
readonly id: string;
readonly email: string;
readonly roles: RoleType[];
}

View File

@ -0,0 +1,23 @@
import { ConfigModule, ConfigType } from '@nestjs/config';
import { TestingModule, Test } from '@nestjs/testing';
import jwtConfig from './jwt.config';
describe('jwtConfig', () => {
let config: ConfigType<typeof jwtConfig>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forFeature(jwtConfig)],
}).compile();
config = module.get<ConfigType<typeof jwtConfig>>(jwtConfig.KEY);
});
it('should be defined', () => {
expect(jwtConfig).toBeDefined();
});
it('should contains expiresIn and secret key', async () => {
expect(config.expiresIn).toBe('3600s');
expect(config.secretKey).toBe('rzxlszyykpbgqcflzxsqcysyhljt');
});
});

6
src/config/jwt.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secretKey: process.env.JWT_SECRET_KEY || 'rzxlszyykpbgqcflzxsqcysyhljt',
expiresIn: process.env.JWT_EXPIRES_IN || '3600s',
}));

View File

@ -0,0 +1,22 @@
import { ConfigModule, ConfigType } from '@nestjs/config';
import { TestingModule, Test } from '@nestjs/testing';
import mongodbConfig from './mongodb.config';
describe('mongodbConfig', () => {
let config: ConfigType<typeof mongodbConfig>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forFeature(mongodbConfig)],
}).compile();
config = module.get<ConfigType<typeof mongodbConfig>>(mongodbConfig.KEY);
});
it('should be defined', () => {
expect(mongodbConfig).toBeDefined();
});
it('should contains uri key', async () => {
expect(config.uri).toBe('mongodb://localhost/blog');
});
});

View File

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('mongodb', () => ({
uri: process.env.MONGODB_URI || 'mongodb://localhost/blog',
}));

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { databaseProviders } from './database.providers';
import { ConfigModule } from '@nestjs/config';
import mongodbConfig from '../config/mongodb.config';
@Module({
imports: [ConfigModule.forFeature(mongodbConfig)],
providers: [...databaseProviders],
exports: [...databaseProviders],
})

View File

@ -9,15 +9,20 @@ import {
COMMENT_MODEL,
} from './database.constants';
import { CommentSchema } from './comment.model';
import mongodbConfig from '../config/mongodb.config';
import { ConfigType } from '@nestjs/config';
export const databaseProviders = [
{
provide: DATABASE_CONNECTION,
useFactory: (): Promise<typeof mongoose> =>
connect('mongodb://localhost/blog', {
useFactory: (
dbConfig: ConfigType<typeof mongodbConfig>,
): Promise<typeof mongoose> =>
connect(dbConfig.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
}),
inject: [mongodbConfig.KEY],
},
{
provide: POST_MODEL,

View File

@ -6,6 +6,9 @@ async function bootstrap() {
// enable shutdown hooks explicitly.
app.enableShutdownHooks();
app.enableCors();
//app.useLogger();
await app.listen(3000);
}
bootstrap();

View File

@ -2,12 +2,12 @@ import {
Inject,
Injectable,
OnModuleDestroy,
OnModuleInit,
OnModuleInit
} from '@nestjs/common';
import { Model } from 'mongoose';
import { Post } from 'src/database/post.model';
import { Comment } from '../database/comment.model';
import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants';
import { Post } from '../database/post.model';
import { CreatePostDto } from './create-post.dto';
@Injectable()

View File

@ -35,8 +35,8 @@ describe('Post Controller', () => {
expect(posts.length).toBe(3);
});
it('GET on /posts/:id should return one post ', done => {
controller.getPostById('1').subscribe(data => {
it('GET on /posts/:id should return one post ', (done) => {
controller.getPostById('1').subscribe((data) => {
expect(data._id).toEqual('1');
done();
});
@ -51,24 +51,41 @@ describe('Post Controller', () => {
expect(saved.title).toEqual('test title');
});
it('PUT on /posts/:id should update the existing post', done => {
it('PUT on /posts/:id should update the existing post', (done) => {
const post: UpdatePostDto = {
title: 'test title',
content: 'test content',
};
controller.updatePost('1', post).subscribe(data => {
controller.updatePost('1', post).subscribe((data) => {
expect(data.title).toEqual('test title');
expect(data.content).toEqual('test content');
done();
});
});
it('DELETE on /posts/:id should delete post', done => {
controller.deletePostById('1').subscribe(data => {
it('DELETE on /posts/:id should delete post', (done) => {
controller.deletePostById('1').subscribe((data) => {
expect(data).toBeTruthy();
done();
});
});
it('POST on /posts/:id/comments', async () => {
const result = await controller
.createCommentForPost('testpost', { content: 'testcomment' })
.toPromise();
expect(result.content).toBe('testcomment');
expect(result.post._id).toBe('testpost');
});
it('GET on /posts/:id/comments', async () => {
const result = await controller
.getAllCommentsOfPost('testpost')
.toPromise();
expect(result.length).toBe(1);
});
});
describe('Replace PostService in provider(useValue: fake object)', () => {

View File

@ -3,12 +3,13 @@ import { Test, TestingModule } from '@nestjs/testing';
import { FilterQuery, Model } from 'mongoose';
import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants';
import { Post } from '../database/post.model';
import { Comment } from '../database/comment.model';
import { PostService } from './post.service';
describe('PostService', () => {
let service: PostService;
let model: Model<Post>;
let commentModel: Model<Comment>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -48,7 +49,7 @@ describe('PostService', () => {
provide: REQUEST,
useValue: {
user: {
_id: 'dummnyId',
id: 'dummyId',
},
},
},
@ -57,6 +58,7 @@ describe('PostService', () => {
service = await module.resolve<PostService>(PostService);
model = module.get<Model<Post>>(POST_MODEL);
commentModel = module.get<Model<Comment>>(COMMENT_MODEL);
});
it('should be defined', () => {
@ -117,7 +119,7 @@ describe('PostService', () => {
});
});
it('findById with an existing id should return one post', done => {
it('findById with an existing id should return one post', (done) => {
const found = {
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
@ -129,11 +131,11 @@ describe('PostService', () => {
} as any);
service.findById('1').subscribe({
next: data => {
next: (data) => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(data.title).toEqual('Generate a NestJS project');
},
error: error => console.log(error),
error: (error) => console.log(error),
complete: done(),
});
});
@ -156,13 +158,13 @@ describe('PostService', () => {
expect(model.create).toBeCalledWith({
...toCreated,
createdBy: {
_id: 'dummnyId',
_id: 'dummyId',
},
});
expect(model.create).toBeCalledTimes(1);
});
it('should update post', done => {
it('should update post', (done) => {
const toUpdated = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
@ -174,15 +176,15 @@ describe('PostService', () => {
} as any);
service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({
next: data => {
next: (data) => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
},
error: error => console.log(error),
error: (error) => console.log(error),
complete: done(),
});
});
it('should delete post', done => {
it('should delete post', (done) => {
const toDeleted = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
@ -193,13 +195,13 @@ describe('PostService', () => {
} as any);
service.deleteById('anystring').subscribe({
next: data => expect(data._id).toEqual('5ee49c3115a4e75254bb732e'),
error: error => console.log(error),
next: (data) => expect(data._id).toEqual('5ee49c3115a4e75254bb732e'),
error: (error) => console.log(error),
complete: done(),
});
});
it('should delete all post', done => {
it('should delete all post', (done) => {
jest.spyOn(model, 'deleteMany').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce({
deletedCount: 1,
@ -207,9 +209,57 @@ describe('PostService', () => {
} as any);
service.deleteAll().subscribe({
next: data => expect(data).toBeTruthy,
error: error => console.log(error),
next: (data) => expect(data).toBeTruthy,
error: (error) => console.log(error),
complete: done(),
});
});
it('should create comment ', async () => {
const comment = { content: 'test' };
jest.spyOn(commentModel, 'create').mockResolvedValue({
...comment,
post: { _id: 'test' },
} as any);
const result = await service.createCommentFor('test', comment).toPromise();
expect(result.content).toEqual('test');
expect(commentModel.create).toBeCalledWith({
...comment,
post: { _id: 'test' },
createdBy: { _id: 'dummyId' },
});
});
it('should get comments of post ', async () => {
jest
.spyOn(commentModel, 'find')
.mockImplementation(
(
conditions: FilterQuery<Comment>,
callback?: (err: any, res: Comment[]) => void,
) => {
return {
select: jest
.fn()
.mockReturnValue({
exec: jest
.fn()
.mockResolvedValue([
{
_id: 'test',
content: 'content',
post: { _id: '_test_id' },
}
] as any),
}),
} as any;
},
);
const result = await service.commentsOf('test').toPromise();
expect(result.length).toBe(1);
expect(result[0].content).toEqual('content');
expect(commentModel.find).toBeCalledWith({ post: { _id: 'test' } });
});
});

View File

@ -10,8 +10,7 @@ import { Comment } from '../database/comment.model';
// use `Pick<T, key of T>` instead of writing an extra interface.
// see: https://dev.to/jonrimmer/typesafe-mocking-in-typescript-3b50
// also see: https://www.typescriptlang.org/docs/handbook/utility-types.html#picktk
export class PostServiceStub implements Pick<PostService, keyof PostService>{
export class PostServiceStub implements Pick<PostService, keyof PostService> {
private posts: Post[] = [
{
_id: '5ee49c3115a4e75254bb732e',
@ -22,12 +21,19 @@ export class PostServiceStub implements Pick<PostService, keyof PostService>{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
} as Post,
} as Post,
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
} as Post,
} as Post,
];
private comments: Comment[] = [
{
post: { _id: '5ee49c3115a4e75254bb732e' },
content: 'comment of post',
} as Comment,
];
findAll(): Observable<Post[]> {
@ -39,31 +45,30 @@ export class PostServiceStub implements Pick<PostService, keyof PostService>{
return of({ _id: id, title, content } as Post);
}
save(data: CreatePostDto) : Observable<Post>{
save(data: CreatePostDto): Observable<Post> {
return of({ _id: this.posts[0]._id, ...data } as Post);
}
update(id: string, data: UpdatePostDto) : Observable<Post>{
update(id: string, data: UpdatePostDto): Observable<Post> {
return of({ _id: id, ...data } as Post);
}
deleteById(id: string) : Observable<Post>{
deleteById(id: string): Observable<Post> {
return of({ ...this.posts[0], _id: id } as Post);
}
deleteAll(): Observable<any> {
throw new Error("Method not implemented.");
throw new Error('Method not implemented.');
}
createCommentFor(
id: string,
postid: string,
data: CreateCommentDto,
): Observable<Comment> {
throw new Error('Method not implemented.');
return of({ id: 'test', post: { _id: postid }, ...data } as Comment);
}
commentsOf(
id: string,
): Observable<Comment[]> {
throw new Error('Method not implemented.');
commentsOf(id: string): Observable<Comment[]> {
return of(this.comments);
}
}

View File

@ -2,7 +2,7 @@ import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Model } from 'mongoose';
import { from, Observable } from 'rxjs';
import { AuthenticatedRequest } from 'src/auth/authenticated-request.interface';
import { AuthenticatedRequest } from '../auth/authenticated-request.interface';
import { Comment } from '../database/comment.model';
import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants';
import { Post } from '../database/post.model';
@ -47,7 +47,7 @@ export class PostService {
//console.log('req.user:'+JSON.stringify(this.req.user));
const createPost = this.postModel.create({
...data,
createdBy: { _id: this.req.user._id },
createdBy: { _id: this.req.user.id },
});
return from(createPost);
}
@ -57,7 +57,7 @@ export class PostService {
this.postModel
.findOneAndUpdate(
{ _id: id },
{ ...data, updatedBy: { _id: this.req.user._id } },
{ ...data, updatedBy: { _id: this.req.user.id } },
)
.exec(),
);
@ -76,7 +76,7 @@ export class PostService {
const createdComment = this.commentModel.create({
post: { _id: id },
...data,
createdBy: { _id: this.req.user._id },
createdBy: { _id: this.req.user.id },
});
return from(createdComment);
}

View File

@ -0,0 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProfileController } from './profile.controller';
import { AuthenticatedRequest } from '../auth/authenticated-request.interface';
describe('ProfileController', () => {
let controller: ProfileController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [ProfileController],
}).compile();
controller = app.get<ProfileController>(ProfileController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should call req', async () => {
const req = {user :{username:'test'}} as AuthenticatedRequest;
expect(controller.getProfile(req).username).toBe('test');
});
});

View File

@ -0,0 +1,13 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller()
export class ProfileController {
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() req: Request): any {
return req.user;
}
}

13
src/user/user.dto.ts Normal file
View File

@ -0,0 +1,13 @@
import { RoleType } from '../database/role-type.enum';
export class UserDto {
readonly id: string;
readonly username: string;
readonly email: string;
readonly password: string;
readonly firstName?: string;
readonly lastName?: string;
readonly roles?: RoleType[];
readonly createdAt?: Date;
readonly updatedAt?: Date;
}

View File

@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { DatabaseModule } from '../database/database.module';
import { ProfileController } from './profile.controller';
import { UserDataInitializerService } from './user-data-initializer.service';
import { DatabaseModule } from 'src/database/database.module';
import { UserService } from './user.service';
@Module({
imports: [DatabaseModule],
providers: [
UserService,
UserDataInitializerService,
],
providers: [UserService, UserDataInitializerService],
exports: [UserService],
controllers: [ProfileController],
})
export class UserModule {}

View File

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*stub.ts"]
}

View File

@ -9,7 +9,7 @@
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"baseUrl": "./src",
"incremental": true
}
}