From e27d8da67e79252360daf534e9a42bf7674d2f3a Mon Sep 17 00:00:00 2001 From: John Brunton Date: Sat, 24 Aug 2024 14:19:35 +0100 Subject: [PATCH] test: improve coverage --- .../dynamodb.users.repository.e2e-spec.ts | 21 +++ .../repositories/dynamodb.users.repository.ts | 7 +- .../domain/entities/users/users.repository.ts | 2 +- .../usecases/commands/parse/parse.spec.ts | 71 +++++++++ .../commands/parse/parsers/invite-parser.ts | 2 +- .../src/domain/usecases/rooms/invite.spec.ts | 148 ++++++++++++++++++ .../api/src/domain/usecases/rooms/invite.ts | 12 ++ .../src/domain/usecases/rooms/leave.spec.ts | 61 ++++++++ .../fixtures/data/test.users.repository.ts | 8 +- 9 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 services/api/src/domain/usecases/rooms/invite.spec.ts create mode 100644 services/api/src/domain/usecases/rooms/leave.spec.ts diff --git a/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts b/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts index 109bb86d..45a16df5 100644 --- a/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts +++ b/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts @@ -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: 'some.user@example.com', + name: 'Some User', + }; + + await repo.saveUser(params); + const found = await repo.findUser('some.user@example.com'); + + expect(found).toMatchObject({ + id: 'user:google_123', + email: 'some.user@example.com', + name: 'Some User', + }); + + expect(await repo.findUser('not-a-user@example.com')).toBeNull(); + }); + test.each(testCases)('[$name] updates users', async ({ name }) => { const repo = repos[name]; const params: SaveUserParams = { diff --git a/services/api/src/data/repositories/dynamodb.users.repository.ts b/services/api/src/data/repositories/dynamodb.users.repository.ts index 1daa006e..a6cb84e4 100644 --- a/services/api/src/data/repositories/dynamodb.users.repository.ts +++ b/services/api/src/data/repositories/dynamodb.users.repository.ts @@ -34,7 +34,7 @@ export class DynamoDBUsersRepository extends UsersRepository { return userFromRecord(user); } - override async findUser(email: string): Promise { + override async findUser(email: string): Promise { const [user] = await this.adapter.User.scan( {}, { @@ -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 { diff --git a/services/api/src/domain/entities/users/users.repository.ts b/services/api/src/domain/entities/users/users.repository.ts index 3f19ecdf..a8b60ec4 100644 --- a/services/api/src/domain/entities/users/users.repository.ts +++ b/services/api/src/domain/entities/users/users.repository.ts @@ -12,7 +12,7 @@ export type UpdateUserParams = Partial> & Pick; export abstract class UsersRepository { abstract saveUser(params: SaveUserParams): Promise; abstract getUser(id: string): Promise; - abstract findUser(email: string): Promise; + abstract findUser(email: string): Promise; abstract updateUser(params: UpdateUserParams): Promise; } diff --git a/services/api/src/domain/usecases/commands/parse/parse.spec.ts b/services/api/src/domain/usecases/commands/parse/parse.spec.ts index 16abf5b3..8a3f6adc 100644 --- a/services/api/src/domain/usecases/commands/parse/parse.spec.ts +++ b/services/api/src/domain/usecases/commands/parse/parse.spec.ts @@ -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; @@ -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 joe.bloggs@example.com').expectCommand({ + tag: 'inviteUser', + params: { + email: 'joe.bloggs@example.com', + }, + }); + }); + + 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', diff --git a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts index f563f20c..8d2a4d78 100644 --- a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts +++ b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts @@ -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(([, email]) => ({ tag: 'inviteUser', params: { email }, diff --git a/services/api/src/domain/usecases/rooms/invite.spec.ts b/services/api/src/domain/usecases/rooms/invite.spec.ts new file mode 100644 index 00000000..799f1842 --- /dev/null +++ b/services/api/src/domain/usecases/rooms/invite.spec.ts @@ -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; + + 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()); + auth.stubPermission({ user: owner, subject: room, action: Role.Manage }); + + dispatcher = mock(); + + 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: 'not-a-user@example.com', + }); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: 'No user exists with email not-a-user@example.com', + 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, + }); + }); +}); diff --git a/services/api/src/domain/usecases/rooms/invite.ts b/services/api/src/domain/usecases/rooms/invite.ts index 052471a9..24f2c1ba 100644 --- a/services/api/src/domain/usecases/rooms/invite.ts +++ b/services/api/src/domain/usecases/rooms/invite.ts @@ -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, ); diff --git a/services/api/src/domain/usecases/rooms/leave.spec.ts b/services/api/src/domain/usecases/rooms/leave.spec.ts new file mode 100644 index 00000000..41d68290 --- /dev/null +++ b/services/api/src/domain/usecases/rooms/leave.spec.ts @@ -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; + + const user = UserFactory.build({ name: 'Joe Bloggs' }); + + const now = new Date(1000); + + beforeEach(() => { + rooms = new TestRoomsRepository(); + memberships = new TestMembershipsRepository(); + auth = new TestAuthService(mock()); + dispatcher = mock(); + 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], + }); + }); +}); diff --git a/services/api/src/fixtures/data/test.users.repository.ts b/services/api/src/fixtures/data/test.users.repository.ts index 5055a44c..c8199b88 100644 --- a/services/api/src/fixtures/data/test.users.repository.ts +++ b/services/api/src/fixtures/data/test.users.repository.ts @@ -37,12 +37,8 @@ export class TestUsersRepository extends UsersRepository { return user; } - override async findUser(email: string): Promise { - 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 { + return find((user) => email === user.email, this.users) ?? null; } override async updateUser(params: UpdateUserParams): Promise {