From 94a2d9637447a8f08a9b22ff05d53108ab1a4f80 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 15 Feb 2026 02:26:29 -0500 Subject: [PATCH 1/9] Made route and body template for upload Profile pic route --- backend/package-lock.json | 33 ++++++++++++++++++++++++++-- backend/package.json | 1 + backend/src/user/types/user.types.ts | 12 +++++----- backend/src/user/user.controller.ts | 13 +++++++++-- backend/src/user/user.service.ts | 5 +++++ 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 9c25102..4a48a51 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "@types/aws-serverless-express": "^3.3.10", "@types/cookie-parser": "^1.4.10", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^25.0.9", "aws-sdk": "^2.1693.0", "esbuild": "^0.27.2", @@ -205,6 +206,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2304,6 +2306,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2336,6 +2339,7 @@ "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2419,6 +2423,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3192,6 +3197,7 @@ "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -3286,6 +3292,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "25.0.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", @@ -4000,6 +4016,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4026,6 +4043,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4492,6 +4510,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4712,6 +4731,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4759,13 +4779,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6549,6 +6571,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8131,6 +8154,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -8522,7 +8546,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -8727,6 +8752,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9759,6 +9785,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9995,6 +10022,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10218,6 +10246,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/backend/package.json b/backend/package.json index c33d7fc..2e99e47 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "@types/aws-serverless-express": "^3.3.10", "@types/cookie-parser": "^1.4.10", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^25.0.9", "aws-sdk": "^2.1693.0", "esbuild": "^0.27.2", diff --git a/backend/src/user/types/user.types.ts b/backend/src/user/types/user.types.ts index 8cd99c2..b067502 100644 --- a/backend/src/user/types/user.types.ts +++ b/backend/src/user/types/user.types.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { UserStatus } from '../../../../middle-layer/types/UserStatus'; +import { User } from '../../types/User'; export class ChangeRoleBody { - user!: { - userId: string, - position: UserStatus, - email: string - }; + user!: User; groupName!: UserStatus; +} + +export class UploadProfilePicBody{ + user! : User ; + file! : Express.Multer.File; } \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index f32729a..38a6ad8 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,10 +1,11 @@ -import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req } from "@nestjs/common"; +import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req, Post, UseInterceptors, UploadedFile } from "@nestjs/common"; import { UserService } from "./user.service"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; import { VerifyAdminRoleGuard, VerifyUserGuard, VerifyAdminOrEmployeeRoleGuard } from "../guards/auth.guard"; import { ApiResponse, ApiParam , ApiBearerAuth} from "@nestjs/swagger"; -import { ChangeRoleBody } from "./types/user.types"; +import { ChangeRoleBody, UploadProfilePicBody } from "./types/user.types"; +import { FileInterceptor } from "@nestjs/platform-express"; @Controller("user") export class UserController { @@ -231,4 +232,12 @@ export class UserController { async getUserById(@Param('id') userId: string): Promise { return await this.userService.getUserById(userId); } + + @Post('upload-pfp') + @UseInterceptors(FileInterceptor('profilePic')) + async uploadProfilePic( +@Body() UploadProfilePicBody: UploadProfilePicBody, +){ + + } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 34105d8..a74ae6f 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -22,7 +22,12 @@ export class UserService { private readonly logger = new Logger(UserService.name); private dynamoDb = new AWS.DynamoDB.DocumentClient(); private ses = new AWS.SES({ region: process.env.AWS_REGION }); + private s3 = new AWS.S3(); + private profilePicBucket = process.env.PROFILE_PICTURE_BUCKET; + async uploadProfilePic(user : User, pic : Express.Multer.File) : Promise{ + throw new Error("Not implemented") + } // purpose statement: deletes user from database; only admin can delete users // use case: employee is no longer with BCAN async deleteUser(user: User, requestedBy: User): Promise { From ffad6f72d6e04412ca93a1ec30eaa5a1ad400580 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 15 Feb 2026 02:39:06 -0500 Subject: [PATCH 2/9] Upload profile pic route has been made --- backend/src/user/user.controller.ts | 4 +- backend/src/user/user.service.ts | 120 +++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 38a6ad8..5e62699 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -236,8 +236,8 @@ export class UserController { @Post('upload-pfp') @UseInterceptors(FileInterceptor('profilePic')) async uploadProfilePic( -@Body() UploadProfilePicBody: UploadProfilePicBody, +@Body() body: UploadProfilePicBody, ){ - + return await this.userService.uploadProfilePic(body.user,body.file) } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index a74ae6f..2d26600 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -23,11 +23,125 @@ export class UserService { private dynamoDb = new AWS.DynamoDB.DocumentClient(); private ses = new AWS.SES({ region: process.env.AWS_REGION }); private s3 = new AWS.S3(); - private profilePicBucket = process.env.PROFILE_PICTURE_BUCKET; + private profilePicBucket : string = process.env.PROFILE_PICTURE_BUCKET!; - async uploadProfilePic(user : User, pic : Express.Multer.File) : Promise{ - throw new Error("Not implemented") + async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { + const tableName = process.env.DYNAMODB_USER_TABLE_NAME; + + // 1. Validate all inputs + this.validateUploadInputs(user, pic, tableName); + + // 2. Generate filename: userId-profilepic.ext + const fileExtension = pic.originalname.split('.').pop()?.toLowerCase() || 'jpg'; + const key = `${user.userId}-profilepic.${fileExtension}`; + + this.logger.log(`Uploading profile picture for user ${user.userId} with key: ${key}`); + + try { + // 3. Upload to S3 + const uploadParams: AWS.S3.PutObjectRequest = { + Bucket: this.profilePicBucket, + Key: key, + Body: pic.buffer, + ContentType: pic.mimetype, + }; + + const uploadResult = await this.s3.upload(uploadParams).promise(); + this.logger.log(`✓ Profile picture uploaded to S3: ${uploadResult.Location}`); + + // 4. Update user's profile picture URL in DynamoDB + const updateParams = { + TableName: tableName!, + Key: { userId: user.userId }, + UpdateExpression: "SET profilePictureUrl = :url", + ExpressionAttributeValues: { + ":url": uploadResult.Location, + }, + ReturnValues: "ALL_NEW" as const, + }; + + const updateResult = await this.dynamoDb.update(updateParams).promise(); + + if (!updateResult.Attributes) { + this.logger.error(`DynamoDB update did not return updated attributes for ${user.userId}`); + throw new InternalServerErrorException("Failed to retrieve updated user data"); + } + + this.logger.log(`✅ Profile picture uploaded successfully for user ${user.userId}`); + return updateResult.Attributes as User; + + } catch (error: any) { + this.logger.error(`Failed to upload profile picture for ${user.userId}:`, error); + + // Handle S3 errors + if (error.code === 'NoSuchBucket') { + this.logger.error(`S3 bucket does not exist: ${this.profilePicBucket}`); + throw new InternalServerErrorException('Storage bucket not found'); + } else if (error.code === 'AccessDenied') { + this.logger.error('Access denied to S3 bucket'); + throw new InternalServerErrorException('Insufficient permissions to upload file'); + } + + // Handle DynamoDB errors + if (error.code === 'ResourceNotFoundException') { + this.logger.error('DynamoDB table does not exist'); + throw new InternalServerErrorException('Database table not found'); + } else if (error.code === 'ValidationException') { + this.logger.error(`Invalid DynamoDB update parameters`); + throw new BadRequestException(`Invalid update parameters`); + } + + if (error instanceof HttpException) { + throw error; + } + + throw new InternalServerErrorException('Failed to upload profile picture'); + } +} + +// Validation helper method for profile picture uploads +private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: string | undefined): void { + // Validate environment variables + if (!this.profilePicBucket) { + this.logger.error("Profile Picture Bucket is not defined in environment variables"); + throw new InternalServerErrorException("Server configuration error"); + } + + if (!tableName) { + this.logger.error("DynamoDB User Table Name is not defined in environment variables"); + throw new InternalServerErrorException("Server configuration error"); + } + + // Validate user object + if (!user || !user.userId) { + this.logger.error("Invalid user object provided for profile picture upload"); + throw new BadRequestException("Valid user object is required"); + } + + // Validate file exists + if (!pic || !pic.buffer) { + this.logger.error("Invalid file provided for upload"); + throw new BadRequestException("Valid image file is required"); + } + + // Validate file type + const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedMimeTypes.includes(pic.mimetype)) { + this.logger.error(`Invalid file type: ${pic.mimetype}`); + throw new BadRequestException( + `Invalid file type. Allowed types: ${allowedMimeTypes.join(', ')}` + ); + } + + // Validate file size (5MB max) + const maxSizeInBytes = 5 * 1024 * 1024; + if (pic.size > maxSizeInBytes) { + this.logger.error(`File too large: ${pic.size} bytes`); + throw new BadRequestException( + `File too large. Maximum size: ${maxSizeInBytes / (1024 * 1024)}MB` + ); } +} // purpose statement: deletes user from database; only admin can delete users // use case: employee is no longer with BCAN async deleteUser(user: User, requestedBy: User): Promise { From 15ca97d0ac114aa359a9e39d1074dbfe99e92586 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 15 Feb 2026 02:51:44 -0500 Subject: [PATCH 3/9] Added swagger docs to upload pfp and it works correctly --- backend/src/user/user.controller.ts | 68 ++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 5e62699..d9944fd 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,9 +1,9 @@ -import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req, Post, UseInterceptors, UploadedFile } from "@nestjs/common"; +import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req, Post, UseInterceptors, UploadedFile, BadRequestException } from "@nestjs/common"; import { UserService } from "./user.service"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; import { VerifyAdminRoleGuard, VerifyUserGuard, VerifyAdminOrEmployeeRoleGuard } from "../guards/auth.guard"; -import { ApiResponse, ApiParam , ApiBearerAuth} from "@nestjs/swagger"; +import { ApiResponse, ApiParam , ApiBearerAuth, ApiOperation, ApiConsumes, ApiBody} from "@nestjs/swagger"; import { ChangeRoleBody, UploadProfilePicBody } from "./types/user.types"; import { FileInterceptor } from "@nestjs/platform-express"; @@ -234,10 +234,64 @@ export class UserController { } @Post('upload-pfp') - @UseInterceptors(FileInterceptor('profilePic')) - async uploadProfilePic( -@Body() body: UploadProfilePicBody, -){ - return await this.userService.uploadProfilePic(body.user,body.file) +@ApiOperation({ + summary: 'Upload profile picture', + description: 'Uploads a profile picture for a user to S3 and updates the user record in DynamoDB' +}) +@ApiConsumes('multipart/form-data') +@ApiBody({ + description: 'Profile picture upload with user information', + schema: { + type: 'object', + required: ['profilePic', 'user'], + properties: { + profilePic: { + type: 'string', + format: 'binary', + description: 'Image file (jpg, jpeg, png, gif, webp). Max size: 5MB' + }, + user: { + type: 'string', + description: 'User object as JSON string', + example: '{"userId":"user-123","position":"Employee","email":"john@example.com"}' + } + } } +}) +@ApiResponse({ + status: 200, + description: 'Profile picture uploaded successfully', +}) +@ApiResponse({ + status: 400, + description: 'Bad Request - Invalid file type, file too large, or invalid user data', +}) +@ApiResponse({ + status: 401, + description: 'Unauthorized', +}) +@ApiResponse({ + status: 403, + description: 'Forbidden', +}) +@ApiResponse({ + status: 500, + description: 'Internal Server Error', +}) +@UseGuards(VerifyAdminOrEmployeeRoleGuard) +@ApiBearerAuth() +@UseInterceptors(FileInterceptor('profilePic')) +async uploadProfilePic( + @UploadedFile() file: Express.Multer.File, + @Body('user') userJson: string, // ✅ Comes as JSON string +) { + try { + // Parse the JSON string to User object + const user: User = JSON.parse(userJson); + + return await this.userService.uploadProfilePic(user, file); + } catch (error) { + throw new BadRequestException('Invalid user data format'); + } +} } From e594bb932428128a6c03cf5861fc8e0ed5152054 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 15 Feb 2026 02:54:54 -0500 Subject: [PATCH 4/9] Updated test --- .../src/user/__test__/user.service.spec.ts | 667 ++++++------------ 1 file changed, 218 insertions(+), 449 deletions(-) diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index 1d713e5..ba55dbb 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -25,6 +25,9 @@ const mockAdminDeleteUser = vi.fn(); // Mock SES functions const mockSendEmail = vi.fn(); +// Mock S3 functions +const mockS3Upload = vi.fn(); + // Mock AWS SDK ONCE with proper structure for import * as AWS vi.mock('aws-sdk', () => { return { @@ -51,6 +54,11 @@ vi.mock('aws-sdk', () => { return { sendEmail: mockSendEmail, }; + }), + S3: vi.fn(function() { + return { + upload: mockS3Upload, + }; }) }, CognitoIdentityServiceProvider: vi.fn(function() { @@ -75,6 +83,11 @@ vi.mock('aws-sdk', () => { return { sendEmail: mockSendEmail, }; + }), + S3: vi.fn(function() { + return { + upload: mockS3Upload, + }; }) }; }); @@ -93,8 +106,6 @@ vi.mock('../../guards/auth.guard', () => ({ })); // 🗄️ Mock Database with test data -// This simulates a DynamoDB table with realistic test data -// Contains: 2 Admins, 3 Employees, 4 Inactive users (9 total) const mockDatabase = { users: [ { userId: 'admin1', email: 'admin1@example.com', position: UserStatus.Admin }, @@ -108,16 +119,13 @@ const mockDatabase = { { userId: 'inactive4', email: 'inactive4@example.com', position: UserStatus.Inactive }, ] as User[], - // Helper function to simulate DynamoDB scan with FilterExpression scan: (params: any) => { let filteredUsers = [...mockDatabase.users]; if (params.FilterExpression) { - // Handle FilterExpression for inactive users: #pos IN (:inactive) if (params.FilterExpression.includes('(:inactive)')) { filteredUsers = filteredUsers.filter(u => u.position === 'Inactive'); } - // Handle FilterExpression for active users: #pos IN (:admin, :employee) else if (params.FilterExpression.includes('(:admin, :employee)')) { filteredUsers = filteredUsers.filter(u => u.position === 'Admin' || u.position === 'Employee'); } @@ -126,7 +134,6 @@ const mockDatabase = { return { Items: filteredUsers }; }, - // Helper function to simulate DynamoDB get operation get: (params: any) => { const userId = params.Key.userId; const user = mockDatabase.users.find(u => u.userId === userId); @@ -142,27 +149,31 @@ describe('UserController', () => { // Set up environment variables process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.PROFILE_PICTURE_BUCKET = 'test-profile-pics-bucket'; }); beforeEach(async () => { // Clear all mocks before each test vi.clearAllMocks(); - // Setup DynamoDB mocks to return chainable objects with .promise() + // Setup DynamoDB mocks mockScan.mockReturnValue({ promise: mockPromise }); mockGet.mockReturnValue({ promise: mockPromise }); mockDelete.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); mockPut.mockReturnValue({ promise: mockPromise }); - // Setup Cognito mocks to return chainable objects with .promise() + // Setup Cognito mocks mockAdminAddUserToGroup.mockReturnValue({ promise: mockPromise }); mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockPromise }); mockAdminDeleteUser.mockReturnValue({ promise: mockPromise }); - // Setup SES mocks to return chainable objects with .promise() + // Setup SES mocks mockSendEmail.mockReturnValue({ promise: mockPromise }); + // Setup S3 mocks + mockS3Upload.mockReturnValue({ promise: mockPromise }); + // Reset promise mocks to default resolved state mockPromise.mockResolvedValue({}); @@ -175,496 +186,254 @@ describe('UserController', () => { userService = module.get(UserService); }); - it('should get all users from mock database', async () => { - // Setup the mock response using our mock database - mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); + // ======================================== + // Tests for uploadProfilePic + // ======================================== - const result = await userService.getAllUsers(); - - expect(result).toHaveLength(9); // All 9 users in mock database - expect(mockScan).toHaveBeenCalledWith({ - TableName: 'test-users-table' + describe('uploadProfilePic', () => { + const createMockFile = (overrides?: Partial): Express.Multer.File => ({ + fieldname: 'profilePic', + originalname: 'test-image.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1024 * 1024, // 1MB + buffer: Buffer.from('fake-image-data'), + destination: '', + filename: '', + path: '', + stream: null as any, + ...overrides, }); - }); - - it('should get user by id from mock database', async () => { - // Setup the mock response using our mock database - mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { userId: 'admin1' } })); - const result = await userService.getUserById('admin1'); - - expect(result.userId).toBe('admin1'); - expect(result.position).toBe('Admin'); - expect(result.email).toBe('admin1@example.com'); - expect(mockGet).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Key: { userId: 'admin1' } + it('should successfully upload profile picture', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + // Mock S3 upload success + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + Bucket: 'test-profile-pics-bucket', + }); + + // Mock DynamoDB update success + mockPromise.mockResolvedValueOnce({ + Attributes: { + ...user, + profilePictureUrl: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + }, + }); + + const result = await userService.uploadProfilePic(user, mockFile); + + expect(result.profilePictureUrl).toBe('https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg'); + expect(mockS3Upload).toHaveBeenCalledWith({ + Bucket: 'test-profile-pics-bucket', + Key: 'emp1-profilepic.jpg', + Body: mockFile.buffer, + ContentType: 'image/jpeg', + }); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Key: { userId: 'emp1' }, + UpdateExpression: 'SET profilePictureUrl = :url', + ExpressionAttributeValues: { + ':url': 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + }, + ReturnValues: 'ALL_NEW', + }); }); - }); - it('should throw BadRequestException when userId is invalid', async () => { - await expect(userService.getUserById('')).rejects.toThrow('Valid user ID is required'); - await expect(userService.getUserById(null as any)).rejects.toThrow('Valid user ID is required'); - await expect(userService.getUserById(' ')).rejects.toThrow('Valid user ID is required'); - }); - - it('should throw NotFoundException when user does not exist in mock database', async () => { - // Mock empty response (user not found) using mock database - mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { userId: 'nonexistent' } })); + it('should generate correct filename with different extensions', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile({ originalname: 'test.png', mimetype: 'image/png' }); - await expect(userService.getUserById('nonexistent')).rejects.toThrow("User 'nonexistent' does not exist"); - expect(mockGet).toHaveBeenCalled(); - }); + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.png', + Key: 'emp1-profilepic.png', + }); + mockPromise.mockResolvedValueOnce({ + Attributes: { ...user, profilePictureUrl: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.png' }, + }); - it('should handle errors when getting all users', async () => { - // Mock an error with AWS error structure - const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(awsError); - - await expect(userService.getAllUsers()).rejects.toThrow('Database table not found'); - expect(mockScan).toHaveBeenCalled(); - }); + await userService.uploadProfilePic(user, mockFile); - it('should handle generic DynamoDB errors when getting all users', async () => { - // Mock a generic error - const awsError = { code: 'UnknownError', message: 'Unknown DynamoDB error' }; - mockPromise.mockRejectedValueOnce(awsError); + expect(mockS3Upload).toHaveBeenCalledWith( + expect.objectContaining({ + Key: 'emp1-profilepic.png', + }) + ); + }); - await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users'); - expect(mockScan).toHaveBeenCalled(); - }); + it('should throw BadRequestException when user object is invalid', async () => { + const mockFile = createMockFile(); - it('should handle errors when getting user by id', async () => { - // Mock an AWS error with specific error code - const awsError = { code: 'ValidationException', message: 'Invalid request' }; - mockPromise.mockRejectedValueOnce(awsError); + await expect( + userService.uploadProfilePic(null as any, mockFile) + ).rejects.toThrow('Valid user object is required'); - await expect(userService.getUserById('1')).rejects.toThrow('Invalid request: Invalid request'); - expect(mockGet).toHaveBeenCalled(); - }); + await expect( + userService.uploadProfilePic({ userId: '' } as any, mockFile) + ).rejects.toThrow('Valid user object is required'); + }); - it('should handle ResourceNotFoundException when getting user by id', async () => { - // Mock a ResourceNotFoundException - const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(awsError); + it('should throw BadRequestException when file is invalid', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - await expect(userService.getUserById('1')).rejects.toThrow('Database table not found'); - expect(mockGet).toHaveBeenCalled(); - }); + await expect( + userService.uploadProfilePic(user, null as any) + ).rejects.toThrow('Valid image file is required'); - it('should get all inactive users from mock database', async () => { - // Setup the mock response using our mock database with filter - const scanParams = { - TableName: 'test-users-table', - FilterExpression: '#pos IN (:inactive)', - ExpressionAttributeNames: { '#pos': 'position' }, - ExpressionAttributeValues: { ':inactive': 'Inactive' } - }; - - mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); + await expect( + userService.uploadProfilePic(user, { buffer: null } as any) + ).rejects.toThrow('Valid image file is required'); + }); - const result = await userService.getAllInactiveUsers(); - - // Should return exactly 4 inactive users from mock database - expect(result).toHaveLength(4); - expect(result.every(u => u.position === 'Inactive')).toBe(true); - expect(result.map(u => u.userId).sort()).toEqual(['inactive1', 'inactive2', 'inactive3', 'inactive4']); - expect(mockScan).toHaveBeenCalledWith(scanParams); - }); + it('should throw BadRequestException for invalid file type', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile({ mimetype: 'application/pdf' }); - it('should handle errors when getting inactive users', async () => { - const awsError = { code: 'ValidationException', message: 'Invalid filter' }; - mockPromise.mockRejectedValueOnce(awsError); + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Invalid file type'); + }); - await expect(userService.getAllInactiveUsers()).rejects.toThrow('Invalid filter expression'); - expect(mockScan).toHaveBeenCalled(); - }); + it('should throw BadRequestException for file too large', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile({ size: 10 * 1024 * 1024 }); // 10MB - it('should get all active users from mock database', async () => { - // Setup the mock response using our mock database with filter - const scanParams = { - TableName: 'test-users-table', - FilterExpression: '#pos IN (:admin, :employee)', - ExpressionAttributeNames: { '#pos': 'position' }, - ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' } - }; - - mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); - - const result = await userService.getAllActiveUsers(); - - // Should return exactly 5 active users (2 admins + 3 employees) from mock database - expect(result).toHaveLength(5); - expect(result.every(u => u.position === 'Admin' || u.position === 'Employee')).toBe(true); - - const admins = result.filter(u => u.position === 'Admin'); - const employees = result.filter(u => u.position === 'Employee'); - expect(admins).toHaveLength(2); - expect(employees).toHaveLength(3); - - expect(mockScan).toHaveBeenCalledWith(scanParams); - }); + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('File too large'); + }); - it('should throw NotFoundException when no active users found', async () => { - // Mock empty response - mockPromise.mockResolvedValueOnce({ Items: undefined }); + it('should accept all allowed image types', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; - await expect(userService.getAllActiveUsers()).rejects.toThrow('No active users found.'); - expect(mockScan).toHaveBeenCalled(); - }); + for (const mimetype of allowedTypes) { + vi.clearAllMocks(); + mockS3Upload.mockReturnValue({ promise: mockPromise }); + mockPromise.mockResolvedValueOnce({ Location: 'https://test.com/image', Key: 'key' }); + mockPromise.mockResolvedValueOnce({ Attributes: user }); - it('should handle ProvisionedThroughputExceededException', async () => { - const awsError = { code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded' }; - mockPromise.mockRejectedValueOnce(awsError); + const mockFile = createMockFile({ mimetype }); + await expect(userService.uploadProfilePic(user, mockFile)).resolves.toBeDefined(); + } + }); - await expect(userService.getAllActiveUsers()).rejects.toThrow('Database is temporarily unavailable, please try again'); - expect(mockScan).toHaveBeenCalled(); - }); + it('should handle S3 NoSuchBucket error', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); - // ======================================== - // Tests for addUserToGroup (Change Role) - // ======================================== + const s3Error = { code: 'NoSuchBucket', message: 'Bucket does not exist' }; + mockPromise.mockRejectedValueOnce(s3Error); - it('should successfully change user role from Inactive to Employee', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get to verify user exists - mockPromise.mockResolvedValueOnce({ Item: user }); - - // Mock Cognito remove from old group (no-op for Inactive) - mockPromise.mockResolvedValueOnce({}); - - // Mock Cognito add to new group - mockPromise.mockResolvedValueOnce({}); - - // Mock SES sendEmail (verification email for Inactive -> Employee) - mockPromise.mockResolvedValueOnce({ MessageId: 'test-message-id' }); - - // Mock DynamoDB update - mockPromise.mockResolvedValueOnce({ - Attributes: { ...user, position: UserStatus.Employee } + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Storage bucket not found'); }); - const result = await userService.addUserToGroup(user, UserStatus.Employee, admin); - - expect(result.position).toBe(UserStatus.Employee); - expect(mockGet).toHaveBeenCalled(); - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Employee', - UserPoolId: 'test-pool-id', - Username: 'inactive1' - }); - expect(mockSendEmail).toHaveBeenCalled(); // Verify email was sent - expect(mockUpdate).toHaveBeenCalled(); - }); + it('should handle S3 AccessDenied error', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); - it('should successfully promote Employee to Admin', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: user }); - - // Mock Cognito remove from Employee group - mockPromise.mockResolvedValueOnce({}); - - // Mock Cognito add to Admin group - mockPromise.mockResolvedValueOnce({}); - - // Mock DynamoDB update - mockPromise.mockResolvedValueOnce({ - Attributes: { ...user, position: UserStatus.Admin } - }); + const s3Error = { code: 'AccessDenied', message: 'Access denied' }; + mockPromise.mockRejectedValueOnce(s3Error); - const result = await userService.addUserToGroup(user, UserStatus.Admin, admin); - - expect(result.position).toBe(UserStatus.Admin); - expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledWith({ - GroupName: 'Employee', - UserPoolId: 'test-pool-id', - Username: 'emp1' - }); - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Admin', - UserPoolId: 'test-pool-id', - Username: 'emp1' + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Insufficient permissions to upload file'); }); - }); - it('should return user unchanged if already in requested group', async () => { - const user = mockDatabase.users.find(u => u.userId === 'admin1')!; - const requestedBy = mockDatabase.users.find(u => u.userId === 'admin2')!; - - // Mock DynamoDB get - user already Admin - mockPromise.mockResolvedValueOnce({ Item: user }); + it('should handle DynamoDB update failure', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); - const result = await userService.addUserToGroup(user, UserStatus.Admin, requestedBy); - - expect(result.position).toBe(UserStatus.Admin); - // Should not call Cognito if already in group - expect(mockAdminAddUserToGroup).not.toHaveBeenCalled(); - }); + // S3 upload succeeds + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + }); - it('should throw BadRequestException when user object is invalid', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - await expect( - userService.addUserToGroup(null as any, UserStatus.Employee, admin) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.addUserToGroup({ userId: '' } as any, UserStatus.Employee, admin) - ).rejects.toThrow('Valid user object is required'); - }); + // DynamoDB update fails + const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; + mockPromise.mockRejectedValueOnce(dynamoError); - it('should throw BadRequestException when group name is invalid', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - await expect( - userService.addUserToGroup(user, '' as any, admin) - ).rejects.toThrow('Group name is required'); - - await expect( - userService.addUserToGroup(user, 'InvalidGroup' as any, admin) - ).rejects.toThrow('Invalid group name'); - }); + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Database table not found'); + }); - it('should throw UnauthorizedException when non-admin tries to change role', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const employee = mockDatabase.users.find(u => u.userId === 'emp1')!; - - await expect( - userService.addUserToGroup(user, UserStatus.Employee, employee) - ).rejects.toThrow('Only administrators can modify user groups'); - }); + it('should handle DynamoDB ValidationException', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); - it('should throw BadRequestException when admin tries to demote themselves', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: admin }); - - await expect( - userService.addUserToGroup(admin, UserStatus.Employee, admin) - ).rejects.toThrow('Administrators cannot demote themselves'); - }); + // S3 upload succeeds + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + }); - it('should throw NotFoundException when user does not exist', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - const fakeUser: User = { userId: 'nonexistent', email: 'fake@test.com', position: UserStatus.Inactive }; - - // Mock DynamoDB get - user not found - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.addUserToGroup(fakeUser, UserStatus.Employee, admin) - ).rejects.toThrow("User 'nonexistent' does not exist"); - }); + // DynamoDB update fails with ValidationException + const dynamoError = { code: 'ValidationException', message: 'Invalid parameters' }; + mockPromise.mockRejectedValueOnce(dynamoError); - it('should handle Cognito UserNotFoundException', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: user }); - - // Mock Cognito remove from old group (no-op for Inactive, but still called) - mockPromise.mockResolvedValueOnce({}); - - // Mock Cognito add to new group - this should fail - const cognitoError = { code: 'UserNotFoundException', message: 'User not found in Cognito' }; - mockPromise.mockRejectedValueOnce(cognitoError); - - await expect( - userService.addUserToGroup(user, UserStatus.Employee, admin) - ).rejects.toThrow('not found in authentication system'); - }); + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Invalid update parameters'); + }); - it('should rollback Cognito change if DynamoDB update fails', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: user }); - - // Mock Cognito operations succeed - mockPromise.mockResolvedValueOnce({}); // Remove from old group - mockPromise.mockResolvedValueOnce({}); // Add to new group - - // Mock DynamoDB update fails - const dynamoError = { code: 'ValidationException', message: 'Invalid update' }; - mockPromise.mockRejectedValueOnce(dynamoError); - - // Mock rollback operations - mockPromise.mockResolvedValueOnce({}); // Remove from new group - mockPromise.mockResolvedValueOnce({}); // Add back to old group - - await expect( - userService.addUserToGroup(user, UserStatus.Admin, admin) - ).rejects.toThrow('Invalid update parameters'); - - // Verify rollback was attempted - expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledTimes(2); // Once for change, once for rollback - expect(mockAdminAddUserToGroup).toHaveBeenCalledTimes(2); // Once for change, once for rollback - }); + it('should throw InternalServerErrorException when DynamoDB does not return attributes', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); - // ======================================== - // Tests for deleteUser - // ======================================== + // S3 upload succeeds + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + }); - it('should successfully delete a user', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get to verify user exists - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete - mockPromise.mockResolvedValueOnce({ - Attributes: userToDelete - }); - - // Mock Cognito delete - mockPromise.mockResolvedValueOnce({}); + // DynamoDB update succeeds but doesn't return Attributes + mockPromise.mockResolvedValueOnce({}); - const result = await userService.deleteUser(userToDelete, admin); - - expect(result.userId).toBe('emp1'); - expect(mockGet).toHaveBeenCalled(); - expect(mockDelete).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Key: { userId: 'emp1' }, - ReturnValues: 'ALL_OLD' + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Failed to retrieve updated user data'); }); - expect(mockAdminDeleteUser).toHaveBeenCalledWith({ - UserPoolId: 'test-pool-id', - Username: 'emp1' - }); - }); - - it('should throw BadRequestException when user object is invalid', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - await expect( - userService.deleteUser(null as any, admin) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.deleteUser({ userId: '' } as any, admin) - ).rejects.toThrow('Valid user object is required'); - }); - it('should throw BadRequestException when requestedBy is invalid', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - - await expect( - userService.deleteUser(user, null as any) - ).rejects.toThrow('Valid requesting user is required'); - }); + it('should throw InternalServerErrorException when bucket env var is not set', async () => { + const originalBucket = process.env.PROFILE_PICTURE_BUCKET; + delete process.env.PROFILE_PICTURE_BUCKET; - it('should throw UnauthorizedException when non-admin tries to delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp2')!; - const employee = mockDatabase.users.find(u => u.userId === 'emp1')!; - - await expect( - userService.deleteUser(userToDelete, employee) - ).rejects.toThrow('Only administrators can delete users'); - }); + // Create a new service instance to pick up the env change + const module: TestingModule = await Test.createTestingModule({ + providers: [UserService], + }).compile(); + const testService = module.get(UserService); - it('should throw BadRequestException when admin tries to delete themselves', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - await expect( - userService.deleteUser(admin, admin) - ).rejects.toThrow('Administrators cannot delete their own account'); - }); + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); - it('should throw NotFoundException when user to delete does not exist', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - const fakeUser: User = { userId: 'nonexistent', email: 'fake@test.com', position: UserStatus.Employee }; - - // Mock DynamoDB get - user not found - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.deleteUser(fakeUser, admin) - ).rejects.toThrow("User 'nonexistent' does not exist"); - }); + await expect( + testService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Server configuration error'); - it('should handle Cognito UserNotFoundException during delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete succeeds - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - - // Mock Cognito delete fails - const cognitoError = { code: 'UserNotFoundException', message: 'User not found' }; - mockPromise.mockRejectedValueOnce(cognitoError); - - // Mock rollback (restore to DynamoDB) - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('not found in authentication system'); - - // Verify rollback was attempted - expect(mockPut).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Item: userToDelete + // Restore env var + process.env.PROFILE_PICTURE_BUCKET = originalBucket; }); }); - it('should rollback DynamoDB delete if Cognito delete fails', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete succeeds - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - - // Mock Cognito delete fails with generic error - const cognitoError = { code: 'InternalError', message: 'Cognito internal error' }; - mockPromise.mockRejectedValueOnce(cognitoError); - - // Mock rollback succeeds - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('Failed to delete user from authentication system'); - - // Verify rollback was attempted - expect(mockPut).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Item: userToDelete - }); - }); + // ======================================== + // Existing tests... + // ======================================== - it('should handle DynamoDB delete failure', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete fails - const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(dynamoError); - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('Failed to delete user from database'); - - // Cognito delete should not be called if DynamoDB fails - expect(mockAdminDeleteUser).not.toHaveBeenCalled(); + it('should get all users from mock database', async () => { + mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); + const result = await userService.getAllUsers(); + expect(result).toHaveLength(9); + expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-users-table' }); }); + + // ... (rest of your existing tests remain the same) }); \ No newline at end of file From 0c1e5cfc6e34d94a8bca321565b2be2ea677e159 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 15 Feb 2026 03:10:27 -0500 Subject: [PATCH 5/9] Fixed test again --- .../src/user/__test__/user.service.spec.ts | 534 +++++++++++++++++- backend/src/user/user.controller.ts | 2 +- backend/src/user/user.service.ts | 4 +- 3 files changed, 526 insertions(+), 14 deletions(-) diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index ba55dbb..47596d9 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -106,6 +106,8 @@ vi.mock('../../guards/auth.guard', () => ({ })); // 🗄️ Mock Database with test data +// This simulates a DynamoDB table with realistic test data +// Contains: 2 Admins, 3 Employees, 4 Inactive users (9 total) const mockDatabase = { users: [ { userId: 'admin1', email: 'admin1@example.com', position: UserStatus.Admin }, @@ -119,13 +121,16 @@ const mockDatabase = { { userId: 'inactive4', email: 'inactive4@example.com', position: UserStatus.Inactive }, ] as User[], + // Helper function to simulate DynamoDB scan with FilterExpression scan: (params: any) => { let filteredUsers = [...mockDatabase.users]; if (params.FilterExpression) { + // Handle FilterExpression for inactive users: #pos IN (:inactive) if (params.FilterExpression.includes('(:inactive)')) { filteredUsers = filteredUsers.filter(u => u.position === 'Inactive'); } + // Handle FilterExpression for active users: #pos IN (:admin, :employee) else if (params.FilterExpression.includes('(:admin, :employee)')) { filteredUsers = filteredUsers.filter(u => u.position === 'Admin' || u.position === 'Employee'); } @@ -134,6 +139,7 @@ const mockDatabase = { return { Items: filteredUsers }; }, + // Helper function to simulate DynamoDB get operation get: (params: any) => { const userId = params.Key.userId; const user = mockDatabase.users.find(u => u.userId === userId); @@ -156,22 +162,22 @@ describe('UserController', () => { // Clear all mocks before each test vi.clearAllMocks(); - // Setup DynamoDB mocks + // Setup DynamoDB mocks to return chainable objects with .promise() mockScan.mockReturnValue({ promise: mockPromise }); mockGet.mockReturnValue({ promise: mockPromise }); mockDelete.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); mockPut.mockReturnValue({ promise: mockPromise }); - // Setup Cognito mocks + // Setup Cognito mocks to return chainable objects with .promise() mockAdminAddUserToGroup.mockReturnValue({ promise: mockPromise }); mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockPromise }); mockAdminDeleteUser.mockReturnValue({ promise: mockPromise }); - // Setup SES mocks + // Setup SES mocks to return chainable objects with .promise() mockSendEmail.mockReturnValue({ promise: mockPromise }); - // Setup S3 mocks + // Setup S3 mocks to return chainable objects with .promise() mockS3Upload.mockReturnValue({ promise: mockPromise }); // Reset promise mocks to default resolved state @@ -226,7 +232,8 @@ describe('UserController', () => { const result = await userService.uploadProfilePic(user, mockFile); - expect(result.profilePictureUrl).toBe('https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg'); + // ✅ Result is now just the URL string, not the User object + expect(result).toBe('https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg'); expect(mockS3Upload).toHaveBeenCalledWith({ Bucket: 'test-profile-pics-bucket', Key: 'emp1-profilepic.jpg', @@ -312,13 +319,34 @@ describe('UserController', () => { const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; for (const mimetype of allowedTypes) { + // Clear all mocks vi.clearAllMocks(); + + // Reset the mock implementations mockS3Upload.mockReturnValue({ promise: mockPromise }); - mockPromise.mockResolvedValueOnce({ Location: 'https://test.com/image', Key: 'key' }); - mockPromise.mockResolvedValueOnce({ Attributes: user }); + mockUpdate.mockReturnValue({ promise: mockPromise }); + + // Mock S3 upload success + mockPromise + .mockResolvedValueOnce({ + Location: 'https://test.com/image.jpg', + Key: 'key', + Bucket: 'test-profile-pics-bucket' + }) + // Mock DynamoDB update success + .mockResolvedValueOnce({ + Attributes: { + ...user, + profilePictureUrl: 'https://test.com/image.jpg' + } + }); const mockFile = createMockFile({ mimetype }); - await expect(userService.uploadProfilePic(user, mockFile)).resolves.toBeDefined(); + const result = await userService.uploadProfilePic(user, mockFile); + + // ✅ Result is now just the URL string + expect(result).toBeDefined(); + expect(result).toBe('https://test.com/image.jpg'); } }); @@ -429,11 +457,495 @@ describe('UserController', () => { // ======================================== it('should get all users from mock database', async () => { + // Setup the mock response using our mock database mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); + const result = await userService.getAllUsers(); - expect(result).toHaveLength(9); - expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-users-table' }); + + expect(result).toHaveLength(9); // All 9 users in mock database + expect(mockScan).toHaveBeenCalledWith({ + TableName: 'test-users-table' + }); + }); + + it('should get user by id from mock database', async () => { + // Setup the mock response using our mock database + mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { userId: 'admin1' } })); + + const result = await userService.getUserById('admin1'); + + expect(result.userId).toBe('admin1'); + expect(result.position).toBe('Admin'); + expect(result.email).toBe('admin1@example.com'); + expect(mockGet).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Key: { userId: 'admin1' } + }); + }); + + it('should throw BadRequestException when userId is invalid', async () => { + await expect(userService.getUserById('')).rejects.toThrow('Valid user ID is required'); + await expect(userService.getUserById(null as any)).rejects.toThrow('Valid user ID is required'); + await expect(userService.getUserById(' ')).rejects.toThrow('Valid user ID is required'); + }); + + it('should throw NotFoundException when user does not exist in mock database', async () => { + // Mock empty response (user not found) using mock database + mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { userId: 'nonexistent' } })); + + await expect(userService.getUserById('nonexistent')).rejects.toThrow("User 'nonexistent' does not exist"); + expect(mockGet).toHaveBeenCalled(); + }); + + it('should handle errors when getting all users', async () => { + // Mock an error with AWS error structure + const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(userService.getAllUsers()).rejects.toThrow('Database table not found'); + expect(mockScan).toHaveBeenCalled(); + }); + + it('should handle generic DynamoDB errors when getting all users', async () => { + // Mock a generic error + const awsError = { code: 'UnknownError', message: 'Unknown DynamoDB error' }; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users'); + expect(mockScan).toHaveBeenCalled(); + }); + + it('should handle errors when getting user by id', async () => { + // Mock an AWS error with specific error code + const awsError = { code: 'ValidationException', message: 'Invalid request' }; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(userService.getUserById('1')).rejects.toThrow('Invalid request: Invalid request'); + expect(mockGet).toHaveBeenCalled(); + }); + + it('should handle ResourceNotFoundException when getting user by id', async () => { + // Mock a ResourceNotFoundException + const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(userService.getUserById('1')).rejects.toThrow('Database table not found'); + expect(mockGet).toHaveBeenCalled(); + }); + + it('should get all inactive users from mock database', async () => { + // Setup the mock response using our mock database with filter + const scanParams = { + TableName: 'test-users-table', + FilterExpression: '#pos IN (:inactive)', + ExpressionAttributeNames: { '#pos': 'position' }, + ExpressionAttributeValues: { ':inactive': 'Inactive' } + }; + + mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); + + const result = await userService.getAllInactiveUsers(); + + // Should return exactly 4 inactive users from mock database + expect(result).toHaveLength(4); + expect(result.every(u => u.position === 'Inactive')).toBe(true); + expect(result.map(u => u.userId).sort()).toEqual(['inactive1', 'inactive2', 'inactive3', 'inactive4']); + expect(mockScan).toHaveBeenCalledWith(scanParams); + }); + + it('should handle errors when getting inactive users', async () => { + const awsError = { code: 'ValidationException', message: 'Invalid filter' }; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(userService.getAllInactiveUsers()).rejects.toThrow('Invalid filter expression'); + expect(mockScan).toHaveBeenCalled(); + }); + + it('should get all active users from mock database', async () => { + // Setup the mock response using our mock database with filter + const scanParams = { + TableName: 'test-users-table', + FilterExpression: '#pos IN (:admin, :employee)', + ExpressionAttributeNames: { '#pos': 'position' }, + ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' } + }; + + mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); + + const result = await userService.getAllActiveUsers(); + + // Should return exactly 5 active users (2 admins + 3 employees) from mock database + expect(result).toHaveLength(5); + expect(result.every(u => u.position === 'Admin' || u.position === 'Employee')).toBe(true); + + const admins = result.filter(u => u.position === 'Admin'); + const employees = result.filter(u => u.position === 'Employee'); + expect(admins).toHaveLength(2); + expect(employees).toHaveLength(3); + + expect(mockScan).toHaveBeenCalledWith(scanParams); + }); + + it('should throw NotFoundException when no active users found', async () => { + // Mock empty response + mockPromise.mockResolvedValueOnce({ Items: undefined }); + + await expect(userService.getAllActiveUsers()).rejects.toThrow('No active users found.'); + expect(mockScan).toHaveBeenCalled(); + }); + + it('should handle ProvisionedThroughputExceededException', async () => { + const awsError = { code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded' }; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(userService.getAllActiveUsers()).rejects.toThrow('Database is temporarily unavailable, please try again'); + expect(mockScan).toHaveBeenCalled(); + }); + + // ======================================== + // Tests for addUserToGroup (Change Role) + // ======================================== + + it('should successfully change user role from Inactive to Employee', async () => { + const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get to verify user exists + mockPromise.mockResolvedValueOnce({ Item: user }); + + // Mock Cognito remove from old group (no-op for Inactive) + mockPromise.mockResolvedValueOnce({}); + + // Mock Cognito add to new group + mockPromise.mockResolvedValueOnce({}); + + // Mock SES sendEmail (verification email for Inactive -> Employee) + mockPromise.mockResolvedValueOnce({ MessageId: 'test-message-id' }); + + // Mock DynamoDB update + mockPromise.mockResolvedValueOnce({ + Attributes: { ...user, position: UserStatus.Employee } + }); + + const result = await userService.addUserToGroup(user, UserStatus.Employee, admin); + + expect(result.position).toBe(UserStatus.Employee); + expect(mockGet).toHaveBeenCalled(); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ + GroupName: 'Employee', + UserPoolId: 'test-pool-id', + Username: 'inactive1' + }); + expect(mockSendEmail).toHaveBeenCalled(); // Verify email was sent + expect(mockUpdate).toHaveBeenCalled(); + }); + + it('should successfully promote Employee to Admin', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: user }); + + // Mock Cognito remove from Employee group + mockPromise.mockResolvedValueOnce({}); + + // Mock Cognito add to Admin group + mockPromise.mockResolvedValueOnce({}); + + // Mock DynamoDB update + mockPromise.mockResolvedValueOnce({ + Attributes: { ...user, position: UserStatus.Admin } + }); + + const result = await userService.addUserToGroup(user, UserStatus.Admin, admin); + + expect(result.position).toBe(UserStatus.Admin); + expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledWith({ + GroupName: 'Employee', + UserPoolId: 'test-pool-id', + Username: 'emp1' + }); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ + GroupName: 'Admin', + UserPoolId: 'test-pool-id', + Username: 'emp1' + }); + }); + + it('should return user unchanged if already in requested group', async () => { + const user = mockDatabase.users.find(u => u.userId === 'admin1')!; + const requestedBy = mockDatabase.users.find(u => u.userId === 'admin2')!; + + // Mock DynamoDB get - user already Admin + mockPromise.mockResolvedValueOnce({ Item: user }); + + const result = await userService.addUserToGroup(user, UserStatus.Admin, requestedBy); + + expect(result.position).toBe(UserStatus.Admin); + // Should not call Cognito if already in group + expect(mockAdminAddUserToGroup).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when user object is invalid', async () => { + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + await expect( + userService.addUserToGroup(null as any, UserStatus.Employee, admin) + ).rejects.toThrow('Valid user object is required'); + + await expect( + userService.addUserToGroup({ userId: '' } as any, UserStatus.Employee, admin) + ).rejects.toThrow('Valid user object is required'); + }); + + it('should throw BadRequestException when group name is invalid', async () => { + const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + await expect( + userService.addUserToGroup(user, '' as any, admin) + ).rejects.toThrow('Group name is required'); + + await expect( + userService.addUserToGroup(user, 'InvalidGroup' as any, admin) + ).rejects.toThrow('Invalid group name'); + }); + + it('should throw UnauthorizedException when non-admin tries to change role', async () => { + const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; + const employee = mockDatabase.users.find(u => u.userId === 'emp1')!; + + await expect( + userService.addUserToGroup(user, UserStatus.Employee, employee) + ).rejects.toThrow('Only administrators can modify user groups'); + }); + + it('should throw BadRequestException when admin tries to demote themselves', async () => { + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: admin }); + + await expect( + userService.addUserToGroup(admin, UserStatus.Employee, admin) + ).rejects.toThrow('Administrators cannot demote themselves'); + }); + + it('should throw NotFoundException when user does not exist', async () => { + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + const fakeUser: User = { userId: 'nonexistent', email: 'fake@test.com', position: UserStatus.Inactive }; + + // Mock DynamoDB get - user not found + mockPromise.mockResolvedValueOnce({}); + + await expect( + userService.addUserToGroup(fakeUser, UserStatus.Employee, admin) + ).rejects.toThrow("User 'nonexistent' does not exist"); + }); + + it('should handle Cognito UserNotFoundException', async () => { + const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: user }); + + // Mock Cognito remove from old group (no-op for Inactive, but still called) + mockPromise.mockResolvedValueOnce({}); + + // Mock Cognito add to new group - this should fail + const cognitoError = { code: 'UserNotFoundException', message: 'User not found in Cognito' }; + mockPromise.mockRejectedValueOnce(cognitoError); + + await expect( + userService.addUserToGroup(user, UserStatus.Employee, admin) + ).rejects.toThrow('not found in authentication system'); + }); + + it('should rollback Cognito change if DynamoDB update fails', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: user }); + + // Mock Cognito operations succeed + mockPromise.mockResolvedValueOnce({}); // Remove from old group + mockPromise.mockResolvedValueOnce({}); // Add to new group + + // Mock DynamoDB update fails + const dynamoError = { code: 'ValidationException', message: 'Invalid update' }; + mockPromise.mockRejectedValueOnce(dynamoError); + + // Mock rollback operations + mockPromise.mockResolvedValueOnce({}); // Remove from new group + mockPromise.mockResolvedValueOnce({}); // Add back to old group + + await expect( + userService.addUserToGroup(user, UserStatus.Admin, admin) + ).rejects.toThrow('Invalid update parameters'); + + // Verify rollback was attempted + expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledTimes(2); // Once for change, once for rollback + expect(mockAdminAddUserToGroup).toHaveBeenCalledTimes(2); // Once for change, once for rollback + }); + + // ======================================== + // Tests for deleteUser + // ======================================== + + it('should successfully delete a user', async () => { + const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get to verify user exists + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); + + // Mock DynamoDB delete + mockPromise.mockResolvedValueOnce({ + Attributes: userToDelete + }); + + // Mock Cognito delete + mockPromise.mockResolvedValueOnce({}); + + const result = await userService.deleteUser(userToDelete, admin); + + expect(result.userId).toBe('emp1'); + expect(mockGet).toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Key: { userId: 'emp1' }, + ReturnValues: 'ALL_OLD' + }); + expect(mockAdminDeleteUser).toHaveBeenCalledWith({ + UserPoolId: 'test-pool-id', + Username: 'emp1' + }); }); - // ... (rest of your existing tests remain the same) + it('should throw BadRequestException when user object is invalid', async () => { + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + await expect( + userService.deleteUser(null as any, admin) + ).rejects.toThrow('Valid user object is required'); + + await expect( + userService.deleteUser({ userId: '' } as any, admin) + ).rejects.toThrow('Valid user object is required'); + }); + + it('should throw BadRequestException when requestedBy is invalid', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + + await expect( + userService.deleteUser(user, null as any) + ).rejects.toThrow('Valid requesting user is required'); + }); + + it('should throw UnauthorizedException when non-admin tries to delete', async () => { + const userToDelete = mockDatabase.users.find(u => u.userId === 'emp2')!; + const employee = mockDatabase.users.find(u => u.userId === 'emp1')!; + + await expect( + userService.deleteUser(userToDelete, employee) + ).rejects.toThrow('Only administrators can delete users'); + }); + + it('should throw BadRequestException when admin tries to delete themselves', async () => { + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + await expect( + userService.deleteUser(admin, admin) + ).rejects.toThrow('Administrators cannot delete their own account'); + }); + + it('should throw NotFoundException when user to delete does not exist', async () => { + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + const fakeUser: User = { userId: 'nonexistent', email: 'fake@test.com', position: UserStatus.Employee }; + + // Mock DynamoDB get - user not found + mockPromise.mockResolvedValueOnce({}); + + await expect( + userService.deleteUser(fakeUser, admin) + ).rejects.toThrow("User 'nonexistent' does not exist"); + }); + + it('should handle Cognito UserNotFoundException during delete', async () => { + const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); + + // Mock DynamoDB delete succeeds + mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); + + // Mock Cognito delete fails + const cognitoError = { code: 'UserNotFoundException', message: 'User not found' }; + mockPromise.mockRejectedValueOnce(cognitoError); + + // Mock rollback (restore to DynamoDB) + mockPromise.mockResolvedValueOnce({}); + + await expect( + userService.deleteUser(userToDelete, admin) + ).rejects.toThrow('not found in authentication system'); + + // Verify rollback was attempted + expect(mockPut).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Item: userToDelete + }); + }); + + it('should rollback DynamoDB delete if Cognito delete fails', async () => { + const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); + + // Mock DynamoDB delete succeeds + mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); + + // Mock Cognito delete fails with generic error + const cognitoError = { code: 'InternalError', message: 'Cognito internal error' }; + mockPromise.mockRejectedValueOnce(cognitoError); + + // Mock rollback succeeds + mockPromise.mockResolvedValueOnce({}); + + await expect( + userService.deleteUser(userToDelete, admin) + ).rejects.toThrow('Failed to delete user from authentication system'); + + // Verify rollback was attempted + expect(mockPut).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Item: userToDelete + }); + }); + + it('should handle DynamoDB delete failure', async () => { + const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; + const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; + + // Mock DynamoDB get + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); + + // Mock DynamoDB delete fails + const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; + mockPromise.mockRejectedValueOnce(dynamoError); + + await expect( + userService.deleteUser(userToDelete, admin) + ).rejects.toThrow('Failed to delete user from database'); + + // Cognito delete should not be called if DynamoDB fails + expect(mockAdminDeleteUser).not.toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index d9944fd..4447368 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -284,7 +284,7 @@ export class UserController { async uploadProfilePic( @UploadedFile() file: Express.Multer.File, @Body('user') userJson: string, // ✅ Comes as JSON string -) { +) : Promise { try { // Parse the JSON string to User object const user: User = JSON.parse(userJson); diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 2d26600..78380fd 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -25,7 +25,7 @@ export class UserService { private s3 = new AWS.S3(); private profilePicBucket : string = process.env.PROFILE_PICTURE_BUCKET!; - async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { + async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { const tableName = process.env.DYNAMODB_USER_TABLE_NAME; // 1. Validate all inputs @@ -68,7 +68,7 @@ export class UserService { } this.logger.log(`✅ Profile picture uploaded successfully for user ${user.userId}`); - return updateResult.Attributes as User; + return updateResult.Attributes.profilePictureUrl; } catch (error: any) { this.logger.error(`Failed to upload profile picture for ${user.userId}:`, error); From 6641734b0ae709986502a8a638b0506073276477 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 15 Feb 2026 17:31:21 -0500 Subject: [PATCH 6/9] changes --- backend/src/user/user.controller.ts | 47 +++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 4447368..5137f31 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -233,10 +233,10 @@ export class UserController { return await this.userService.getUserById(userId); } - @Post('upload-pfp') +@Post('upload-pfp') @ApiOperation({ summary: 'Upload profile picture', - description: 'Uploads a profile picture for a user to S3 and updates the user record in DynamoDB' + description: 'Uploads a profile picture for a user to S3 and updates the user record in DynamoDB with the image URL. Returns the S3 URL of the uploaded image.' }) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -252,7 +252,7 @@ export class UserController { }, user: { type: 'string', - description: 'User object as JSON string', + description: 'User object as JSON string containing userId, position, and email', example: '{"userId":"user-123","position":"Employee","email":"john@example.com"}' } } @@ -260,31 +260,58 @@ export class UserController { }) @ApiResponse({ status: 200, - description: 'Profile picture uploaded successfully', + description: 'Profile picture uploaded successfully. Returns the S3 URL of the uploaded image.', + schema: { + type: 'string', + example: 'https://bcan-pics.s3.amazonaws.com/user-123-profilepic.jpg', + description: 'Full S3 URL where the profile picture is stored' + } }) @ApiResponse({ status: 400, - description: 'Bad Request - Invalid file type, file too large, or invalid user data', + description: 'Bad Request - Invalid file type, file too large, invalid user data format, missing required fields, or JSON parse error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { + type: 'string', + example: 'Invalid file type. Allowed types: image/jpeg, image/jpg, image/png, image/gif, image/webp' + }, + error: { type: 'string', example: 'Bad Request' } + } + } }) @ApiResponse({ status: 401, - description: 'Unauthorized', + description: 'Unauthorized - Missing or invalid authentication token' }) @ApiResponse({ status: 403, - description: 'Forbidden', + description: 'Forbidden - User does not have permission to upload profile pictures' }) @ApiResponse({ status: 500, - description: 'Internal Server Error', + description: 'Internal Server Error - S3 upload failed, DynamoDB update failed, or server configuration error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 500 }, + message: { + type: 'string', + example: 'Failed to upload profile picture' + }, + error: { type: 'string', example: 'Internal Server Error' } + } + } }) @UseGuards(VerifyAdminOrEmployeeRoleGuard) @ApiBearerAuth() @UseInterceptors(FileInterceptor('profilePic')) async uploadProfilePic( @UploadedFile() file: Express.Multer.File, - @Body('user') userJson: string, // ✅ Comes as JSON string -) : Promise { + @Body('user') userJson: string, +): Promise { try { // Parse the JSON string to User object const user: User = JSON.parse(userJson); From 0d60d243bb234e501568ace8dcfd6147dd3eda76 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Sun, 22 Feb 2026 19:56:04 -0500 Subject: [PATCH 7/9] Resolved pr comments --- backend/src/user/user.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 78380fd..048736f 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -95,6 +95,7 @@ export class UserService { throw error; } + this.logger.error(`Failed to upload profile pic error: ${error}`) throw new InternalServerErrorException('Failed to upload profile picture'); } } @@ -112,12 +113,6 @@ private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: st throw new InternalServerErrorException("Server configuration error"); } - // Validate user object - if (!user || !user.userId) { - this.logger.error("Invalid user object provided for profile picture upload"); - throw new BadRequestException("Valid user object is required"); - } - // Validate file exists if (!pic || !pic.buffer) { this.logger.error("Invalid file provided for upload"); From 231446de467a907e89fe2d86fc5ab72538d9fb4f Mon Sep 17 00:00:00 2001 From: prooflesben Date: Mon, 23 Feb 2026 19:18:53 -0500 Subject: [PATCH 8/9] updated testand added more validation for profile pic --- .../src/auth/__test__/auth.service.spec.ts | 789 +++++++++--------- backend/src/user/user.service.ts | 5 + 2 files changed, 387 insertions(+), 407 deletions(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 9b51cdc..8b56317 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -1,506 +1,481 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { AuthService } from "../auth.service"; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from '../auth.service'; +import { User } from '../../../../middle-layer/types/User'; +import { UserStatus } from '../../../../middle-layer/types/UserStatus'; import { - Logger, - UnauthorizedException, + BadRequestException, + ConflictException, InternalServerErrorException, -} from "@nestjs/common"; -import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest"; - -vi.mock('../../guards/auth.guard', () => ({ - VerifyUserGuard: vi.fn(class MockVerifyUserGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), - VerifyAdminRoleGuard: vi.fn(class MockVerifyAdminRoleGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), -})); - -// Create mock functions for Cognito operations - at module level for test access -const mockAdminCreateUser = vi.fn(); -const mockAdminSetUserPassword = vi.fn(); -const mockInitiateAuth = vi.fn(); -const mockGetUser = vi.fn(); -const mockRespondToAuthChallenge = vi.fn(); -const mockAdminAddUserToGroup = vi.fn(); -const mockAdminDeleteUser = vi.fn(); -const mockCognitoPromise = vi.fn(); - -// Create mock functions for DynamoDB operations - at module level for test access -const mockDynamoGet = vi.fn(); -const mockDynamoPut = vi.fn(); -const mockDynamoUpdate = vi.fn(); -const mockDynamoScan = vi.fn(); -const mockDynamoPromise = vi.fn(); - -// Mock AWS SDK - reference module-level mocks + UnauthorizedException, +} from '@nestjs/common'; +import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; + +// ─── Mock function declarations ─────────────────────────────────────────────── +const mockPromise = vi.fn(); + +// DynamoDB +const mockScan = vi.fn(() => ({ promise: mockPromise })); +const mockGet = vi.fn(() => ({ promise: mockPromise })); +const mockUpdate = vi.fn(() => ({ promise: mockPromise })); +const mockPut = vi.fn(() => ({ promise: mockPromise })); +const mockDelete = vi.fn(() => ({ promise: mockPromise })); + +// Cognito +const mockAdminAddUserToGroup = vi.fn(() => ({ promise: mockPromise })); +const mockAdminRemoveUserFromGroup = vi.fn(() => ({ promise: mockPromise })); +const mockAdminDeleteUser = vi.fn(() => ({ promise: mockPromise })); +const mockAdminCreateUser = vi.fn(() => ({ promise: mockPromise })); +const mockAdminSetUserPassword = vi.fn(() => ({ promise: mockPromise })); +const mockInitiateAuth = vi.fn(() => ({ promise: mockPromise })); +const mockGetUser = vi.fn(() => ({ promise: mockPromise })); +const mockRespondToAuthChallenge = vi.fn(() => ({ promise: mockPromise })); + +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── vi.mock('aws-sdk', () => { - return { - CognitoIdentityServiceProvider: vi.fn(function() { - return { - adminCreateUser: mockAdminCreateUser, - adminSetUserPassword: mockAdminSetUserPassword, - initiateAuth: mockInitiateAuth, - getUser: mockGetUser, - respondToAuthChallenge: mockRespondToAuthChallenge, - adminAddUserToGroup: mockAdminAddUserToGroup, - adminDeleteUser: mockAdminDeleteUser, - }; - }), - DynamoDB: { - DocumentClient: vi.fn(function() { - return { - get: mockDynamoGet, - put: mockDynamoPut, - update: mockDynamoUpdate, - scan: mockDynamoScan, - }; - }) - }, - SES: vi.fn(function() { - return {}; - }), + const cognitoFactory = vi.fn(function () { + return { + adminAddUserToGroup: mockAdminAddUserToGroup, + adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, + adminDeleteUser: mockAdminDeleteUser, + adminCreateUser: mockAdminCreateUser, + adminSetUserPassword: mockAdminSetUserPassword, + initiateAuth: mockInitiateAuth, + getUser: mockGetUser, + respondToAuthChallenge: mockRespondToAuthChallenge, + }; + }); + + const documentClientFactory = vi.fn(function () { + return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; + }); + + const awsMock = { + CognitoIdentityServiceProvider: cognitoFactory, + DynamoDB: { DocumentClient: documentClientFactory }, }; + + return { ...awsMock, default: awsMock }; }); -describe("AuthService", () => { - let service: AuthService; +// ─── Test suite ─────────────────────────────────────────────────────────────── +describe('AuthService', () => { + let authService: AuthService; - // Set up environment variables for testing beforeAll(() => { - process.env.COGNITO_USER_POOL_ID = "test-user-pool-id"; - process.env.COGNITO_CLIENT_ID = "test-client-id"; - process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; - process.env.DYNAMODB_USER_TABLE_NAME = "test-users-table"; - process.env.FISH_EYE_LENS = "sha256"; + process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.COGNITO_CLIENT_ID = 'test-client-id'; + process.env.COGNITO_CLIENT_SECRET = 'test-client-secret'; + process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; + process.env.FISH_EYE_LENS = 'sha256'; }); beforeEach(async () => { - // Clear all mocks before each test vi.clearAllMocks(); - // Setup Cognito mocks to return chainable objects with .promise() - mockAdminCreateUser.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminSetUserPassword.mockReturnValue({ promise: mockCognitoPromise }); - mockInitiateAuth.mockReturnValue({ promise: mockCognitoPromise }); - mockGetUser.mockReturnValue({ promise: mockCognitoPromise }); - mockRespondToAuthChallenge.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminAddUserToGroup.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminDeleteUser.mockReturnValue({ promise: mockCognitoPromise }); - - // Setup DynamoDB mocks to return chainable objects with .promise() - mockDynamoGet.mockReturnValue({ promise: mockDynamoPromise }); - mockDynamoPut.mockReturnValue({ promise: mockDynamoPromise }); - mockDynamoUpdate.mockReturnValue({ promise: mockDynamoPromise }); - mockDynamoScan.mockReturnValue({ promise: mockDynamoPromise }); + mockScan.mockReturnValue({ promise: mockPromise }); + mockGet.mockReturnValue({ promise: mockPromise }); + mockUpdate.mockReturnValue({ promise: mockPromise }); + mockPut.mockReturnValue({ promise: mockPromise }); + mockDelete.mockReturnValue({ promise: mockPromise }); + + mockAdminAddUserToGroup.mockReturnValue({ promise: mockPromise }); + mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockPromise }); + mockAdminDeleteUser.mockReturnValue({ promise: mockPromise }); + mockAdminCreateUser.mockReturnValue({ promise: mockPromise }); + mockAdminSetUserPassword.mockReturnValue({ promise: mockPromise }); + mockInitiateAuth.mockReturnValue({ promise: mockPromise }); + mockGetUser.mockReturnValue({ promise: mockPromise }); + mockRespondToAuthChallenge.mockReturnValue({ promise: mockPromise }); + + mockPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ providers: [AuthService], }).compile(); - service = module.get(AuthService); - - // Reset mock promises to default resolved state - mockCognitoPromise.mockResolvedValue({}); - mockDynamoPromise.mockResolvedValue({}); + authService = module.get(AuthService); }); - describe("register", () => { - // 1. Set up mock responses for Cognito adminCreateUser and adminSetUserPassword - // 2. Set up mock response for DynamoDB put operation - // 3. Call service.register with test data - // 4. Assert that Cognito methods were called with correct parameters - // 5. Assert that DynamoDB put was called with correct user data - it("should successfully register a user", async () => { - // Ensure scan returns no items (email not in use) - mockDynamoPromise.mockResolvedValueOnce({ Items: [] }); - // Ensure get returns no items (username not in use) - mockDynamoPromise.mockResolvedValueOnce({}); - - // Cognito promise chain - // adminCreateUser().promise() - mockCognitoPromise.mockResolvedValueOnce({}); - // adminSetUserPassword().promise() - mockCognitoPromise.mockResolvedValueOnce({}); - // adminAddUserToGroup().promise() - needs to be mocked here - mockCognitoPromise.mockResolvedValueOnce({}); - // DynamoDB put().promise() - mockDynamoPromise.mockResolvedValueOnce({}); - - await service.register("c4c", "Pass123!", "c4c@example.com"); - - expect(mockAdminCreateUser).toHaveBeenCalledWith({ - UserPoolId: "test-user-pool-id", - Username: "c4c", - UserAttributes: [ - { Name: "email", Value: "c4c@example.com" }, - { Name: "email_verified", Value: "true" }, - ], - MessageAction: "SUPPRESS", - }); + // ── register ──────────────────────────────────────────────────────────────── - expect(mockAdminSetUserPassword).toHaveBeenCalledWith({ - UserPoolId: "test-user-pool-id", - Username: "c4c", - Password: "Pass123!", - Permanent: true, - }); + describe('register', () => { + it('should successfully register a new user', async () => { + // Email check - not found + mockPromise + .mockResolvedValueOnce({ Items: [] }) // email scan + .mockResolvedValueOnce({}) // username get (not found) + .mockResolvedValueOnce({}) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockResolvedValueOnce({}) // adminAddUserToGroup (Inactive) + .mockResolvedValueOnce({}); // DynamoDB put - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: "Inactive", - UserPoolId: "test-user-pool-id", - Username: "c4c", - }); + await expect( + authService.register('newuser', 'Password123!', 'new@example.com') + ).resolves.toBeUndefined(); - expect(mockDynamoPut).toHaveBeenCalledWith( + expect(mockAdminCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ Username: 'newuser', UserPoolId: 'test-pool-id' }) + ); + expect(mockAdminSetUserPassword).toHaveBeenCalledWith( + expect.objectContaining({ Username: 'newuser', Password: 'Password123!', Permanent: true }) + ); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith( + expect.objectContaining({ GroupName: 'Inactive', Username: 'newuser' }) + ); + expect(mockPut).toHaveBeenCalledWith( expect.objectContaining({ - TableName: "test-users-table", - Item: expect.objectContaining({ - userId: "c4c", - email: "c4c@example.com", - position: "Inactive", - }), + TableName: 'test-users-table', + Item: expect.objectContaining({ userId: 'newuser', position: UserStatus.Inactive }), }) ); }); - it("should deny someone from making an email when it is already in use", async () => {}); - }); + it('should throw BadRequestException when username is empty', async () => { + await expect(authService.register('', 'Password123!', 'test@test.com')).rejects.toThrow('Username is required'); + await expect(authService.register(' ', 'Password123!', 'test@test.com')).rejects.toThrow('Username is required'); + }); - describe("login", () => { - // 1. Mock successful Cognito initiateAuth response with tokens - // 2. Mock Cognito getUser response with user attributes - // 3. Mock DynamoDB get to return existing user - // 4. Call service.login and verify returned token and user data - it("should successfully login existing user", async () => { - // Mock Cognito initiateAuth - const mockInitiateAuth = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "id-token", - AccessToken: "access-token", - }, - }), - }); + it('should throw BadRequestException when password is too short', async () => { + await expect(authService.register('user', 'short', 'test@test.com')).rejects.toThrow('Password must be at least 8 characters long'); + }); - // Mock Cognito getUser - const mockGetUser = () => ({ - promise: () => - Promise.resolve({ - UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], - }), - }); + it('should throw BadRequestException when email is invalid', async () => { + await expect(authService.register('user', 'Password123!', 'not-an-email')).rejects.toThrow('Valid email address is required'); + await expect(authService.register('user', 'Password123!', '')).rejects.toThrow('Valid email address is required'); + }); - // Mock DynamoDB get to return existing user - const mockGet = () => ({ - promise: () => - Promise.resolve({ - Item: { userId: "c4c", - email: "c4c@example.com", - position: "Inactive", }, - }), - }); + it('should throw InternalServerErrorException when COGNITO_USER_POOL_ID is missing', async () => { + const original = process.env.COGNITO_USER_POOL_ID; + delete process.env.COGNITO_USER_POOL_ID; - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - (service["cognito"] as any).getUser = mockGetUser; - (service["dynamoDb"] as any).get = mockGet; + const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); + const svc = module.get(AuthService); - // Call the login method - const result = await service.login("c4c", "Pass123!"); + await expect(svc.register('user', 'Password123!', 'test@test.com')).rejects.toThrow('Server configuration error'); + process.env.COGNITO_USER_POOL_ID = original; + }); - // Verify the results - expect(result.access_token).toBe("access-token"); - expect(result.user).toEqual({ - userId: "c4c", - email: "c4c@example.com", - position: "Inactive", - }); - expect(result.message).toBe("Login Successful!"); - }); - - // 1. Mock Cognito initiateAuth to return NEW_PASSWORD_REQUIRED challenge - // 2. Call service.login and verify challenge response structure - it("should handle NEW_PASSWORD_REQUIRED challenge", async () => { - // Mock Cognito initiateAuth - const mockInitiateAuth = () => ({ - promise: () => - Promise.resolve({ - ChallengeName: "NEW_PASSWORD_REQUIRED", - Session: "session-123", - ChallengeParameters: { - requiredAttributes: '["email"]', - }, - }), - }); + it('should throw InternalServerErrorException when DYNAMODB_USER_TABLE_NAME is missing', async () => { + const original = process.env.DYNAMODB_USER_TABLE_NAME; + delete process.env.DYNAMODB_USER_TABLE_NAME; - // Replace the cognito method with mock - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - - // Call login and expect challenge response - const result = await service.login("c4c", "newPassword"); - - // Verify the challenge response - expect(result.challenge).toBe("NEW_PASSWORD_REQUIRED"); - expect(result.session).toBe("session-123"); - expect(result.requiredAttributes).toEqual(["email"]); - expect(result.username).toBe("c4c"); - expect(result.access_token).toBeUndefined(); - expect(result.user).toEqual({}); - }); - - it("should create new DynamoDB user if not exists", async () => { - const mockInitiateAuth = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "id-token", - AccessToken: "access-token", - }, - }), - }); + const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); + const svc = module.get(AuthService); - // Mock getUser - const mockGetUser = () => ({ - promise: () => - Promise.resolve({ - UserAttributes: [{ Name: "email", Value: "c4c@gmail.com" }], - }), - }); + await expect(svc.register('user', 'Password123!', 'test@test.com')).rejects.toThrow('Server configuration error'); + process.env.DYNAMODB_USER_TABLE_NAME = original; + }); - // Mock DynamoDB - const mockGet = () => ({ - promise: () => Promise.resolve({}), + it('should throw ConflictException when email already exists', async () => { + mockPromise.mockResolvedValueOnce({ + Items: [{ userId: 'existing', email: 'existing@test.com' }], }); - // Mock DynamoDB put - const mockPut = () => ({ - promise: () => Promise.resolve({}), - }); + await expect( + authService.register('newuser', 'Password123!', 'existing@test.com') + ).rejects.toThrow('An account with this email already exists'); + }); - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - (service["cognito"] as any).getUser = mockGetUser; - (service["dynamoDb"] as any).get = mockGet; - (service["dynamoDb"] as any).put = mockPut; + it('should throw ConflictException when username already exists', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) // email scan + .mockResolvedValueOnce({ Item: { userId: 'taken', email: 'x@x.com' } }); // username get - const result = await service.login("c4c", "Pass123!"); + await expect( + authService.register('taken', 'Password123!', 'new@test.com') + ).rejects.toThrow('This username is already taken'); + }); - expect(result.access_token).toBe("access-token"); - expect(result.user).toEqual({ - userId: "c4c", - email: "c4c@gmail.com", - position: "Inactive", - }); - expect(result.message).toBe("Login Successful!"); + it('should throw ConflictException when Cognito UsernameExistsException', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'UsernameExistsException', message: 'User exists' }); - expect(result.user.userId).toBe("c4c"); - expect(result.user.email).toBe("c4c@gmail.com"); + await expect( + authService.register('existing', 'Password123!', 'new@test.com') + ).rejects.toThrow('Username already exists in authentication system'); }); - // 1. Mock Cognito to throw NotAuthorizedException - // 2. Verify UnauthorizedException is thrown by service - it("should handle NotAuthorizedException", async () => { - const mockInitiateAuth = () => ({ - promise: () => - Promise.reject({ - code: "NotAuthorizedException", - message: "Incorrect username or password", - }), - }); + it('should throw BadRequestException for Cognito InvalidPasswordException on create', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'InvalidPasswordException', message: 'Bad password' }); - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - await expect(service.login("c4c", "wrongpassword")).rejects.toThrow( - UnauthorizedException - ); + await expect( + authService.register('user', 'Password123!', 'test@test.com') + ).rejects.toThrow('Password does not meet security requirements'); }); - // 1. Remove environment variables - // 2. Expect service to throw configuration error - it("should handle missing client credentials", async () => { - delete process.env.COGNITO_CLIENT_ID; - delete process.env.COGNITO_CLIENT_SECRET; + it('should throw BadRequestException for Cognito InvalidParameterException on create', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'InvalidParameterException', message: 'Bad param' }); - await expect(service.login("john", "123")).rejects.toThrow( - "Cognito Client ID or Secret is not defined." - ); - - process.env.COGNITO_CLIENT_ID = "test-client-id"; - process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; + await expect( + authService.register('user', 'Password123!', 'test@test.com') + ).rejects.toThrow('Invalid registration parameters'); }); - // 1. Mock Cognito to return response without required tokens - // 2. Verify appropriate error is thrown - it("should handle missing tokens in response", async () => { - mockCognitoPromise.mockResolvedValueOnce({ - AuthenticationResult: {}, - }); + it('should rollback Cognito user if adminSetUserPassword fails', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) // adminCreateUser succeeds + .mockRejectedValueOnce({ code: 'InvalidPasswordException', message: 'Bad' }) // set password fails + .mockResolvedValueOnce({}); // rollback delete - await expect(service.login("c4c", "c4c@gmail.com")).rejects.toThrow( - "Authentication failed: Missing IdToken or AccessToken" + await expect( + authService.register('user', 'Password123!', 'test@test.com') + ).rejects.toThrow(); + + expect(mockAdminDeleteUser).toHaveBeenCalledWith( + expect.objectContaining({ Username: 'user', UserPoolId: 'test-pool-id' }) ); }); - it("handle generic Cognito errors with descriptive message", async () => { - const mockInitiateAuth = () => ({ - promise: () => - Promise.reject({ - code: "SomeAwsError", - message: "AWS error occurred", - }), - }); + it('should rollback Cognito user if adminAddUserToGroup fails', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Group not found' }) // add to group fails + .mockResolvedValueOnce({}); // rollback delete - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - await expect(service.login("testuser", "Pass123!")).rejects.toThrow( - InternalServerErrorException - ); + await expect( + authService.register('user', 'Password123!', 'test@test.com') + ).rejects.toThrow("User group 'Inactive' does not exist in the system"); + + expect(mockAdminDeleteUser).toHaveBeenCalled(); + }); + + it('should rollback Cognito user if DynamoDB put fails', async () => { + mockPromise + .mockResolvedValueOnce({ Items: [] }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockResolvedValueOnce({}) // adminAddUserToGroup + .mockRejectedValueOnce({ code: 'InternalError', message: 'DB error' }) // put fails + .mockResolvedValueOnce({}); // rollback delete + + await expect( + authService.register('user', 'Password123!', 'test@test.com') + ).rejects.toThrow('Failed to save user data to database'); + + expect(mockAdminDeleteUser).toHaveBeenCalled(); }); }); - describe("setNewPassword", () => { - // 1. Mock successful respondToAuthChallenge response - // 2. Call service.setNewPassword with test data - // 3. Verify correct parameters passed to Cognito - // 4. Verify returned access token - it("should successfully set new password", async () => { - const mockRespondToAuthChallenge = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "new-id-token", - }, - }), + // ── login ──────────────────────────────────────────────────────────────────── + + describe('login', () => { + const mockUser: User = { userId: 'emp1', email: 'emp1@example.com', position: UserStatus.Employee }; + + it('should successfully login and return access token and user', async () => { + mockPromise + .mockResolvedValueOnce({ + AuthenticationResult: { + IdToken: 'mock-id-token', + AccessToken: 'mock-access-token', + }, + }) + .mockResolvedValueOnce({ + Username: 'emp1', + UserAttributes: [{ Name: 'email', Value: 'emp1@example.com' }], + }) + .mockResolvedValueOnce({ Item: mockUser }); + + const result = await authService.login('emp1', 'Password123!'); + + expect(result.access_token).toBe('mock-access-token'); + expect(result.user).toEqual(mockUser); + expect(result.message).toBe('Login Successful!'); + expect(mockInitiateAuth).toHaveBeenCalled(); + }); + + it('should return NEW_PASSWORD_REQUIRED challenge', async () => { + mockPromise.mockResolvedValueOnce({ + ChallengeName: 'NEW_PASSWORD_REQUIRED', + Session: 'mock-session', + ChallengeParameters: { requiredAttributes: '[]' }, }); - (service["cognito"] as any).respondToAuthChallenge = - mockRespondToAuthChallenge; + const result = await authService.login('emp1', 'Password123!'); + + expect(result.challenge).toBe('NEW_PASSWORD_REQUIRED'); + expect(result.session).toBe('mock-session'); + }); - const result = await service.setNewPassword( - "NewPass123!", - "session123", - "c4c", - "c4c@gmail.com" + it('should create user in DynamoDB if not found after login', async () => { + mockPromise + .mockResolvedValueOnce({ + AuthenticationResult: { IdToken: 'id-token', AccessToken: 'access-token' }, + }) + .mockResolvedValueOnce({ + Username: 'newuser', + UserAttributes: [{ Name: 'email', Value: 'new@test.com' }], + }) + .mockResolvedValueOnce({}) // DynamoDB get - user not found + .mockResolvedValueOnce({}); // DynamoDB put - create new user + + const result = await authService.login('newuser', 'Password123!'); + + expect(mockPut).toHaveBeenCalledWith( + expect.objectContaining({ + TableName: 'test-users-table', + Item: expect.objectContaining({ userId: 'newuser', position: UserStatus.Inactive }), + }) ); + expect(result.user.position).toBe(UserStatus.Inactive); + }); - expect(result.access_token).toBe("new-id-token"); + it('should throw BadRequestException when username is empty', async () => { + await expect(authService.login('', 'password')).rejects.toThrow('Username is required'); + await expect(authService.login(' ', 'password')).rejects.toThrow('Username is required'); }); - // 1. Mock Cognito to return response without IdToken - // 2. Verify error is thrown - it("should handle failed password setting", async () => { - mockCognitoPromise.mockResolvedValueOnce({ - AuthenticationResult: {}, + it('should throw BadRequestException when password is empty', async () => { + await expect(authService.login('user', '')).rejects.toThrow('Password is required'); + }); + + it('should throw UnauthorizedException for NotAuthorizedException', async () => { + mockPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Bad credentials' }); + await expect(authService.login('user', 'wrongpass')).rejects.toThrow('Incorrect username or password.'); + }); + + it('should throw InternalServerErrorException for other Cognito errors', async () => { + mockPromise.mockRejectedValueOnce({ code: 'InternalError', message: 'Something broke' }); + await expect(authService.login('user', 'Password123!')).rejects.toThrow('An error occurred during login.'); + }); + }); + + // ── setNewPassword ─────────────────────────────────────────────────────────── + + describe('setNewPassword', () => { + it('should successfully set a new password', async () => { + mockPromise.mockResolvedValueOnce({ + AuthenticationResult: { IdToken: 'new-id-token' }, }); - await expect( - service.setNewPassword("NewPass123!", "s123", "c4c") - ).rejects.toThrow("Failed to set new password"); + const result = await authService.setNewPassword('NewPass123!', 'mock-session', 'emp1'); + expect(result.access_token).toBe('new-id-token'); + expect(mockRespondToAuthChallenge).toHaveBeenCalled(); }); - // 1. Mock Cognito to throw error - // 2. Verify error handling - it("should handle Cognito errors", async () => { - const mockRespondToAuthChallenge = () => ({ - promise: () => Promise.reject(new Error("Cognito Error")), + it('should include email in challenge response when provided', async () => { + mockPromise.mockResolvedValueOnce({ + AuthenticationResult: { IdToken: 'new-id-token' }, }); - (service["cognito"] as any).respondToAuthChallenge = - mockRespondToAuthChallenge; + await authService.setNewPassword('NewPass123!', 'mock-session', 'emp1', 'emp1@test.com'); - await expect( - service.setNewPassword("NewPass123!", "s123", "c4c") - ).rejects.toThrow("Cognito Error"); + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + ChallengeResponses: expect.objectContaining({ email: 'emp1@test.com' }), + }) + ); + }); + + it('should throw BadRequestException when newPassword is empty', async () => { + await expect(authService.setNewPassword('', 'session', 'user')).rejects.toThrow('New password is required'); + }); + + it('should throw BadRequestException when session is empty', async () => { + await expect(authService.setNewPassword('Password123!', '', 'user')).rejects.toThrow('Session is required'); + }); + + it('should throw BadRequestException when username is empty', async () => { + await expect(authService.setNewPassword('Password123!', 'session', '')).rejects.toThrow('Username is required'); + await expect(authService.setNewPassword('Password123!', 'session', ' ')).rejects.toThrow('Username is required'); + }); + + it('should throw when AuthenticationResult is missing from response', async () => { + mockPromise.mockResolvedValueOnce({}); + await expect(authService.setNewPassword('Password123!', 'session', 'user')).rejects.toThrow('Failed to set new password'); + }); + + it('should throw when respondToAuthChallenge fails', async () => { + mockPromise.mockRejectedValueOnce(new Error('Challenge failed')); + await expect(authService.setNewPassword('Password123!', 'session', 'user')).rejects.toThrow('Challenge failed'); }); }); - describe("updateProfile", () => { - // 1. Mock successful DynamoDB update - // 2. Call service.updateProfile with test data - // 3. Verify correct UpdateExpression and parameters - it("should successfully update user profile", async () => { - mockDynamoPromise.mockResolvedValueOnce({}); - - await service.updateProfile( - "c4c", - "c4c@example.com", - "Software Developer" - ); + // ── updateProfile ──────────────────────────────────────────────────────────── - expect(mockDynamoUpdate).toHaveBeenCalledWith( + describe('updateProfile', () => { + it('should successfully update user profile', async () => { + mockPromise.mockResolvedValueOnce({}); + await expect(authService.updateProfile('emp1', 'newemail@test.com', 'Engineer')).resolves.toBeUndefined(); + expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ - Key: { userId: "c4c" }, - UpdateExpression: - "SET email = :email, position_or_role = :position_or_role", - ExpressionAttributeValues: { - ":email": "c4c@example.com", - ":position_or_role": "Software Developer", - }, + TableName: 'test-users-table', + Key: { userId: 'emp1' }, }) ); }); - // 1. Mock DynamoDB to throw error - // 2. Verify error handling - it("handle DynamoDB update errors", async () => { - const mockUpdate = vi.fn().mockReturnValue({ - promise: vi.fn().mockRejectedValue(new Error("DB error")) - }); - - (service['dynamoDb'] as any).update = mockUpdate; + it('should throw BadRequestException when username is empty', async () => { + await expect(authService.updateProfile('', 'email@test.com', 'role')).rejects.toThrow('Username is required'); + await expect(authService.updateProfile(' ', 'email@test.com', 'role')).rejects.toThrow('Username is required'); + }); - await expect( - service.updateProfile("c4c", "c4c@example.com", "Active") - ).rejects.toThrow("DB error"); + it('should throw BadRequestException when email is empty', async () => { + await expect(authService.updateProfile('user', '', 'role')).rejects.toThrow('Email is required'); + await expect(authService.updateProfile('user', ' ', 'role')).rejects.toThrow('Email is required'); + }); + + it('should throw BadRequestException when position_or_role is empty', async () => { + await expect(authService.updateProfile('user', 'email@test.com', '')).rejects.toThrow('Position or role is required'); + await expect(authService.updateProfile('user', 'email@test.com', ' ')).rejects.toThrow('Position or role is required'); + }); + + it('should throw when DynamoDB update fails', async () => { + mockPromise.mockRejectedValueOnce(new Error('DynamoDB failure')); + await expect(authService.updateProfile('user', 'email@test.com', 'role')).rejects.toThrow('DynamoDB failure'); }); }); - describe("validateSession", () => { - it("should successfully validate a session", async () => { - // Mock Cognito getUser response - mockCognitoPromise.mockResolvedValueOnce({ - Username: "c4c", - UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], - }); + // ── validateSession ────────────────────────────────────────────────────────── - // Mock DynamoDB get response - mockDynamoPromise.mockResolvedValueOnce({ - Item: { userId: "c4c", email: "c4c@example.com", position: "Active" }, - }); + describe('validateSession', () => { + const mockUser: User = { userId: 'emp1', email: 'emp1@example.com', position: UserStatus.Employee }; - const result = await service.validateSession("valid-token"); + it('should successfully validate a session and return user', async () => { + mockPromise + .mockResolvedValueOnce({ + Username: 'emp1', + UserAttributes: [{ Name: 'email', Value: 'emp1@example.com' }], + }) + .mockResolvedValueOnce({ Item: mockUser }); - expect(result).toEqual({ - userId: "c4c", - email: "c4c@example.com", - position: "Active", - }); + const result = await authService.validateSession('valid-access-token'); + expect(result).toEqual(mockUser); + expect(mockGetUser).toHaveBeenCalledWith({ AccessToken: 'valid-access-token' }); }); - it("should reject missing access token", async () => { - await expect( - service.validateSession("") - ).rejects.toThrow(UnauthorizedException); + it('should throw UnauthorizedException when token is expired', async () => { + mockPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Token expired' }); + await expect(authService.validateSession('expired-token')).rejects.toThrow('Session expired or invalid'); }); - it("should handle NotAuthorizedException", async () => { - mockCognitoPromise.mockRejectedValueOnce({ - code: "NotAuthorizedException", - message: "Invalid token", - }); + it('should throw UnauthorizedException when user not found in DynamoDB', async () => { + mockPromise + .mockResolvedValueOnce({ + Username: 'ghost', + UserAttributes: [{ Name: 'email', Value: 'ghost@test.com' }], + }) + .mockResolvedValueOnce({}); // no Item - await expect( - service.validateSession("invalid-token") - ).rejects.toThrow(UnauthorizedException); + await expect(authService.validateSession('some-token')).rejects.toThrow('Failed to validate session'); }); - it("should handle InvalidParameterException", async () => { - mockCognitoPromise.mockRejectedValueOnce({ - code: "InvalidParameterException", - message: "Invalid token format", - }); - - await expect( - service.validateSession("bad-format") - ).rejects.toThrow(UnauthorizedException); + it('should throw UnauthorizedException for any other error', async () => { + mockPromise.mockRejectedValueOnce(new Error('Unknown error')); + await expect(authService.validateSession('bad-token')).rejects.toThrow('Failed to validate session'); }); }); }); \ No newline at end of file diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 048736f..6031488 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -108,6 +108,11 @@ private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: st throw new InternalServerErrorException("Server configuration error"); } + if (!user || !user.userId || user.userId.trim().length === 0) { + this.logger.error("Invalid user object provided for upload"); + throw new BadRequestException("Valid user object is required"); + } + if (!tableName) { this.logger.error("DynamoDB User Table Name is not defined in environment variables"); throw new InternalServerErrorException("Server configuration error"); From 1881549fbb3020c64bd1621f6c710ee1337b0d09 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Mon, 23 Feb 2026 20:54:29 -0500 Subject: [PATCH 9/9] Merged main and confirmed everything works with the new user object --- .../src/auth/__test__/auth.service.spec.ts | 626 ++++++------- .../src/user/__test__/user.service.spec.ts | 846 ++++++------------ backend/src/user/user.controller.ts | 89 ++ backend/src/user/user.service.ts | 22 +- 4 files changed, 650 insertions(+), 933 deletions(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 2d5ba39..7eca97a 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -10,32 +10,34 @@ import { } from '@nestjs/common'; import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; -// ─── Mock function declarations ─────────────────────────────────────────────── -const mockPromise = vi.fn(); - -const mockAdminCreateUser = vi.fn(); -const mockAdminSetUserPassword = vi.fn(); -const mockInitiateAuth = vi.fn(); -const mockGetUser = vi.fn(); -const mockRespondToAuthChallenge = vi.fn(); -const mockAdminAddUserToGroup = vi.fn(); -const mockAdminDeleteUser = vi.fn(); +// ─── Cognito mocks ──────────────────────────────────────────────────────────── const mockCognitoPromise = vi.fn(); - -const mockDynamoGet = vi.fn(); -const mockDynamoPut = vi.fn(); -const mockDynamoUpdate = vi.fn(); -const mockDynamoScan = vi.fn(); +const mockAdminCreateUser = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminSetUserPassword = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminAddUserToGroup = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminRemoveUserFromGroup = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminDeleteUser = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockInitiateAuth = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockGetUser = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockRespondToAuthChallenge = vi.fn(() => ({ promise: mockCognitoPromise })); + +// ─── DynamoDB mocks ─────────────────────────────────────────────────────────── const mockDynamoPromise = vi.fn(); +const mockDynamoGet = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoPut = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoUpdate = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoScan = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoDelete = vi.fn(() => ({ promise: mockDynamoPromise })); +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── vi.mock('aws-sdk', () => { const cognitoFactory = vi.fn(function () { return { + adminCreateUser: mockAdminCreateUser, + adminSetUserPassword: mockAdminSetUserPassword, adminAddUserToGroup: mockAdminAddUserToGroup, adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, adminDeleteUser: mockAdminDeleteUser, - adminCreateUser: mockAdminCreateUser, - adminSetUserPassword: mockAdminSetUserPassword, initiateAuth: mockInitiateAuth, getUser: mockGetUser, respondToAuthChallenge: mockRespondToAuthChallenge, @@ -43,7 +45,13 @@ vi.mock('aws-sdk', () => { }); const documentClientFactory = vi.fn(function () { - return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; + return { + get: mockDynamoGet, + put: mockDynamoPut, + update: mockDynamoUpdate, + scan: mockDynamoScan, + delete: mockDynamoDelete, + }; }); const awsMock = { @@ -56,7 +64,7 @@ vi.mock('aws-sdk', () => { // ─── Test suite ─────────────────────────────────────────────────────────────── describe('AuthService', () => { - let authService: AuthService; + let service: AuthService; beforeAll(() => { process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; @@ -69,432 +77,325 @@ describe('AuthService', () => { beforeEach(async () => { vi.clearAllMocks(); + // Re-attach after clearAllMocks mockAdminCreateUser.mockReturnValue({ promise: mockCognitoPromise }); mockAdminSetUserPassword.mockReturnValue({ promise: mockCognitoPromise }); + mockAdminAddUserToGroup.mockReturnValue({ promise: mockCognitoPromise }); + mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockCognitoPromise }); + mockAdminDeleteUser.mockReturnValue({ promise: mockCognitoPromise }); mockInitiateAuth.mockReturnValue({ promise: mockCognitoPromise }); mockGetUser.mockReturnValue({ promise: mockCognitoPromise }); mockRespondToAuthChallenge.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminAddUserToGroup.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminDeleteUser.mockReturnValue({ promise: mockCognitoPromise }); mockDynamoGet.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoPut.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoUpdate.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoScan.mockReturnValue({ promise: mockDynamoPromise }); + mockDynamoDelete.mockReturnValue({ promise: mockDynamoPromise }); + + mockCognitoPromise.mockResolvedValue({}); + mockDynamoPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ providers: [AuthService], }).compile(); service = module.get(AuthService); - - mockCognitoPromise.mockResolvedValue({}); - mockDynamoPromise.mockResolvedValue({}); }); - describe("register", () => { - it("should successfully register a user", async () => { - // adminCreateUser returns a user with sub attribute - mockCognitoPromise.mockResolvedValueOnce({ - User: { - Attributes: [{ Name: "sub", Value: "test-sub-123" }], - }, - }); - // adminSetUserPassword - mockCognitoPromise.mockResolvedValueOnce({}); - // adminAddUserToGroup - mockCognitoPromise.mockResolvedValueOnce({}); - // DynamoDB put - mockDynamoPromise.mockResolvedValueOnce({}); + // ── register ──────────────────────────────────────────────────────────────── + + describe('register', () => { + it('should successfully register a user', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'test-sub-123' }] } }) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockResolvedValueOnce({}); // adminAddUserToGroup + + mockDynamoPromise.mockResolvedValueOnce({}); // DynamoDB put - // register now takes (email, password, firstName, lastName) - await service.register("c4c@example.com", "Pass123!", "John", "Doe"); + await service.register('c4c@example.com', 'Pass123!', 'John', 'Doe'); expect(mockAdminCreateUser).toHaveBeenCalledWith({ - UserPoolId: "test-user-pool-id", - Username: "c4c@example.com", + UserPoolId: 'test-pool-id', + Username: 'c4c@example.com', UserAttributes: [ - { Name: "email", Value: "c4c@example.com" }, - { Name: "email_verified", Value: "true" }, + { Name: 'email', Value: 'c4c@example.com' }, + { Name: 'email_verified', Value: 'true' }, ], - MessageAction: "SUPPRESS", + MessageAction: 'SUPPRESS', }); expect(mockAdminSetUserPassword).toHaveBeenCalledWith({ - UserPoolId: "test-user-pool-id", - Username: "c4c@example.com", - Password: "Pass123!", + UserPoolId: 'test-pool-id', + Username: 'c4c@example.com', + Password: 'Pass123!', Permanent: true, }); expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: "Inactive", - UserPoolId: "test-user-pool-id", - Username: "c4c@example.com", + GroupName: 'Inactive', + UserPoolId: 'test-pool-id', + Username: 'c4c@example.com', }); expect(mockDynamoPut).toHaveBeenCalledWith( expect.objectContaining({ - TableName: "test-users-table", + TableName: 'test-users-table', Item: expect.objectContaining({ - email: "c4c@example.com", - position: "Inactive", - firstName: "John", - lastName: "Doe", + email: 'c4c@example.com', + position: UserStatus.Inactive, + firstName: 'John', + lastName: 'Doe', }), }) ); }); - it("should deny someone from making an account when email is already in use", async () => { + it('should throw ConflictException when email already exists', async () => { mockCognitoPromise.mockRejectedValueOnce({ - code: "UsernameExistsException", - message: "User already exists", + code: 'UsernameExistsException', + message: 'User already exists', }); await expect( - service.register("existing@example.com", "Pass123!", "John", "Doe") - ).rejects.toThrow("An account with this email already exists"); + service.register('existing@example.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow('An account with this email already exists'); }); - }); - - describe("login", () => { - it("should successfully login existing user", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "id-token", - AccessToken: "access-token", - RefreshToken: "refresh-token", - }, - }), - }); - - const mockGetUserFn = () => ({ - promise: () => - Promise.resolve({ - UserAttributes: [ - { Name: "email", Value: "c4c@example.com" }, - { Name: "sub", Value: "test-sub-123" }, - ], - }), - }); - - // DynamoDB get returns existing user keyed by email - const mockGetFn = () => ({ - promise: () => - Promise.resolve({ - Item: { - email: "c4c@example.com", - position: "Inactive", - firstName: "John", - lastName: "Doe", - }, - }), - }); - - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - (service["cognito"] as any).getUser = mockGetUserFn; - (service["dynamoDb"] as any).get = mockGetFn; - - const result = await service.login("c4c@example.com", "Pass123!"); - expect(result.access_token).toBe("access-token"); - expect(result.user).toEqual({ - email: "c4c@example.com", - position: "Inactive", - firstName: "John", - lastName: "Doe", - }); - expect(result.message).toBe("Login Successful!"); - }); - - it("should handle NEW_PASSWORD_REQUIRED challenge", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.resolve({ - ChallengeName: "NEW_PASSWORD_REQUIRED", - Session: "session-123", - ChallengeParameters: { - requiredAttributes: '["email"]', - }, - }), - }); - - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - - const result = await service.login("c4c@example.com", "newPassword"); + it('should throw BadRequestException for invalid email', async () => { + await expect( + service.register('not-an-email', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow('Valid email address is required'); + }); - expect(result.challenge).toBe("NEW_PASSWORD_REQUIRED"); - expect(result.session).toBe("session-123"); - expect(result.requiredAttributes).toEqual(["email"]); - // username is no longer returned in challenge response - expect(result.access_token).toBeUndefined(); - expect(result.user).toEqual({}); - }); - - it("should create new DynamoDB user if not exists", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "id-token", - AccessToken: "access-token", - RefreshToken: "refresh-token", - }, - }), - }); + it('should throw BadRequestException when password is too short', async () => { + await expect( + service.register('test@test.com', 'short', 'John', 'Doe') + ).rejects.toThrow('Password must be at least 8 characters long'); + }); - const mockGetUserFn = () => ({ - promise: () => - Promise.resolve({ - UserAttributes: [ - { Name: "email", Value: "c4c@gmail.com" }, - { Name: "sub", Value: "test-sub-456" }, - ], - }), - }); + it('should throw InternalServerErrorException when COGNITO_USER_POOL_ID is missing', async () => { + const original = process.env.COGNITO_USER_POOL_ID; + delete process.env.COGNITO_USER_POOL_ID; - // DynamoDB get returns nothing (user doesn't exist yet) - const mockGetFn = () => ({ - promise: () => Promise.resolve({}), - }); + const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); + const svc = module.get(AuthService); - const mockPutFn = () => ({ - promise: () => Promise.resolve({}), - }); + await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); + process.env.COGNITO_USER_POOL_ID = original; + }); - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - (service["cognito"] as any).getUser = mockGetUserFn; - (service["dynamoDb"] as any).get = mockGetFn; - (service["dynamoDb"] as any).put = mockPutFn; + it('should throw InternalServerErrorException when DYNAMODB_USER_TABLE_NAME is missing', async () => { + const original = process.env.DYNAMODB_USER_TABLE_NAME; + delete process.env.DYNAMODB_USER_TABLE_NAME; - const result = await service.login("c4c@gmail.com", "Pass123!"); + const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); + const svc = module.get(AuthService); - expect(result.access_token).toBe("access-token"); - expect(result.user).toEqual({ - email: "c4c@gmail.com", - position: "Inactive", - firstName: "", - lastName: "", - }); - expect(result.message).toBe("Login Successful!"); + await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); + process.env.DYNAMODB_USER_TABLE_NAME = original; }); - it("should handle NotAuthorizedException", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.reject({ - code: "NotAuthorizedException", - message: "Incorrect username or password", - }), - }); + it('should rollback Cognito user if adminSetUserPassword fails', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'sub-123' }] } }) // adminCreateUser + .mockRejectedValueOnce({ code: 'InvalidPasswordException', message: 'Bad password' }) // setPassword fails + .mockResolvedValueOnce({}); // rollback delete - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - await expect(service.login("c4c@example.com", "wrongpassword")).rejects.toThrow( - UnauthorizedException + await expect( + service.register('test@test.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow(); + + expect(mockAdminDeleteUser).toHaveBeenCalledWith( + expect.objectContaining({ Username: 'test@test.com', UserPoolId: 'test-pool-id' }) ); }); - it("should handle missing client credentials", async () => { - delete process.env.COGNITO_CLIENT_ID; - delete process.env.COGNITO_CLIENT_SECRET; + it('should rollback Cognito user if adminAddUserToGroup fails', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'sub-123' }] } }) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Group not found' }) // group fails + .mockResolvedValueOnce({}); // rollback delete - await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( - "Cognito Client ID or Secret is not defined." - ); + await expect( + service.register('test@test.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow("User group 'Inactive' does not exist in the system"); - await expect(svc.register('user', 'Password123!', 'test@test.com')).rejects.toThrow('Server configuration error'); - process.env.DYNAMODB_USER_TABLE_NAME = original; + expect(mockAdminDeleteUser).toHaveBeenCalled(); }); - it("should handle missing tokens in response", async () => { - mockCognitoPromise.mockResolvedValueOnce({ - AuthenticationResult: {}, - }); + it('should rollback Cognito user if DynamoDB put fails', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'sub-123' }] } }) + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockResolvedValueOnce({}) // adminAddUserToGroup + .mockResolvedValueOnce({}); // rollback delete - await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( - "Authentication failed: Missing IdToken or AccessToken" - ); - }); + mockDynamoPromise.mockRejectedValueOnce(new Error('DB error')); - it("should handle generic Cognito errors", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.reject({ - code: "SomeAwsError", - message: "AWS error occurred", - }), - }); + await expect( + service.register('test@test.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow('Failed to save user data to database'); - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( - InternalServerErrorException - ); + expect(mockAdminDeleteUser).toHaveBeenCalled(); }); }); - describe("setNewPassword", () => { - it("should successfully set new password", async () => { - const mockRespondFn = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "new-id-token", - }, - }), - }); - - (service["cognito"] as any).respondToAuthChallenge = mockRespondFn; + // ── login ──────────────────────────────────────────────────────────────────── - // setNewPassword no longer takes a separate username — just (newPassword, session, email) - const result = await service.setNewPassword( - "NewPass123!", - "session123", - "c4c@example.com" - ); - - expect(result.access_token).toBe("new-id-token"); - }); + describe('login', () => { + it('should successfully login and return tokens and user', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ + AuthenticationResult: { + IdToken: 'id-token', + AccessToken: 'access-token', + RefreshToken: 'refresh-token', + }, + }) + .mockResolvedValueOnce({ + UserAttributes: [ + { Name: 'email', Value: 'c4c@example.com' }, + { Name: 'sub', Value: 'test-sub-123' }, + ], + }); - it("should handle failed password setting", async () => { - mockCognitoPromise.mockResolvedValueOnce({ - AuthenticationResult: {}, + mockDynamoPromise.mockResolvedValueOnce({ + Item: { email: 'c4c@example.com', position: UserStatus.Inactive, firstName: 'John', lastName: 'Doe' }, }); - await expect( - service.setNewPassword("NewPass123!", "s123", "c4c@example.com") - ).rejects.toThrow("Failed to set new password"); + const result = await service.login('c4c@example.com', 'Pass123!'); + + expect(result.access_token).toBe('access-token'); + expect(result.refreshToken).toBe('refresh-token'); + expect(result.idToken).toBe('id-token'); + expect(result.user).toEqual({ email: 'c4c@example.com', position: UserStatus.Inactive, firstName: 'John', lastName: 'Doe' }); + expect(result.message).toBe('Login Successful!'); }); - it("should handle Cognito errors", async () => { - const mockRespondFn = () => ({ - promise: () => Promise.reject(new Error("Cognito Error")), + it('should return NEW_PASSWORD_REQUIRED challenge', async () => { + mockCognitoPromise.mockResolvedValueOnce({ + ChallengeName: 'NEW_PASSWORD_REQUIRED', + Session: 'session-123', + ChallengeParameters: { requiredAttributes: '["email"]' }, }); - (service["cognito"] as any).respondToAuthChallenge = mockRespondFn; + const result = await service.login('c4c@example.com', 'Pass123!'); - await expect( - service.setNewPassword("NewPass123!", "s123", "c4c@example.com") - ).rejects.toThrow("Cognito Error"); + expect(result.challenge).toBe('NEW_PASSWORD_REQUIRED'); + expect(result.session).toBe('session-123'); + expect(result.requiredAttributes).toEqual(['email']); + expect(result.access_token).toBeUndefined(); }); - }); - describe("updateProfile", () => { - it("should successfully update user profile", async () => { - mockDynamoPromise.mockResolvedValueOnce({}); - - // updateProfile now takes (email, position_or_role) — no username - await service.updateProfile("c4c@example.com", "Software Developer"); - - expect(mockDynamoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - Key: { email: "c4c@example.com" }, - UpdateExpression: "SET email = :email, position_or_role = :position_or_role", - ExpressionAttributeValues: { - ":email": "c4c@example.com", - ":position_or_role": "Software Developer", + it('should create new DynamoDB user if not found after login', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ + AuthenticationResult: { + IdToken: 'id-token', + AccessToken: 'access-token', + RefreshToken: 'refresh-token', }, }) .mockResolvedValueOnce({ - Username: 'emp1', - UserAttributes: [{ Name: 'email', Value: 'emp1@example.com' }], - }) - .mockResolvedValueOnce({ Item: mockUser }); + UserAttributes: [ + { Name: 'email', Value: 'new@example.com' }, + { Name: 'sub', Value: 'new-sub-456' }, + ], + }); - const result = await authService.login('emp1', 'Password123!'); + mockDynamoPromise + .mockResolvedValueOnce({}) // get - user not found + .mockResolvedValueOnce({}); // put - create new user - expect(result.access_token).toBe('mock-access-token'); - expect(result.user).toEqual(mockUser); - expect(result.message).toBe('Login Successful!'); - expect(mockInitiateAuth).toHaveBeenCalled(); + const result = await service.login('new@example.com', 'Pass123!'); + + expect(result.user).toEqual({ email: 'new@example.com', position: UserStatus.Inactive, firstName: '', lastName: '' }); + expect(mockDynamoPut).toHaveBeenCalled(); }); - it("should handle DynamoDB update errors", async () => { - const mockUpdateFn = vi.fn().mockReturnValue({ - promise: vi.fn().mockRejectedValue(new Error("DB error")), - }); + it('should throw BadRequestException when email is empty', async () => { + await expect(service.login('', 'Pass123!')).rejects.toThrow('Email is required'); + await expect(service.login(' ', 'Pass123!')).rejects.toThrow('Email is required'); + }); - (service['dynamoDb'] as any).update = mockUpdateFn; + it('should throw BadRequestException when password is empty', async () => { + await expect(service.login('test@test.com', '')).rejects.toThrow('Password is required'); + }); - await expect( - service.updateProfile("c4c@example.com", "Active") - ).rejects.toThrow("DB error"); + it('should throw UnauthorizedException for NotAuthorizedException', async () => { + mockCognitoPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Bad credentials' }); + await expect(service.login('test@test.com', 'wrongpass')).rejects.toThrow(UnauthorizedException); }); - }); - describe("validateSession", () => { - it("should successfully validate a session", async () => { - // Cognito getUser returns email in attributes - mockCognitoPromise.mockResolvedValueOnce({ - Username: "c4c@example.com", - UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], - }); + it('should throw InternalServerErrorException for other Cognito errors', async () => { + mockCognitoPromise.mockRejectedValueOnce({ code: 'SomeAwsError', message: 'AWS error' }); + await expect(service.login('test@test.com', 'Pass123!')).rejects.toThrow(InternalServerErrorException); + }); - // DynamoDB get returns user keyed by email - mockDynamoPromise.mockResolvedValueOnce({ - Item: { email: "c4c@example.com", position: "Active", firstName: "John", lastName: "Doe" }, - }); + it('should throw when client credentials are missing', async () => { + const origId = process.env.COGNITO_CLIENT_ID; + const origSecret = process.env.COGNITO_CLIENT_SECRET; + delete process.env.COGNITO_CLIENT_ID; + delete process.env.COGNITO_CLIENT_SECRET; - const result = await service.validateSession("valid-token"); + await expect(service.login('test@test.com', 'Pass123!')).rejects.toThrow('Cognito Client ID or Secret is not defined.'); - expect(result).toEqual({ - email: "c4c@example.com", - position: "Active", - firstName: "John", - lastName: "Doe", - }); + process.env.COGNITO_CLIENT_ID = origId; + process.env.COGNITO_CLIENT_SECRET = origSecret; }); - it("should reject missing access token", async () => { - // Empty string still calls Cognito which then throws, caught as UnauthorizedException - mockCognitoPromise.mockRejectedValueOnce({ - code: "NotAuthorizedException", - message: "Invalid token", + it('should throw when AuthenticationResult is missing tokens', async () => { + mockCognitoPromise.mockResolvedValueOnce({ AuthenticationResult: {} }); + await expect(service.login('test@test.com', 'Pass123!')).rejects.toThrow('Authentication failed: Missing IdToken or AccessToken'); + }); + }); + + // ── setNewPassword ─────────────────────────────────────────────────────────── + + describe('setNewPassword', () => { + it('should successfully set a new password', async () => { + mockCognitoPromise.mockResolvedValueOnce({ + AuthenticationResult: { IdToken: 'new-id-token' }, }); - await expect( - service.validateSession("") - ).rejects.toThrow(UnauthorizedException); + const result = await service.setNewPassword('NewPass123!', 'session123', 'c4c@example.com'); + expect(result.access_token).toBe('new-id-token'); + expect(mockRespondToAuthChallenge).toHaveBeenCalled(); }); - it("should handle NotAuthorizedException", async () => { - mockCognitoPromise.mockRejectedValueOnce({ - code: "NotAuthorizedException", - message: "Invalid token", + it('should include email in challenge responses', async () => { + mockCognitoPromise.mockResolvedValueOnce({ + AuthenticationResult: { IdToken: 'new-id-token' }, }); - await authService.setNewPassword('NewPass123!', 'mock-session', 'emp1', 'emp1@test.com'); + await service.setNewPassword('NewPass123!', 'session123', 'c4c@example.com'); expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( expect.objectContaining({ - ChallengeResponses: expect.objectContaining({ email: 'emp1@test.com' }), + ChallengeResponses: expect.objectContaining({ email: 'c4c@example.com' }), }) ); }); it('should throw BadRequestException when newPassword is empty', async () => { - await expect(authService.setNewPassword('', 'session', 'user')).rejects.toThrow('New password is required'); + await expect(service.setNewPassword('', 'session', 'c4c@example.com')).rejects.toThrow('New password is required'); }); it('should throw BadRequestException when session is empty', async () => { - await expect(authService.setNewPassword('Password123!', '', 'user')).rejects.toThrow('Session is required'); - }); - - it('should throw BadRequestException when username is empty', async () => { - await expect(authService.setNewPassword('Password123!', 'session', '')).rejects.toThrow('Username is required'); - await expect(authService.setNewPassword('Password123!', 'session', ' ')).rejects.toThrow('Username is required'); + await expect(service.setNewPassword('NewPass123!', '', 'c4c@example.com')).rejects.toThrow('Session is required'); }); it('should throw when AuthenticationResult is missing from response', async () => { - mockPromise.mockResolvedValueOnce({}); - await expect(authService.setNewPassword('Password123!', 'session', 'user')).rejects.toThrow('Failed to set new password'); + mockCognitoPromise.mockResolvedValueOnce({}); + await expect(service.setNewPassword('NewPass123!', 'session', 'c4c@example.com')).rejects.toThrow('Failed to set new password'); }); it('should throw when respondToAuthChallenge fails', async () => { - mockPromise.mockRejectedValueOnce(new Error('Challenge failed')); - await expect(authService.setNewPassword('Password123!', 'session', 'user')).rejects.toThrow('Challenge failed'); + mockCognitoPromise.mockRejectedValueOnce(new Error('Challenge failed')); + await expect(service.setNewPassword('NewPass123!', 'session', 'c4c@example.com')).rejects.toThrow('Challenge failed'); }); }); @@ -502,74 +403,75 @@ describe('AuthService', () => { describe('updateProfile', () => { it('should successfully update user profile', async () => { - mockPromise.mockResolvedValueOnce({}); - await expect(authService.updateProfile('emp1', 'newemail@test.com', 'Engineer')).resolves.toBeUndefined(); - expect(mockUpdate).toHaveBeenCalledWith( + mockDynamoPromise.mockResolvedValueOnce({}); + + await expect(service.updateProfile('c4c@example.com', 'Software Developer')).resolves.toBeUndefined(); + + expect(mockDynamoUpdate).toHaveBeenCalledWith( expect.objectContaining({ TableName: 'test-users-table', - Key: { userId: 'emp1' }, + Key: { email: 'c4c@example.com' }, + ExpressionAttributeValues: expect.objectContaining({ + ':email': 'c4c@example.com', + ':position_or_role': 'Software Developer', + }), }) ); }); - it('should throw BadRequestException when username is empty', async () => { - await expect(authService.updateProfile('', 'email@test.com', 'role')).rejects.toThrow('Username is required'); - await expect(authService.updateProfile(' ', 'email@test.com', 'role')).rejects.toThrow('Username is required'); - }); - it('should throw BadRequestException when email is empty', async () => { - await expect(authService.updateProfile('user', '', 'role')).rejects.toThrow('Email is required'); - await expect(authService.updateProfile('user', ' ', 'role')).rejects.toThrow('Email is required'); + await expect(service.updateProfile('', 'role')).rejects.toThrow('Email is required'); + await expect(service.updateProfile(' ', 'role')).rejects.toThrow('Email is required'); }); it('should throw BadRequestException when position_or_role is empty', async () => { - await expect(authService.updateProfile('user', 'email@test.com', '')).rejects.toThrow('Position or role is required'); - await expect(authService.updateProfile('user', 'email@test.com', ' ')).rejects.toThrow('Position or role is required'); + await expect(service.updateProfile('c4c@example.com', '')).rejects.toThrow('Position or role is required'); + await expect(service.updateProfile('c4c@example.com', ' ')).rejects.toThrow('Position or role is required'); }); it('should throw when DynamoDB update fails', async () => { - mockPromise.mockRejectedValueOnce(new Error('DynamoDB failure')); - await expect(authService.updateProfile('user', 'email@test.com', 'role')).rejects.toThrow('DynamoDB failure'); + mockDynamoPromise.mockRejectedValueOnce(new Error('DB error')); + await expect(service.updateProfile('c4c@example.com', 'role')).rejects.toThrow('DB error'); }); }); // ── validateSession ────────────────────────────────────────────────────────── describe('validateSession', () => { - const mockUser: User = { userId: 'emp1', email: 'emp1@example.com', position: UserStatus.Employee }; - it('should successfully validate a session and return user', async () => { - mockPromise - .mockResolvedValueOnce({ - Username: 'emp1', - UserAttributes: [{ Name: 'email', Value: 'emp1@example.com' }], - }) - .mockResolvedValueOnce({ Item: mockUser }); + mockCognitoPromise.mockResolvedValueOnce({ + Username: 'c4c@example.com', + UserAttributes: [{ Name: 'email', Value: 'c4c@example.com' }], + }); + + mockDynamoPromise.mockResolvedValueOnce({ + Item: { email: 'c4c@example.com', position: UserStatus.Employee, firstName: 'John', lastName: 'Doe' }, + }); + + const result = await service.validateSession('valid-access-token'); - const result = await authService.validateSession('valid-access-token'); - expect(result).toEqual(mockUser); + expect(result).toEqual({ email: 'c4c@example.com', position: UserStatus.Employee, firstName: 'John', lastName: 'Doe' }); expect(mockGetUser).toHaveBeenCalledWith({ AccessToken: 'valid-access-token' }); }); it('should throw UnauthorizedException when token is expired', async () => { - mockPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Token expired' }); - await expect(authService.validateSession('expired-token')).rejects.toThrow('Session expired or invalid'); + mockCognitoPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Token expired' }); + await expect(service.validateSession('expired-token')).rejects.toThrow('Session expired or invalid'); }); it('should throw UnauthorizedException when user not found in DynamoDB', async () => { - mockPromise - .mockResolvedValueOnce({ - Username: 'ghost', - UserAttributes: [{ Name: 'email', Value: 'ghost@test.com' }], - }) - .mockResolvedValueOnce({}); // no Item + mockCognitoPromise.mockResolvedValueOnce({ + Username: 'ghost@test.com', + UserAttributes: [{ Name: 'email', Value: 'ghost@test.com' }], + }); + mockDynamoPromise.mockResolvedValueOnce({}); // no Item - await expect(authService.validateSession('some-token')).rejects.toThrow('Failed to validate session'); + await expect(service.validateSession('some-token')).rejects.toThrow('Failed to validate session'); }); it('should throw UnauthorizedException for any other error', async () => { - mockPromise.mockRejectedValueOnce(new Error('Unknown error')); - await expect(authService.validateSession('bad-token')).rejects.toThrow('Failed to validate session'); + mockCognitoPromise.mockRejectedValueOnce(new Error('Unknown error')); + await expect(service.validateSession('bad-token')).rejects.toThrow('Failed to validate session'); }); }); }); \ No newline at end of file diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index 088abae..7441145 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -3,133 +3,117 @@ import { UserController } from '../user.controller'; import { UserService } from '../user.service'; import { User } from '../../../../middle-layer/types/User'; import { UserStatus } from '../../../../middle-layer/types/UserStatus'; -import * as AWS from 'aws-sdk'; import { VerifyUserGuard, VerifyAdminRoleGuard, VerifyAdminOrEmployeeRoleGuard } from '../../guards/auth.guard'; import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; -const mockScan = vi.fn(); -const mockGet = vi.fn(); -const mockUpdate = vi.fn(); -const mockPut = vi.fn(); -const mockDelete = vi.fn(); +// ─── Mock function declarations ─────────────────────────────────────────────── const mockPromise = vi.fn(); -const mockAdminAddUserToGroup = vi.fn(); -const mockAdminRemoveUserFromGroup = vi.fn(); -const mockAdminDeleteUser = vi.fn(); -const mockSendEmail = vi.fn(); +const mockScan = vi.fn(() => ({ promise: mockPromise })); +const mockGet = vi.fn(() => ({ promise: mockPromise })); +const mockUpdate = vi.fn(() => ({ promise: mockPromise })); +const mockPut = vi.fn(() => ({ promise: mockPromise })); +const mockDelete = vi.fn(() => ({ promise: mockPromise })); -// Mock S3 functions -const mockS3Upload = vi.fn(); +const mockAdminAddUserToGroup = vi.fn(() => ({ promise: mockPromise })); +const mockAdminRemoveUserFromGroup = vi.fn(() => ({ promise: mockPromise })); +const mockAdminDeleteUser = vi.fn(() => ({ promise: mockPromise })); +const mockSendEmail = vi.fn(() => ({ promise: mockPromise })); +const mockS3Upload = vi.fn(() => ({ promise: mockPromise })); -// Mock AWS SDK ONCE with proper structure for import * as AWS +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── vi.mock('aws-sdk', () => { - return { - default: { - CognitoIdentityServiceProvider: vi.fn(function() { - return { - adminAddUserToGroup: mockAdminAddUserToGroup, - adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, - adminDeleteUser: mockAdminDeleteUser, - }; - }), - DynamoDB: { - DocumentClient: vi.fn(function() { - return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; - }) - }, - SES: vi.fn(function() { - return { sendEmail: mockSendEmail }; - return { - sendEmail: mockSendEmail, - }; - }), - S3: vi.fn(function() { - return { - upload: mockS3Upload, - }; - }) - }, - CognitoIdentityServiceProvider: vi.fn(function() { - return { - adminAddUserToGroup: mockAdminAddUserToGroup, - adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, - adminDeleteUser: mockAdminDeleteUser, - }; - }), - DynamoDB: { - DocumentClient: vi.fn(function() { - return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; - }) - }, - SES: vi.fn(function() { - return { - sendEmail: mockSendEmail, - }; - }), - S3: vi.fn(function() { - return { - upload: mockS3Upload, - }; - return { sendEmail: mockSendEmail }; - }) + const cognitoFactory = vi.fn(function () { + return { + adminAddUserToGroup: mockAdminAddUserToGroup, + adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, + adminDeleteUser: mockAdminDeleteUser, + }; + }); + + const documentClientFactory = vi.fn(function () { + return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; + }); + + const sesFactory = vi.fn(function () { + return { sendEmail: mockSendEmail }; + }); + + const s3Factory = vi.fn(function () { + return { upload: mockS3Upload }; + }); + + const awsMock = { + CognitoIdentityServiceProvider: cognitoFactory, + DynamoDB: { DocumentClient: documentClientFactory }, + SES: sesFactory, + S3: s3Factory, }; + + return { ...awsMock, default: awsMock }; }); +// ─── Auth guard mock ────────────────────────────────────────────────────────── vi.mock('../../guards/auth.guard', () => ({ - VerifyUserGuard: vi.fn(class MockVerifyUserGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), - VerifyAdminRoleGuard: vi.fn(class MockVerifyAdminRoleGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), - VerifyAdminOrEmployeeRoleGuard: vi.fn(class MockVerifyAdminOrEmployeeRoleGuard { - canActivate = vi.fn().mockResolvedValue(true); - }) + VerifyUserGuard: vi.fn(class { canActivate = vi.fn().mockResolvedValue(true); }), + VerifyAdminRoleGuard: vi.fn(class { canActivate = vi.fn().mockResolvedValue(true); }), + VerifyAdminOrEmployeeRoleGuard: vi.fn(class { canActivate = vi.fn().mockResolvedValue(true); }), })); -// Mock database now keyed by email since that's the partition key +// ─── Mock database (email is now the partition key) ─────────────────────────── const mockDatabase = { users: [ - { email: 'admin1@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'One' }, - { email: 'admin2@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'Two' }, - { email: 'emp1@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'One' }, - { email: 'emp2@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Two' }, - { email: 'emp3@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Three' }, - { email: 'inactive1@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'One' }, - { email: 'inactive2@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Two' }, + { email: 'admin1@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'One' }, + { email: 'admin2@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'Two' }, + { email: 'emp1@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'One' }, + { email: 'emp2@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Two' }, + { email: 'emp3@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Three' }, + { email: 'inactive1@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'One' }, + { email: 'inactive2@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Two' }, { email: 'inactive3@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Three' }, - { email: 'inactive4@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Four' }, + { email: 'inactive4@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Four' }, ] as User[], - scan: (params: any) => { - let filteredUsers = [...mockDatabase.users]; - if (params.FilterExpression) { - if (params.FilterExpression.includes('(:inactive)')) { - filteredUsers = filteredUsers.filter(u => u.position === 'Inactive'); - } else if (params.FilterExpression.includes('(:admin, :employee)')) { - filteredUsers = filteredUsers.filter(u => u.position === 'Admin' || u.position === 'Employee'); - } + scan(params: any) { + let users = [...this.users]; + if (params.FilterExpression?.includes('(:inactive)')) { + users = users.filter(u => u.position === UserStatus.Inactive); + } else if (params.FilterExpression?.includes('(:admin, :employee)')) { + users = users.filter(u => u.position === UserStatus.Admin || u.position === UserStatus.Employee); } - return { Items: filteredUsers }; + return { Items: users }; }, - // Now looks up by email instead of userId - get: (params: any) => { - const email = params.Key.email; - const user = mockDatabase.users.find(u => u.email === email); + get(params: any) { + const user = this.users.find(u => u.email === params.Key.email); return user ? { Item: user } : {}; - } + }, }; +// ─── Helpers ────────────────────────────────────────────────────────────────── +const createMockFile = (overrides?: Partial): Express.Multer.File => ({ + fieldname: 'profilePic', + originalname: 'test-image.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1024 * 1024, + buffer: Buffer.from('fake-image-data'), + destination: '', + filename: '', + path: '', + stream: null as any, + ...overrides, +}); + +// ─── Test suite ─────────────────────────────────────────────────────────────── describe('UserController', () => { let controller: UserController; let userService: UserService; beforeAll(() => { process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; - process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; - process.env.PROFILE_PICTURE_BUCKET = 'test-profile-pics-bucket'; + process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.PROFILE_PICTURE_BUCKET = 'test-profile-pics-bucket'; }); beforeEach(async () => { @@ -137,18 +121,15 @@ describe('UserController', () => { mockScan.mockReturnValue({ promise: mockPromise }); mockGet.mockReturnValue({ promise: mockPromise }); - mockDelete.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); mockPut.mockReturnValue({ promise: mockPromise }); + mockDelete.mockReturnValue({ promise: mockPromise }); mockAdminAddUserToGroup.mockReturnValue({ promise: mockPromise }); mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockPromise }); mockAdminDeleteUser.mockReturnValue({ promise: mockPromise }); mockSendEmail.mockReturnValue({ promise: mockPromise }); - - // Setup S3 mocks to return chainable objects with .promise() mockS3Upload.mockReturnValue({ promise: mockPromise }); - - // Reset promise mocks to default resolved state + mockPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ @@ -156,298 +137,174 @@ describe('UserController', () => { providers: [UserService], }).compile(); - controller = module.get(UserController); + controller = module.get(UserController); userService = module.get(UserService); }); - // ======================================== - // Tests for uploadProfilePic - // ======================================== + // ── uploadProfilePic ──────────────────────────────────────────────────────── describe('uploadProfilePic', () => { - const createMockFile = (overrides?: Partial): Express.Multer.File => ({ - fieldname: 'profilePic', - originalname: 'test-image.jpg', - encoding: '7bit', - mimetype: 'image/jpeg', - size: 1024 * 1024, // 1MB - buffer: Buffer.from('fake-image-data'), - destination: '', - filename: '', - path: '', - stream: null as any, - ...overrides, - }); - it('should successfully upload profile picture', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); - - // Mock S3 upload success - mockPromise.mockResolvedValueOnce({ - Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', - Key: 'emp1-profilepic.jpg', - Bucket: 'test-profile-pics-bucket', - }); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const file = createMockFile(); + // key format: firstName-lastName-firstThreeOfEmail-profilepic.ext + const s3Url = 'https://test-profile-pics-bucket.s3.amazonaws.com/Emp-One-emp-profilepic.jpg'; - // Mock DynamoDB update success - mockPromise.mockResolvedValueOnce({ - Attributes: { - ...user, - profilePictureUrl: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', - }, - }); + mockPromise + .mockResolvedValueOnce({ Location: s3Url, Key: 'Emp-One-emp-profilepic.jpg', Bucket: 'test-profile-pics-bucket' }) + .mockResolvedValueOnce({ Attributes: { ...user, profilePicUrl: s3Url } }); - const result = await userService.uploadProfilePic(user, mockFile); + const result = await userService.uploadProfilePic(user, file); - // ✅ Result is now just the URL string, not the User object - expect(result).toBe('https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg'); + expect(result).toBe(s3Url); expect(mockS3Upload).toHaveBeenCalledWith({ Bucket: 'test-profile-pics-bucket', - Key: 'emp1-profilepic.jpg', - Body: mockFile.buffer, + Key: 'Emp-One-emp-profilepic.jpg', + Body: file.buffer, ContentType: 'image/jpeg', }); expect(mockUpdate).toHaveBeenCalledWith({ TableName: 'test-users-table', - Key: { userId: 'emp1' }, - UpdateExpression: 'SET profilePictureUrl = :url', - ExpressionAttributeValues: { - ':url': 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', - }, + Key: { email: 'emp1@example.com' }, + UpdateExpression: 'SET profilePicUrl = :url', + ExpressionAttributeValues: { ':url': s3Url }, ReturnValues: 'ALL_NEW', }); }); - it('should generate correct filename with different extensions', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile({ originalname: 'test.png', mimetype: 'image/png' }); + it('should generate correct filename for non-jpg extensions', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const file = createMockFile({ originalname: 'test.png', mimetype: 'image/png' }); + const s3Url = 'https://test-profile-pics-bucket.s3.amazonaws.com/Emp-One-emp-profilepic.png'; - mockPromise.mockResolvedValueOnce({ - Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.png', - Key: 'emp1-profilepic.png', - }); - mockPromise.mockResolvedValueOnce({ - Attributes: { ...user, profilePictureUrl: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.png' }, - }); + mockPromise + .mockResolvedValueOnce({ Location: s3Url, Key: 'Emp-One-emp-profilepic.png' }) + .mockResolvedValueOnce({ Attributes: { ...user, profilePicUrl: s3Url } }); - await userService.uploadProfilePic(user, mockFile); + await userService.uploadProfilePic(user, file); expect(mockS3Upload).toHaveBeenCalledWith( - expect.objectContaining({ - Key: 'emp1-profilepic.png', - }) + expect.objectContaining({ Key: 'Emp-One-emp-profilepic.png' }) ); }); it('should throw BadRequestException when user object is invalid', async () => { - const mockFile = createMockFile(); - - await expect( - userService.uploadProfilePic(null as any, mockFile) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.uploadProfilePic({ userId: '' } as any, mockFile) - ).rejects.toThrow('Valid user object is required'); + const file = createMockFile(); + await expect(userService.uploadProfilePic(null as any, file)).rejects.toThrow('Valid user object is required'); + await expect(userService.uploadProfilePic({ email: '' } as any, file)).rejects.toThrow('Valid user object is required'); }); it('should throw BadRequestException when file is invalid', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - - await expect( - userService.uploadProfilePic(user, null as any) - ).rejects.toThrow('Valid image file is required'); - - await expect( - userService.uploadProfilePic(user, { buffer: null } as any) - ).rejects.toThrow('Valid image file is required'); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + await expect(userService.uploadProfilePic(user, null as any)).rejects.toThrow('Valid image file is required'); + await expect(userService.uploadProfilePic(user, { buffer: null } as any)).rejects.toThrow('Valid image file is required'); }); it('should throw BadRequestException for invalid file type', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile({ mimetype: 'application/pdf' }); - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Invalid file type'); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + await expect(userService.uploadProfilePic(user, createMockFile({ mimetype: 'application/pdf' }))).rejects.toThrow('Invalid file type'); }); it('should throw BadRequestException for file too large', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile({ size: 10 * 1024 * 1024 }); // 10MB - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('File too large'); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + await expect(userService.uploadProfilePic(user, createMockFile({ size: 10 * 1024 * 1024 }))).rejects.toThrow('File too large'); }); it('should accept all allowed image types', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const s3Url = 'https://test.com/image.jpg'; - for (const mimetype of allowedTypes) { - // Clear all mocks + for (const mimetype of ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']) { vi.clearAllMocks(); - - // Reset the mock implementations mockS3Upload.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); - - // Mock S3 upload success mockPromise - .mockResolvedValueOnce({ - Location: 'https://test.com/image.jpg', - Key: 'key', - Bucket: 'test-profile-pics-bucket' - }) - // Mock DynamoDB update success - .mockResolvedValueOnce({ - Attributes: { - ...user, - profilePictureUrl: 'https://test.com/image.jpg' - } - }); - - const mockFile = createMockFile({ mimetype }); - const result = await userService.uploadProfilePic(user, mockFile); - - // ✅ Result is now just the URL string - expect(result).toBeDefined(); - expect(result).toBe('https://test.com/image.jpg'); + .mockResolvedValueOnce({ Location: s3Url, Key: 'key', Bucket: 'test-profile-pics-bucket' }) + .mockResolvedValueOnce({ Attributes: { ...user, profilePicUrl: s3Url } }); + + const result = await userService.uploadProfilePic(user, createMockFile({ mimetype })); + expect(result).toBe(s3Url); } }); it('should handle S3 NoSuchBucket error', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); - - const s3Error = { code: 'NoSuchBucket', message: 'Bucket does not exist' }; - mockPromise.mockRejectedValueOnce(s3Error); - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Storage bucket not found'); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise.mockRejectedValueOnce({ code: 'NoSuchBucket', message: 'Bucket does not exist' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Storage bucket not found'); }); it('should handle S3 AccessDenied error', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); - - const s3Error = { code: 'AccessDenied', message: 'Access denied' }; - mockPromise.mockRejectedValueOnce(s3Error); - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Insufficient permissions to upload file'); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise.mockRejectedValueOnce({ code: 'AccessDenied', message: 'Access denied' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Insufficient permissions to upload file'); }); - it('should handle DynamoDB update failure', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); - - // S3 upload succeeds - mockPromise.mockResolvedValueOnce({ - Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', - Key: 'emp1-profilepic.jpg', - }); - - // DynamoDB update fails - const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(dynamoError); - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Database table not found'); + it('should handle DynamoDB update failure after S3 upload succeeds', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise + .mockResolvedValueOnce({ Location: 'https://test.com/img.jpg', Key: 'key' }) + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Database table not found'); }); it('should handle DynamoDB ValidationException', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); - - // S3 upload succeeds - mockPromise.mockResolvedValueOnce({ - Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', - Key: 'emp1-profilepic.jpg', - }); - - // DynamoDB update fails with ValidationException - const dynamoError = { code: 'ValidationException', message: 'Invalid parameters' }; - mockPromise.mockRejectedValueOnce(dynamoError); - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Invalid update parameters'); + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise + .mockResolvedValueOnce({ Location: 'https://test.com/img.jpg', Key: 'key' }) + .mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid parameters' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Invalid update parameters'); }); - it('should throw InternalServerErrorException when DynamoDB does not return attributes', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); - - // S3 upload succeeds - mockPromise.mockResolvedValueOnce({ - Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', - Key: 'emp1-profilepic.jpg', - }); - - // DynamoDB update succeeds but doesn't return Attributes - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Failed to retrieve updated user data'); + it('should throw InternalServerErrorException when DynamoDB returns no Attributes', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise + .mockResolvedValueOnce({ Location: 'https://test.com/img.jpg', Key: 'key' }) + .mockResolvedValueOnce({}); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Failed to retrieve updated user data'); }); it('should throw InternalServerErrorException when bucket env var is not set', async () => { - const originalBucket = process.env.PROFILE_PICTURE_BUCKET; + const original = process.env.PROFILE_PICTURE_BUCKET; delete process.env.PROFILE_PICTURE_BUCKET; - // Create a new service instance to pick up the env change - const module: TestingModule = await Test.createTestingModule({ - providers: [UserService], - }).compile(); - const testService = module.get(UserService); - - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const mockFile = createMockFile(); + const module = await Test.createTestingModule({ providers: [UserService] }).compile(); + const svc = module.get(UserService); - await expect( - testService.uploadProfilePic(user, mockFile) - ).rejects.toThrow('Server configuration error'); - - // Restore env var - process.env.PROFILE_PICTURE_BUCKET = originalBucket; + await expect(svc.uploadProfilePic(mockDatabase.users[2], createMockFile())).rejects.toThrow('Server configuration error'); + process.env.PROFILE_PICTURE_BUCKET = original; }); }); - // ======================================== - // Existing tests... - // ======================================== + // ── getAllUsers ────────────────────────────────────────────────────────────── - it('should get all users from mock database', async () => { + it('should get all users', async () => { mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); - const result = await userService.getAllUsers(); - expect(result).toHaveLength(9); expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-users-table' }); }); - // getUserById is now getUserByEmail in the service - it('should get user by email from mock database', async () => { - mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'admin1@example.com' } })); + it('should handle ResourceNotFoundException when getting all users', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(userService.getAllUsers()).rejects.toThrow('Database table not found'); + }); - const result = await userService.getUserByEmail('admin1@example.com'); + it('should handle generic DynamoDB errors when getting all users', async () => { + mockPromise.mockRejectedValueOnce({ code: 'UnknownError', message: 'Unknown error' }); + await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users'); + }); + // ── getUserByEmail ─────────────────────────────────────────────────────────── + + it('should get user by email', async () => { + mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'admin1@example.com' } })); + const result = await userService.getUserByEmail('admin1@example.com'); expect(result.email).toBe('admin1@example.com'); - expect(result.position).toBe('Admin'); - expect(mockGet).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Key: { email: 'admin1@example.com' } - }); + expect(result.position).toBe(UserStatus.Admin); + expect(mockGet).toHaveBeenCalledWith({ TableName: 'test-users-table', Key: { email: 'admin1@example.com' } }); }); - it('should throw BadRequestException when email is invalid', async () => { + it('should throw BadRequestException for invalid email', async () => { await expect(userService.getUserByEmail('')).rejects.toThrow('Valid user email is required'); await expect(userService.getUserByEmail(null as any)).rejects.toThrow('Valid user email is required'); await expect(userService.getUserByEmail(' ')).rejects.toThrow('Valid user email is required'); @@ -455,392 +312,261 @@ describe('UserController', () => { it('should throw NotFoundException when user does not exist', async () => { mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'nonexistent@example.com' } })); - await expect(userService.getUserByEmail('nonexistent@example.com')).rejects.toThrow("User 'nonexistent@example.com' does not exist"); - expect(mockGet).toHaveBeenCalled(); - }); - - it('should handle errors when getting all users', async () => { - const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(awsError); - - await expect(userService.getAllUsers()).rejects.toThrow('Database table not found'); - expect(mockScan).toHaveBeenCalled(); - }); - - it('should handle generic DynamoDB errors when getting all users', async () => { - const awsError = { code: 'UnknownError', message: 'Unknown DynamoDB error' }; - mockPromise.mockRejectedValueOnce(awsError); - - await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users'); - expect(mockScan).toHaveBeenCalled(); }); - it('should handle errors when getting user by email', async () => { - const awsError = { code: 'ValidationException', message: 'Invalid request' }; - mockPromise.mockRejectedValueOnce(awsError); - + it('should handle ValidationException when getting user by email', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid request' }); await expect(userService.getUserByEmail('user@example.com')).rejects.toThrow('Invalid request: Invalid request'); - expect(mockGet).toHaveBeenCalled(); }); it('should handle ResourceNotFoundException when getting user by email', async () => { - const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(awsError); - + mockPromise.mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); await expect(userService.getUserByEmail('user@example.com')).rejects.toThrow('Database table not found'); - expect(mockGet).toHaveBeenCalled(); }); - it('should get all inactive users from mock database', async () => { + // ── getAllInactiveUsers ────────────────────────────────────────────────────── + + it('should get all inactive users', async () => { const scanParams = { TableName: 'test-users-table', FilterExpression: '#pos IN (:inactive)', ExpressionAttributeNames: { '#pos': 'position' }, - ExpressionAttributeValues: { ':inactive': 'Inactive' } + ExpressionAttributeValues: { ':inactive': 'Inactive' }, }; - mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); - const result = await userService.getAllInactiveUsers(); - expect(result).toHaveLength(4); - expect(result.every((u: User) => u.position === 'Inactive')).toBe(true); + expect(result.every((u: User) => u.position === UserStatus.Inactive)).toBe(true); expect(result.map((u: User) => u.email).sort()).toEqual([ - 'inactive1@example.com', - 'inactive2@example.com', - 'inactive3@example.com', - 'inactive4@example.com' + 'inactive1@example.com', 'inactive2@example.com', 'inactive3@example.com', 'inactive4@example.com', ]); expect(mockScan).toHaveBeenCalledWith(scanParams); }); - it('should handle errors when getting inactive users', async () => { - const awsError = { code: 'ValidationException', message: 'Invalid filter' }; - mockPromise.mockRejectedValueOnce(awsError); - + it('should handle ValidationException when getting inactive users', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid filter' }); await expect(userService.getAllInactiveUsers()).rejects.toThrow('Invalid filter expression'); - expect(mockScan).toHaveBeenCalled(); }); - it('should get all active users from mock database', async () => { + // ── getAllActiveUsers ──────────────────────────────────────────────────────── + + it('should get all active users', async () => { const scanParams = { TableName: 'test-users-table', FilterExpression: '#pos IN (:admin, :employee)', ExpressionAttributeNames: { '#pos': 'position' }, - ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' } + ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' }, }; - mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); - const result = await userService.getAllActiveUsers(); - expect(result).toHaveLength(5); - expect(result.every((u: User) => u.position === 'Admin' || u.position === 'Employee')).toBe(true); - expect(result.filter((u: User) => u.position === 'Admin')).toHaveLength(2); - expect(result.filter((u: User) => u.position === 'Employee')).toHaveLength(3); + expect(result.filter((u: User) => u.position === UserStatus.Admin)).toHaveLength(2); + expect(result.filter((u: User) => u.position === UserStatus.Employee)).toHaveLength(3); expect(mockScan).toHaveBeenCalledWith(scanParams); }); it('should throw NotFoundException when no active users found', async () => { mockPromise.mockResolvedValueOnce({ Items: undefined }); - await expect(userService.getAllActiveUsers()).rejects.toThrow('No active users found.'); - expect(mockScan).toHaveBeenCalled(); }); it('should handle ProvisionedThroughputExceededException', async () => { - const awsError = { code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded' }; - mockPromise.mockRejectedValueOnce(awsError); - + mockPromise.mockRejectedValueOnce({ code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded' }); await expect(userService.getAllActiveUsers()).rejects.toThrow('Database is temporarily unavailable, please try again'); - expect(mockScan).toHaveBeenCalled(); }); - // ======================================== - // Tests for addUserToGroup (Change Role) - // ======================================== + // ── addUserToGroup ─────────────────────────────────────────────────────────── - it('should successfully change user role from Inactive to Employee', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should change role from Inactive to Employee and send email', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); // DynamoDB get - mockPromise.mockResolvedValueOnce({}); // Cognito remove from old group - mockPromise.mockResolvedValueOnce({}); // Cognito add to new group - mockPromise.mockResolvedValueOnce({ MessageId: 'test' }); // SES sendEmail - mockPromise.mockResolvedValueOnce({ - Attributes: { ...user, position: UserStatus.Employee } - }); // DynamoDB update + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ MessageId: 'test-id' }) + .mockResolvedValueOnce({ Attributes: { ...user, position: UserStatus.Employee } }); const result = await userService.addUserToGroup(user, UserStatus.Employee, admin); - expect(result.position).toBe(UserStatus.Employee); - expect(mockGet).toHaveBeenCalled(); - // Cognito calls now use email as Username - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Employee', - UserPoolId: 'test-pool-id', - Username: 'inactive1@example.com' - }); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ GroupName: 'Employee', UserPoolId: 'test-pool-id', Username: 'inactive1@example.com' }); expect(mockSendEmail).toHaveBeenCalled(); expect(mockUpdate).toHaveBeenCalled(); }); - it('should successfully promote Employee to Admin', async () => { - const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + it('should promote Employee to Admin', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); - mockPromise.mockResolvedValueOnce({}); // Remove from Employee - mockPromise.mockResolvedValueOnce({}); // Add to Admin - mockPromise.mockResolvedValueOnce({ Attributes: { ...user, position: UserStatus.Admin } }); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ Attributes: { ...user, position: UserStatus.Admin } }); const result = await userService.addUserToGroup(user, UserStatus.Admin, admin); - expect(result.position).toBe(UserStatus.Admin); - expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledWith({ - GroupName: 'Employee', - UserPoolId: 'test-pool-id', - Username: 'emp1@example.com' - }); - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Admin', - UserPoolId: 'test-pool-id', - Username: 'emp1@example.com' - }); + expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledWith({ GroupName: 'Employee', UserPoolId: 'test-pool-id', Username: 'emp1@example.com' }); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ GroupName: 'Admin', UserPoolId: 'test-pool-id', Username: 'emp1@example.com' }); }); it('should return user unchanged if already in requested group', async () => { - const user = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - const requestedBy = mockDatabase.users.find(u => u.email === 'admin2@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; + const admin = mockDatabase.users.find(u => u.email === 'admin2@example.com')!; mockPromise.mockResolvedValueOnce({ Item: user }); - const result = await userService.addUserToGroup(user, UserStatus.Admin, requestedBy); - + const result = await userService.addUserToGroup(user, UserStatus.Admin, admin); expect(result.position).toBe(UserStatus.Admin); expect(mockAdminAddUserToGroup).not.toHaveBeenCalled(); }); - it('should throw BadRequestException when user object is invalid', async () => { + it('should throw BadRequestException for invalid user in addUserToGroup', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.addUserToGroup(null as any, UserStatus.Employee, admin) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.addUserToGroup({ email: '' } as any, UserStatus.Employee, admin) - ).rejects.toThrow('Valid user object is required'); + await expect(userService.addUserToGroup(null as any, UserStatus.Employee, admin)).rejects.toThrow('Valid user object is required'); + await expect(userService.addUserToGroup({ email: '' } as any, UserStatus.Employee, admin)).rejects.toThrow('Valid user object is required'); }); - it('should throw BadRequestException when group name is invalid', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should throw BadRequestException for invalid group name', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.addUserToGroup(user, '' as any, admin) - ).rejects.toThrow('Group name is required'); - - await expect( - userService.addUserToGroup(user, 'InvalidGroup' as any, admin) - ).rejects.toThrow('Invalid group name'); + await expect(userService.addUserToGroup(user, '' as any, admin)).rejects.toThrow('Group name is required'); + await expect(userService.addUserToGroup(user, 'InvalidGroup' as any, admin)).rejects.toThrow('Invalid group name'); }); - it('should throw UnauthorizedException when non-admin tries to change role', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should throw UnauthorizedException when non-admin changes role', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const employee = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; - - await expect( - userService.addUserToGroup(user, UserStatus.Employee, employee) - ).rejects.toThrow('Only administrators can modify user groups'); + await expect(userService.addUserToGroup(user, UserStatus.Employee, employee)).rejects.toThrow('Only administrators can modify user groups'); }); - it('should throw BadRequestException when admin tries to demote themselves', async () => { + it('should throw BadRequestException when admin demotes themselves', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: admin }); - - await expect( - userService.addUserToGroup(admin, UserStatus.Employee, admin) - ).rejects.toThrow('Administrators cannot demote themselves'); + await expect(userService.addUserToGroup(admin, UserStatus.Employee, admin)).rejects.toThrow('Administrators cannot demote themselves'); }); - it('should throw NotFoundException when user does not exist', async () => { - const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com') as User; + it('should throw NotFoundException when user does not exist in addUserToGroup', async () => { + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; const fakeUser: User = { email: 'fake@test.com', position: UserStatus.Inactive, firstName: '', lastName: '' }; - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.addUserToGroup(fakeUser, UserStatus.Employee, admin) - ).rejects.toThrow("User 'fake@test.com' does not exist"); + await expect(userService.addUserToGroup(fakeUser, UserStatus.Employee, admin)).rejects.toThrow("User 'fake@test.com' does not exist"); }); - it('should handle Cognito UserNotFoundException', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should handle Cognito UserNotFoundException in addUserToGroup', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); - mockPromise.mockResolvedValueOnce({}); // Remove from old group - const cognitoError = { code: 'UserNotFoundException', message: 'User not found in Cognito' }; - mockPromise.mockRejectedValueOnce(cognitoError); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'UserNotFoundException', message: 'User not found in Cognito' }); - await expect( - userService.addUserToGroup(user, UserStatus.Employee, admin) - ).rejects.toThrow('not found in authentication system'); + await expect(userService.addUserToGroup(user, UserStatus.Employee, admin)).rejects.toThrow('not found in authentication system'); }); it('should rollback Cognito change if DynamoDB update fails', async () => { - const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); - mockPromise.mockResolvedValueOnce({}); // Remove from old group - mockPromise.mockResolvedValueOnce({}); // Add to new group - const dynamoError = { code: 'ValidationException', message: 'Invalid update' }; - mockPromise.mockRejectedValueOnce(dynamoError); - mockPromise.mockResolvedValueOnce({}); // Rollback: remove from new group - mockPromise.mockResolvedValueOnce({}); // Rollback: add back to old group - - await expect( - userService.addUserToGroup(user, UserStatus.Admin, admin) - ).rejects.toThrow('Invalid update parameters'); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid update' }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + await expect(userService.addUserToGroup(user, UserStatus.Admin, admin)).rejects.toThrow('Invalid update parameters'); expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledTimes(2); expect(mockAdminAddUserToGroup).toHaveBeenCalledTimes(2); }); - // ======================================== - // Tests for deleteUser - // ======================================== + // ── deleteUser ─────────────────────────────────────────────────────────────── it('should successfully delete a user', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); // DynamoDB get - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); // DynamoDB delete - mockPromise.mockResolvedValueOnce({}); // Cognito delete - - const result = await userService.deleteUser(userToDelete, admin); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({ Attributes: user }) + .mockResolvedValueOnce({}); + const result = await userService.deleteUser(user, admin); expect(result.email).toBe('emp1@example.com'); - expect(mockGet).toHaveBeenCalled(); - // DynamoDB key is now email - expect(mockDelete).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Key: { email: 'emp1@example.com' }, - ReturnValues: 'ALL_OLD' - }); - // Cognito Username is now email - expect(mockAdminDeleteUser).toHaveBeenCalledWith({ - UserPoolId: 'test-pool-id', - Username: 'emp1@example.com' - }); + expect(mockDelete).toHaveBeenCalledWith({ TableName: 'test-users-table', Key: { email: 'emp1@example.com' }, ReturnValues: 'ALL_OLD' }); + expect(mockAdminDeleteUser).toHaveBeenCalledWith({ UserPoolId: 'test-pool-id', Username: 'emp1@example.com' }); }); - it('should throw BadRequestException when user object is invalid', async () => { + it('should throw BadRequestException for invalid user in deleteUser', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.deleteUser(null as any, admin) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.deleteUser({ email: '' } as any, admin) - ).rejects.toThrow('Valid user object is required'); + await expect(userService.deleteUser(null as any, admin)).rejects.toThrow('Valid user object is required'); + await expect(userService.deleteUser({ email: '' } as any, admin)).rejects.toThrow('Valid user object is required'); }); - it('should throw BadRequestException when requestedBy is invalid', async () => { + it('should throw BadRequestException for invalid requestedBy', async () => { const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; - - await expect( - userService.deleteUser(user, null as any) - ).rejects.toThrow('Valid requesting user is required'); + await expect(userService.deleteUser(user, null as any)).rejects.toThrow('Valid requesting user is required'); }); - it('should throw UnauthorizedException when non-admin tries to delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp2@example.com')!; + it('should throw UnauthorizedException when non-admin deletes user', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp2@example.com')!; const employee = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; - - await expect( - userService.deleteUser(userToDelete, employee) - ).rejects.toThrow('Only administrators can delete users'); + await expect(userService.deleteUser(user, employee)).rejects.toThrow('Only administrators can delete users'); }); - it('should throw BadRequestException when admin tries to delete themselves', async () => { + it('should throw BadRequestException when admin deletes themselves', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.deleteUser(admin, admin) - ).rejects.toThrow('Administrators cannot delete their own account'); + await expect(userService.deleteUser(admin, admin)).rejects.toThrow('Administrators cannot delete their own account'); }); it('should throw NotFoundException when user to delete does not exist', async () => { - const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com') as User; + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; const fakeUser: User = { email: 'fake@test.com', position: UserStatus.Employee, firstName: '', lastName: '' }; - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.deleteUser(fakeUser, admin) - ).rejects.toThrow("User 'fake@test.com' does not exist"); + await expect(userService.deleteUser(fakeUser, admin)).rejects.toThrow("User 'fake@test.com' does not exist"); }); - it('should handle Cognito UserNotFoundException during delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + it('should handle Cognito UserNotFoundException during delete and rollback', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - const cognitoError = { code: 'UserNotFoundException', message: 'User not found' }; - mockPromise.mockRejectedValueOnce(cognitoError); - mockPromise.mockResolvedValueOnce({}); // Rollback restore - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('not found in authentication system'); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({ Attributes: user }) + .mockRejectedValueOnce({ code: 'UserNotFoundException', message: 'User not found' }) + .mockResolvedValueOnce({}); - expect(mockPut).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Item: userToDelete - }); + await expect(userService.deleteUser(user, admin)).rejects.toThrow('not found in authentication system'); + expect(mockPut).toHaveBeenCalledWith({ TableName: 'test-users-table', Item: user }); }); it('should rollback DynamoDB delete if Cognito delete fails', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - const cognitoError = { code: 'InternalError', message: 'Cognito internal error' }; - mockPromise.mockRejectedValueOnce(cognitoError); - mockPromise.mockResolvedValueOnce({}); // Rollback + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({ Attributes: user }) + .mockRejectedValueOnce({ code: 'InternalError', message: 'Cognito internal error' }) + .mockResolvedValueOnce({}); - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('Failed to delete user from authentication system'); - - expect(mockPut).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Item: userToDelete - }); + await expect(userService.deleteUser(user, admin)).rejects.toThrow('Failed to delete user from authentication system'); + expect(mockPut).toHaveBeenCalledWith({ TableName: 'test-users-table', Item: user }); }); - it('should handle DynamoDB delete failure', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + it('should handle DynamoDB delete failure without calling Cognito', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(dynamoError); - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('Failed to delete user from database'); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(userService.deleteUser(user, admin)).rejects.toThrow('Failed to delete user from database'); expect(mockAdminDeleteUser).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index d45fce0..e433896 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -232,4 +232,93 @@ export class UserController { async getUserById(@Param('email') email: string): Promise { return await this.userService.getUserByEmail(email); } + + @Post('upload-pfp') +@ApiOperation({ + summary: 'Upload profile picture', + description: 'Uploads a profile picture for a user to S3 and updates the user record in DynamoDB with the image URL. Returns the S3 URL of the uploaded image.' +}) +@ApiConsumes('multipart/form-data') +@ApiBody({ + description: 'Profile picture upload with user information', + schema: { + type: 'object', + required: ['profilePic', 'user'], + properties: { + profilePic: { + type: 'string', + format: 'binary', + description: 'Image file (jpg, jpeg, png, gif, webp). Max size: 5MB' + }, + user: { + type: 'string', + description: 'User object as JSON string containing userId, position, and email', + example: '{"firstName":"john", "lastName":"doe","position":"Employee","email":"john@example.com"}' + } + } + } +}) +@ApiResponse({ + status: 201, + description: 'Profile picture uploaded successfully. Returns the S3 URL of the uploaded image.', + schema: { + type: 'string', + example: 'https://bcan-pics.s3.amazonaws.com/user-123-profilepic.jpg', + description: 'Full S3 URL where the profile picture is stored' + } +}) +@ApiResponse({ + status: 400, + description: 'Bad Request - Invalid file type, file too large, invalid user data format, missing required fields, or JSON parse error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { + type: 'string', + example: 'Invalid file type. Allowed types: image/jpeg, image/jpg, image/png, image/gif, image/webp' + }, + error: { type: 'string', example: 'Bad Request' } + } + } +}) +@ApiResponse({ + status: 401, + description: 'Unauthorized - Missing or invalid authentication token' +}) +@ApiResponse({ + status: 403, + description: 'Forbidden - User does not have permission to upload profile pictures' +}) +@ApiResponse({ + status: 500, + description: 'Internal Server Error - S3 upload failed, DynamoDB update failed, or server configuration error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 500 }, + message: { + type: 'string', + example: 'Failed to upload profile picture' + }, + error: { type: 'string', example: 'Internal Server Error' } + } + } +}) +@UseGuards(VerifyAdminOrEmployeeRoleGuard) +@ApiBearerAuth() +@UseInterceptors(FileInterceptor('profilePic')) +async uploadProfilePic( + @UploadedFile() file: Express.Multer.File, + @Body('user') userJson: string, +): Promise { + try { + // Parse the JSON string to User object + const user: User = JSON.parse(userJson); + + return await this.userService.uploadProfilePic(user, file); + } catch (error) { + throw new BadRequestException('Invalid user data format'); + } +} } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 6573d19..db04a5f 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -25,17 +25,17 @@ export class UserService { private s3 = new AWS.S3(); private profilePicBucket : string = process.env.PROFILE_PICTURE_BUCKET!; - async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { +async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { const tableName = process.env.DYNAMODB_USER_TABLE_NAME; // 1. Validate all inputs this.validateUploadInputs(user, pic, tableName); - // 2. Generate filename: userId-profilepic.ext + // 2. Generate filename: firstName-lastName-profilepic.ext const fileExtension = pic.originalname.split('.').pop()?.toLowerCase() || 'jpg'; - const key = `${user.userId}-profilepic.${fileExtension}`; + const key = `${user.firstName}-${user.lastName}-${user.email.slice(0,3)}-profilepic.${fileExtension}`; - this.logger.log(`Uploading profile picture for user ${user.userId} with key: ${key}`); + this.logger.log(`Uploading profile picture for user ${user.firstName} ${user.lastName} with key: ${key}`); try { // 3. Upload to S3 @@ -52,8 +52,8 @@ export class UserService { // 4. Update user's profile picture URL in DynamoDB const updateParams = { TableName: tableName!, - Key: { userId: user.userId }, - UpdateExpression: "SET profilePictureUrl = :url", + Key: { email: user.email }, + UpdateExpression: "SET profilePicUrl = :url", ExpressionAttributeValues: { ":url": uploadResult.Location, }, @@ -63,15 +63,15 @@ export class UserService { const updateResult = await this.dynamoDb.update(updateParams).promise(); if (!updateResult.Attributes) { - this.logger.error(`DynamoDB update did not return updated attributes for ${user.userId}`); + this.logger.error(`DynamoDB update did not return updated attributes for ${user.email}`); throw new InternalServerErrorException("Failed to retrieve updated user data"); } - this.logger.log(`✅ Profile picture uploaded successfully for user ${user.userId}`); - return updateResult.Attributes.profilePictureUrl; + this.logger.log(`✅ Profile picture uploaded successfully for user ${user.email}`); + return updateResult.Attributes.profilePicUrl; } catch (error: any) { - this.logger.error(`Failed to upload profile picture for ${user.userId}:`, error); + this.logger.error(`Failed to upload profile picture for ${user.email}:`, error); // Handle S3 errors if (error.code === 'NoSuchBucket') { @@ -108,7 +108,7 @@ private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: st throw new InternalServerErrorException("Server configuration error"); } - if (!user || !user.userId || user.userId.trim().length === 0) { + if (!user || !user.firstName || !user.lastName || !user.email) { this.logger.error("Invalid user object provided for upload"); throw new BadRequestException("Valid user object is required"); }