Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
262062f468 ci: Bump version to 0.2.28 [skip ci] 2025-12-30 05:38:33 +05:00
0a14193371 fix unit tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 40s
2025-12-29 16:37:55 -08:00
10 changed files with 1010 additions and 11 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.2.27",
"version": "0.2.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.2.27",
"version": "0.2.28",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.2.27",
"version": "0.2.28",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -0,0 +1,143 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerReviewPage } from './FlyerReviewPage';
import { MemoryRouter } from 'react-router-dom';
import * as apiClient from '../../services/apiClient';
import { logger } from '../../services/logger.client';
// Mock dependencies
vi.mock('../../services/apiClient', () => ({
getFlyersForReview: vi.fn(),
}));
vi.mock('../../services/logger.client', () => ({
logger: {
error: vi.fn(),
},
}));
// Mock LoadingSpinner to simplify DOM and avoid potential issues
vi.mock('../../components/LoadingSpinner', () => ({
LoadingSpinner: () => <div data-testid="loading-spinner">Loading...</div>,
}));
describe('FlyerReviewPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading spinner initially', () => {
// Mock a promise that doesn't resolve immediately to check loading state
vi.mocked(apiClient.getFlyersForReview).mockReturnValue(new Promise(() => {}));
render(
<MemoryRouter>
<FlyerReviewPage />
</MemoryRouter>
);
expect(screen.getByRole('status', { name: /loading flyers for review/i })).toBeInTheDocument();
});
it('renders empty state when no flyers are returned', async () => {
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
render(
<MemoryRouter>
<FlyerReviewPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
expect(screen.getByText(/the review queue is empty/i)).toBeInTheDocument();
});
it('renders a list of flyers when API returns data', async () => {
const mockFlyers = [
{
flyer_id: 1,
file_name: 'flyer1.jpg',
created_at: '2023-01-01T00:00:00Z',
store: { name: 'Store A' },
icon_url: 'icon1.jpg',
},
{
flyer_id: 2,
file_name: 'flyer2.jpg',
created_at: '2023-01-02T00:00:00Z',
store: { name: 'Store B' },
icon_url: 'icon2.jpg',
},
];
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
ok: true,
json: async () => mockFlyers,
} as Response);
render(
<MemoryRouter>
<FlyerReviewPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
expect(screen.getByText('Store A')).toBeInTheDocument();
expect(screen.getByText('flyer1.jpg')).toBeInTheDocument();
expect(screen.getByText('Store B')).toBeInTheDocument();
expect(screen.getByText('flyer2.jpg')).toBeInTheDocument();
});
it('renders error message when API response is not ok', async () => {
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
ok: false,
json: async () => ({ message: 'Server error' }),
} as Response);
render(
<MemoryRouter>
<FlyerReviewPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
expect(screen.getByText('Server error')).toBeInTheDocument();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to fetch flyers for review'
);
});
it('renders error message when API throws an error', async () => {
const networkError = new Error('Network error');
vi.mocked(apiClient.getFlyersForReview).mockRejectedValue(networkError);
render(
<MemoryRouter>
<FlyerReviewPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
expect(screen.getByText('Network error')).toBeInTheDocument();
expect(logger.error).toHaveBeenCalledWith(
{ err: networkError },
'Failed to fetch flyers for review'
);
});
});

View File

@@ -1,7 +1,6 @@
// src/routes/system.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import systemRouter from './system.routes'; // This was a duplicate, fixed.
import { exec, type ExecException, type ExecOptions } from 'child_process';
import { geocodingService } from '../services/geocodingService.server';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -65,6 +64,9 @@ vi.mock('../services/logger.server', () => ({
},
}));
// Import the router AFTER all mocks are defined to ensure systemService picks up the mocked util.promisify
import systemRouter from './system.routes';
describe('System Routes (/api/system)', () => {
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AnalyticsService } from './analyticsService.server';
import { logger } from './logger.server';
import type { Job } from 'bullmq';
import type { AnalyticsJobData, WeeklyAnalyticsJobData } from '../types/job-data';
// Mock logger
vi.mock('./logger.server', () => ({
logger: {
child: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
}));
describe('AnalyticsService', () => {
let service: AnalyticsService;
let mockLoggerInstance: any;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Setup mock logger instance returned by child()
mockLoggerInstance = {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
vi.mocked(logger.child).mockReturnValue(mockLoggerInstance);
service = new AnalyticsService();
});
afterEach(() => {
vi.useRealTimers();
});
const createMockJob = <T>(data: T): Job<T> =>
({
id: 'job-123',
name: 'analytics-job',
data,
attemptsMade: 1,
updateProgress: vi.fn(),
} as unknown as Job<T>);
describe('processDailyReportJob', () => {
it('should process successfully', async () => {
const job = createMockJob<AnalyticsJobData>({ reportDate: '2023-10-27' } as AnalyticsJobData);
const promise = service.processDailyReportJob(job);
// Fast-forward time to bypass the 10s delay
await vi.advanceTimersByTimeAsync(10000);
const result = await promise;
expect(result).toEqual({ status: 'success', reportDate: '2023-10-27' });
expect(logger.child).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'job-123',
reportDate: '2023-10-27',
}),
);
expect(mockLoggerInstance.info).toHaveBeenCalledWith('Picked up daily analytics job.');
expect(mockLoggerInstance.info).toHaveBeenCalledWith(
'Successfully generated report for 2023-10-27.',
);
});
it('should handle failure when reportDate is FAIL', async () => {
const job = createMockJob<AnalyticsJobData>({ reportDate: 'FAIL' } as AnalyticsJobData);
const promise = service.processDailyReportJob(job);
await expect(promise).rejects.toThrow('This is a test failure for the analytics job.');
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
attemptsMade: 1,
}),
'Daily analytics job failed.',
);
});
});
describe('processWeeklyReportJob', () => {
it('should process successfully', async () => {
const job = createMockJob<WeeklyAnalyticsJobData>({
reportYear: 2023,
reportWeek: 43,
} as WeeklyAnalyticsJobData);
const promise = service.processWeeklyReportJob(job);
await vi.advanceTimersByTimeAsync(30000);
const result = await promise;
expect(result).toEqual({ status: 'success', reportYear: 2023, reportWeek: 43 });
expect(logger.child).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'job-123',
reportYear: 2023,
reportWeek: 43,
}),
);
expect(mockLoggerInstance.info).toHaveBeenCalledWith('Picked up weekly analytics job.');
expect(mockLoggerInstance.info).toHaveBeenCalledWith(
'Successfully generated weekly report for week 43, 2023.',
);
});
it('should handle errors during processing', async () => {
const job = createMockJob<WeeklyAnalyticsJobData>({
reportYear: 2023,
reportWeek: 43,
} as WeeklyAnalyticsJobData);
// Make the second info call throw to simulate an error inside the try block
mockLoggerInstance.info
.mockImplementationOnce(() => {}) // "Picked up..."
.mockImplementationOnce(() => {
throw new Error('Processing failed');
}); // "Successfully generated..."
const promise = service.processWeeklyReportJob(job);
await vi.advanceTimersByTimeAsync(30000);
await expect(promise).rejects.toThrow('Processing failed');
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
attemptsMade: 1,
}),
'Weekly analytics job failed.',
);
});
});
});

View File

@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Set environment variables before importing the service
process.env.JWT_SECRET = 'test-secret';
process.env.FRONTEND_URL = 'http://localhost:3000';
import { authService } from './authService';
import * as bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { userRepo, adminRepo } from './db/index.db';
import { UniqueConstraintError } from './db/errors.db';
import { logger } from './logger.server';
import { sendPasswordResetEmail } from './emailService.server';
import type { UserProfile } from '../types';
// Mock dependencies
vi.mock('bcrypt');
vi.mock('jsonwebtoken');
vi.mock('crypto', () => ({
default: {
randomBytes: vi.fn().mockReturnValue({
toString: vi.fn().mockReturnValue('mocked-random-string'),
}),
},
}));
vi.mock('./db/index.db', () => ({
userRepo: {
createUser: vi.fn(),
saveRefreshToken: vi.fn(),
findUserByEmail: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
updateUserPassword: vi.fn(),
deleteResetToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
findUserProfileById: vi.fn(),
deleteRefreshToken: vi.fn(),
},
adminRepo: {
logActivity: vi.fn(),
},
}));
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('./emailService.server', () => ({
sendPasswordResetEmail: vi.fn(),
}));
vi.mock('./db/connection.db', () => ({
getPool: vi.fn(),
}));
vi.mock('../utils/authUtils', () => ({
validatePasswordStrength: vi.fn(),
}));
describe('AuthService', () => {
const reqLog = {}; // Mock request logger object
const mockUser = {
user_id: 'user-123',
email: 'test@example.com',
password_hash: 'hashed-password',
};
const mockUserProfile: UserProfile = {
user: mockUser,
role: 'user',
} as unknown as UserProfile;
beforeEach(() => {
vi.clearAllMocks();
});
describe('registerUser', () => {
it('should successfully register a new user', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
const result = await authService.registerUser(
'test@example.com',
'password123',
'Test User',
undefined,
reqLog,
);
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
expect(userRepo.createUser).toHaveBeenCalledWith(
'test@example.com',
'hashed-password',
{ full_name: 'Test User', avatar_url: undefined },
reqLog,
);
expect(adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: 'user_registered',
userId: 'user-123',
}),
reqLog,
);
expect(result).toEqual(mockUserProfile);
});
it('should throw UniqueConstraintError if email already exists', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
const error = new UniqueConstraintError('Email exists');
vi.mocked(userRepo.createUser).mockRejectedValue(error);
await expect(
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
).rejects.toThrow(UniqueConstraintError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log expected unique constraint errors as system errors
});
it('should log and throw other errors', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
const error = new Error('Database failed');
vi.mocked(userRepo.createUser).mockRejectedValue(error);
await expect(
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
).rejects.toThrow('Database failed');
expect(logger.error).toHaveBeenCalled();
});
});
describe('registerAndLoginUser', () => {
it('should register user and return tokens', async () => {
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
vi.mocked(jwt.sign).mockImplementation(() => 'access-token' as any);
const result = await authService.registerAndLoginUser(
'test@example.com',
'password123',
'Test User',
undefined,
reqLog,
);
expect(result).toEqual({
newUserProfile: mockUserProfile,
accessToken: 'access-token',
refreshToken: 'mocked-random-string',
});
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith(
'user-123',
'mocked-random-string',
reqLog,
);
});
});
describe('generateAuthTokens', () => {
it('should generate access and refresh tokens', () => {
vi.mocked(jwt.sign).mockImplementation(() => 'access-token' as any);
const result = authService.generateAuthTokens(mockUserProfile);
expect(jwt.sign).toHaveBeenCalledWith(
{
user_id: 'user-123',
email: 'test@example.com',
role: 'user',
},
'test-secret',
{ expiresIn: '15m' },
);
expect(result).toEqual({
accessToken: 'access-token',
refreshToken: 'mocked-random-string',
});
});
});
describe('saveRefreshToken', () => {
it('should save refresh token to db', async () => {
await authService.saveRefreshToken('user-123', 'token', reqLog);
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
});
it('should log and throw error on failure', async () => {
const error = new Error('DB Error');
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
'DB Error',
);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ error }),
expect.stringContaining('Failed to save refresh token'),
);
});
});
describe('resetPassword', () => {
it('should process password reset for existing user', async () => {
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');
const result = await authService.resetPassword('test@example.com', reqLog);
expect(userRepo.createPasswordResetToken).toHaveBeenCalledWith(
'user-123',
'hashed-token',
expect.any(Date),
reqLog,
);
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
'test@example.com',
expect.stringContaining('/reset-password/mocked-random-string'),
reqLog,
);
expect(result).toBe('mocked-random-string');
});
it('should log warning and return undefined for non-existent user', async () => {
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(null);
const result = await authService.resetPassword('unknown@example.com', reqLog);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Password reset requested for non-existent email'),
);
expect(sendPasswordResetEmail).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should log error and throw on failure', async () => {
const error = new Error('DB Error');
vi.mocked(userRepo.findUserByEmail).mockRejectedValue(error);
await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(
'DB Error',
);
expect(logger.error).toHaveBeenCalled();
});
});
describe('updatePassword', () => {
it('should update password if token is valid', async () => {
const mockTokenRecord = {
user_id: 'user-123',
token_hash: 'hashed-token',
};
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
vi.mocked(bcrypt.compare).mockImplementation(async () => true); // Match found
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
expect(userRepo.updateUserPassword).toHaveBeenCalledWith(
'user-123',
'new-hashed-password',
reqLog,
);
expect(userRepo.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
expect(adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: 'password_reset' }),
reqLog,
);
expect(result).toBe(true);
});
it('should return null if token is invalid or not found', async () => {
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);
const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);
expect(userRepo.updateUserPassword).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('getUserByRefreshToken', () => {
it('should return user profile if token exists', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
const result = await authService.getUserByRefreshToken('valid-token', reqLog);
expect(result).toEqual(mockUserProfile);
});
it('should return null if token not found', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(null);
const result = await authService.getUserByRefreshToken('invalid-token', reqLog);
expect(result).toBeNull();
});
});
describe('logout', () => {
it('should delete refresh token', async () => {
await authService.logout('token', reqLog);
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
});
it('should log and throw on error', async () => {
const error = new Error('DB Error');
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
await expect(authService.logout('token', reqLog)).rejects.toThrow('DB Error');
expect(logger.error).toHaveBeenCalled();
});
});
describe('refreshAccessToken', () => {
it('should return new access token if user found', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
vi.mocked(jwt.sign).mockImplementation(() => 'new-access-token' as any);
const result = await authService.refreshAccessToken('valid-token', reqLog);
expect(result).toEqual({ accessToken: 'new-access-token' });
});
it('should return null if user not found', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(null);
const result = await authService.refreshAccessToken('invalid-token', reqLog);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { brandService } from './brandService';
import * as db from './db/index.db';
import type { Logger } from 'pino';
// Mock dependencies
vi.mock('./db/index.db', () => ({
adminRepo: {
updateBrandLogo: vi.fn(),
},
}));
describe('BrandService', () => {
const mockLogger = {} as Logger;
beforeEach(() => {
vi.clearAllMocks();
});
describe('updateBrandLogo', () => {
it('should update brand logo and return the new URL', async () => {
const brandId = 123;
const mockFile = {
filename: 'test-logo.jpg',
} as Express.Multer.File;
vi.mocked(db.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
const result = await brandService.updateBrandLogo(brandId, mockFile, mockLogger);
expect(result).toBe('/flyer-images/test-logo.jpg');
expect(db.adminRepo.updateBrandLogo).toHaveBeenCalledWith(
brandId,
'/flyer-images/test-logo.jpg',
mockLogger,
);
});
it('should throw error if database update fails', async () => {
const brandId = 123;
const mockFile = {
filename: 'test-logo.jpg',
} as Express.Multer.File;
const dbError = new Error('DB Error');
vi.mocked(db.adminRepo.updateBrandLogo).mockRejectedValue(dbError);
await expect(brandService.updateBrandLogo(brandId, mockFile, mockLogger)).rejects.toThrow('DB Error');
});
});
});

View File

@@ -276,7 +276,7 @@ describe('FlyerProcessingService', () => {
message: 'An AI quota has been exceeded. Please try again later.',
stages: [
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'An AI quota has been exceeded. Please try again later.' },
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model quota exceeded' },
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
{ name: 'Saving to Database', status: 'skipped', critical: true },
],
@@ -303,7 +303,7 @@ describe('FlyerProcessingService', () => {
stderr: 'pdftocairo error',
stages: [
{ name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.' },
{ name: 'Extracting Data with AI', status: 'skipped', critical: true, detail: 'Communicating with AI model...' },
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
{ name: 'Saving to Database', status: 'skipped', critical: true },
],
@@ -332,7 +332,7 @@ describe('FlyerProcessingService', () => {
rawData: {},
stages: expect.any(Array), // Stages will be dynamically generated
},
'AI Data Validation failed.',
'A known processing error occurred: AiDataValidationError',
);
// Use `toHaveBeenLastCalledWith` to check only the final error payload.
// FIX: The payload from AiDataValidationError includes validationErrors and rawData.
@@ -389,7 +389,7 @@ describe('FlyerProcessingService', () => {
message: 'Database transaction failed',
stages: [
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
{ name: 'Extracting Data with AI', status: 'completed', critical: true },
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
{ name: 'Transforming AI Data', status: 'completed', critical: true },
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'Database transaction failed' },
],
@@ -442,7 +442,7 @@ describe('FlyerProcessingService', () => {
message: 'Icon generation failed.',
stages: [
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
{ name: 'Extracting Data with AI', status: 'completed', critical: true },
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
{ name: 'Transforming AI Data', status: 'failed', critical: true, detail: 'Icon generation failed.' },
{ name: 'Saving to Database', status: 'skipped', critical: true },
],

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { exec, type ExecException } from 'child_process';
import { logger } from './logger.server';
// Mock logger
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock util.promisify to handle child_process.exec signature
vi.mock('util', async (importOriginal) => {
const actual = await importOriginal<typeof import('util')>();
return {
...actual,
promisify: (fn: Function) => {
return (...args: any[]) => {
return new Promise((resolve, reject) => {
fn(...args, (err: Error | null, stdout: string, stderr: string) => {
if (err) {
// Attach stdout/stderr to error for the catch block in service
Object.assign(err, { stdout, stderr });
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
};
},
};
});
// Mock child_process
vi.mock('child_process', () => {
const mockExec = vi.fn();
return {
exec: mockExec,
};
});
// Import service AFTER mocks to ensure top-level promisify uses the mock
import { systemService } from './systemService';
describe('SystemService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getPm2Status', () => {
it('should return success: true when process is online', async () => {
const stdout = `
┌────┬──────────────────────┬──────────┐
│ id │ name │ status │
├────┼──────────────────────┼──────────┤
│ 0 │ flyer-crawler-api │ online │
└────┴──────────────────────┴──────────┘
`;
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
callback(null, stdout, '');
return {} as any;
});
const result = await systemService.getPm2Status();
expect(result).toEqual({
success: true,
message: 'Application is online and running under PM2.',
});
});
it('should return success: false when process is stopped', async () => {
const stdout = `
┌────┬──────────────────────┬──────────┐
│ id │ name │ status │
├────┼──────────────────────┼──────────┤
│ 0 │ flyer-crawler-api │ stopped │
└────┴──────────────────────┴──────────┘
`;
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
callback(null, stdout, '');
return {} as any;
});
const result = await systemService.getPm2Status();
expect(result).toEqual({
success: false,
message: 'Application process exists but is not online.',
});
});
it('should throw error if stderr has content', async () => {
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
callback(null, 'some stdout', 'some stderr warning');
return {} as any;
});
await expect(systemService.getPm2Status()).rejects.toThrow('PM2 command produced an error: some stderr warning');
});
it('should return success: false when process does not exist', async () => {
const error = new Error('Command failed') as ExecException & { stdout?: string; stderr?: string };
error.code = 1;
error.stderr = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
callback(error, '', error.stderr);
return {} as any;
});
const result = await systemService.getPm2Status();
expect(result).toEqual({
success: false,
message: 'Application process is not running under PM2.',
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('PM2 process "flyer-crawler-api" not found')
);
});
});
});

View File

@@ -1,13 +1,22 @@
// src/services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Address } from '../types';
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 type { Job } from 'bullmq';
import type { TokenCleanupJobData } from '../types/job-data';
// --- 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.
@@ -24,13 +33,33 @@ const mocks = vi.hoisted(() => {
// 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.
@@ -53,7 +82,13 @@ vi.mock('./db/user.db', () => ({
vi.mock('./logger.server', () => ({
// Provide a default mock for the logger
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
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.
@@ -138,4 +173,163 @@ describe('UserService', () => {
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
});
});
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.',
);
});
});
describe('updateUserAvatar', () => {
it('should construct avatar URL and update profile', async () => {
const { logger } = await import('./logger.server');
const userId = 'user-123';
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
const expectedUrl = '/uploads/avatars/avatar.jpg';
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
await userService.updateUserAvatar(userId, file, logger);
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith(
userId,
{ avatar_url: expectedUrl },
logger,
);
});
});
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();
});
});
});