Skip to content

Commit

Permalink
blog-template: server: add more unit tests
Browse files Browse the repository at this point in the history
* also add decorator for request.res and update user create method
  • Loading branch information
wight554 committed Feb 7, 2022
1 parent 1cb1a2a commit a67d34b
Show file tree
Hide file tree
Showing 14 changed files with 10,866 additions and 8,284 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ dist
dist-ssr
*.local

coverage

.vscode
18,708 changes: 10,442 additions & 8,266 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build": "npm run build:client && npm run build:server",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "npm run test -- --coverage",
"lint": "eslint \"{src,server}/**/*.{ts,tsx}\"",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"server/**/*.ts\"",
Expand All @@ -35,6 +36,7 @@
"rxjs": "^7.5.2"
},
"devDependencies": {
"@babel/preset-env": "^7.16.11",
"@nestjs/testing": "^8.2.6",
"@preact/preset-vite": "^2.1.5",
"@testing-library/preact": "^2.0.1",
Expand All @@ -48,6 +50,7 @@
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
"alias-hq": "^5.3.2",
"c8": "^7.11.0",
"eslint": "^8.8.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.3.0",
Expand Down
8 changes: 4 additions & 4 deletions server/auth/AuthController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Request } from 'express';
import { Response } from 'express';
import {
Controller,
Post,
Expand All @@ -8,7 +8,6 @@ import {
Param,
ForbiddenException,
UseInterceptors,
Req,
} from '@nestjs/common';

import {
Expand All @@ -25,6 +24,7 @@ import { JwtAuthGuard } from '@server/auth/guards/JwtAuthGuard';
import { User } from '@server/decorators/UserDecorator';
import { User as UserType } from '@server/user/schemas/UserSchema';
import { MongooseClassSerializerInterceptor } from '@server/interceptors/MongooseClassSerializerInterceptor';
import { ReqRes } from '@server/decorators/ReqResDecorator';

@Controller(AUTH_CONTROLLER_ROUTE)
@UseInterceptors(MongooseClassSerializerInterceptor(UserType))
Expand All @@ -33,10 +33,10 @@ export class AuthController {

@UseGuards(LocalAuthGuard)
@Post(AUTH_LOGIN_ENDPOINT)
async login(@Req() req: Request, @User() user: UserType) {
async login(@ReqRes() res: Response, @User() user: UserType) {
const cookie = this.authService.getCookieWithJwtToken(user.id);

req.res?.setHeader('Set-Cookie', cookie);
res.setHeader('Set-Cookie', cookie);

return user;
}
Expand Down
6 changes: 6 additions & 0 deletions server/decorators/ReqResDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const ReqRes = createParamDecorator((_: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.res;
});
4 changes: 2 additions & 2 deletions server/user/UserService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ export class UserService {
constructor(@InjectModel(User.name) private userModel: Model<UserDocument>) {}

async create(user: CreateUserDto): Promise<UserDocument> {
const createdUser = new this.userModel(user);
let createdUser: UserDocument | null;

try {
await createdUser.save();
createdUser = await this.userModel.create(user);
} catch (error: any) {
if (error?.code === MongoError.DuplicateKey) {
throw new BadRequestException('User with that username already exists');
Expand Down
34 changes: 24 additions & 10 deletions test/server/auth/AuthController.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ForbiddenException } from '@nestjs/common';
import { response } from 'express';
import sinon from 'sinon';

import { AuthController } from '@server/auth/AuthController';
import { AuthService } from '@server/auth/AuthService';
import { UserDocument } from '@server/user/schemas/UserSchema';
import { response, request } from 'express';
import sinon from 'sinon';

const cookieMock = 'cookie';

Expand All @@ -17,8 +19,6 @@ const upsertUserMock = {
};

const mockResponse = sinon.stub(response);
const mockRequest = sinon.stub(request);
mockRequest.res = mockResponse;

const mockAuthService = sinon.createStubInstance(AuthService);

Expand All @@ -35,21 +35,21 @@ describe('AuthController', () => {

describe('login', () => {
it('should get token', async () => {
await authController.login(mockRequest, userMock);
await authController.login(mockResponse, userMock);

sinon.assert.calledWith(mockAuthService.getCookieWithJwtToken, userMock.id);
});

it('should set cookie header', async () => {
mockAuthService.getCookieWithJwtToken.returns(cookieMock);

await authController.login(mockRequest, userMock);
await authController.login(mockResponse, userMock);

sinon.assert.calledWith(mockResponse.setHeader, 'Set-Cookie', cookieMock);
});

it('should return user', async () => {
expect(await authController.login(mockRequest, userMock)).toBe(userMock);
expect(await authController.login(mockResponse, userMock)).toBe(userMock);
});
});

Expand Down Expand Up @@ -82,10 +82,24 @@ describe('AuthController', () => {
describe('update', () => {
const userId = '1';

it('should update user', async () => {
await authController.update(userId, upsertUserMock, userMock);
describe('user id param matches current user id', () => {
it('should update user', async () => {
await authController.update(userId, upsertUserMock, userMock);

sinon.assert.calledWith(mockAuthService.updateUser, userId, upsertUserMock);
});
});

sinon.assert.calledWith(mockAuthService.updateUser, userId, upsertUserMock);
describe('user id param does not match current user id', () => {
it('should update user', async () => {
const badUserId = '2';

try {
await authController.update(badUserId, upsertUserMock, userMock);
} catch (error) {
expect(error).toBeInstanceOf(ForbiddenException);
}
});
});

describe('auth service success', () => {
Expand Down
65 changes: 63 additions & 2 deletions test/server/auth/AuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const userMock = <UserDocument>{
password,
};

const updatedUserMock = <UserDocument>{
...userMock,
username: 'username 2',
};

const upsertUserMock = {
username,
password,
Expand Down Expand Up @@ -88,8 +93,8 @@ describe('AuthService', () => {
});
});

describe('crypto service success', () => {
describe('createUser', () => {
describe('createUser', () => {
describe('crypto service success', () => {
it('should get hashed password from crypto service', async () => {
await authService.createUser(upsertUserMock);

Expand Down Expand Up @@ -143,5 +148,61 @@ describe('AuthService', () => {
});
});
});

describe('updateUser', () => {
describe('crypto service success', () => {
it('should get hashed password from crypto service', async () => {
await authService.updateUser(userId, upsertUserMock);

sinon.assert.calledWith(mockCryptoService.hash, password, 10);
});

it('should update user', async () => {
const hashedPassword = 'hashedPassword';
mockCryptoService.hash.resolves(hashedPassword);

await authService.updateUser(userId, upsertUserMock);

sinon.assert.calledWith(mockUserService.update, userId, {
...upsertUserMock,
password: hashedPassword,
});
});
});

describe('crypto service error', () => {
it('should throw error', async () => {
const error = new Error('Internal Error');
mockCryptoService.hash.rejects(error);

expect.assertions(1);

await expect(authService.updateUser(userId, upsertUserMock)).rejects.toEqual(error);
});
});

describe('user service success', () => {
it('should return updated user', async () => {
const hashedPassword = 'hashedPassword';
mockCryptoService.hash.resolves(hashedPassword);
mockUserService.update.resolves(updatedUserMock);

expect(await authService.updateUser(userId, upsertUserMock)).toBe(updatedUserMock);
});
});

describe('user service error', () => {
it('should throw error', async () => {
const hashedPassword = 'hashedPassword';
const error = new Error('Internal Error');
mockCryptoService.hash.resolves(hashedPassword);
mockUserService.update.rejects(error);

expect.assertions(1);

await expect(authService.updateUser(userId, upsertUserMock)).rejects.toEqual(error);
});
});
});
});
});
29 changes: 29 additions & 0 deletions test/server/crypto/CryptoService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CryptoService } from '@server/crypto/CryptoService';

const comparisonResult = true;
const hashedString = 'hashedString';

vi.mock('bcrypt', () => ({
compare: () => comparisonResult,
hash: () => hashedString,
}));

describe('CryptoService', () => {
let cryptoService: CryptoService;

beforeEach(() => {
cryptoService = new CryptoService();
});

describe('compare', () => {
it('should return comparison result', async () => {
expect(cryptoService.compare('a', 'b')).toEqual(comparisonResult);
});
});

describe('hash', () => {
it('should return hashed string', async () => {
expect(cryptoService.hash('a', 10)).toEqual(hashedString);
});
});
});
27 changes: 27 additions & 0 deletions test/server/decorators/ReqResDecorator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ExecutionContext, HttpArgumentsHost } from '@nestjs/common/interfaces';

import { getParamDecoratorFactory } from '@test/utils/get-param-decorator-factory';
import { ReqRes } from '@server/decorators/ReqResDecorator';

const responseMock = 'response';

const switchToHttpMock = (): HttpArgumentsHost => ({
getRequest: vi.fn().mockReturnValue({
res: responseMock,
}),
getResponse: vi.fn(),
getNext: vi.fn(),
});

const ctxMock = <ExecutionContext>{
switchToHttp: switchToHttpMock,
};

describe('ReqResDecorator', function () {
it('should return response', function () {
const factory = getParamDecoratorFactory(ReqRes);
const result = factory(null, ctxMock);

expect(result).toBe(responseMock);
});
});
32 changes: 32 additions & 0 deletions test/server/decorators/UserDecorator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ExecutionContext, HttpArgumentsHost } from '@nestjs/common/interfaces';

import { User } from '@server/decorators/UserDecorator';
import { UserDocument } from '@server/user/schemas/UserSchema';
import { getParamDecoratorFactory } from '@test/utils/get-param-decorator-factory';

const userMock = <UserDocument>{
id: '1',
username: 'username',
password: 'password',
};

const switchToHttpMock = (): HttpArgumentsHost => ({
getRequest: vi.fn().mockReturnValue({
user: userMock,
}),
getResponse: vi.fn(),
getNext: vi.fn(),
});

const ctxMock = <ExecutionContext>{
switchToHttp: switchToHttpMock,
};

describe('UserDecorator', function () {
it('should return user', function () {
const factory = getParamDecoratorFactory(User);
const result = factory(null, ctxMock);

expect(result).toBe(userMock);
});
});

0 comments on commit a67d34b

Please sign in to comment.