test(e2e): add endpoints testing

This commit is contained in:
hantsy 2020-07-02 18:14:07 +08:00 committed by Hantsy Bai
parent 202f2b0e8e
commit 450537dab7
21 changed files with 751 additions and 184 deletions

46
.github/workflows/e2e.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: e2e
on:
push:
paths-ignore:
- "docs/**"
branches:
- master
- release/*
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup NodeJS
uses: actions/setup-node@v2-beta
with:
node-version: "14"
- name: Cache Node.js modules
uses: actions/cache@v2
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.OS }}-node-
${{ runner.OS }}-
- name: Running mongodb service
run: |
docker-compose up -d mongodb
docker ps -a
# install dependencies and build the project
- name: Running e2e testing
run: |
npm install
npm run test:e2e -- --runInBand --forceExit

61
package-lock.json generated
View File

@ -1189,10 +1189,10 @@
"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=",
"@golevelup/ts-jest": {
"version": "0.3.0",
"resolved": "https://registry.npm.taobao.org/@golevelup/ts-jest/download/@golevelup/ts-jest-0.3.0.tgz",
"integrity": "sha1-coMoYwTPl7FzYOeOOtHWbnGcQy8=",
"dev": true
},
"@istanbuljs/load-nyc-config": {
@ -2208,6 +2208,13 @@
"lodash.get": "4.4.2",
"lodash.set": "4.3.2",
"uuid": "8.1.0"
},
"dependencies": {
"uuid": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz",
"integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg=="
}
}
},
"@nestjs/core": {
@ -2768,6 +2775,11 @@
}
}
},
"@types/validator": {
"version": "13.0.0",
"resolved": "https://registry.npm.taobao.org/@types/validator/download/@types/validator-13.0.0.tgz",
"integrity": "sha1-Nl8b+Taurd0IVvxBqh1vgtiO5bM="
},
"@types/webpack": {
"version": "4.41.17",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.17.tgz",
@ -4226,6 +4238,11 @@
"safe-buffer": "^5.0.1"
}
},
"class-transformer": {
"version": "0.2.3",
"resolved": "https://registry.npm.taobao.org/class-transformer/download/class-transformer-0.2.3.tgz",
"integrity": "sha1-WYySynHcynP5HMuHXXSjhHzPoy0="
},
"class-utils": {
"version": "0.3.6",
"resolved": "https://registry.npm.taobao.org/class-utils/download/class-utils-0.3.6.tgz",
@ -4249,6 +4266,17 @@
}
}
},
"class-validator": {
"version": "0.12.2",
"resolved": "https://registry.npm.taobao.org/class-validator/download/class-validator-0.12.2.tgz",
"integrity": "sha1-LOty+Ihz6ccUz1+cJ4y8cfb2yO8=",
"requires": {
"@types/validator": "13.0.0",
"google-libphonenumber": "^3.2.8",
"tslib": ">=1.9.0",
"validator": "13.0.0"
}
},
"cli-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.0.tgz",
@ -6954,6 +6982,11 @@
}
}
},
"google-libphonenumber": {
"version": "3.2.10",
"resolved": "https://registry.npm.taobao.org/google-libphonenumber/download/google-libphonenumber-3.2.10.tgz",
"integrity": "sha1-AhoxRlJ0fXNqOeLmDcZw8EMUJa0="
},
"graceful-fs": {
"version": "4.2.4",
"resolved": "https://registry.npm.taobao.org/graceful-fs/download/graceful-fs-4.2.4.tgz",
@ -9231,6 +9264,15 @@
}
}
},
"jest-mock-extended": {
"version": "1.0.9",
"resolved": "https://registry.npm.taobao.org/jest-mock-extended/download/jest-mock-extended-1.0.9.tgz",
"integrity": "sha1-NIk4eOLOi4Yjzx8Wq+KGHeH+JbQ=",
"dev": true,
"requires": {
"ts-essentials": "^4.0.0"
}
},
"jest-pnp-resolver": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
@ -13904,6 +13946,12 @@
"integrity": "sha1-n5up2e+odkw4dpi8v+sshI8RrbM=",
"dev": true
},
"ts-essentials": {
"version": "4.0.0",
"resolved": "https://registry.npm.taobao.org/ts-essentials/download/ts-essentials-4.0.0.tgz",
"integrity": "sha1-UGxCsnC70EZVdLkEFlMxdbCSBas=",
"dev": true
},
"ts-jest": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.1.tgz",
@ -14313,6 +14361,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"validator": {
"version": "13.0.0",
"resolved": "https://registry.npm.taobao.org/validator/download/validator-13.0.0.tgz",
"integrity": "sha1-D7bGu1IY6iPTaKg0fm0PWnDjvKs="
},
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -21,12 +21,14 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^7.2.0",
"@nestjs/common": "^7.0.0",
"@nestjs/config": "^0.5.0",
"@nestjs/core": "^7.2.0",
"@nestjs/core": "^7.0.0",
"@nestjs/jwt": "^7.0.0",
"@nestjs/passport": "^7.1.0",
"@nestjs/platform-express": "^7.2.0",
"@nestjs/platform-express": "^7.0.0",
"class-transformer": "^0.2.3",
"class-validator": "^0.12.2",
"mongoose": "^5.9.20",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
@ -38,10 +40,10 @@
"devDependencies": {
"@commitlint/cli": "^9.0.1",
"@commitlint/config-conventional": "^9.0.1",
"@golevelup/nestjs-testing": "^0.1.2",
"@golevelup/ts-jest": "^0.3.0",
"@nestjs/cli": "^7.4.1",
"@nestjs/schematics": "^7.0.0",
"@nestjs/testing": "^7.2.0",
"@nestjs/testing": "^7.0.0",
"@types/express": "^4.17.3",
"@types/jest": "26.0.3",
"@types/mongoose": "^5.7.29",
@ -56,6 +58,7 @@
"eslint-plugin-import": "^2.22.0",
"husky": "^4.2.5",
"jest": "26.1.0",
"jest-mock-extended": "^1.0.9",
"prettier": "^2.0.5",
"supertest": "^4.0.2",
"ts-jest": "26.1.1",

View File

@ -1,5 +1,5 @@
import { createMock } from '@golevelup/nestjs-testing';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { createMock } from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtAuthGuard } from './jwt-auth.guard';

View File

@ -1,12 +1,24 @@
import { RolesGuard } from './roles.guard';
import { Reflector } from '@nestjs/core';
import { TestingModule, Test } from '@nestjs/testing';
import { createMock } from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';
import { createMock } from '@golevelup/nestjs-testing';
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import {
anyFunction,
anyString,
instance,
mock,
verify,
when,
reset,
} from 'ts-mockito';
import { mock as jestMock, any, mockClear, mockDeep } from 'jest-mock-extended';
import { RoleType } from '../database/role-type.enum';
import { HAS_ROLES_KEY } from './auth.constants';
import { AuthenticatedRequest } from './authenticated-request.interface';
import { RolesGuard } from './roles.guard';
describe('RolesGuard', () => {
xdescribe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
@ -27,59 +39,208 @@ describe('RolesGuard', () => {
reflector = module.get<Reflector>(Reflector);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
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);
jest.spyOn(reflector, 'get').mockImplementation((a: any, b: any) => []);
const context = createMock<ExecutionContext>();
const result = await guard.canActivate(context);
expect(result).toBeTruthy();
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>({
jest
.spyOn(reflector, 'get')
.mockImplementation((a: any, b: any) => [RoleType.USER]);
const context = createMock<ExecutionContext>({
getHandler: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
user: { roles: [RoleType.USER]},
user: { roles: [RoleType.USER] },
} as AuthenticatedRequest),
}),
}),
),
).toBe(true);
});
const result = await guard.canActivate(context);
expect(result).toBeTruthy();
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];
jest.spyOn(reflector, 'get').mockReturnValue([RoleType.ADMIN]);
const request = {
user: { roles: [RoleType.USER] },
} as AuthenticatedRequest;
const context = createMock<ExecutionContext>();
const httpArgsHost = createMock<HttpArgumentsHost>({
getRequest: () => request,
});
expect(
guard.canActivate(
createMock<ExecutionContext>({
getHandler: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
user: { roles: [RoleType.USER]},
} as AuthenticatedRequest),
}),
}),
),
).toBe(false);
context.switchToHttp.mockImplementation(() => httpArgsHost);
const result = await guard.canActivate(context);
expect(result).toBeFalsy();
expect(reflector.get).toBeCalled();
});
});
describe('RolesGuard(ts-mockito)', () => {
let guard: RolesGuard;
const reflecter = mock(Reflector);
beforeEach(() => {
guard = new RolesGuard(instance(reflecter));
});
afterEach(() => {
reset();
});
it('should skip(return true) if the `HasRoles` decorator is not set', async () => {
const context = mock<ExecutionContext>();
when(context.getHandler()).thenReturn({} as any);
const contextInstacne = instance(context);
when(
reflecter.get<RoleType[]>(HAS_ROLES_KEY, contextInstacne.getHandler()),
).thenReturn([] as RoleType[]);
const result = await guard.canActivate(contextInstacne);
expect(result).toBeTruthy();
verify(
reflecter.get<RoleType[]>(HAS_ROLES_KEY, contextInstacne.getHandler()),
).once();
});
it('should return true if the `HasRoles` decorator is set', async () => {
const context = mock<ExecutionContext>();
when(context.getHandler()).thenReturn({} as any);
const arguHost = mock<HttpArgumentsHost>();
when(arguHost.getRequest()).thenReturn({
user: { roles: [RoleType.USER] },
} as any);
when(context.switchToHttp()).thenReturn(instance(arguHost));
const contextInstacne = instance(context);
when(
reflecter.get<RoleType[]>(HAS_ROLES_KEY, contextInstacne.getHandler()),
).thenReturn([RoleType.USER] as RoleType[]);
const result = await guard.canActivate(contextInstacne);
console.log(result);
expect(result).toBeTruthy();
verify(
reflecter.get<RoleType[]>(HAS_ROLES_KEY, contextInstacne.getHandler()),
).once();
});
it('should return false if the `HasRoles` decorator is set but role is not allowed', async () => {
const context = mock<ExecutionContext>();
when(context.getHandler()).thenReturn({} as any);
// logged in as USER
const arguHost = mock<HttpArgumentsHost>();
when(arguHost.getRequest()).thenReturn({
user: { roles: [RoleType.USER] },
} as any);
when(context.switchToHttp()).thenReturn(instance(arguHost));
const contextInstacne = instance(context);
// but requires ADMIN
when(
reflecter.get<RoleType[]>(HAS_ROLES_KEY, contextInstacne.getHandler()),
).thenReturn([RoleType.ADMIN] as RoleType[]);
const result = await guard.canActivate(contextInstacne);
console.log(result);
expect(result).toBeFalsy();
verify(
reflecter.get<RoleType[]>(HAS_ROLES_KEY, contextInstacne.getHandler()),
).once();
});
});
describe('RoelsGuard(jest-mock-extended)', () => {
let guard: RolesGuard;
const reflecter = jestMock<Reflector>();
beforeEach(() => {
guard = new RolesGuard(reflecter);
});
afterEach(() => {
mockClear(reflecter);
});
it('should be defined', () => {
expect(guard).toBeDefined();
});
it('should skip(return true) if the `HasRoles` decorator is not set', async () => {
const context = jestMock<ExecutionContext>();
context.getHandler.mockReturnValue({} as any);
reflecter.get
.mockReturnValue([])
.calledWith(HAS_ROLES_KEY, context.getHandler());
const result = await guard.canActivate(context);
expect(result).toBeTruthy();
expect(reflecter.get).toBeCalledTimes(1);
});
it('should return true if the `HasRoles` decorator is set', async () => {
const context = jestMock<ExecutionContext>();
context.getHandler.mockReturnValue({} as any);
const arguHost = jestMock<HttpArgumentsHost>();
arguHost.getRequest.mockReturnValue({
user: { roles: [RoleType.USER] },
} as any);
context.switchToHttp.mockReturnValue(arguHost);
reflecter.get
.mockReturnValue([RoleType.USER])
.calledWith(HAS_ROLES_KEY, context.getHandler());
const result = await guard.canActivate(context);
expect(result).toBeTruthy();
expect(reflecter.get).toBeCalledTimes(1);
});
it('should return false if the `HasRoles` decorator is set but role is not allowed', async () => {
// logged in as USER
const context = jestMock<ExecutionContext>();
context.getHandler.mockReturnValue({} as any);
const arguHost = jestMock<HttpArgumentsHost>();
arguHost.getRequest.mockReturnValue({
user: { roles: [RoleType.USER] },
} as any);
context.switchToHttp.mockReturnValue(arguHost);
//but requires ADMIN
reflecter.get
.mockReturnValue([RoleType.ADMIN])
.calledWith(HAS_ROLES_KEY, context.getHandler());
const result = await guard.canActivate(context);
expect(result).toBeFalsy();
expect(reflecter.get).toBeCalledTimes(1);
});
});

View File

@ -1,6 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { RoleType } from '../database/role-type.enum';
import { HAS_ROLES_KEY } from './auth.constants';
import { AuthenticatedRequest } from './authenticated-request.interface';
@ -15,11 +15,13 @@ export class RolesGuard implements CanActivate {
HAS_ROLES_KEY,
context.getHandler(),
);
if (!roles) {
if (!roles || roles.length == 0) {
return true;
}
const { user }= context.switchToHttp().getRequest() as AuthenticatedRequest;
return user.roles && user.roles.some(r => roles.includes(r));
const {
user,
} = context.switchToHttp().getRequest() as AuthenticatedRequest;
return user.roles && user.roles.some((r) => roles.includes(r));
}
}

View File

@ -21,6 +21,8 @@ export const databaseProviders = [
connect(dbConfig.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
//see: https://mongoosejs.com/docs/deprecations.html#findandmodify
useFindAndModify: false
}),
inject: [mongodbConfig.KEY],
},

View File

@ -1,3 +1,4 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
@ -7,6 +8,7 @@ async function bootstrap() {
// enable shutdown hooks explicitly.
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe());
app.enableCors();
//app.useLogger();
await app.listen(3000);

View File

@ -1,3 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class CreateCommentDto {
@IsNotEmpty()
readonly content: string;
}

View File

@ -1,4 +1,9 @@
import { IsNotEmpty } from 'class-validator';
export class CreatePostDto {
@IsNotEmpty()
readonly title: string;
@IsNotEmpty()
readonly content: string;
}

View File

@ -0,0 +1,28 @@
import * as mongoose from 'mongoose';
import { ParseObjectIdPipe } from './parse-object-id.pipe';
describe('ParseObjectIdPipe', () => {
let isObjectId;
beforeEach(() => {
isObjectId = new ParseObjectIdPipe();
});
it('should be defined', () => {
expect(isObjectId).toBeDefined();
});
it('if valid', () => {
const validId = new mongoose.Types.ObjectId().toHexString();
const result = isObjectId.transform(validId, {} as any);
expect(result).toEqual(validId);
});
it('if invalid', () => {
try {
const result = isObjectId.transform('anerror', {} as any);
} catch (e) {
expect(e).not.toBeNull();
}
});
});

View File

@ -0,0 +1,17 @@
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common';
import * as mongoose from 'mongoose';
@Injectable()
export class ParseObjectIdPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata) {
if (!mongoose.isValidObjectId(value)) {
throw new BadRequestException(`$value is not a valid mongoose object id`);
}
return value;
}
}

View File

@ -2,7 +2,7 @@ import {
Inject,
Injectable,
OnModuleDestroy,
OnModuleInit
OnModuleInit,
} from '@nestjs/common';
import { Model } from 'mongoose';
import { Comment } from '../database/comment.model';
@ -32,33 +32,21 @@ export class PostDataInitializerService
@Inject(POST_MODEL) private postModel: Model<Post>,
@Inject(COMMENT_MODEL) private commentModel: Model<Comment>,
) {}
onModuleInit(): void {
console.log('(PostModule) is initialized...');
this.data.forEach(d => {
this.postModel.create(d).then(saved => console.log(saved));
});
this.postModel
.create({
title: 'Model relations in Mongoose',
content: 'content of Model relations in Mongoose',
})
.then(post =>
this.commentModel.create({
post: { _id: post._id },
content: 'comment of Model relations in Mongoose',
}),
)
.then(saved => console.log(saved));
this.postModel.insertMany(this.data).then((r) => console.log(r));
// Promise.all(this.data.map((d) => this.postModel.create(d))).then((saved) =>
// console.log(saved),
// );
}
onModuleDestroy(): void {
console.log('(PostModule) is being destroyed...');
this.postModel
.deleteMany({})
.then(del => console.log(`deleted ${del.deletedCount} rows of posts`));
this.commentModel
.deleteMany({})
.then(del => console.log(`deleted ${del.deletedCount} rows of comments`));
Promise.all([
this.postModel.deleteMany({}),
this.commentModel.deleteMany({}),
]).then((data) => {
console.log(data);
});
}
}

View File

@ -7,7 +7,7 @@ import { PostController } from './post.controller';
import { PostService } from './post.service';
import { PostServiceStub } from './post.service.stub';
import { UpdatePostDto } from './update-post.dto';
import { createMock } from '@golevelup/nestjs-testing';
import { createMock } from '@golevelup/ts-jest';
import { Response } from 'express';
describe('Post Controller', () => {

View File

@ -26,6 +26,7 @@ import { CreatePostDto } from './create-post.dto';
import { PostService } from './post.service';
import { UpdatePostDto } from './update-post.dto';
import { map } from 'rxjs/operators';
import { ParseObjectIdPipe } from './parse-object-id.pipe';
@Controller({ path: 'posts', scope: Scope.REQUEST })
export class PostController {
@ -41,7 +42,7 @@ export class PostController {
}
@Get(':id')
getPostById(@Param('id') id: string): Observable<BlogPost> {
getPostById(@Param('id', ParseObjectIdPipe) id: string): Observable<BlogPost> {
return this.postService.findById(id);
}
@ -66,7 +67,7 @@ export class PostController {
@UseGuards(JwtAuthGuard, RolesGuard)
@HasRoles(RoleType.USER, RoleType.ADMIN)
updatePost(
@Param('id') id: string,
@Param('id', ParseObjectIdPipe) id: string,
@Body() post: UpdatePostDto,
@Res() res: Response,
): Observable<Response> {
@ -81,7 +82,7 @@ export class PostController {
@UseGuards(JwtAuthGuard, RolesGuard)
@HasRoles(RoleType.ADMIN)
deletePostById(
@Param('id') id: string,
@Param('id', ParseObjectIdPipe) id: string,
@Res() res: Response,
): Observable<Response> {
return this.postService.deleteById(id).pipe(
@ -95,7 +96,7 @@ export class PostController {
@UseGuards(JwtAuthGuard, RolesGuard)
@HasRoles(RoleType.USER)
createCommentForPost(
@Param('id') id: string,
@Param('id', ParseObjectIdPipe) id: string,
@Body() data: CreateCommentDto,
@Res() res: Response,
): Observable<Response> {
@ -110,7 +111,7 @@ export class PostController {
}
@Get(':id/comments')
getAllCommentsOfPost(@Param('id') id: string): Observable<Comment[]> {
getAllCommentsOfPost(@Param('id', ParseObjectIdPipe) id: string): Observable<Comment[]> {
return this.postService.commentsOf(id);
}
}

View File

@ -1,9 +1,9 @@
import { REQUEST } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { FilterQuery, Model } from 'mongoose';
import { Comment } from '../database/comment.model';
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', () => {
@ -28,6 +28,7 @@ describe('PostService', () => {
exec: jest.fn(),
deleteMany: jest.fn(),
deleteOne: jest.fn(),
updateOne: jest.fn(),
findOneAndUpdate: jest.fn(),
findOneAndDelete: jest.fn(),
},
@ -39,6 +40,8 @@ describe('PostService', () => {
constructor: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
updateOne: jest.fn(),
deleteOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
@ -119,7 +122,8 @@ describe('PostService', () => {
});
});
it('findById with an existing id should return one post', (done) => {
describe('findByid', () => {
it('if exists return one post', (done) => {
const found = {
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
@ -140,6 +144,23 @@ describe('PostService', () => {
});
});
it('if not found throw an NotFoundException', (done) => {
jest.spyOn(model, 'findOne').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(null) as any,
} as any);
service.findById('1').subscribe({
next: (data) => {
console.log(data);
},
error: (error) => {
expect(error).toBeDefined();
},
complete: done(),
});
});
});
it('should save post', async () => {
const toCreated = {
title: 'test title',
@ -164,7 +185,8 @@ describe('PostService', () => {
expect(model.create).toBeCalledTimes(1);
});
it('should update post', (done) => {
describe('update', () => {
it('perform update if post exists', (done) => {
const toUpdated = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
@ -172,19 +194,41 @@ describe('PostService', () => {
};
jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(toUpdated) as any,
exec: jest.fn().mockResolvedValue(toUpdated) as any,
} as any);
service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({
next: (data) => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(data).toBeTruthy();
expect(model.findOneAndUpdate).toBeCalled();
},
error: (error) => console.log(error),
complete: done(),
});
});
it('should delete post', (done) => {
it('throw an NotFoundException if post not exists', (done) => {
const toUpdated = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
content: 'test content',
};
jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({
exec: jest.fn().mockResolvedValue(null) as any,
} as any);
service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({
error: (error) => {
expect(error).toBeDefined();
expect(model.findOneAndUpdate).toHaveBeenCalledTimes(1);
},
complete: done(),
});
});
});
describe('delete', () => {
it('perform delete if post exists', (done) => {
const toDeleted = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
@ -195,12 +239,29 @@ describe('PostService', () => {
} as any);
service.deleteById('anystring').subscribe({
next: (data) => expect(data._id).toEqual('5ee49c3115a4e75254bb732e'),
next: (data) => {
expect(data).toBeTruthy();
expect(model.findOneAndDelete).toBeCalled();
},
error: (error) => console.log(error),
complete: done(),
});
});
it('throw an NotFoundException if post not exists', (done) => {
jest.spyOn(model, 'findOneAndDelete').mockReturnValue({
exec: jest.fn().mockResolvedValue(null),
} as any);
service.deleteById('anystring').subscribe({
error: (error) => {
expect(error).toBeDefined();
expect(model.findOneAndDelete).toBeCalledTimes(1);
},
complete: done(),
});
});
});
it('should delete all post', (done) => {
jest.spyOn(model, 'deleteMany').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce({
@ -240,17 +301,13 @@ describe('PostService', () => {
callback?: (err: any, res: Comment[]) => void,
) => {
return {
select: jest
.fn()
.mockReturnValue({
exec: jest
.fn()
.mockResolvedValue([
select: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue([
{
_id: 'test',
content: 'content',
post: { _id: '_test_id' },
}
},
] as any),
}),
} as any;

View File

@ -54,7 +54,7 @@ export class PostServiceStub implements Pick<PostService, keyof PostService> {
}
deleteById(id: string): Observable<Post> {
return of({ ...this.posts[0], _id: id } as Post);
return of({ _id: id, title:'test title', content:'content' } as Post);
}
deleteAll(): Observable<any> {

View File

@ -1,7 +1,7 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Inject, Injectable, Scope, NotFoundException } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Model } from 'mongoose';
import { from, Observable } from 'rxjs';
import { from, Observable, EMPTY, of } from 'rxjs';
import { AuthenticatedRequest } from '../auth/authenticated-request.interface';
import { Comment } from '../database/comment.model';
import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants';
@ -9,7 +9,7 @@ import { Post } from '../database/post.model';
import { CreateCommentDto } from './create-comment.dto';
import { CreatePostDto } from './create-post.dto';
import { UpdatePostDto } from './update-post.dto';
import { throwIfEmpty, flatMap, filter, map, switchMap } from 'rxjs/operators';
@Injectable({ scope: Scope.REQUEST })
export class PostService {
@ -29,18 +29,15 @@ export class PostService {
.exec(),
);
} else {
return from(
this.postModel
.find({})
.skip(skip)
.limit(limit)
.exec(),
);
return from(this.postModel.find({}).skip(skip).limit(limit).exec());
}
}
findById(id: string): Observable<Post> {
return from(this.postModel.findOne({ _id: id }).exec());
return from(this.postModel.findOne({ _id: id }).exec()).pipe(
flatMap((p) => (p ? of(p) : EMPTY)),
throwIfEmpty(() => new NotFoundException(`post:$id was not found`)),
);
}
save(data: CreatePostDto): Observable<Post> {
@ -58,13 +55,39 @@ export class PostService {
.findOneAndUpdate(
{ _id: id },
{ ...data, updatedBy: { _id: this.req.user.id } },
{ new: true },
)
.exec(),
).pipe(
flatMap((p) => (p ? of(p) : EMPTY)),
throwIfEmpty(() => new NotFoundException(`post:$id was not found`)),
);
// const filter = { _id: id };
// const update = { ...data, updatedBy: { _id: this.req.user.id } };
// return from(this.postModel.findOne(filter).exec()).pipe(
// flatMap((post) => (post ? of(post) : EMPTY)),
// throwIfEmpty(() => new NotFoundException(`post:$id was not found`)),
// switchMap((p, i) => {
// return from(this.postModel.updateOne(filter, update).exec());
// }),
// map((res) => res.nModified),
// );
}
deleteById(id: string): Observable<Post> {
return from(this.postModel.findOneAndDelete({ _id: id }).exec());
return from(this.postModel.findOneAndDelete({ _id: id }).exec()).pipe(
flatMap((p) => (p ? of(p) : EMPTY)),
throwIfEmpty(() => new NotFoundException(`post:$id was not found`)),
);
// const filter = { _id: id };
// return from(this.postModel.findOne(filter).exec()).pipe(
// flatMap((post) => (post ? of(post) : EMPTY)),
// throwIfEmpty(() => new NotFoundException(`post:$id was not found`)),
// switchMap((p, i) => {
// return from(this.postModel.deleteOne(filter).exec());
// }),
// map((res) => res.deletedCount),
// );
}
deleteAll(): Observable<any> {

View File

@ -1,4 +1,10 @@
import { IsNotEmpty } from "class-validator";
export class UpdatePostDto {
@IsNotEmpty()
readonly title: string;
@IsNotEmpty()
readonly content: string;
}

View File

@ -5,31 +5,39 @@ import {
OnModuleInit,
} from '@nestjs/common';
import { Model } from 'mongoose';
import { RoleType } from '../database/role-type.enum';
import { USER_MODEL } from '../database/database.constants';
import { RoleType } from '../database/role-type.enum';
import { User } from '../database/user.model';
@Injectable()
export class UserDataInitializerService
implements OnModuleInit, OnModuleDestroy {
constructor(@Inject(USER_MODEL) private userModel: Model<User>) {
//console.log(`userModel in UserDataInitializerService:${userModel}`);
}
constructor(@Inject(USER_MODEL) private userModel: Model<User>) {}
onModuleInit(): void {
console.log('(UserModule) is initialized...');
this.userModel
.create({
const user = {
username: 'hantsy',
password: 'password',
email: 'hantsy@example.com',
roles: [RoleType.USER],
})
.then(data => console.log(data));
};
const admin = {
username: 'admin',
password: 'password',
email: 'admin@example.com',
roles: [RoleType.ADMIN],
};
[user, admin].map((u) =>
this.userModel.create(u).then((data) => console.log(data)),
);
}
onModuleDestroy(): void {
console.log('(UserModule) is being destroyed...');
this.userModel
.deleteMany({})
.then(del => console.log(`deleted ${del.deletedCount} rows`));
this.userModel.deleteMany({}).then((del) => {
console.log(`deleted ${del.deletedCount} rows`);
});
}
}

View File

@ -1,24 +1,186 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import * as mongoose from 'mongoose';
describe('AppController (e2e)', () => {
describe('API endpoints testing (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe());
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
afterAll(async () => {
await app.close();
});
describe('if user is not logged in', () => {
it('/posts (GET)', async () => {
const res = await request(app.getHttpServer()).get('/posts').send();
expect(res.status).toBe(200);
expect(res.body.length).toEqual(3);
});
it('/posts (GET) if none existing should return 404', async () => {
const id = mongoose.Types.ObjectId();
const res = await request(app.getHttpServer()).get('/posts/' + id);
expect(res.status).toBe(404);
});
it('/posts (GET) if invalid id should return 400', async () => {
const id = "invalidid";
const res = await request(app.getHttpServer()).get('/posts/' + id);
expect(res.status).toBe(400);
});
it('/posts (POST) should return 401', async () => {
const res = await request(app.getHttpServer())
.post('/posts')
.send({ title: 'test title', content: 'test content' });
expect(res.status).toBe(401);
});
it('/posts (PUT) should return 401', async () => {
const id = mongoose.Types.ObjectId();
const res = await request(app.getHttpServer())
.put('/posts/' + id)
.send({ title: 'test title', content: 'test content' });
expect(res.status).toBe(401);
});
it('/posts (DELETE) should return 401', async () => {
const id = mongoose.Types.ObjectId();
const res = await request(app.getHttpServer())
.delete('/posts/' + id)
.send();
expect(res.status).toBe(401);
});
});
describe('if user is logged in as (USER)', () => {
let jwttoken: any;
beforeEach(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ username: 'hantsy', password: 'password' });
expect(res.status).toBe(201);
jwttoken = res.body.access_token;
//console.log(jwttoken);
});
it('/posts (GET)', async () => {
const res = await request(app.getHttpServer()).get('/posts');
expect(res.status).toBe(200);
expect(res.body.length).toEqual(3);
});
it('/posts (POST) with empty body should return 400', async () => {
const res = await request(app.getHttpServer())
.post('/posts')
.set('Authorization', 'Bearer ' + jwttoken)
.send({});
expect(res.status).toBe(400);
});
it('/posts (PUT) if none existing should return 404', async () => {
const id = mongoose.Types.ObjectId();
const res = await request(app.getHttpServer())
.put('/posts/' + id)
.set('Authorization', 'Bearer ' + jwttoken)
.send({ title: 'test title', content: 'test content' });
expect(res.status).toBe(404);
});
it('/posts (DELETE) if none existing should return 403', async () => {
const id = mongoose.Types.ObjectId();
const res = await request(app.getHttpServer())
.delete('/posts/' + id)
.set('Authorization', 'Bearer ' + jwttoken)
.send();
expect(res.status).toBe(403);
});
it('/posts crud flow', async () => {
//create a post
const res = await request(app.getHttpServer())
.post('/posts')
.set('Authorization', 'Bearer ' + jwttoken)
.send({ title: 'test title', content: 'test content' });
expect(res.status).toBe(201);
const saveduri = res.get('Location');
//console.log(saveduri);
// get the saved post
const resget = await request(app.getHttpServer()).get(saveduri);
expect(resget.status).toBe(200);
expect(resget.body.title).toBe('test title');
expect(resget.body.content).toBe('test content');
expect(resget.body.createdAt).toBeDefined();
// update the post
const updateres = await request(app.getHttpServer())
.put(saveduri)
.set('Authorization', 'Bearer ' + jwttoken)
.send({ title: 'updated title', content: 'updated content' });
expect(updateres.status).toBe(204);
// verify the updated post
const updatedres = await request(app.getHttpServer()).get(saveduri);
expect(updatedres.status).toBe(200);
expect(updatedres.body.title).toBe('updated title');
expect(updatedres.body.content).toBe('updated content');
expect(updatedres.body.updatedAt).toBeDefined();
// creat a comment
const commentres = await request(app.getHttpServer())
.post(saveduri + '/comments')
.set('Authorization', 'Bearer ' + jwttoken)
.send({ content: 'test content' });
expect(commentres.status).toBe(201);
expect(commentres.get('Location')).toBeTruthy();
// get the comments of post
const getCommentsRes = await request(app.getHttpServer()).get(
saveduri + '/comments',
);
expect(getCommentsRes.status).toBe(200);
expect(getCommentsRes.body.length).toEqual(1);
// delete the posts
const deleteRes = await request(app.getHttpServer())
.delete(saveduri)
.set('Authorization', 'Bearer ' + jwttoken)
.send();
expect(deleteRes.status).toBe(403);
});
});
describe('if user is logged in as (ADMIN)', () => {
let jwttoken: any;
beforeEach(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ username: 'admin', password: 'password' });
jwttoken = res.body.access_token;
// console.log(jwttoken);
});
it('/posts (DELETE) if none existing should return 404', async () => {
const id = mongoose.Types.ObjectId();
const res = await request(app.getHttpServer())
.delete('/posts/' + id)
.set('Authorization', 'Bearer ' + jwttoken)
.send();
expect(res.status).toBe(404);
});
});
});