Skip to content

Commit

Permalink
test: improve coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
jbrunton committed Aug 24, 2024
1 parent 697acfc commit e27d8da
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ describe('RoomsRepository', () => {
});
});

test.each(testCases)('[$name] finds users by email', async ({ name }) => {
const repo = repos[name];

const params: SaveUserParams = {
sub: 'google_123',
email: '[email protected]',
name: 'Some User',
};

await repo.saveUser(params);
const found = await repo.findUser('[email protected]');

expect(found).toMatchObject({
id: 'user:google_123',
email: '[email protected]',
name: 'Some User',
});

expect(await repo.findUser('[email protected]')).toBeNull();
});

test.each(testCases)('[$name] updates users', async ({ name }) => {
const repo = repos[name];
const params: SaveUserParams = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class DynamoDBUsersRepository extends UsersRepository {
return userFromRecord(user);
}

override async findUser(email: string): Promise<User> {
override async findUser(email: string): Promise<User | null> {
const [user] = await this.adapter.User.scan(
{},
{
Expand All @@ -45,10 +45,7 @@ export class DynamoDBUsersRepository extends UsersRepository {
hidden: true,
},
);
if (!user) {
throw new NotFoundException(`User with email ${email} not found`);
}
return userFromRecord(user);
return user ? userFromRecord(user) : null;
}

override async updateUser(params: UpdateUserParams): Promise<User> {
Expand Down
2 changes: 1 addition & 1 deletion services/api/src/domain/entities/users/users.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type UpdateUserParams = Partial<Pick<User, 'name'>> & Pick<User, 'id'>;
export abstract class UsersRepository {
abstract saveUser(params: SaveUserParams): Promise<User>;
abstract getUser(id: string): Promise<User>;
abstract findUser(email: string): Promise<User>;
abstract findUser(email: string): Promise<User | null>;
abstract updateUser(params: UpdateUserParams): Promise<User>;
}

Expand Down
71 changes: 71 additions & 0 deletions services/api/src/domain/usecases/commands/parse/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from '@entities/command.entity';
import { BadRequestException } from '@nestjs/common';
import { ParseCommandUseCase } from '.';
import { ParsedCommand } from './command.parser';
import { JoinPolicy } from '@entities/room.entity';

describe('ParseCommandUseCase', () => {
let parse: ParseCommandUseCase;
Expand Down Expand Up @@ -118,6 +119,76 @@ describe('ParseCommandUseCase', () => {
});
});

describe('/rename user', () => {
it('parses valid commands', () => {
withMessage('/rename user My User').expectCommand({
tag: 'renameUser',
params: {
newName: 'My User',
},
});
});

it('validates the number of arguments', () => {
withMessage('/rename user').expectError(
'Error in command `/rename user`:',
`* Received too few arguments. Expected: \`/rename user {name}\``,
);
});
});

describe('/leave', () => {
it('parses valid commands', () => {
withMessage('/leave').expectCommand({
tag: 'leave',
params: null,
});
});
});

describe('/set room join policy', () => {
it('parses valid commands', () => {
withMessage('/set room join policy anyone').expectCommand({
tag: 'changeRoomJoinPolicy',
params: {
newJoinPolicy: JoinPolicy.Anyone,
},
});
});

it('validates the number of arguments', () => {
withMessage('/set room join policy').expectError(
'Error in command `/set room join policy`:',
`* Received too few arguments. Expected: \`/set room join policy {'anyone', 'invite'}\``,
);
});
});

describe('/invite', () => {
it('parses valid commands', () => {
withMessage('/invite [email protected]').expectCommand({
tag: 'inviteUser',
params: {
email: '[email protected]',
},
});
});

it('validates the number of arguments', () => {
withMessage('/invite').expectError(
'Error in command `/invite`:',
`* Received too few arguments. Expected: \`/invite {email}\``,
);
});

it('validates the email', () => {
withMessage('/invite not-an-email').expectError(
'Error in command `/invite not-an-email`:',
`* Argument 1 (\`not-an-email\`): Invalid email`,
);
});
});

it('responds if the command is unrecognised', () => {
const command: Command = {
roomId: 'my-room',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod';
import { CommandParser, ParsedCommand } from '../command.parser';

const schema = z
.tuple([z.literal('invite'), z.string()])
.tuple([z.literal('invite'), z.string().email()])
.transform<ParsedCommand>(([, email]) => ({
tag: 'inviteUser',
params: { email },
Expand Down
148 changes: 148 additions & 0 deletions services/api/src/domain/usecases/rooms/invite.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { JoinPolicy } from '@entities/room.entity';
import { TestAuthService } from '@fixtures/auth/test-auth-service';
import { TestMembershipsRepository } from '@fixtures/data/test.memberships.repository';
import { TestRoomsRepository } from '@fixtures/data/test.rooms.repository';
import { RoomFactory } from '@fixtures/messages/room.factory';
import { UserFactory } from '@fixtures/messages/user.factory';
import { UnauthorizedException } from '@nestjs/common';
import { AppLogger } from '@app/app.logger';
import { Role } from '@usecases/auth.service';
import mock, { MockProxy } from 'jest-mock-extended/lib/Mock';
import { Dispatcher } from '@entities/messages/message';
import { InviteUseCase } from './invite';
import { TestUsersRepository } from '@fixtures/data/test.users.repository';
import { MembershipStatus } from '@entities/membership.entity';

describe('InviteUseCase', () => {
let invite: InviteUseCase;
let rooms: TestRoomsRepository;
let users: TestUsersRepository;
let memberships: TestMembershipsRepository;
let auth: TestAuthService;
let dispatcher: MockProxy<Dispatcher>;

const owner = UserFactory.build({ name: 'Alice' });
const otherUser = UserFactory.build({ name: 'Bob' });
const room = RoomFactory.build({ joinPolicy: JoinPolicy.Invite });

const now = new Date(1000);

beforeEach(() => {
rooms = new TestRoomsRepository();
rooms.setData([room]);

users = new TestUsersRepository();
users.setData([owner, otherUser]);

memberships = new TestMembershipsRepository();

auth = new TestAuthService(mock<AppLogger>());
auth.stubPermission({ user: owner, subject: room, action: Role.Manage });

dispatcher = mock<Dispatcher>();

invite = new InviteUseCase(rooms, users, memberships, auth, dispatcher);

jest.useFakeTimers({ now });
});

it('invites a user to the room', async () => {
await invite.exec({
authenticatedUser: owner,
roomId: room.id,
email: otherUser.email,
});

expect(memberships.getData()).toEqual([
{
userId: otherUser.id,
roomId: room.id,
status: MembershipStatus.PendingInvite,
from: now.getTime(),
},
]);

expect(dispatcher.send).toHaveBeenCalledWith({
content: 'Alice invited Bob to join the room',
authorId: 'system',
roomId: room.id,
});
});

it('authorizes the user', async () => {
await expect(
invite.exec({
authenticatedUser: otherUser,
roomId: room.id,
email: otherUser.email,
}),
).rejects.toEqual(
new UnauthorizedException(
'You do not have permission to perform this action.',
),
);
});

it('checks the user exists', async () => {
await invite.exec({
authenticatedUser: owner,
roomId: room.id,
email: '[email protected]',
});

expect(dispatcher.send).toHaveBeenCalledWith({
content: 'No user exists with email [email protected]',
authorId: 'system',
roomId: room.id,
recipientId: owner.id,
});
});

it('checks the user is not already a member', async () => {
await memberships.setData([
{
from: 0,
roomId: room.id,
userId: otherUser.id,
status: MembershipStatus.Joined,
},
]);

await invite.exec({
authenticatedUser: owner,
roomId: room.id,
email: otherUser.email,
});

expect(dispatcher.send).toHaveBeenCalledWith({
content: 'Bob is already a member of this room',
authorId: 'system',
roomId: room.id,
recipientId: owner.id,
});
});

it('checks the user doesn not already have an invite', async () => {
await memberships.setData([
{
from: 0,
roomId: room.id,
userId: otherUser.id,
status: MembershipStatus.PendingInvite,
},
]);

await invite.exec({
authenticatedUser: owner,
roomId: room.id,
email: otherUser.email,
});

expect(dispatcher.send).toHaveBeenCalledWith({
content: 'Bob already has an invite to this room',
authorId: 'system',
roomId: room.id,
recipientId: owner.id,
});
});
});
12 changes: 12 additions & 0 deletions services/api/src/domain/usecases/rooms/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ export class InviteUseCase {

const invitedUser = await this.users.findUser(email);

if (!invitedUser) {
const message: DraftMessage = {
content: `No user exists with email ${email}`,
roomId: room.id,
authorId: 'system',
recipientId: authenticatedUser.id,
};

await this.dispatcher.send(message);
return;
}

const existingMemberships = await this.memberships.getMemberships(
invitedUser.id,
);
Expand Down
61 changes: 61 additions & 0 deletions services/api/src/domain/usecases/rooms/leave.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { JoinPolicy } from '@entities/room.entity';
import { TestAuthService } from '@fixtures/auth/test-auth-service';
import { TestMembershipsRepository } from '@fixtures/data/test.memberships.repository';
import { TestRoomsRepository } from '@fixtures/data/test.rooms.repository';
import { RoomFactory } from '@fixtures/messages/room.factory';
import { UserFactory } from '@fixtures/messages/user.factory';
import { UnauthorizedException } from '@nestjs/common';
import { JoinRoomUseCase } from './join';
import { AppLogger } from '@app/app.logger';
import { Role } from '@usecases/auth.service';
import mock, { MockProxy } from 'jest-mock-extended/lib/Mock';
import { Dispatcher, UpdatedEntity } from '@entities/messages/message';
import { LeaveRoomUseCase } from './leave';
import { MembershipStatus } from '@entities/membership.entity';

describe(':eaveRoomUseCase', () => {
let leave: LeaveRoomUseCase;
let rooms: TestRoomsRepository;
let memberships: TestMembershipsRepository;
let auth: TestAuthService;
let dispatcher: MockProxy<Dispatcher>;

const user = UserFactory.build({ name: 'Joe Bloggs' });

const now = new Date(1000);

beforeEach(() => {
rooms = new TestRoomsRepository();
memberships = new TestMembershipsRepository();
auth = new TestAuthService(mock<AppLogger>());
dispatcher = mock<Dispatcher>();
leave = new LeaveRoomUseCase(rooms, memberships, dispatcher);
jest.useFakeTimers({ now });
});

it('assigns a membership to the user', async () => {
const room = RoomFactory.build({
joinPolicy: JoinPolicy.Anyone,
});
rooms.setData([room]);
auth.stubPermission({ user, subject: room, action: Role.Read });

await leave.exec({ authenticatedUser: user, roomId: room.id });

expect(memberships.getData()).toEqual([
{
userId: user.id,
roomId: room.id,
status: MembershipStatus.Revoked,
from: now.getTime(),
},
]);

expect(dispatcher.send).toHaveBeenCalledWith({
content: 'Joe Bloggs left the room.',
authorId: 'system',
roomId: room.id,
updatedEntities: [UpdatedEntity.Room, UpdatedEntity.Users],
});
});
});
8 changes: 2 additions & 6 deletions services/api/src/fixtures/data/test.users.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,8 @@ export class TestUsersRepository extends UsersRepository {
return user;
}

override async findUser(email: string): Promise<User> {
const user = find((user) => email === user.email, this.users);
if (!user) {
throw new NotFoundException(`User with email ${email} does not exist`);
}
return user;
override async findUser(email: string): Promise<User | null> {
return find((user) => email === user.email, this.users) ?? null;
}

override async updateUser(params: UpdateUserParams): Promise<User> {
Expand Down

0 comments on commit e27d8da

Please sign in to comment.