Skip to content

Commit

Permalink
full test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
marode-cap committed Oct 17, 2023
1 parent 8f7f7dc commit 59fb6a9
Show file tree
Hide file tree
Showing 15 changed files with 236 additions and 36 deletions.
3 changes: 2 additions & 1 deletion config/config.dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
},
"FRONTEND": {
"PORT": 9091,
"SESSION_SECRET": "DevelopmentSecret"
"SESSION_SECRET": "DevelopmentSecret",
"BACKEND_ADDRESS": "http://127.0.0.1:9090"
},
"DB": {
"CLIENT_URL": "postgres://admin:[email protected]:5432",
Expand Down
3 changes: 2 additions & 1 deletion config/config.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"PORT": 8080
},
"FRONTEND": {
"PORT": 8081
"PORT": 8081,
"BACKEND_ADDRESS": "http://127.0.0.1:8080"
},
"DB": {
"CLIENT_URL": "postgres://127.0.0.1:5432",
Expand Down
7 changes: 4 additions & 3 deletions src/modules/frontend/api/authentication.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticatedGuard } from './authentication.guard.js';
import { createMock } from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';
import { SessionData } from './session.js';
import { HttpArgumentsHost } from '@nestjs/common/interfaces/index.js';
import { Test, TestingModule } from '@nestjs/testing';

import { AuthenticatedGuard } from './authentication.guard.js';
import { SessionData } from './frontend.controller.js';

describe('AuthenticatedGuard', () => {
let module: TestingModule;
Expand Down
2 changes: 1 addition & 1 deletion src/modules/frontend/api/authentication.guard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';

import { SessionData } from './session.js';
import { SessionData } from './frontend.controller.js';

@Injectable()
export class AuthenticatedGuard implements CanActivate {
Expand Down
10 changes: 5 additions & 5 deletions src/modules/frontend/api/authentication.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticationInterceptor } from './authentication.interceptor.js';
import { HttpService } from '@nestjs/axios';
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { HttpService } from '@nestjs/axios';
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { AxiosInstance } from 'axios';
import { HttpArgumentsHost } from '@nestjs/common/interfaces/index.js';
import { SessionData } from './session.js';
import { Test, TestingModule } from '@nestjs/testing';
import { AxiosInstance } from 'axios';
import { AuthenticationInterceptor } from './authentication.interceptor.js';
import { SessionData } from './frontend.controller.js';

describe('AuthenticatedGuard', () => {
let module: TestingModule;
Expand Down
2 changes: 1 addition & 1 deletion src/modules/frontend/api/authentication.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nes
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
import { Observable } from 'rxjs';

import { SessionData } from './session.js';
import { SessionData } from './frontend.controller.js';

@Injectable()
export class AuthenticationInterceptor implements NestInterceptor {
Expand Down
116 changes: 106 additions & 10 deletions src/modules/frontend/api/frontend.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import { faker } from '@faker-js/faker';
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { AxiosResponse } from 'axios';
import { Response } from 'express';
import { TokenSet } from 'openid-client';
import { of, throwError } from 'rxjs';

import { FrontendController } from './frontend.controller.js';
import { HttpStatus } from '@nestjs/common';
import { LoginService } from '../outbound/login.service.js';
import { AuthenticationInterceptor } from './authentication.interceptor.js';
import { FrontendController, SessionData } from './frontend.controller.js';
import { LoginParams } from './user.params.js';

describe('FrontendController', () => {
let module: TestingModule;
let frontendController: FrontendController;
let loginService: DeepMocked<LoginService>;

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [FrontendController],
}).compile();
providers: [FrontendController, { provide: LoginService, useValue: createMock<LoginService>() }],
})
.overrideInterceptor(AuthenticationInterceptor)
.useValue(createMock<AuthenticationInterceptor>())
.compile();

frontendController = module.get(FrontendController);
loginService = module.get(LoginService);
});

afterEach(() => {
jest.resetAllMocks();
});

afterAll(async () => {
Expand All @@ -24,20 +42,98 @@ describe('FrontendController', () => {
});

describe('Login', () => {
it('should not throw', () => {
const loginResponse: string = frontendController.login();
it('should not throw', async () => {
const loginData: LoginParams = {
username: faker.internet.userName(),
password: faker.internet.password(),
};
loginService.login.mockReturnValueOnce(
of({
data: { access_token: faker.string.uuid() },
} as AxiosResponse<TokenSet>),
);

const loginResponse: string = await frontendController.login(loginData, createMock());

expect(loginResponse).toBe('Logged in.');
});

expect(loginResponse).toBe('Login!');
it('should call login-service with username and password', async () => {
const loginData: LoginParams = {
username: faker.internet.userName(),
password: faker.internet.password(),
};
loginService.login.mockReturnValueOnce(
of({
data: { access_token: faker.string.uuid() },
} as AxiosResponse<TokenSet>),
);

await frontendController.login(loginData, createMock());

expect(loginService.login).toHaveBeenCalledWith(loginData.username, loginData.password);
});

it('should set session', async () => {
const loginData: LoginParams = {
username: faker.internet.userName(),
password: faker.internet.password(),
};
const accessToken: string = faker.string.uuid();
loginService.login.mockReturnValueOnce(
of({
data: { access_token: accessToken },
} as AxiosResponse<TokenSet>),
);
const sessionMock: SessionData = createMock<SessionData>();

await frontendController.login(loginData, sessionMock);

expect(sessionMock.access_token).toEqual(accessToken);
});

it('should throw error if backend returns error', async () => {
const loginData: LoginParams = {
username: faker.internet.userName(),
password: faker.internet.password(),
};
const error: Error = new Error('Some error from backend');
loginService.login.mockReturnValueOnce(throwError(() => error));
const sessionMock: SessionData = createMock<SessionData>();

const loginPromise: Promise<string> = frontendController.login(loginData, sessionMock);

await expect(loginPromise).rejects.toEqual(error);
});
});

describe('Logout', () => {
it('should not throw', () => {
const userDummy: unknown = { id: faker.string.uuid() };
it('should set OK-status when the session is destroyed', () => {
const sessionMock: SessionData = createMock<SessionData>({
destroy(cb: (err?: unknown) => void) {
cb();
return this;
},
});
const responseMock: Response = createMock<Response>();

frontendController.logout(sessionMock, responseMock);

expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.OK);
});

it('should set ERROR-status when the session could not be destroyed', () => {
const sessionMock: SessionData = createMock<SessionData>({
destroy(cb: (err?: unknown) => void) {
cb('some error');
return this;
},
});
const responseMock: Response = createMock<Response>();

const loginResponse: string = frontendController.logout(userDummy);
frontendController.logout(sessionMock, responseMock);

expect(loginResponse).toBe(`Logout! ${JSON.stringify(userDummy)}`);
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
});
});
});
32 changes: 25 additions & 7 deletions src/modules/frontend/api/frontend.controller.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
import { Controller, Post, Res, Session, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, HttpStatus, Post, Res, Session, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiAcceptedResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';

import { AuthenticatedGuard } from './authentication.guard.js';
import { AuthenticationInterceptor } from './authentication.interceptor.js';
import { SessionData } from './session.js';
import { LoginService } from '../outbound/login.service.js';
import { firstValueFrom } from 'rxjs';
import { AxiosResponse } from 'axios';
import { TokenSet } from 'openid-client';
import { LoginParams } from './user.params.js';

export type SessionData = Express.Request['session'] &
Partial<{
user_id: string;
access_token: string;
}>;

@ApiTags('frontend')
@Controller({ path: 'frontend' })
@UseInterceptors(AuthenticationInterceptor)
export class FrontendController {
public constructor(private loginService: LoginService) {}

@Post('login')
@ApiAcceptedResponse({ description: 'The person was successfully logged in.' })
public login(@Session() session: SessionData): string {
session.user_id = '';
public async login(@Body() loginParams: LoginParams, @Session() session: SessionData): Promise<string> {
const response: AxiosResponse<TokenSet> = await firstValueFrom(
this.loginService.login(loginParams.username, loginParams.password),
);

if (response.data.access_token) {
session.access_token = response.data.access_token;
}

return 'Login!';
return 'Logged in.';
}

@UseGuards(AuthenticatedGuard)
Expand All @@ -24,9 +42,9 @@ export class FrontendController {
public logout(@Session() session: SessionData, @Res() res: Response): void {
session.destroy((err: unknown) => {
if (err) {
res.status(400).send('Error while logging out');
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'Error while logging out' });
} else {
res.status(200).send('Logged out');
res.status(HttpStatus.OK).json({ message: 'Successfully logged out' });
}
});
}
Expand Down
5 changes: 0 additions & 5 deletions src/modules/frontend/api/session.ts

This file was deleted.

15 changes: 15 additions & 0 deletions src/modules/frontend/api/user.params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AutoMap } from '@automapper/classes';
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class LoginParams {
@AutoMap()
@IsString()
@ApiProperty({ name: 'username', required: true })
public readonly username!: string;

@AutoMap()
@IsString()
@ApiProperty({ name: 'password', required: true })
public readonly password!: string;
}
7 changes: 5 additions & 2 deletions src/modules/frontend/frontend-api.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { AuthenticatedGuard } from './api/authentication.guard.js';
import { AuthenticationInterceptor } from './api/authentication.interceptor.js';
import { FrontendController } from './api/frontend.controller.js';
import { HttpModule } from '@nestjs/axios';
import { LoginService } from './outbound/login.service.js';

@Module({
imports: [HttpModule],
providers: [],
providers: [LoginService, AuthenticationInterceptor, AuthenticatedGuard],
controllers: [FrontendController],
})
export class FrontendApiModule {}
45 changes: 45 additions & 0 deletions src/modules/frontend/outbound/login.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { faker } from '@faker-js/faker';
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';

import { ConfigTestModule } from '../../../../test/utils/index.js';
import { LoginService } from './login.service.js';

describe('LoginService', () => {
let module: TestingModule;
let sut: LoginService;
let httpService: DeepMocked<HttpService>;

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [ConfigTestModule],
providers: [LoginService, { provide: HttpService, useValue: createMock<HttpService>() }],
}).compile();

sut = module.get(LoginService);
httpService = module.get(HttpService);
});

afterAll(async () => {
await module.close();
});

it('should be defined', () => {
expect(sut).toBeDefined();
});

describe('login', () => {
it('should call backend', () => {
const username: string = faker.internet.userName();
const password: string = faker.internet.password();

sut.login(username, password);

expect(httpService.post).toHaveBeenCalledWith(expect.stringContaining('/api/login'), {
username,
password,
});
});
});
});
20 changes: 20 additions & 0 deletions src/modules/frontend/outbound/login.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosResponse } from 'axios';
import { TokenSet } from 'openid-client';
import { Observable } from 'rxjs';
import { FrontendConfig, ServerConfig } from '../../../shared/config/index.js';

@Injectable()
export class LoginService {
private backend: string;

public constructor(private httpService: HttpService, config: ConfigService<ServerConfig>) {
this.backend = config.getOrThrow<FrontendConfig>('FRONTEND').BACKEND_ADDRESS;
}

public login(username: string, password: string): Observable<AxiosResponse<TokenSet>> {
return this.httpService.post<TokenSet>(`${this.backend}/api/login`, { username, password });
}
}
1 change: 1 addition & 0 deletions src/shared/config/config.loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('configloader', () => {
},
FRONTEND: {
PORT: 8081,
BACKEND_ADDRESS: 'http://localhost:8080',
},
DB: {
CLIENT_URL: 'postgres://localhost:5432',
Expand Down
4 changes: 4 additions & 0 deletions src/shared/config/frontend.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ export class FrontendConfig {
@IsString()
@IsNotEmpty()
public readonly SESSION_SECRET!: string;

@IsString()
@IsNotEmpty()
public readonly BACKEND_ADDRESS!: string;
}

0 comments on commit 59fb6a9

Please sign in to comment.