Skip to content

Commit

Permalink
Merge pull request #455 from internxt/feat/pb-3593-migrate-user-endpo…
Browse files Browse the repository at this point in the history
…ints

[PB-3601] feat: migrate user endpoints
  • Loading branch information
evillalba94 authored Jan 21, 2025
2 parents 9cb2725 + baac9a0 commit 6cb8ba7
Show file tree
Hide file tree
Showing 5 changed files with 435 additions and 1 deletion.
48 changes: 48 additions & 0 deletions src/modules/user/dto/update-profile.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
import { UserAttributes } from '../user.attributes';

export class UpdateProfileDto {
@IsOptional()
@IsString()
@ValidateIf((o) => o.name !== null)
@IsNotEmpty({ message: 'Name should not be empty if provided.' })
@MaxLength(100, { message: 'Name must be at most 100 characters long.' })
@ApiProperty({
example: 'Internxt',
description: 'Name of the new user',
})
name?: UserAttributes['name'];

@ValidateIf((o) => o.name === null)
@IsNotEmpty({ message: 'Name should not be null if provided.' })
@ApiProperty({
example: 'Internxt',
description: 'Name of the new user',
})
nameNullCheck?: string;

@IsOptional()
@IsString()
@ValidateIf((o) => o.lastname !== null)
@MaxLength(100, { message: 'Lastname must be at most 100 characters long.' })
@ApiProperty({
example: 'Lastname',
description: 'Last name of the new user',
})
lastname?: UserAttributes['lastname'];

@ValidateIf((o) => o.lastname === null)
@IsNotEmpty({ message: 'Lastname should not be null if provided.' })
@ApiProperty({
example: 'Lastname',
description: 'Last name of the new user',
})
lastnameNullCheck?: string;
}
173 changes: 172 additions & 1 deletion src/modules/user/user.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { generateBase64PrivateKeyStub, newUser } from '../../../test/fixtures';
import getEnv from '../../config/configuration';
import { UserController } from './user.controller';
import { MailLimitReachedException, UserUseCases } from './user.usecase';
Expand All @@ -13,10 +15,10 @@ import { KeyServerUseCases } from '../keyserver/key-server.usecase';
import { CryptoService } from '../../externals/crypto/crypto.service';
import { SharingService } from '../sharing/sharing.service';
import { SignWithCustomDuration } from '../../middlewares/passport';
import { generateBase64PrivateKeyStub, newUser } from '../../../test/fixtures';
import { AccountTokenAction } from './user.domain';
import { v4 } from 'uuid';
import { DeviceType } from './dto/register-notification-token.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';

jest.mock('../../config/configuration', () => {
return {
Expand All @@ -30,6 +32,15 @@ jest.mock('../../config/configuration', () => {
appId: 'jitsi-app-id',
apiKey: 'jitsi-api-key',
},
avatar: {
accessKey: 'accessKey',
secretKey: 'secretKey',
bucket: 'bucket',
region: 'region',
endpoint: 'http://localhost:9001',
endpointForSignedUrls: 'http://localhost:9000',
forcePathStyle: true,
},
})),
};
});
Expand Down Expand Up @@ -253,4 +264,164 @@ describe('User Controller', () => {
);
});
});

describe('PATCH /profile', () => {
const user = newUser();
it('When name is provided and valid, then it should call updateProfile with the correct parameters', async () => {
const updateProfileDto: UpdateProfileDto = {
name: 'Internxt',
};

await userController.updateProfile(user, updateProfileDto);

expect(userUseCases.updateProfile).toHaveBeenCalledWith(
user,
updateProfileDto,
);
});

it('When lastname is provided as an empty string, then it should call updateProfile with the correct parameters', async () => {
const updateProfileDto: UpdateProfileDto = {
lastname: '',
};

await userController.updateProfile(user, updateProfileDto);

expect(userUseCases.updateProfile).toHaveBeenCalledWith(
user,
updateProfileDto,
);
});

it('When both name and lastname are not provided, then it should throw', async () => {
const updateProfileDto: UpdateProfileDto = {
name: undefined,
lastname: undefined,
};

await expect(
userController.updateProfile(user, updateProfileDto),
).rejects.toThrow(BadRequestException);
});

it('When name is null, then it should throw', async () => {
const updateProfileDto: UpdateProfileDto = {
name: null,
};

await expect(
userController.updateProfile(user, updateProfileDto),
).rejects.toThrow(BadRequestException);
});

it('When lastname is null, then it should throw', async () => {
const updateProfileDto: UpdateProfileDto = {
lastname: null,
};

await expect(
userController.updateProfile(user, updateProfileDto),
).rejects.toThrow(BadRequestException);
});

it('When both name and lastname are null, then it should throw', async () => {
const updateProfileDto: UpdateProfileDto = {
name: null,
lastname: null,
};

await expect(
userController.updateProfile(user, updateProfileDto),
).rejects.toThrow(BadRequestException);
});
});

describe('PUT /avatar', () => {
const user = newUser();
const newAvatarKey = v4();
const avatar: Express.Multer.File | any = {
stream: undefined,
fieldname: undefined,
originalname: undefined,
encoding: undefined,
mimetype: undefined,
size: undefined,
filename: undefined,
destination: undefined,
path: undefined,
buffer: undefined,
};

it('When uploadAvatar is called with a valid avatar then it should upload the avatar', async () => {
avatar.key = newAvatarKey;
const avatarURL = 'https://localhost:9000/avatars/' + v4();
const mockResponse = { avatar: avatarURL };
jest
.spyOn(userUseCases, 'upsertAvatar')
.mockResolvedValue({ avatar: avatarURL });
const result = await userController.uploadAvatar(avatar, user);
expect(result).toEqual(mockResponse);
expect(userUseCases.upsertAvatar).toHaveBeenCalledWith(
user,
newAvatarKey,
);
});

it('When uploadAvatar is called without an avatar then it should throw', async () => {
const mockAvatar = undefined;
await expect(
userController.uploadAvatar(mockAvatar as any, user),
).rejects.toThrow(BadRequestException);
});

it('When uploadAvatar is called without a key then it should throw', async () => {
avatar.key = null;
await expect(userController.uploadAvatar(avatar, user)).rejects.toThrow(
InternalServerErrorException,
);
});

it('When upsertAvatar throws an error, then it should log the error and rethrow it', async () => {
avatar.key = newAvatarKey;
const errorMessage = 'Failed to upload avatar';
jest
.spyOn(userUseCases, 'upsertAvatar')
.mockRejectedValue(new Error(errorMessage));
const loggerSpy = jest.spyOn(Logger, 'error').mockImplementation();

await expect(userController.uploadAvatar(avatar, user)).rejects.toThrow(
Error,
);
expect(loggerSpy).toHaveBeenCalledWith(
`[USER/UPLOAD_AVATAR] Error uploading avatar for user: ${user.id}. Error: ${errorMessage}`,
);

loggerSpy.mockRestore();
});
});

describe('DELETE /avatar', () => {
const user = newUser();
it('When deleteAvatar is called then it should delete the user avatar', async () => {
jest.spyOn(userUseCases, 'deleteAvatar').mockResolvedValue(undefined);

await userController.deleteAvatar(user);
expect(userUseCases.deleteAvatar).toHaveBeenCalledWith(user);
});

it('When deleteAvatar throws an error, then it should log the error and rethrow it', async () => {
const errorMessage = 'Failed to delete avatar';
jest
.spyOn(userUseCases, 'deleteAvatar')
.mockRejectedValue(new Error(errorMessage));
const loggerSpy = jest.spyOn(Logger, 'error').mockImplementation();

await expect(userController.deleteAvatar(user)).rejects.toThrow(Error);
expect(loggerSpy).toHaveBeenCalledWith(
`[USER/DELETE_AVATAR] Error deleting the avatar for the user: ${user.id} has failed. Error: ${errorMessage}`,
);

loggerSpy.mockRestore();
});
});
});
74 changes: 74 additions & 0 deletions src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ import {
Patch,
Request as RequestDecorator,
Put,
UploadedFile,
Delete,
Query,
UnauthorizedException,
BadRequestException,
UseFilters,
InternalServerErrorException,
HttpException,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBadRequestResponse,
ApiBearerAuth,
Expand Down Expand Up @@ -72,6 +76,8 @@ import { getFutureIAT } from '../../middlewares/passport';
import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator';
import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes';
import { VerifyEmailDto } from './dto/verify-email.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { avatarStorageS3Config } from '../../externals/multer';

@ApiTags('User')
@Controller('users')
Expand Down Expand Up @@ -841,4 +847,72 @@ export class UserController {
async verifyAccountEmail(@Body() body: VerifyEmailDto) {
return this.userUseCases.verifyUserEmail(body.verificationToken);
}

@Patch('/profile')
@HttpCode(200)
@ApiBearerAuth()
@ApiOperation({
summary: 'Update user profile',
})
@ApiOkResponse({
description: 'Updated user profile',
})
async updateProfile(
@UserDecorator() user: User,
@Body() updateProfileDto: UpdateProfileDto,
) {
if (!updateProfileDto.name && updateProfileDto.lastname == undefined) {
throw new BadRequestException(
'At least one of name or lastname must be provided.',
);
}
return this.userUseCases.updateProfile(user, updateProfileDto);
}

@Put('/avatar')
@ApiBearerAuth()
@HttpCode(200)
@ApiOkResponse({
description: 'Avatar added to the user',
})
@UseInterceptors(FileInterceptor('avatar', avatarStorageS3Config))
async uploadAvatar(
@UploadedFile() avatar: Express.Multer.File | any,
@UserDecorator() user: User,
) {
if (!avatar) {
throw new BadRequestException('avatar is required');
}
if (!avatar.key) {
throw new InternalServerErrorException('Avatar could not be uploaded');
}

try {
return await this.userUseCases.upsertAvatar(user, avatar.key);
} catch (err) {
Logger.error(
`[USER/UPLOAD_AVATAR] Error uploading avatar for user: ${user.id}. Error: ${err.message}`,
);
throw err;
}
}

@Delete('/avatar')
@HttpCode(200)
@ApiBearerAuth()
@ApiOkResponse({
description: 'Avatar deleted from the workspace',
})
async deleteAvatar(@UserDecorator() user: User) {
try {
return await this.userUseCases.deleteAvatar(user);
} catch (err) {
Logger.error(
`[USER/DELETE_AVATAR] Error deleting the avatar for the user: ${
user.id
} has failed. Error: ${(err as Error).message}`,
);
throw err;
}
}
}
Loading

0 comments on commit 6cb8ba7

Please sign in to comment.