+
Goodbye, {{name}}
+
Your account and all related data have been permanently deleted from our system.
+
If this was a mistake, please contact our support team as soon as possible.
+
Best regards,
The CatBytes Team
+
+
+
\ No newline at end of file
diff --git a/tests/middleware/authentication.test.js b/tests/middleware/authentication.test.js
new file mode 100644
index 0000000..de25eab
--- /dev/null
+++ b/tests/middleware/authentication.test.js
@@ -0,0 +1,72 @@
+const { authenticate } = require('../../middleware/authentication');
+const { respondWithError } = require('../../routes/helpers');
+const userService = require('../../services/user_service');
+
+jest.mock('../../services/user_service');
+jest.mock('../../routes/helpers', () => ({
+ respondWithError: jest.fn((res, statusCode, message) => {
+ if (statusCode && message) {
+ res.statusCode = statusCode;
+ res.body = { error: message };
+ } else {
+ res.statusCode = 500;
+ res.body = { error: 'Internal Server Error' };
+ }
+ return res;
+ }),
+}));
+
+describe('Authenticate', () => {
+ it('Successful authentication sets user info in req', async () => {
+ const req = { cookies: { userUID: 'valid-uid' } };
+ const res = {};
+ const next = jest.fn();
+ const user = { id: 42, email: 'test@test.com' };
+ userService.getUserByFirebaseId.mockResolvedValue(user);
+
+ await authenticate()(req, res, next);
+
+ expect(userService.getUserByFirebaseId).toHaveBeenCalledWith('valid-uid');
+ expect(req.userId).toBe(user.id);
+ expect(req.userEmail).toBe(user.email);
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('Missing UID in cookies continues request without authentication', async () => {
+ const res = {};
+ const next = jest.fn();
+ await authenticate()({}, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.statusCode).toBeUndefined();
+ expect(respondWithError).not.toHaveBeenCalled();
+ });
+
+ it('Returns 401 if user not found in database', async () => {
+ const req = { cookies: { userUID: 'nonexistent-uid' } };
+ const res = {};
+ const next = jest.fn();
+ userService.getUserByFirebaseId.mockResolvedValue(null);
+
+ await authenticate()(req, res, next);
+
+ expect(userService.getUserByFirebaseId).toHaveBeenCalledWith('nonexistent-uid');
+ expect(res.statusCode).toBe(401);
+ expect(res.body).toEqual({ error: "User with provided UID not found" });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('Unexpected error handled properly', async () => {
+ const req = { cookies: { userUID: 'valid-uid' } };
+ const res = {};
+ const next = jest.fn();
+ userService.getUserByFirebaseId.mockRejectedValue(new Error('Database error'));
+
+ await authenticate()(req, res, next);
+
+ expect(userService.getUserByFirebaseId).toHaveBeenCalledWith('valid-uid');
+ expect(res.statusCode).toBe(500);
+ expect(res.body).toEqual({ error: 'Internal Server Error' });
+ expect(next).not.toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/tests/middleware/authorization.test.js b/tests/middleware/authorization.test.js
new file mode 100644
index 0000000..b7ada50
--- /dev/null
+++ b/tests/middleware/authorization.test.js
@@ -0,0 +1,135 @@
+const { verifyRoles, verifyOwnership, OWNED_ENTITIES } = require('../../middleware/authorization');
+const { ROLE_NAMES } = require("../../utils");
+const utils = require('../../utils');
+
+jest.mock('../../routes/helpers', () => ({
+ respondWithError: jest.fn((res, statusCode, message) => (res.statusCode = statusCode, res.body = { error: message }, res)),
+}));
+
+jest.spyOn(utils, 'getRole').mockImplementation(() => ("rolename"));
+
+describe('verifyRoles', () => {
+ it('Returns 401 on missing userId', async () => {
+ const res = {};
+ const next = jest.fn();
+
+ await verifyRoles([ROLE_NAMES.member])({}, res, next);
+
+ expect(res.statusCode).toBe(401);
+ expect(res.body).toEqual({ error: "User not authenticated" });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ const roleCheckTestCases = [
+ {
+ description: 'Calls next on success for member role',
+ rolesToVerify: [ROLE_NAMES.member],
+ userRoles: [{ role_name: 'member', role_id: 1 }],
+ expectedNextCalled: true,
+ },
+ {
+ description: 'Calls next on success for admin role',
+ rolesToVerify: [ROLE_NAMES.mentor, ROLE_NAMES.admin],
+ userRoles: [{ role_name: 'admin', role_id: 3 }],
+ expectedNextCalled: true,
+ },
+ {
+ description: 'Returns 403 when user does not have the required role',
+ rolesToVerify: [ROLE_NAMES.mentor, ROLE_NAMES.admin],
+ userRoles: [{ role_name: 'member', role_id: 1 }],
+ expectedNextCalled: false,
+ },
+ ];
+
+ roleCheckTestCases.forEach(({ description, rolesToVerify, userRoles, expectedNextCalled }) => {
+ it(description, async () => {
+ const req = { userId: 1 };
+ const res = {};
+ const next = jest.fn();
+
+ jest.spyOn(require('../../repositories/authorization_repository'), 'getRolesByUserId')
+ .mockResolvedValue(userRoles);
+
+ await verifyRoles(rolesToVerify)(req, res, next);
+
+ if (expectedNextCalled) {
+ expect(req.userRoles).toBe(userRoles);
+ expect(next).toHaveBeenCalled();
+ } else {
+ expect(res.statusCode).toBe(403);
+ expect(res.body).toEqual({ error: "You're not allowed to access this resource" });
+ expect(next).not.toHaveBeenCalled();
+ }
+ });
+ });
+});
+
+describe('VerifyOwnership', () => {
+ it('Returns 401 on missing userId', async () => {
+ const res = {};
+ const next = jest.fn();
+
+ await verifyOwnership(OWNED_ENTITIES.USER)({}, res, next);
+
+ expect(res.statusCode).toBe(401);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('Returns 400 on missing resourceId', async () => {
+ const req = { userId: 1 };
+ const res = {};
+ const next = jest.fn();
+
+ await verifyOwnership(OWNED_ENTITIES.USER)(req, res, next);
+
+ expect(res.statusCode).toBe(400);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('Returns 403 on insufficient permissions for user entity', async () => {
+ const req = { userId: 1, params: { id: 2 } };
+ const res = {};
+ const next = jest.fn();
+
+ await verifyOwnership(OWNED_ENTITIES.USER)(req, res, next);
+
+ expect(res.statusCode).toBe(403);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('Returns 403 on insufficient permissions for mentor entity', async () => {
+ const req = { userId: 1, params: { id: 2 } };
+ const res = {};
+ const next = jest.fn();
+
+ jest.spyOn(require('../../repositories/authorization_repository'), 'verifyOwnership').mockResolvedValue([]);
+
+ await verifyOwnership(OWNED_ENTITIES.MENTOR)(req, res, next);
+
+ expect(res.statusCode).toBe(403);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('Calls next on successful ownership verification for user entity', async () => {
+ const req = { userId: 1, params: { id: 1 } };
+ const res = {};
+ const next = jest.fn();
+
+ await verifyOwnership(OWNED_ENTITIES.USER)(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('Calls next on successful ownership verification for mentor entity', async () => {
+ const req = { userId: 1, params: { id: 2 } };
+ const res = {};
+ const next = jest.fn();
+
+ jest.spyOn(require('../../repositories/authorization_repository'), 'verifyOwnership')
+ .mockResolvedValue({ rows: [{ id: 2 }] });
+
+ await verifyOwnership(OWNED_ENTITIES.MENTOR)(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/tests/routes/admin.test.js b/tests/routes/admin.test.js
new file mode 100644
index 0000000..bf72e18
--- /dev/null
+++ b/tests/routes/admin.test.js
@@ -0,0 +1,95 @@
+const request = require('supertest');
+const rolesRepo = require('../../repositories/roles_repository');
+
+const app = require('../../appForTests');
+
+jest.mock('../../repositories/roles_repository');
+jest.mock('../../middleware/authorization', () => {
+ const actual = jest.requireActual('../../middleware/authorization');
+ return {
+ ...actual,
+ verifyRoles: jest.fn(() => (req, res, next) => next()),
+ };
+});
+
+describe('POST /admin/grant-role', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Role assignment success', async () => {
+ rolesRepo.assignRoleToUser.mockResolvedValue();
+
+ const payload = { userId: 42, roleId: 1 };
+ const res = await request(app)
+ .post('/admin/grant-role')
+ .send(payload);
+
+ expect(rolesRepo.assignRoleToUser).toHaveBeenCalledWith(payload.userId, payload.roleId);
+ expect(res.statusCode).toBe(200);
+ expect(res.body.message).toBe('Role assigned successfully');
+ });
+
+ it('Return 400 on incomplete payload data', async () => {
+ const res = await request(app)
+ .post('/admin/grant-role')
+ .send({ userId: 42 });
+
+ expect(rolesRepo.assignRoleToUser).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(400);
+ expect(res.body.message).toBe('Missing userId or roleId');
+ });
+
+ it('Handle unexpected errors', async () => {
+ rolesRepo.assignRoleToUser.mockRejectedValue(new Error('Unexpected error'));
+
+ const payload = { userId: 42, roleId: 1 };
+ const res = await request(app)
+ .post('/admin/grant-role')
+ .send(payload);
+
+ expect(rolesRepo.assignRoleToUser).toHaveBeenCalledWith(payload.userId, payload.roleId);
+ expect(res.statusCode).toBe(500);
+ });
+});
+
+describe('POST /admin/revoke-role', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Role revocation success', async () => {
+ rolesRepo.removeRoleFromUser.mockResolvedValue();
+
+ const payload = { userId: 42, roleId: 1 };
+ const res = await request(app)
+ .post('/admin/revoke-role')
+ .send(payload);
+
+ expect(rolesRepo.removeRoleFromUser).toHaveBeenCalledWith(payload.userId, payload.roleId);
+ expect(res.statusCode).toBe(200);
+ expect(res.body.message).toBe('Role removed successfully');
+ });
+
+ it('Return 400 on incomplete payload data', async () => {
+ const res = await request(app)
+ .post('/admin/revoke-role')
+ .send({ userId: 42 });
+
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(400);
+ expect(res.body.message).toBe('Missing userId or roleId');
+ });
+
+ it('Handle unexpected errors', async () => {
+ rolesRepo.removeRoleFromUser.mockRejectedValue(new Error('Unexpected error'));
+
+ const payload = { userId: 42, roleId: 1 };
+ const res = await request(app)
+ .post('/admin/revoke-role')
+ .send(payload);
+
+ expect(rolesRepo.removeRoleFromUser).toHaveBeenCalledWith(payload.userId, payload.roleId);
+ expect(res.statusCode).toBe(500);
+ });
+});
diff --git a/tests/routes/applications.test.js b/tests/routes/applications.test.js
new file mode 100644
index 0000000..b51ff21
--- /dev/null
+++ b/tests/routes/applications.test.js
@@ -0,0 +1,237 @@
+const request = require('supertest');
+const applicationService = require('../../services/applications_service');
+const mailerService = require('../../services/mailer_service');
+const discordService = require('../../services/discord_bot_service');
+
+const mockedUserId = 123;
+
+jest.mock('../../services/applications_service');
+jest.mock('../../services/mailer_service');
+jest.mock('../../services/discord_bot_service');
+jest.mock('../../middleware/authorization', () => {
+ const actual = jest.requireActual('../../middleware/authorization');
+ return {
+ ...actual,
+ verifyRoles: jest.fn(() => (req, res, next) => next()),
+ };
+});
+
+jest.mock('../../middleware/authentication', () => {
+ return {
+ authenticate: jest.fn(() => (req, res, next) => {
+ req.userId = mockedUserId;
+ next();
+ }),
+ };
+});
+
+const app = require('../../appForTests');
+
+describe('GET /applications', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Return all applications success', async () => {
+ const mockApplications = [{ id: 1, name: 'Test Application' }];
+ applicationService.getAllApplications.mockResolvedValue(mockApplications);
+
+ const res = await request(app).get('/applications');
+
+ expect(applicationService.getAllApplications).toHaveBeenCalled();
+ expect(res.statusCode).toBe(200);
+ expect(res.body.applications).toEqual(mockApplications);
+ });
+});
+
+describe('POST /applications', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const applicationPayload = {
+ name : 'Test Application',
+ about: 'This is a test application',
+ email: 'test@mail.com',
+ video_link: 'http://example.com/video',
+ discord_nickname: 'TestUser'
+ };
+
+ it('Create new application success', async () => {
+ applicationService.createNewApplication.mockResolvedValue({ id: 1, ...applicationPayload });
+ const res = await request(app)
+ .post('/applications')
+ .send(applicationPayload);
+
+ expect(applicationService.createNewApplication).toHaveBeenCalledWith(applicationPayload);
+ expect(res.statusCode).toBe(201);
+ expect(res.body).toEqual({ id: 1, ...applicationPayload });
+ });
+
+ it('Create new application unique constraint violation', async () => {
+ const constraintError = new Error('Unique constraint violation');
+ constraintError.constraint = 'applications_email_key';
+ constraintError.code = '23505';
+
+ applicationService.createNewApplication.mockRejectedValue(constraintError);
+ const res = await request(app)
+ .post('/applications')
+ .send(applicationPayload);
+
+ expect(applicationService.createNewApplication).toHaveBeenCalledWith(applicationPayload);
+ expect(res.statusCode).toBe(409);
+ expect(res.body.error).toContain('already exists');
+ });
+
+ it('Create new application notnull constraint violation', async () => {
+ const constraintError = new Error('Notnull constraint violation');
+ constraintError.constraint = 'applications_email_key';
+ constraintError.code = '23502';
+
+ applicationService.createNewApplication.mockRejectedValue(constraintError);
+ const res = await request(app)
+ .post('/applications')
+ .send(applicationPayload);
+
+ expect(applicationService.createNewApplication).toHaveBeenCalledWith(applicationPayload);
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toContain('email is required');
+ });
+
+ it('Create new application unexpected error', async () => {
+ applicationService.createNewApplication.mockRejectedValue(new Error('Unexpected error'));
+ const res = await request(app)
+ .post('/applications')
+ .send(applicationPayload);
+
+ expect(applicationService.createNewApplication).toHaveBeenCalledWith(applicationPayload);
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toContain('Internal Server Error');
+ });
+
+ describe('Create new application - video field validation', () => {
+ const basePayload = {
+ name: 'Test Application',
+ about: 'This is a test application',
+ email: 'test@test.com',
+ discord_nickname: 'TestUser'
+ };
+
+ const testCases = [
+ ['both missing', {}],
+ ['both empty strings', { video_link: '', video_filename: '' }],
+ ['video_link missing, video_filename empty', { video_filename: '' }],
+ ['video_link empty, video_filename missing', { video_link: '' }],
+ ];
+
+ test.each(testCases)(
+ 'should return 400 if %s',
+ async (_desc, videoFields) => {
+ const payload = { ...basePayload, ...videoFields };
+
+ const res = await request(app).post('/applications').send(payload);
+
+ expect(applicationService.createNewApplication).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toContain('Video link or filename is required');
+ }
+ );
+ });
+});
+
+describe('PUT /applications/:id', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const applicationId = '1';
+ const updatePayload = { status: 'approved', comment: 'Looks good!' };
+
+ it('Change application status success', async () => {
+ const mockedApplication = {
+ id: applicationId,
+ name: 'Test Application',
+ email: 'test@test.com',
+ status: 'pending'
+ };
+ applicationService.getApplicationById.mockResolvedValue(mockedApplication);
+ applicationService.updateApplicationStatus.mockResolvedValue({ id: applicationId, ...mockedApplication, ...updatePayload });
+ discordService.generateInviteLink.mockResolvedValue("link");
+
+ const res = await request(app)
+ .put(`/applications/${applicationId}`)
+ .send(updatePayload);
+
+ expect(applicationService.getApplicationById).toHaveBeenCalledWith(applicationId);
+ expect(applicationService.updateApplicationStatus).toHaveBeenCalledWith(
+ applicationId,
+ updatePayload.status,
+ updatePayload.comment,
+ mockedUserId,
+ expect.any(Date)
+ );
+ expect(mailerService.sendEmailOnApplicationStatusChange).toHaveBeenCalledWith(
+ mockedApplication.email,
+ mockedApplication.name,
+ updatePayload.status,
+ expect.any(String)
+ );
+ expect(res.statusCode).toBe(200);
+ expect(res.body.id).toBe(applicationId);
+ expect(res.body.status).toBe(updatePayload.status);
+ });
+
+ it('Update application status not found', async () => {
+ applicationService.getApplicationById.mockResolvedValue(null);
+
+ const res = await request(app)
+ .put(`/applications/${applicationId}`)
+ .send(updatePayload);
+
+ expect(applicationService.getApplicationById).toHaveBeenCalledWith(applicationId);
+ expect(applicationService.updateApplicationStatus).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(404);
+ expect(res.body.error).toBe('Application not found');
+ });
+
+ it('Update application status already rejected', async () => {
+ applicationService.getApplicationById.mockResolvedValue({ id: applicationId, status: 'rejected' });
+
+ const res = await request(app)
+ .put(`/applications/${applicationId}`)
+ .send(updatePayload);
+
+ expect(applicationService.getApplicationById).toHaveBeenCalledWith(applicationId);
+ expect(applicationService.updateApplicationStatus).not.toHaveBeenCalled();
+ expect(mailerService.sendEmailOnApplicationStatusChange).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('Application is already in rejected status.');
+ });
+
+ it('Update application invalid status provided', async () => {
+ const invalidPayload = { ...updatePayload, status: 'invalid_status' };
+
+ const res = await request(app)
+ .put(`/applications/${applicationId}`)
+ .send(invalidPayload);
+
+ expect(applicationService.getApplicationById).not.toHaveBeenCalled();
+ expect(mailerService.sendEmailOnApplicationStatusChange).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('Invalid status provided');
+ });
+
+ it('Update application rejected without comment', async () => {
+ const invalidPayload = { status: 'rejected' };
+
+ const res = await request(app)
+ .put(`/applications/${applicationId}`)
+ .send(invalidPayload);
+
+ expect(applicationService.getApplicationById).not.toHaveBeenCalled();
+ expect(applicationService.updateApplicationStatus).not.toHaveBeenCalled();
+ expect(mailerService.sendEmailOnApplicationStatusChange).not.toHaveBeenCalled();
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('Comment is required for rejected applications');
+ });
+});
\ No newline at end of file
diff --git a/tests/routes/mentors.test.js b/tests/routes/mentors.test.js
new file mode 100644
index 0000000..34452ca
--- /dev/null
+++ b/tests/routes/mentors.test.js
@@ -0,0 +1,290 @@
+const request = require('supertest');
+const mentorService = require('../../services/mentor_service');
+const { MentorAlreadyExistsError, DataRequiresElevatedRoleError, MentorDoesNotExistError } = require('../../errors');
+
+const defautlUserId = 42;
+const defaultUserRoles = [{ role_name: 'member', role_id: 1 }];
+const mockedMentor = {
+ id: 13,
+ user_id: defautlUserId,
+ contact: 'sample@test.com',
+ about: 'I am a mentor',
+ status: 'pending',
+ created_at: '2024-01-01T00:00:00.000Z',
+ last_modified_by: 10,
+ last_modified_at: '2024-01-01T00:00:00.000Z',
+ name: 'Test User',
+};
+
+jest.mock('../../services/auth_service');
+jest.mock('../../services/mentor_service');
+// added below so PATCH mentor success route test doesn't fail
+jest.mock('../../services/user_service', () => ({
+ getUserById: jest.fn().mockResolvedValue({ email: 'user@test.com', name: 'Test User' }),
+}));
+jest.mock('../../services/mailer_service', () => ({
+ sendEmailOnMentorApplicationStatusChange: jest.fn().mockResolvedValue(),
+}));
+jest.mock('../../middleware/authorization', () => {
+ const actual = jest.requireActual('../../middleware/authorization');
+ return {
+ ...actual,
+ verifyRoles: jest.fn(() => (req, res, next) => {
+ req.userId = defautlUserId;
+ req.userRoles = defaultUserRoles;
+ next();
+ }),
+ verifyMentorOwnership: jest.fn(() => false),
+ verifyOwnership: jest.fn(() => (req, res, next) => {
+ req.userId = defautlUserId;
+ next();
+ }),
+ };
+});
+
+const app = require('../../appForTests');
+
+describe('POST /mentors', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Create mentor application success', async () => {
+ mentorService.createMentor.mockResolvedValue(mockedMentor.id);
+
+ const payload = {
+ contact: 'sample@test.com',
+ about: 'I am a mentor',
+ };
+
+ const res = await request(app)
+ .post('/mentors')
+ .send(payload);
+
+ expect(mentorService.createMentor).toHaveBeenCalledWith(mockedMentor.user_id, payload);
+
+ expect(res.statusCode).toBe(201);
+ expect(res.body).toStrictEqual({ id: mockedMentor.id });
+ });
+
+ it('Create mentor application - user already has mentor entity', async () => {
+ mentorService.createMentor.mockRejectedValue(new MentorAlreadyExistsError('User already has a mentor profile'));
+
+ const res = await request(app)
+ .post('/mentors')
+ .send({ contact: 'sample@test.com', about: 'I am a mentor'});
+
+ expect(res.statusCode).toBe(409);
+ expect(res.body.error).toBe('User already has a mentor profile');
+ });
+
+ it('Create mentor application - unexpected error', async () => {
+ mentorService.createMentor.mockRejectedValue(new Error(""));
+
+ const res = await request(app)
+ .post('/mentors')
+ .send({ contact: 'sample@test.com', about: 'I am a mentor'});
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toBe('Internal Server Error');
+ });
+});
+
+describe('GET /mentors', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Get all mentors success - not authenticated', async () => {
+ mentorService.getMentors.mockResolvedValue([mockedMentor]);
+ const res = await request(app)
+ .get('/mentors');
+
+ expect(mentorService.getMentors).toHaveBeenCalledWith(undefined, undefined, false);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.mentors.length).toBeGreaterThan(0);
+ });
+
+ it('Get all mentors success - authenticated', async () => {
+ mentorService.getMentors.mockResolvedValue([mockedMentor]);
+ const res = await request(app)
+ .get('/mentors')
+ .set('userId', defautlUserId); // simulate authenticated user
+
+ expect(mentorService.getMentors).toHaveBeenCalledWith(defautlUserId, undefined, true);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.mentors.length).toBeGreaterThan(0);
+ });
+
+ it('Get pending mentors - unauthorized', async () => {
+ mentorService.getMentors
+ .mockRejectedValue(new DataRequiresElevatedRoleError("You're not allowed to see mentors with this status."));
+ const res = await request(app)
+ .get('/mentors')
+ .query({ status: 'pending' })
+ .set('userId', defautlUserId); // simulate authenticated user
+
+ expect(mentorService.getMentors).toHaveBeenCalledWith(defautlUserId, 'pending', true);
+
+ expect(res.statusCode).toBe(403);
+ expect(res.body.error).toBe("You're not allowed to see mentors with this status.");
+ });
+
+ it('Get mentors - unexpected error', async () => {
+ mentorService.getMentors.mockRejectedValue(new Error());
+
+ const res = await request(app)
+ .get('/mentors')
+ .set('userId', defautlUserId); // simulate authenticated user
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toBe('Internal Server Error');
+ });
+});
+
+describe('GET /mentors/:id', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Get mentor by ID success', async () => {
+ mentorService.getMentorById.mockResolvedValue(mockedMentor);
+
+ const res = await request(app)
+ .get(`/mentors/${mockedMentor.id}`)
+ .set('userId', defautlUserId);
+
+ expect(mentorService.getMentorById).toHaveBeenCalledWith(defaultUserRoles, mockedMentor.id.toString(), false);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toStrictEqual(mockedMentor);
+ });
+
+ it('Get mentor by ID - not found', async () => {
+ mentorService.getMentorById.mockResolvedValue(null);
+ const res = await request(app)
+ .get(`/mentors/9999`)
+ .set('userId', defautlUserId);
+
+ expect(mentorService.getMentorById).toHaveBeenCalledWith(defaultUserRoles, "9999", false);
+
+ expect(res.statusCode).toBe(404);
+ expect(res.body.error).toBe('Mentor not found');
+ });
+
+ it('Get mentor by ID - unexpected error', async () => {
+ mentorService.getMentorById.mockRejectedValue(new Error());
+
+ const res = await request(app)
+ .get(`/mentors/${mockedMentor.id}`)
+ .set('userId', defautlUserId);
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toBe('Internal Server Error');
+ });
+});
+
+describe('PATCH /mentors/:id', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Update mentor status success', async () => {
+ mentorService.getMentorById.mockResolvedValue({ ...mockedMentor });
+ mentorService.updateMentorStatus.mockResolvedValue(mockedMentor.id);
+
+ const res = await request(app)
+ .patch(`/mentors/${mockedMentor.id}`)
+ .send({ status: 'active' })
+ .set('userId', defautlUserId);
+
+ expect(mentorService.updateMentorStatus)
+ .toHaveBeenCalledWith(defaultUserRoles, mockedMentor.id.toString(), 'active', false);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.id).toStrictEqual(mockedMentor.id);
+ });
+
+ it('Update mentor status - not authorized', async () => {
+ mentorService.getMentorById.mockResolvedValue({ ...mockedMentor });
+ mentorService.updateMentorStatus.mockRejectedValue(
+ new DataRequiresElevatedRoleError("You're not allowed to edit this resource")
+ );
+
+ const res = await request(app)
+ .patch(`/mentors/${mockedMentor.id}`)
+ .send({ status: 'rejected' })
+ .set('userId', defautlUserId);
+
+ expect(mentorService.updateMentorStatus)
+ .toHaveBeenCalledWith(defaultUserRoles, mockedMentor.id.toString(), 'rejected', false);
+
+ expect(res.statusCode).toBe(403);
+ expect(res.body.error).toBe("You're not allowed to edit this resource");
+ });
+
+ it('Update mentor status - mentor not found', async () => {
+ mentorService.updateMentorStatus.mockRejectedValue(new MentorDoesNotExistError('Mentor with id 9999 does not exist'));
+
+ const res = await request(app)
+ .patch('/mentors/9999') // some non-existing mentorId
+ .send({ status: 'active' })
+ .set('userId', defautlUserId);
+
+ expect(mentorService.updateMentorStatus).toHaveBeenCalledWith(defaultUserRoles, '9999', 'active', false);
+ expect(res.statusCode).toBe(404);
+ expect(res.body.error).toContain('does not exist');
+ });
+});
+
+describe('PUT /mentors/:id', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Update mentor success', async () => {
+ mentorService.updateMentor.mockResolvedValue(mockedMentor.id);
+
+ const res = await request(app)
+ .put(`/mentors/${mockedMentor.id}`)
+ .send({ about: 'updated about field', contact: 'updated@email.com' });
+
+ expect(mentorService.updateMentor).toHaveBeenCalledWith(
+ defaultUserRoles,
+ mockedMentor.id.toString(),
+ { about: 'updated about field', contact: 'updated@email.com' }
+ );
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toStrictEqual({ id: mockedMentor.id });
+ });
+ it('Update mentor - invalid field', async () => {
+ const res = await request(app)
+ .put(`/mentors/${mockedMentor.id}`)
+ .send({ name: 'new name' });
+
+ expect(res.statusCode).toBe(400);
+ expect(mentorService.updateMentor).not.toHaveBeenCalled();
+ });
+ it('Update mentor - not authorized', async () => {
+ mentorService.updateMentor.mockRejectedValue(
+ new DataRequiresElevatedRoleError("You're not allowed to edit this resource")
+ );
+
+ const res = await request(app)
+ .put(`/mentors/${mockedMentor.id}`)
+ .set('userId', defautlUserId)
+ .send({ about: 'new about that will not be applied', contact: 'new contact' });
+
+ expect(mentorService.updateMentor).toHaveBeenCalledWith(
+ defaultUserRoles,
+ mockedMentor.id.toString(),
+ { about: 'new about that will not be applied', contact: 'new contact' }
+ );
+
+ expect(res.statusCode).toBe(403);
+ expect(res.body.error).toBe("You're not allowed to edit this resource");
+ });
+});
\ No newline at end of file
diff --git a/tests/routes/s3.test.js b/tests/routes/s3.test.js
new file mode 100644
index 0000000..f94c098
--- /dev/null
+++ b/tests/routes/s3.test.js
@@ -0,0 +1,81 @@
+const request = require('supertest');
+const s3Client = require('../../aws/s3_client');
+const app = require('../../appForTests');
+
+jest.mock('../../aws/s3_client', () => {;
+ const actual = jest.requireActual('../../aws/s3_client');
+ return {
+ ...actual,
+ generateUploadUrl: jest.fn((bucketPrefix, filename, _contentType) => {
+ return {
+ url: `https://mocked-s3-url/${bucketPrefix}/${filename}.mov`,
+ filename: `${filename}.mov`,
+ };
+ })
+ };
+});
+
+describe('POST /presigned-url', () => {
+ it('Successful presigned URL creation', async () => {
+ const res = await request(app)
+ .post('/presigned-url')
+ .send({
+ objectKey: '61a54a37-6c7a-466a-8100-a6b09daecded',
+ contentType: 'video/quicktime',
+ objectType: 'application_video'
+ });
+
+ expect(s3Client.generateUploadUrl).toHaveBeenCalledWith(
+ s3Client.BUCKET_PREFIXES.applications,
+ '61a54a37-6c7a-466a-8100-a6b09daecded',
+ 'video/quicktime'
+ );
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toHaveProperty('url');
+ expect(res.body.url).toBe('https://mocked-s3-url/videos/applications/61a54a37-6c7a-466a-8100-a6b09daecded.mov');
+ expect(res.body).toHaveProperty('filename');
+ expect(res.body.filename).toBe('61a54a37-6c7a-466a-8100-a6b09daecded.mov');
+ });
+
+ it('Return 400 on incomplete payload data', async () => {
+ const res = await request(app)
+ .post('/presigned-url')
+ .send({ objectKey: '61a54a37-6c7a-466a-8100-a6b09daecded'});
+
+ expect(s3Client.generateUploadUrl).not.toHaveBeenCalled();
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('Return 400 on invalid objectKey', async () => {
+ const res = await request(app)
+ .post('/presigned-url')
+ .send({
+ objectKey: 'invalid-objectkey',
+ contentType: 'video/quicktime',
+ objectType: 'application_video'
+ });
+
+ expect(s3Client.generateUploadUrl).not.toHaveBeenCalled();
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('Return 400 on unsupported objectType', async () => {
+ const res = await request(app)
+ .post('/presigned-url')
+ .send({
+ objectKey: '61a54a37-6c7a-466a-8100-a6b09daecded',
+ contentType: 'video/quicktime',
+ objectType: 'something_weird'
+ });
+
+ expect(s3Client.generateUploadUrl).not.toHaveBeenCalled();
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+});
\ No newline at end of file
diff --git a/tests/routes/tags.test.js b/tests/routes/tags.test.js
new file mode 100644
index 0000000..32cc6f7
--- /dev/null
+++ b/tests/routes/tags.test.js
@@ -0,0 +1,32 @@
+const request = require("supertest");
+const tagsService = require("../../services/tags_service");
+const app = require("../../appForTests");
+
+const mockedTags = ["React"];
+
+jest.mock("../../services/tags_service");
+jest.mock("../../middleware/authorization", () => {
+ const actual = jest.requireActual("../../middleware/authorization");
+ return {
+ ...actual,
+ verifyRoles: jest.fn(() => (req, res, next) => next()),
+ };
+});
+
+describe("GET /tags", () => {
+ it("Return all tags success", async () => {
+ tagsService.getAllTags.mockResolvedValue(mockedTags);
+ const res = await request(app).get("/tags");
+ expect(res.statusCode).toBe(200);
+ expect(res.body.tags.length).toBeGreaterThan(0);
+ expect(Array.isArray(res.body.tags)).toBe(true);
+ res.body.tags.forEach((tag) => expect(typeof tag).toBe("string"));
+ });
+
+ it("Unexpected error during tags retrieval", async () => {
+ tagsService.getAllTags.mockRejectedValue(new Error("Database error"));
+ const res = await request(app).get("/tags");
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toBeDefined();
+ });
+});
diff --git a/tests/routes/users.test.js b/tests/routes/users.test.js
new file mode 100644
index 0000000..89b389d
--- /dev/null
+++ b/tests/routes/users.test.js
@@ -0,0 +1,184 @@
+const request = require('supertest');
+const authService = require('../../services/auth_service');
+const userService = require('../../services/user_service');
+
+jest.mock('../../services/auth_service');
+jest.mock('../../services/user_service');
+jest.mock('../../middleware/authorization', () => {
+ const actual = jest.requireActual('../../middleware/authorization');
+ return {
+ ...actual,
+ verifyRoles: jest.fn(() => (req, res, next) => next()),
+ verifyOwnership: jest.fn(() => (req, res, next) => {
+ req.userId = 1;
+ next();
+ }),
+ };
+});
+
+const app = require('../../appForTests');
+
+const mockedUser = { firebaseId: '12345', email: 'test@example.com' };
+
+function checkSuccessResponse(res, user) {
+ expect(res.statusCode).toBe(200);
+ expect(res.body.user).toEqual(user);
+ expect(res.headers['set-cookie']).toBeDefined();
+ expect(res.headers['set-cookie'][0]).toMatch(user.firebaseId);
+}
+
+describe('POST /users/login', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Firebase token auth success', async () => {
+ authService.handleFirebaseAuth.mockResolvedValue({ user: mockedUser, firebaseId: mockedUser.firebaseId });
+ const res = await request(app)
+ .post('/users/login')
+ .set('X-Firebase-Token', 'valid-firebase-token');
+
+ expect(authService.handleFirebaseAuth).toHaveBeenCalledWith('valid-firebase-token');
+ checkSuccessResponse(res, mockedUser);
+ });
+
+ it('Discord code auth success', async () => {
+ authService.handleDiscordAuth.mockResolvedValue({ user: mockedUser, firebaseId: mockedUser.firebaseId });
+ const res = await request(app)
+ .post('/users/login')
+ .set('X-Discord-Code', 'valid-discord-code');
+
+ expect(authService.handleDiscordAuth).toHaveBeenCalledWith('valid-discord-code');
+ checkSuccessResponse(res, mockedUser);
+ });
+
+ it('No auth token provided', async () => {
+ const res = await request(app)
+ .post('/users/login');
+
+ expect(res.statusCode).toBe(401);
+ expect(res.body.error).toBe('No token provided or invalid token');
+ expect(authService.handleFirebaseAuth).not.toHaveBeenCalled();
+ expect(authService.handleDiscordAuth).not.toHaveBeenCalled();
+ });
+
+ it('Unsupported token provided', async () => {
+ const res = await request(app)
+ .post('/users/login')
+ .set('X-Random-Token', 'invalid-token');
+
+ expect(res.statusCode).toBe(401);
+ expect(res.body.error).toBe('No token provided or invalid token');
+ expect(authService.handleFirebaseAuth).not.toHaveBeenCalled();
+ expect(authService.handleDiscordAuth).not.toHaveBeenCalled();
+ });
+
+ it('Firebase auth failure', async () => {
+ authService.handleFirebaseAuth.mockRejectedValue({ status: 401, message: 'Invalid Firebase token' });
+ const res = await request(app)
+ .post('/users/login')
+ .set('X-Firebase-Token', 'invalid-firebase-token');
+
+ expect(res.statusCode).toBe(401);
+ expect(res.body.error).toBe('Invalid Firebase token');
+ });
+
+ it('Discord auth failure', async () => {
+ authService.handleDiscordAuth.mockRejectedValue({ status: 401, message: 'Invalid Discord code' });
+ const res = await request(app)
+ .post('/users/login')
+ .set('X-Discord-Code', 'invalid-discord-code');
+
+ expect(res.statusCode).toBe(401);
+ expect(res.body.error).toBe('Invalid Discord code');
+ });
+});
+
+describe('GET /users', () => {
+ it('Return all users success', async () => {
+ const mockedUsers = [{ id: 1, name: 'Test User' }];
+ userService.getAllUsers.mockResolvedValue(mockedUsers);
+
+ const res = await request(app)
+ .get('/users');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.users.length).toBeGreaterThan(0);
+ expect(res.body.users[0]['id']).toBe(1);
+ });
+
+ it('Unexpected error during users retrieval', async () => {
+ userService.getAllUsers.mockRejectedValue(new Error('Database error'));
+
+ const res = await request(app)
+ .get('/users');
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toBeDefined();
+ });
+});
+
+describe('GET /users/:id', () => {
+ it('Get user by ID success', async () => {
+ userService.getUserById.mockResolvedValue(mockedUser);
+
+ const res = await request(app)
+ .get('/users/1');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toEqual(mockedUser);
+ });
+
+ it('Get user by ID not found', async () => {
+ userService.getUserById.mockResolvedValue(null);
+
+ const res = await request(app)
+ .get('/users/999');
+
+ expect(res.statusCode).toBe(404);
+ expect(res.body.error).toBe('User not found');
+ });
+});
+
+describe('DELETE /users/:id', () => {
+ it('Delete user by ID success', async () => {
+ userService.deleteUserById.mockResolvedValue(1);
+
+ const res = await request(app)
+ .delete('/users/1');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.id).toBe('1');
+
+ expect(userService.deleteUserById).toHaveBeenCalledWith('1');
+ // Expecting the cookie to be cleared
+ expect(res.headers['set-cookie']).toBeDefined();
+ });
+
+ it('Delete user by ID not found', async () => {
+ userService.deleteUserById.mockResolvedValue(0);
+
+ const res = await request(app)
+ .delete('/users/999');
+
+ expect(res.statusCode).toBe(404);
+ expect(res.body.error).toBe('User not found.');
+ expect(userService.deleteUserById).toHaveBeenCalledWith('999');
+ // No changes in cookies - not expecting cookies in response
+ expect(res.headers['set-cookie']).not.toBeDefined();
+ });
+
+ it('Unexpected error during user deletion', async () => {
+ userService.deleteUserById.mockRejectedValue(new Error('Database error'));
+
+ const res = await request(app)
+ .delete('/users/1');
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toBe('Internal Server Error');
+
+ expect(userService.deleteUserById).toHaveBeenCalledWith('1');
+ // No changes in cookies - not expecting cookies in response
+ expect(res.headers['set-cookie']).not.toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/tests/services/applications_service.test.js b/tests/services/applications_service.test.js
new file mode 100644
index 0000000..c5fda54
--- /dev/null
+++ b/tests/services/applications_service.test.js
@@ -0,0 +1,75 @@
+const applicationsService = require('../../services/applications_service');
+const repo = require('../../repositories/applications_repository');
+
+jest.mock('../../repositories/applications_repository');
+
+describe('Create New Application', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const testCases = [
+ {
+ name: 'Sets video_link to empty string if undefined',
+ input: {
+ email: 'test@example.com',
+ discord_nickname: 'testUser',
+ video_filename: 'file.mp4',
+ about: 'Test',
+ },
+ expected: {
+ video_link: '',
+ video_filename: 'file.mp4',
+ },
+ },
+ {
+ name: 'Sets video_filename to empty string if undefined',
+ input: {
+ email: 'test@example.com',
+ discord_nickname: 'testUser',
+ video_link: 'https://example.com/video',
+ about: 'Test',
+ },
+ expected: {
+ video_link: 'https://example.com/video',
+ video_filename: '',
+ },
+ },
+ {
+ name: 'Lowercases email before saving',
+ input: {
+ email: 'USER@Example.COM',
+ discord_nickname: 'testUser',
+ video_link: '',
+ video_filename: '',
+ about: 'Test',
+ },
+ expected: {
+ email: 'user@example.com',
+ },
+ },
+ {
+ name: 'Lowercases discord_nickname before saving',
+ input: {
+ email: 'test@example.com',
+ discord_nickname: 'Test#User',
+ video_link: '',
+ video_filename: '',
+ about: 'Test',
+ },
+ expected: {
+ discord_nickname: 'test#user',
+ },
+ },
+ ];
+
+ testCases.forEach(({ name, input, expected }) => {
+ it(name, async () => {
+ await applicationsService.createNewApplication({ ...input });
+
+ expect(repo.createNewApplication).toHaveBeenCalledWith(
+ expect.objectContaining(expected)
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/services/mentor_service.test.js b/tests/services/mentor_service.test.js
new file mode 100644
index 0000000..986d8b9
--- /dev/null
+++ b/tests/services/mentor_service.test.js
@@ -0,0 +1,425 @@
+const mentorService = require('../../services/mentor_service');
+const { MENTOR_STATUSES } = require('../../utils');
+const rolesService = require('../../services/roles_service');
+const mailerService = require('../../services/mailer_service');
+const repo = require('../../repositories/mentor_repository');
+const rolesRepo = require('../../repositories/roles_repository');
+const tagsRepo = require('../../repositories/tags_repository');
+const { MentorAlreadyExistsError, DataRequiresElevatedRoleError, ActionNotAllowedError } = require('../../errors');
+
+jest.mock('../../repositories/mentor_repository');
+jest.mock('../../repositories/roles_repository');
+jest.mock('../../repositories/tags_repository');
+jest.mock('../../services/roles_service');
+jest.mock('../../services/mailer_service');
+
+const defaultUserId = 42;
+const mockedMentorId = 1;
+const mockedTags = ["React", "PHP"];
+
+describe('Mentor Service', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('createMentor', () => {
+ it('Create mentor in pending state success', async () => {
+ const mentorData = { about: 'I am a mentor', contact: 'mentor@example.com', tags: mockedTags };
+ const createdMentor = { id: 1, name: 'Name', about: mentorData.about, tags: mentorData.tags };
+ repo.getMentorByUserId.mockResolvedValue(null);
+ repo.createMentor.mockResolvedValue(createdMentor);
+
+ const result = await mentorService.createMentor(defaultUserId, mentorData);
+
+ expect(repo.getMentorByUserId).toHaveBeenCalledWith(defaultUserId);
+ expect(repo.createMentor).toHaveBeenCalledWith({
+ user_id: defaultUserId,
+ status: MENTOR_STATUSES.pending,
+ about: mentorData.about,
+ contact: mentorData.contact
+ });
+ expect(tagsRepo.updateMentorTags).toHaveBeenCalledWith(createdMentor.id, mentorData.tags);
+ expect(rolesService.getAdminEmails).toHaveBeenCalled();
+ expect(mailerService.sendEmailOnNewMentorApplication).toHaveBeenCalled();
+ expect(result).toEqual(createdMentor.id);
+ });
+
+ it('Create mentor is not allowed if user already has a mentor', async () => {
+ const mentorData = { about: 'I am a mentor', contact: 'mentor@example.com' };
+ const existingMentor = { id: 1, user_id: defaultUserId };
+ repo.getMentorByUserId.mockResolvedValue(existingMentor);
+ repo.createMentor.mockResolvedValue();
+
+ await expect(mentorService.createMentor(defaultUserId, mentorData)).rejects
+ .toBeInstanceOf(MentorAlreadyExistsError);
+
+ expect(repo.getMentorByUserId).toHaveBeenCalledWith(defaultUserId);
+ expect(repo.createMentor).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getMentors', () => {
+ it('Unauthenticated users see active and inactive mentors with limited fields', async () => {
+ const allowedStatuses = mentorService.generalVisitbleStatuses;
+ const mentor = { mentor_id: 1, user_id: defaultUserId, status: MENTOR_STATUSES.active };
+
+ rolesService.getUserRoles.mockResolvedValue();
+ repo.getMentors.mockResolvedValue([mentor]);
+ tagsRepo.getAssignedTagNames.mockResolvedValue(mockedTags);
+ const result = await mentorService.getMentors(undefined, undefined, false);
+
+ expect(allowedStatuses).toContain(MENTOR_STATUSES.active);
+ expect(allowedStatuses).toContain(MENTOR_STATUSES.inactive);
+ expect(rolesService.getUserRoles).not.toHaveBeenCalled();
+ expect(repo.getMentors).toHaveBeenCalledWith(allowedStatuses, mentorService.baseFields);
+ expect(tagsRepo.getAssignedTagNames).toHaveBeenCalledWith(mentor.mentor_id, 'mentor');
+ expect(result).toEqual([
+ {
+ ...mentor,
+ tags: mockedTags
+ }
+ ]);
+ });
+
+ it('Unauthenticated users cannot see non-active mentors', async () => {
+ rolesService.getUserRoles.mockResolvedValue();
+ repo.getMentors.mockResolvedValue();
+
+ expect(mentorService.getMentors(undefined, MENTOR_STATUSES.pending, false)).rejects
+ .toBeInstanceOf(DataRequiresElevatedRoleError);
+
+ expect(rolesService.getUserRoles).not.toHaveBeenCalled();
+ expect(repo.getMentors).not.toHaveBeenCalled();
+ });
+
+ it('Member users see active and inactive mentors', async () => {
+ const userRoles = [{ role_name: 'member' }];
+ const allowedStatuses = mentorService.generalVisitbleStatuses;
+ const mentor = { id: 1, user_id: defaultUserId, status: MENTOR_STATUSES.active };
+
+ rolesService.getUserRoles.mockResolvedValue(userRoles);
+ repo.getMentors.mockResolvedValue([mentor]);
+
+ await mentorService.getMentors(defaultUserId, undefined, false);
+
+ expect(allowedStatuses).toContain(MENTOR_STATUSES.active);
+ expect(allowedStatuses).toContain(MENTOR_STATUSES.inactive);
+ expect(repo.getMentors).toHaveBeenCalledWith(allowedStatuses, mentorService.baseFields);
+ });
+
+ it('Member users cannot see non-active mentors', async () => {
+ const userRoles = [{ role_name: 'member' }];
+ rolesService.getUserRoles.mockResolvedValue(userRoles);
+ repo.getMentors.mockResolvedValue();
+
+ expect(mentorService.getMentors(defaultUserId, MENTOR_STATUSES.pending, false)).rejects
+ .toBeInstanceOf(DataRequiresElevatedRoleError);
+
+ expect(repo.getMentors).not.toHaveBeenCalled();
+ });
+
+ it('Admin users see all existing mentors', async () => {
+ const userRoles = [{ role_name: 'member' }, { role_name: 'admin' }];
+ const mentorId = 1;
+ const allowedStatuses = mentorService.adminVisibleStatuses;
+ const mentor = { id: mentorId, user_id: defaultUserId, status: MENTOR_STATUSES.active };
+
+ rolesService.getUserRoles.mockResolvedValue(userRoles);
+ repo.getMentors.mockResolvedValue([mentor]);
+
+ await mentorService.getMentors(defaultUserId, undefined, true);
+
+ expect(repo.getMentors).toHaveBeenCalledWith(allowedStatuses, mentorService.allFields);
+ });
+
+ it('Admin users see all existing mentors - filter by pending', async () => {
+ const userRoles = [{ role_name: 'member' }, { role_name: 'admin' }];
+ const mentorId = 1;
+ const allowedStatuses = [MENTOR_STATUSES.pending];
+ const mentor = { id: mentorId, user_id: defaultUserId, status: MENTOR_STATUSES.active };
+
+ rolesService.getUserRoles.mockResolvedValue(userRoles);
+ repo.getMentors.mockResolvedValue([mentor]);
+
+ await mentorService.getMentors(defaultUserId, MENTOR_STATUSES.pending, true);
+
+ expect(repo.getMentors).toHaveBeenCalledWith(allowedStatuses, mentorService.allFields);
+ });
+ });
+
+ describe('getMentorById', () => {
+ it('Returns mentor to member users only if active', async () => {
+ const mentorId = 1;
+ const allowedStatuses = mentorService.generalVisitbleStatuses;
+ const userRoles = [{ role_name: 'member' }];
+ const mentor = { id: mentorId, user_id: defaultUserId, status: MENTOR_STATUSES.active };
+
+ repo.getMentorById.mockResolvedValue(mentor);
+
+ const result = await mentorService.getMentorById(userRoles, mentorId, false);
+
+ expect(repo.getMentorById).toHaveBeenCalledWith(allowedStatuses, mentorService.allFields, mentorId, true);
+ expect(result).toEqual(mentor);
+ expect(result['email']).toBeUndefined();
+ });
+
+ it('Returns mentor to owner even if not active', async () => {
+ const mentorId = 1;
+ const allowedStatuses = mentorService.adminVisibleStatuses;
+ const userRoles = [{ role_name: 'member' }];
+ const mentor = { id: mentorId, user_id: defaultUserId, status: MENTOR_STATUSES.pending };
+
+ repo.getMentorById.mockResolvedValue(mentor);
+
+ const result = await mentorService.getMentorById(userRoles, mentorId, true);
+
+ expect(repo.getMentorById).toHaveBeenCalledWith(allowedStatuses, mentorService.allFields, mentorId, true);
+ expect(result).toEqual(mentor);
+ expect(result['email']).toBeUndefined();
+ });
+
+ it('Returns mentor to admin even if not active', async () => {
+ const mentorId = 1;
+ const allowedStatuses = mentorService.adminVisibleStatuses;
+ const userRoles = [{ role_name: 'member' }, { role_name: 'admin' }];
+ const mentor = { id: mentorId, user_id: defaultUserId, status: MENTOR_STATUSES.pending };
+
+ repo.getMentorById.mockResolvedValue(mentor);
+
+ const result = await mentorService.getMentorById(userRoles, mentorId, false);
+
+ expect(repo.getMentorById).toHaveBeenCalledWith(allowedStatuses, mentorService.allFields, mentorId, true);
+ expect(result).toEqual(mentor);
+ });
+ });
+
+ describe('updateMentorStatus', () => {
+ const mentorId = 13;
+ const userId = defaultUserId;
+
+ it('Successful status change for mentor profile owner', async () => {
+ // mentor profile owner changes status from 'active' to 'inactive'
+ repo.getMentorById.mockResolvedValue({
+ id: mentorId,
+ user_id: userId,
+ status: MENTOR_STATUSES.active,
+ });
+ repo.updateMentorById.mockResolvedValue(mentorId);
+
+ const result = await mentorService.updateMentorStatus(
+ [{ role_name: 'member' }],
+ mentorId.toString(),
+ MENTOR_STATUSES.inactive,
+ true // isOwner
+ );
+
+ expect(repo.updateMentorById).toHaveBeenCalledWith(mentorId.toString(), { status: MENTOR_STATUSES.inactive });
+ expect(rolesRepo.assignRoleToUser).not.toHaveBeenCalled();
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ expect(mailerService.sendEmailOnMentorApplicationStatusChange).not.toHaveBeenCalled();
+ expect(result).toBe(mentorId);
+ });
+
+ it('Successful status change for admin', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mentorId,
+ user_id: userId,
+ status: MENTOR_STATUSES.pending,
+ });
+
+ repo.updateMentorById.mockResolvedValue(mentorId);
+
+ const result = await mentorService.updateMentorStatus(
+ [{ role_name: 'member' }, { role_name: 'admin' }],
+ mentorId.toString(),
+ MENTOR_STATUSES.active,
+ false // isOwner
+ );
+
+ expect(repo.updateMentorById).toHaveBeenCalledWith(
+ mentorId.toString(),
+ { status: MENTOR_STATUSES.active }
+ );
+ expect(rolesRepo.assignRoleToUser).toHaveBeenCalledWith(userId, 2);
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ expect(mailerService.sendEmailOnMentorApplicationStatusChange).toHaveBeenCalled();
+ expect(result).toBe(mentorId);
+ });
+
+ it('User with no ownership nor admin role cannot make changes', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mentorId,
+ user_id: userId,
+ status: MENTOR_STATUSES.pending,
+ });
+
+ await expect(
+ mentorService.updateMentorStatus(
+ [{ role_name: 'member' }],
+ mentorId.toString(),
+ MENTOR_STATUSES.inactive,
+ false // isOwner
+ )
+ ).rejects.toBeInstanceOf(DataRequiresElevatedRoleError);
+
+ expect(repo.getMentorById).toHaveBeenCalledTimes(1);
+ expect(repo.updateMentorById).not.toHaveBeenCalled();
+ expect(rolesRepo.assignRoleToUser).not.toHaveBeenCalled();
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ expect(mailerService.sendEmailOnMentorApplicationStatusChange).not.toHaveBeenCalled();
+ });
+
+ it('User owning the profile cannot change status when current status is pending', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mentorId,
+ user_id: userId,
+ status: MENTOR_STATUSES.pending,
+ });
+
+ await expect(
+ mentorService.updateMentorStatus(
+ [{ role_name: 'member' }],
+ mentorId.toString(),
+ MENTOR_STATUSES.active,
+ true // isOwner
+ )
+ ).rejects.toBeInstanceOf(DataRequiresElevatedRoleError);
+
+ expect(repo.updateMentorById).not.toHaveBeenCalled();
+ expect(rolesRepo.assignRoleToUser).not.toHaveBeenCalled();
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ });
+
+ it('Mentor status cannot be changed when current status is rejected - user', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mentorId,
+ user_id: userId,
+ status: MENTOR_STATUSES.rejected,
+ });
+
+ await expect(
+ mentorService.updateMentorStatus(
+ [{ role_name: 'member' }],
+ mentorId.toString(),
+ MENTOR_STATUSES.inactive,
+ true // isOwner
+ )
+ ).rejects.toBeInstanceOf(ActionNotAllowedError);
+
+ expect(repo.updateMentorById).not.toHaveBeenCalled();
+ expect(rolesRepo.assignRoleToUser).not.toHaveBeenCalled();
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ });
+
+ it('Mentor status cannot be changed when current status is rejected - admin', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mentorId,
+ user_id: userId,
+ status: MENTOR_STATUSES.rejected,
+ });
+
+ await expect(
+ mentorService.updateMentorStatus(
+ [{ role_name: 'admin' }],
+ mentorId.toString(),
+ MENTOR_STATUSES.inactive,
+ false // isOwner
+ )
+ ).rejects.toBeInstanceOf(ActionNotAllowedError);
+
+ expect(repo.updateMentorById).not.toHaveBeenCalled();
+ expect(rolesRepo.assignRoleToUser).not.toHaveBeenCalled();
+ expect(rolesRepo.removeRoleFromUser).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('updateMentor', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Successful update of valid field', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mockedMentorId,
+ user_id: defaultUserId,
+ status: MENTOR_STATUSES.active,
+ });
+ repo.updateMentorById.mockResolvedValue(mockedMentorId);
+
+ const result = await mentorService.updateMentor(
+ [{ role_name: 'mentor' }],
+ mockedMentorId.toString(),
+ { about: 'new value for about field' },
+ true // isOwner
+ );
+
+ expect(repo.updateMentorById).toHaveBeenCalledWith(
+ mockedMentorId.toString(),
+ { about: 'new value for about field' }
+ );
+ expect(result).toBe(mockedMentorId);
+ });
+
+ it('Successful update of more than one valid field', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mockedMentorId,
+ user_id: defaultUserId,
+ status: MENTOR_STATUSES.inactive,
+ });
+ repo.updateMentorById.mockResolvedValue(mockedMentorId);
+
+ const result = await mentorService.updateMentor(
+ [{ role_name: 'mentor' }],
+ mockedMentorId.toString(),
+ { about: 'updated about text', contact: 'updated@example.com' },
+ true // isOwner
+ );
+
+ expect(repo.updateMentorById).toHaveBeenCalledWith(
+ mockedMentorId.toString(),
+ { about: 'updated about text', contact: 'updated@example.com' }
+ );
+ expect(result).toBe(mockedMentorId);
+ });
+
+ it('Successful update of tags field', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mockedMentorId,
+ user_id: defaultUserId,
+ status: MENTOR_STATUSES.inactive,
+ });
+ repo.updateMentorById.mockResolvedValue(mockedMentorId);
+
+ const result = await mentorService.updateMentor(
+ [{ role_name: 'mentor' }],
+ mockedMentorId.toString(),
+ { tags: mockedTags },
+ true // isOwner
+ );
+
+ expect(repo.updateMentorById).not.toHaveBeenCalled();
+ expect(tagsRepo.updateMentorTags).toHaveBeenCalledWith(
+ mockedMentorId.toString(),
+ mockedTags
+ );
+ expect(result).toBe(mockedMentorId);
+ });
+
+ it('Failed update if mentor status is not active or inactive', async () => {
+ repo.getMentorById.mockResolvedValue({
+ id: mockedMentorId,
+ user_id: defaultUserId,
+ status: MENTOR_STATUSES.pending,
+ });
+
+ const result = await mentorService.updateMentor(
+ [{ role_name: 'member' }],
+ mockedMentorId.toString(),
+ { about: 'trying to change' },
+ );
+
+ expect(repo.updateMentorById).not.toHaveBeenCalled();
+ expect(result).toBe(0);
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/services/user_service.test.js b/tests/services/user_service.test.js
new file mode 100644
index 0000000..48601a1
--- /dev/null
+++ b/tests/services/user_service.test.js
@@ -0,0 +1,155 @@
+const userService = require('../../services/user_service');
+const repo = require('../../repositories/user_repository');
+
+const rolesService = require('../../services/roles_service');
+const mentorService = require('../../services/mentor_service');
+const applicationService = require('../../services/applications_service');
+const mailerService = require('../../services/mailer_service');
+const s3client = require('../../aws/s3_client');
+const firebaseAdmin = require('firebase-admin');
+
+jest.mock('../../repositories/user_repository');
+jest.mock('../../services/roles_service');
+jest.mock('../../services/mentor_service');
+jest.mock('../../services/applications_service');
+jest.mock('../../services/mailer_service');
+jest.mock('../../aws/s3_client');
+jest.mock('firebase-admin', () => {
+ return {
+ auth: jest.fn().mockReturnThis(),
+ deleteUser: jest.fn().mockResolvedValue(true),
+ };
+});
+
+const defaultUserId = 42;
+const defaultUserInfo = {
+ id: defaultUserId,
+ email: 'SaMplE@email.com',
+ name: 'Sample User',
+};
+
+describe('User Service', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('deleteUserById', () => {
+ it('Delete user and associated data successfully', async () => {
+ repo.getUserInfoById.mockResolvedValue({
+ ...defaultUserInfo,
+ firebase_id: 'firebase-uid-123',
+ mentor_id: 7});
+ applicationService.getApplicationByEmail.mockResolvedValue({ id: 3, video_filename: 'video-file.mp4' });
+ mentorService.deleteMentorById.mockResolvedValue(true);
+ rolesService.deleteAllUserRoles.mockResolvedValue(true);
+ applicationService.deleteApplicationById.mockResolvedValue(true);
+ s3client.deleteObject.mockResolvedValue(true);
+ mailerService.sendUserDeletionEmail.mockResolvedValue(true);
+ repo.deleteUserById.mockResolvedValue(defaultUserId);
+
+ const result = await userService.deleteUserById(defaultUserId);
+
+ expect(repo.getUserInfoById).toHaveBeenCalledWith(defaultUserId, false);
+ expect(applicationService.getApplicationByEmail).toHaveBeenCalledWith(defaultUserInfo.email);
+ expect(mentorService.deleteMentorById).toHaveBeenCalledWith(7);
+ expect(rolesService.deleteAllUserRoles).toHaveBeenCalledWith(defaultUserId);
+ expect(applicationService.deleteApplicationById).toHaveBeenCalledWith(3);
+ expect(s3client.deleteObject).toHaveBeenCalledWith(s3client.BUCKET_PREFIXES.applications, 'video-file.mp4');
+ expect(firebaseAdmin.auth().deleteUser).toHaveBeenCalledWith('firebase-uid-123');
+ expect(mailerService.sendUserDeletionEmail).toHaveBeenCalledWith(defaultUserInfo.email, 'Sample User');
+ expect(repo.deleteUserById).toHaveBeenCalledWith(defaultUserId);
+ expect(result).toBe(defaultUserId);
+ });
+
+ it('Return 0 when user does not exist', async () => {
+ repo.getUserInfoById.mockResolvedValue(null);
+
+ const result = await userService.deleteUserById(defaultUserId);
+
+ expect(repo.getUserInfoById).toHaveBeenCalledWith(defaultUserId, false);
+ expect(result).toBe(0);
+ expect(applicationService.getApplicationByEmail).not.toHaveBeenCalled();
+ });
+
+ it('Throw error when deleting associated data fails, keep remote data', async () => {
+ repo.getUserInfoById.mockResolvedValue({
+ id: defaultUserId,
+ email: 'sample@email.com',
+ name: 'Sample User',
+ firebase_id: 'firebase-uid-123',
+ mentor_id: 7});
+ applicationService.getApplicationByEmail.mockResolvedValue({ id: 3, video_filename: 'video-file.mp4' });
+ mentorService.deleteMentorById.mockRejectedValue(new Error('DB error'));
+
+ await userService.deleteUserById(defaultUserId).catch((error) => {
+ expect(error.message).toBe('Failed to delete user associated data: DB error');
+ });
+
+ expect(repo.getUserInfoById).toHaveBeenCalledWith(defaultUserId, false);
+ expect(s3client.deleteObject).not.toHaveBeenCalled();
+ expect(firebaseAdmin.auth().deleteUser).not.toHaveBeenCalled();
+ expect(mailerService.sendUserDeletionEmail).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('createNewMemberUser', () => {
+ it('Create new member user successfully', async () => {
+ repo.createNewUser.mockResolvedValue(defaultUserInfo);
+ rolesService.assignRoleToUser.mockResolvedValue(true);
+
+ const result = await userService.createNewMemberUser(
+ defaultUserInfo.name,
+ defaultUserInfo.email,
+ 'About me',
+ ['English', 'Spanish'],
+ 'DiscordNick'
+ );
+
+ expect(repo.createNewUser).toHaveBeenCalledWith({
+ name: defaultUserInfo.name,
+ email: defaultUserInfo.email.toLowerCase(),
+ about: 'About me',
+ languages: ['English', 'Spanish'],
+ discord_nickname: 'discordnick'
+ });
+
+ expect(rolesService.assignRoleToUser).toHaveBeenCalledWith(defaultUserId, 'member');
+ expect(result).toEqual(defaultUserInfo);
+ });
+ });
+
+ describe('getUserById', () => {
+ it('Get user by ID with roles successfully', async () => {
+ repo.getUserInfoById.mockResolvedValue(defaultUserInfo);
+ rolesService.getUserRoles.mockResolvedValue(['member', 'admin']);
+
+ const result = await userService.getUserById(defaultUserId);
+ expect(repo.getUserInfoById).toHaveBeenCalledWith(defaultUserId);
+ expect(rolesService.getUserRoles).toHaveBeenCalledWith(defaultUserId);
+ expect(result).toEqual({
+ ...defaultUserInfo,
+ roles: ['member', 'admin']
+ });
+ });
+
+ it('Get user by ID - no user', async () => {
+ repo.getUserInfoById.mockResolvedValue(null);
+
+ const result = await userService.getUserById(defaultUserId);
+
+ expect(repo.getUserInfoById).toHaveBeenCalledWith(defaultUserId);
+ expect(rolesService.getUserRoles).not.toHaveBeenCalled();
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getUserByEmail', () => {
+ it('Get user by email should set to lowercase before search', async () => {
+ repo.getUserByFields.mockResolvedValue(null);
+
+ await userService.getUserByEmail(defaultUserInfo.email);
+
+ expect(repo.getUserByFields).toHaveBeenCalledWith({ email: defaultUserInfo.email.toLowerCase() });
+ });
+ });
+});
\ No newline at end of file
diff --git a/utils.js b/utils.js
index d5a5709..5e0e58b 100644
--- a/utils.js
+++ b/utils.js
@@ -1,12 +1,14 @@
const repo = require('./repositories/roles_repository');
const { loadSecrets } = require("./aws/ssm-helper");
const config = require('config');
+const logger = require('./logger')(__filename);
let rolesCache = null;
const ROLE_NAMES = {
+ admin: 'admin',
mentor: 'mentor',
- member: 'member'
+ member: 'member',
};
async function getFirebaseSdkServiceAccount() {
@@ -22,8 +24,8 @@ async function getFirebaseSdkServiceAccount() {
serviceAccount = JSON.parse(jsonFile);
}
}
- catch (error) {
- console.error("Error getting service account:", error);
+ catch (err) {
+ logger.error(err, "Error getting service account");
throw new Error("Failed to retrieve service account");
}
return serviceAccount;
@@ -36,14 +38,14 @@ async function loadRolesIntoMemory() {
rolesCache = roles.reduce((acc, role) => {
acc[role.role_name] = role.id;
if (!isRoleExists(role.role_name)) {
- console.warn(`Role ${role.role_name} is in database, but is not in the ROLE_NAMES enum.`);
+ logger.warn(`Role ${role.role_name} is in database, but is not in the ROLE_NAMES enum.`);
}
return acc;
}, {});
- console.log('Roles loaded into memory:', rolesCache);
+ logger.debug(rolesCache, 'Roles loaded into memory');
}
- } catch (error) {
- console.error("Error loading roles:", error);
+ } catch (err) {
+ logger.error(err, "Error loading roles");
throw new Error("Failed to initialize roles");
}
}
@@ -65,4 +67,16 @@ const APPL_STATUSES = {
pending: "pending",
};
-module.exports = { APPL_STATUSES, ROLE_NAMES, isRoleExists, loadRolesIntoMemory, getRole, getFirebaseSdkServiceAccount };
+const MENTOR_STATUSES = {
+ active: 'active',
+ inactive: 'inactive',
+ rejected: 'rejected',
+ pending: 'pending',
+};
+
+const ALLOWED_MENTOR_UPDATES = ['contact', 'about', 'tags'];
+
+module.exports = {
+ APPL_STATUSES, MENTOR_STATUSES, ROLE_NAMES, ALLOWED_MENTOR_UPDATES,
+ isRoleExists, loadRolesIntoMemory, getRole, getFirebaseSdkServiceAccount
+};