Files
flyer-crawler.projectium.com/src/services/userService.test.ts
Torben Sorensen 38165bdb9a
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m14s
get rid of localhost in tests - not a qualified URL - we'll see
2026-01-05 19:10:46 -08:00

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();
});
});
});