All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m14s
370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
// src/services/userService.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { Address, UserProfile } from '../types';
|
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { ValidationError, NotFoundError } from './db/errors.db';
|
|
import { DatabaseError } from './processingErrors';
|
|
import type { Job } from 'bullmq';
|
|
import type { TokenCleanupJobData } from '../types/job-data';
|
|
import { getTestBaseUrl } from '../tests/utils/testHelpers';
|
|
|
|
// Un-mock the service under test to ensure we are testing the real implementation,
|
|
// not the global mock from `tests/setup/tests-setup-unit.ts`.
|
|
vi.unmock('./userService');
|
|
|
|
// --- Hoisted Mocks ---
|
|
const mocks = vi.hoisted(() => {
|
|
// Create mock implementations for the repository methods we'll be using.
|
|
const mockUpsertAddress = vi.fn();
|
|
const mockUpdateUserProfile = vi.fn();
|
|
const mockDeleteExpiredResetTokens = vi.fn();
|
|
const mockUpdateUserPassword = vi.fn();
|
|
const mockFindUserWithPasswordHashById = vi.fn();
|
|
const mockDeleteUserById = vi.fn();
|
|
const mockGetAddressById = vi.fn();
|
|
|
|
return {
|
|
// Mock the withTransaction helper to immediately execute the callback.
|
|
mockWithTransaction: vi.fn().mockImplementation(async (callback) => {
|
|
return callback({});
|
|
}),
|
|
// FIX: Change this from an arrow function to a standard function
|
|
// so that it can be instantiated with `new`.
|
|
MockUserRepository: vi.fn(function () {
|
|
return {
|
|
updateUserProfile: mockUpdateUserProfile,
|
|
};
|
|
}),
|
|
// Expose the method mocks for assertions.
|
|
mockUpsertAddress,
|
|
mockUpdateUserProfile,
|
|
mockDeleteExpiredResetTokens,
|
|
mockUpdateUserPassword,
|
|
mockFindUserWithPasswordHashById,
|
|
mockDeleteUserById,
|
|
mockGetAddressById,
|
|
};
|
|
});
|
|
|
|
// --- Mock Modules ---
|
|
|
|
vi.mock('bcrypt', () => ({
|
|
hash: vi.fn(),
|
|
compare: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./db/index.db', () => ({
|
|
withTransaction: mocks.mockWithTransaction,
|
|
userRepo: {
|
|
deleteExpiredResetTokens: mocks.mockDeleteExpiredResetTokens,
|
|
updateUserProfile: mocks.mockUpdateUserProfile,
|
|
updateUserPassword: mocks.mockUpdateUserPassword,
|
|
findUserWithPasswordHashById: mocks.mockFindUserWithPasswordHashById,
|
|
deleteUserById: mocks.mockDeleteUserById,
|
|
},
|
|
addressRepo: {
|
|
getAddressById: mocks.mockGetAddressById,
|
|
},
|
|
}));
|
|
|
|
// This mock is correct, using a standard function for the constructor.
|
|
vi.mock('./db/address.db', () => {
|
|
return {
|
|
// We define this as a standard function (not an arrow function)
|
|
// so that it can be instantiated with 'new AddressRepository()'
|
|
AddressRepository: vi.fn(function () {
|
|
return {
|
|
upsertAddress: mocks.mockUpsertAddress,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
// This now correctly uses the hoisted mock which is a valid constructor.
|
|
vi.mock('./db/user.db', () => ({
|
|
UserRepository: mocks.MockUserRepository,
|
|
}));
|
|
|
|
vi.mock('./logger.server', () => ({
|
|
// Provide a default mock for the logger
|
|
logger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
child: vi.fn().mockReturnThis(),
|
|
},
|
|
}));
|
|
|
|
// Import the service to be tested AFTER all mocks are set up.
|
|
import { userService } from './userService';
|
|
|
|
describe('UserService', () => {
|
|
beforeEach(() => {
|
|
// Clear call history for all mocks before each test.
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('upsertUserAddress', () => {
|
|
it('should create a new address and link it to a user who has no address', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
// Arrange: A user profile without an existing address_id.
|
|
const user = createMockUserProfile({
|
|
user: { user_id: 'user-123', email: 'test@example.com' },
|
|
address_id: null,
|
|
});
|
|
const addressData: Partial<Address> = { address_line_1: '123 New St', city: 'Newville' };
|
|
|
|
// Mock the address repository to return a new address ID.
|
|
const newAddressId = 99;
|
|
mocks.mockUpsertAddress.mockResolvedValue(newAddressId);
|
|
|
|
// Act: Call the service method.
|
|
const result = await userService.upsertUserAddress(user, addressData, logger);
|
|
|
|
// Assert
|
|
expect(result).toBe(newAddressId);
|
|
// 1. Verify the transaction helper was called.
|
|
expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
|
|
// 2. Verify the address was upserted with the correct data.
|
|
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
|
|
{
|
|
...addressData,
|
|
address_id: undefined, // user.address_id was null, so it should be undefined.
|
|
},
|
|
logger,
|
|
);
|
|
// 3. Verify the user's profile was updated to link the new address ID.
|
|
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledTimes(1);
|
|
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith(
|
|
user.user.user_id,
|
|
{ address_id: newAddressId },
|
|
logger,
|
|
);
|
|
});
|
|
|
|
it('should update an existing address and NOT link it if the ID does not change', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
// Arrange: A user profile with an existing address_id.
|
|
const existingAddressId = 42;
|
|
const user = createMockUserProfile({
|
|
user: { user_id: 'user-123', email: 'test@example.com' },
|
|
address_id: existingAddressId,
|
|
});
|
|
const addressData: Partial<Address> = {
|
|
address_line_1: '123 Updated St',
|
|
city: 'Updateville',
|
|
};
|
|
|
|
// Mock the address repository to return the SAME address ID.
|
|
mocks.mockUpsertAddress.mockResolvedValue(existingAddressId);
|
|
|
|
// Act: Call the service method.
|
|
const result = await userService.upsertUserAddress(user, addressData, logger);
|
|
|
|
// Assert
|
|
expect(result).toBe(existingAddressId);
|
|
// 1. Verify the transaction helper was called.
|
|
expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
|
|
// 2. Verify the address was upserted with the existing ID.
|
|
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
|
|
{
|
|
...addressData,
|
|
address_id: existingAddressId,
|
|
},
|
|
logger,
|
|
);
|
|
// 3. Since the address ID did not change, the user profile should NOT be updated.
|
|
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw a DatabaseError if the transaction fails', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const user = createMockUserProfile({
|
|
user: { user_id: 'user-123' },
|
|
address_id: null,
|
|
});
|
|
const addressData: Partial<Address> = { address_line_1: '123 Fail St' };
|
|
const dbError = new Error('DB connection lost');
|
|
|
|
// Simulate a failure within the transaction (e.g., upsertAddress fails)
|
|
mocks.mockUpsertAddress.mockRejectedValue(dbError);
|
|
|
|
// Act & Assert
|
|
// The service should wrap the generic error in a `DatabaseError`.
|
|
await expect(userService.upsertUserAddress(user, addressData, logger)).rejects.toBeInstanceOf(DatabaseError);
|
|
|
|
// Assert that the error was logged correctly
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, userId: user.user.user_id },
|
|
`Transaction to upsert user address failed: ${dbError.message}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('processTokenCleanupJob', () => {
|
|
it('should delete expired tokens and return the count', async () => {
|
|
const job = {
|
|
id: 'job-1',
|
|
name: 'token-cleanup',
|
|
attemptsMade: 1,
|
|
} as Job<TokenCleanupJobData>;
|
|
|
|
mocks.mockDeleteExpiredResetTokens.mockResolvedValue(5);
|
|
|
|
const result = await userService.processTokenCleanupJob(job);
|
|
|
|
expect(result).toEqual({ deletedCount: 5 });
|
|
expect(mocks.mockDeleteExpiredResetTokens).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should log error and rethrow if cleanup fails', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = {
|
|
id: 'job-1',
|
|
name: 'token-cleanup',
|
|
attemptsMade: 1,
|
|
} as Job<TokenCleanupJobData>;
|
|
const error = new Error('DB Error');
|
|
|
|
mocks.mockDeleteExpiredResetTokens.mockRejectedValue(error);
|
|
|
|
await expect(userService.processTokenCleanupJob(job)).rejects.toThrow('DB Error');
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: error }),
|
|
`Expired token cleanup job failed: ${error.message}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateUserAvatar', () => {
|
|
it('should construct avatar URL and update profile', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const testBaseUrl = getTestBaseUrl();
|
|
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
|
|
|
const userId = 'user-123';
|
|
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
|
|
const expectedUrl = `${testBaseUrl}/uploads/avatars/${file.filename}`;
|
|
|
|
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
|
|
|
|
await userService.updateUserAvatar(userId, file, logger);
|
|
|
|
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith(
|
|
userId,
|
|
{ avatar_url: expectedUrl },
|
|
logger,
|
|
);
|
|
|
|
vi.unstubAllEnvs();
|
|
});
|
|
});
|
|
|
|
describe('updateUserPassword', () => {
|
|
it('should hash password and update user', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const userId = 'user-123';
|
|
const newPassword = 'new-password';
|
|
const hashedPassword = 'hashed-password';
|
|
|
|
vi.mocked(bcrypt.hash).mockImplementation(async () => hashedPassword);
|
|
|
|
await userService.updateUserPassword(userId, newPassword, logger);
|
|
|
|
expect(bcrypt.hash).toHaveBeenCalledWith(newPassword, 10);
|
|
expect(mocks.mockUpdateUserPassword).toHaveBeenCalledWith(userId, hashedPassword, logger);
|
|
});
|
|
});
|
|
|
|
describe('deleteUserAccount', () => {
|
|
it('should delete user if password matches', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const userId = 'user-123';
|
|
const password = 'password';
|
|
const hashedPassword = 'hashed-password';
|
|
|
|
mocks.mockFindUserWithPasswordHashById.mockResolvedValue({
|
|
user_id: userId,
|
|
password_hash: hashedPassword,
|
|
});
|
|
vi.mocked(bcrypt.compare).mockImplementation(async () => true);
|
|
|
|
await userService.deleteUserAccount(userId, password, logger);
|
|
|
|
expect(mocks.mockDeleteUserById).toHaveBeenCalledWith(userId, logger);
|
|
});
|
|
|
|
it('should throw NotFoundError if user not found', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
mocks.mockFindUserWithPasswordHashById.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
userService.deleteUserAccount('user-123', 'password', logger),
|
|
).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('should throw ValidationError if password does not match', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
mocks.mockFindUserWithPasswordHashById.mockResolvedValue({
|
|
user_id: 'user-123',
|
|
password_hash: 'hashed',
|
|
});
|
|
vi.mocked(bcrypt.compare).mockImplementation(async () => false);
|
|
|
|
await expect(
|
|
userService.deleteUserAccount('user-123', 'wrong-password', logger),
|
|
).rejects.toThrow(ValidationError);
|
|
expect(mocks.mockDeleteUserById).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getUserAddress', () => {
|
|
it('should return address if user is authorized', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const userProfile = { address_id: 123 } as UserProfile;
|
|
const address = { address_id: 123, address_line_1: 'Test St' } as Address;
|
|
|
|
mocks.mockGetAddressById.mockResolvedValue(address);
|
|
|
|
const result = await userService.getUserAddress(userProfile, 123, logger);
|
|
|
|
expect(result).toEqual(address);
|
|
expect(mocks.mockGetAddressById).toHaveBeenCalledWith(123, logger);
|
|
});
|
|
|
|
it('should throw ValidationError if address IDs do not match', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const userProfile = { address_id: 123 } as UserProfile;
|
|
|
|
await expect(userService.getUserAddress(userProfile, 456, logger)).rejects.toThrow(
|
|
ValidationError,
|
|
);
|
|
expect(mocks.mockGetAddressById).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('deleteUserAsAdmin', () => {
|
|
it('should delete user if deleter is not the target', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const deleterId = 'admin-1';
|
|
const targetId = 'user-2';
|
|
|
|
await userService.deleteUserAsAdmin(deleterId, targetId, logger);
|
|
|
|
expect(mocks.mockDeleteUserById).toHaveBeenCalledWith(targetId, logger);
|
|
});
|
|
|
|
it('should throw ValidationError if admin tries to delete themselves', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const adminId = 'admin-1';
|
|
|
|
await expect(userService.deleteUserAsAdmin(adminId, adminId, logger)).rejects.toThrow(
|
|
ValidationError,
|
|
);
|
|
expect(mocks.mockDeleteUserById).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|