Skip to content
This repository was archived by the owner on Mar 20, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ module.exports = {
'testHelpers.tsx',
'*.test-module.ts',
'test-utils.ts',
'rest-api-test-utils.ts',
'*Handlers.ts',
'**/mocks/*',
'jest-setup.ts',
Expand Down
32 changes: 30 additions & 2 deletions packages/api/rest-api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ paths:
description: 'Email confirmation was successfully requested. Email was sent.'
tags:
- Email Confirmation
/email-confirmation/approval:
post:
summary: Confirm email for given user.
operationId: ConfirmUserRegistrationPost
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ApproveEmailConfirmationBody'
responses:
204:
description: 'Mail for user successfully confirmed'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
tags:
- Email Confirmation
/users:
get:
summary: All users registered in the platform
Expand Down Expand Up @@ -138,7 +157,7 @@ paths:
get:
summary: How many course activites was completed by user.
operationId: CourseProgress_getCourseProgress
parameters: [ ]
parameters: []
responses:
200:
description: ''
Expand Down Expand Up @@ -259,6 +278,15 @@ components:
example: user-registration
required:
- confirmationFor
ApproveEmailConfirmationBody:
type: object
properties:
confirmationToken:
description: Given confirmation token created meanwhile registration
type: string
example: someExample.Token
required:
- confirmationFor
CourseProgressGetResponseBody:
description: Current course progress of logged user
type: object
Expand Down Expand Up @@ -398,7 +426,7 @@ components:
message:
type: string
description: Error message
example: "Learning materials url was already generated"
example: 'Learning materials url was already generated'
required:
- message
NestErrorResponseBody:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApproveEmailConfirmation } from '@/module/commands/approve-email-confirmation';
import { emailConfirmationWasApprovedEvent } from '@/module/events/email-confirmation-was-approved.domain.event';
import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';

import { EmailConfirmationDomainEvent } from './events';

Expand All @@ -8,13 +9,14 @@ export const approveEmailConfirmation =
(pastEvents: EmailConfirmationDomainEvent[]): EmailConfirmationDomainEvent[] => {
const lastPublishedEmailConfirmation = pastEvents[pastEvents.length - 1];

if (!lastPublishedEmailConfirmation) throw new Error("Couldn't find request which could be approved");
if (!lastPublishedEmailConfirmation)
throw new DomainRuleViolationException("Couldn't find request which could be approved");

if (lastPublishedEmailConfirmation.type === 'EmailConfirmationWasApproved')
throw new Error('Email confirmation has been already approved');
throw new DomainRuleViolationException('Email confirmation has been already approved');

if (lastPublishedEmailConfirmation.data.confirmationToken !== command.data.confirmationToken)
throw new Error('An attempt was made on obsolete confirmation token');
throw new DomainRuleViolationException('An attempt was made on obsolete confirmation token');

return [emailConfirmationWasApprovedEvent(command.data)];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { HttpStatus } from '@nestjs/common';
import { AsyncReturnType } from 'type-fest';

import { APPROVE_ENDPOINT } from '@coderscamp/shared/models/email-confirmation/approve-email-confirmation';

import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
import { initTestModuleRestApi } from '@/shared/rest-api-test-utils';

import { initOpenApiExpect } from '../../../../../../jest-setup';
import { EmailConfirmationRestController } from './email-confirmation.rest-controller';

initOpenApiExpect();

describe('email confirmation | REST API', () => {
let restUnderTest: AsyncReturnType<typeof initTestModuleRestApi>;

beforeAll(async () => {
restUnderTest = await initTestModuleRestApi(EmailConfirmationRestController);
restUnderTest.commandBusExecute.mockClear();
});

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

describe(`POST /email-confirmation${APPROVE_ENDPOINT}`, () => {
it('Failed, user is not authenticated', async () => {
// Given
restUnderTest.commandBusExecute.mockImplementation(() => Promise.resolve());

// When
const response = await restUnderTest.http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({
confirmationToken: 'exampleToken',
});

// Then
expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
expect(response.body.message).toEqual('Unauthorized');
expect(response).toSatisfyApiSpec();
});

it('Failed, trying to approve email confirmation without earlier request', async () => {
// Given
restUnderTest.commandBusExecute.mockRejectedValue(
new DomainRuleViolationException("Couldn't find request which could be approved"),
);

// When
const response = await restUnderTest.asLoggedUser((http) =>
http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({
confirmationToken: 'exampleToken',
}),
);

// Then
expect(response.status).toBe(HttpStatus.BAD_REQUEST);
expect(response.body.message).toBe("Couldn't find request which could be approved");
expect(response).toSatisfyApiSpec();
});

it('Success, email confirmation has been approved', async () => {
// Given
restUnderTest.commandBusExecute.mockImplementation(() => Promise.resolve());

// When
const response = await restUnderTest.asLoggedUser((http) =>
http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({
confirmationToken: 'exampleToken',
}),
);

// Then
expect(response.status).toBe(HttpStatus.NO_CONTENT);
expect(response).toSatisfyApiSpec();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CompleteUserRegistration } from '@/commands/complete-user-registration';
import { userRegistrationWasCompletedEvent } from '@/events/user-registration-was-completed.domain-event';
import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
import { UserRegistrationDomainEvent } from '@/write/user-registration/domain/events';

export const completeUserRegistration =
Expand All @@ -8,10 +9,13 @@ export const completeUserRegistration =
const { data } = command;
const lastUserRegistrationEvent = pastEvents[pastEvents.length - 1];

if (!lastUserRegistrationEvent) throw new Error('Impossible to complete registration while it is not started');
if (!lastUserRegistrationEvent)
throw new DomainRuleViolationException('Impossible to complete registration while it is not started');

if (lastUserRegistrationEvent.type === 'UserRegistrationWasCompleted')
throw new Error(`Registration for user ${lastUserRegistrationEvent.data.fullName} was already completed`);
throw new DomainRuleViolationException(
`Registration for user ${lastUserRegistrationEvent.data.fullName} was already completed`,
);

const { fullName, emailAddress, hashedPassword } = lastUserRegistrationEvent.data;
const userRegistrationWasCompleted = userRegistrationWasCompletedEvent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AsyncReturnType } from 'type-fest';
import { registerError } from '@coderscamp/shared/models/auth/register';

import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception';
import { initTestModuleRestApi } from '@/shared/test-utils';
import { initTestModuleRestApi } from '@/shared/rest-api-test-utils';
import { UserRegistrationRestController } from '@/write/user-registration/presentation/rest/user-registration.rest-controller';

import { initOpenApiExpect } from '../../../../../../jest-setup';
Expand Down
115 changes: 115 additions & 0 deletions packages/api/src/shared/rest-api-test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { INestApplication } from '@nestjs/common';
import { Type } from '@nestjs/common/interfaces/type.interface';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModuleBuilder } from '@nestjs/testing';
import supertest from 'supertest';
import { v4 as uuid } from 'uuid';

import { AuthUser } from '@coderscamp/shared/models/auth';

import { AuthModule } from '@/crud/auth/auth.module';
import { PrismaService } from '@/prisma/prisma.service';
import { cleanupDatabase } from '@/shared/test-utils';
import { ApplicationCommandFactory } from '@/write/shared/application/application-command.factory';
import { UuidGenerator } from '@/write/shared/infrastructure/id-generator/uuid-generator';
import { hashPassword } from '@/write/shared/infrastructure/password-encoder/crypto-password-encoder';
import { SystemTimeProvider } from '@/write/shared/infrastructure/time-provider/system-time-provider';

import { setupMiddlewares } from '../app.middlewares';
import { eventEmitterRootModule } from '../event-emitter.root-module';

const DEFAULT_TEST_PASSWORD = 'stronk';

export async function initTestModuleRestApi(
controller: Type,
config?: (module: TestingModuleBuilder) => TestingModuleBuilder,
) {
const commandBusExecute = jest.fn();
const moduleBuilder = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: { execute: commandBusExecute, register: jest.fn() },
},
{
provide: ApplicationCommandFactory,
useValue: new ApplicationCommandFactory(new UuidGenerator(), new SystemTimeProvider()),
},
],
controllers: [controller],
imports: [eventEmitterRootModule, AuthModule],
});
const moduleRef = await (config ? config(moduleBuilder) : moduleBuilder).compile();

const app: INestApplication = moduleRef.createNestApplication();

const prismaService = app.get<PrismaService>(PrismaService);

await cleanupDatabase(prismaService);

setupMiddlewares(app);

await app.init();

const http = supertest(app.getHttpServer());

const randomUser = () => {
const id = uuid();

return {
id,
email: `${id}@email.com`,
password: DEFAULT_TEST_PASSWORD,
};
};

const registerUser = async (userToCreate: Partial<AuthUser> = randomUser()) => {
const id = userToCreate.id ?? uuid();
const authUser = {
id,
email: `${id}@email.com`,
password: DEFAULT_TEST_PASSWORD,
...userToCreate,
};
const hashedPassword = await hashPassword(authUser.password);

return prismaService.authUser.create({
data: {
...authUser,
password: hashedPassword,
},
});
};

const loginUser = async (request: { email: string } = randomUser()) => {
await registerUser({ email: request.email, password: DEFAULT_TEST_PASSWORD });

const response = await http.post('/api/auth/login').send(request);

if (response.status !== 204) {
throw new Error('Example user login failed');
}

return response.get('set-cookie');
};

const logoutUser = async () => {
const response = await http.post('/api/auth/logout').send();

if (response.status !== 201) {
throw new Error('Logout user failed');
}
};

const asLoggedUser = async (request: (http: supertest.SuperTest<supertest.Test>) => supertest.Test) => {
const cookie = await loginUser();

return request(http).set('Cookie', cookie);
};

async function close() {
await app.close();
}

return { http, close, commandBusExecute, loginUser, logoutUser, asLoggedUser };
}
40 changes: 0 additions & 40 deletions packages/api/src/shared/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { INestApplication } from '@nestjs/common';
import { Abstract } from '@nestjs/common/interfaces';
import { ModuleMetadata } from '@nestjs/common/interfaces/modules/module-metadata.interface';
import { Type } from '@nestjs/common/interfaces/type.interface';
import { CommandBus, ICommand } from '@nestjs/cqrs';
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
import _ from 'lodash';
import supertest from 'supertest';
import { v4 as uuid } from 'uuid';
import waitForExpect from 'wait-for-expect';

Expand All @@ -21,12 +19,9 @@ import { EventStreamName } from '@/write/shared/application/event-stream-name.va
import { SubscriptionId } from '@/write/shared/application/events-subscription/events-subscription';
import { ID_GENERATOR, IdGenerator } from '@/write/shared/application/id-generator';
import { TIME_PROVIDER } from '@/write/shared/application/time-provider.port';
import { UuidGenerator } from '@/write/shared/infrastructure/id-generator/uuid-generator';
import { FixedTimeProvider } from '@/write/shared/infrastructure/time-provider/fixed-time-provider';
import { SystemTimeProvider } from '@/write/shared/infrastructure/time-provider/system-time-provider';
import { SharedModule } from '@/write/shared/shared.module';

import { setupMiddlewares } from '../app.middlewares';
import { AppModule } from '../app.module';
import { eventEmitterRootModule } from '../event-emitter.root-module';

Expand Down Expand Up @@ -373,38 +368,3 @@ export const commandBusNoFailWithoutHandler: Partial<CommandBus> = {
register: jest.fn(),
execute: jest.fn(),
};

export async function initTestModuleRestApi(
controller: Type,
config?: (module: TestingModuleBuilder) => TestingModuleBuilder,
) {
const commandBusExecute = jest.fn();
const moduleBuilder = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: { execute: commandBusExecute, register: jest.fn() },
},
{
provide: ApplicationCommandFactory,
useValue: new ApplicationCommandFactory(new UuidGenerator(), new SystemTimeProvider()),
},
],
controllers: [controller],
});
const moduleRef = await (config ? config(moduleBuilder) : moduleBuilder).compile();

const app: INestApplication = moduleRef.createNestApplication();

setupMiddlewares(app);

await app.init();

const http = supertest(app.getHttpServer());

async function close() {
await app.close();
}

return { http, close, commandBusExecute };
}