Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61a00003a | ||
| 52dba6f890 | |||
| 4242678aab | |||
|
|
b2e086d5ba | ||
| 07a9787570 | |||
|
|
4bf5dc3d58 | ||
| be3d269928 | |||
|
|
80a53fae94 | ||
| e15d2b6c2f | |||
|
|
7a52bf499e | ||
| 2489ec8d2d | |||
|
|
4a4f349805 | ||
| 517a268307 | |||
|
|
a94b2a97b1 | ||
| 542cdfbb82 | |||
|
|
262062f468 | ||
| 0a14193371 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.2.27",
|
||||
"version": "0.2.35",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.2.27",
|
||||
"version": "0.2.35",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.2.27",
|
||||
"version": "0.2.35",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -113,13 +113,14 @@ describe('errorHandler Middleware', () => {
|
||||
expect(response.body.message).toBe('A generic server error occurred.');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
console.log('[DEBUG] errorHandler.test.ts: Received 500 error response with ID:', response.body.errorId);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
errorId: expect.any(String),
|
||||
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||
@@ -226,7 +227,7 @@ describe('errorHandler Middleware', () => {
|
||||
errorId: expect.any(String),
|
||||
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||
|
||||
143
src/pages/admin/FlyerReviewPage.test.tsx
Normal file
143
src/pages/admin/FlyerReviewPage.test.tsx
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -164,11 +164,12 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
console.log('[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -186,7 +187,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'DB connection failed' }),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -220,7 +221,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,7 +240,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -300,7 +301,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -321,7 +322,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'Pool is not initialized' }),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -336,11 +337,12 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect(response.body.message).toBe('Connection timed out');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
console.log('[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -357,7 +359,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
}),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
// 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';
|
||||
|
||||
// FIX: Mock util.promisify to correctly handle child_process.exec's (err, stdout, stderr) signature.
|
||||
// This is required because the standard util.promisify relies on internal symbols on the real exec function,
|
||||
// which are missing on our Vitest mock. Without this, promisify(mockExec) drops the stdout/stderr arguments.
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
promisify: (fn: Function) => {
|
||||
return (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(...args, (err: Error | null, stdout: unknown, stderr: unknown) => {
|
||||
if (err) {
|
||||
// Attach stdout/stderr to the error object to mimic child_process.exec behavior
|
||||
Object.assign(err, { stdout, stderr });
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// FIX: Use the simple factory pattern for child_process to avoid default export issues
|
||||
vi.mock('child_process', () => {
|
||||
const mockExec = vi.fn((command, callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
callback(null, 'PM2 OK', '');
|
||||
}
|
||||
return { unref: () => {} };
|
||||
});
|
||||
|
||||
return {
|
||||
default: { exec: mockExec },
|
||||
exec: mockExec,
|
||||
};
|
||||
});
|
||||
|
||||
// 1. Mock the Service Layer
|
||||
// This decouples the route test from the service's implementation details.
|
||||
vi.mock('../services/systemService', () => ({
|
||||
systemService: {
|
||||
getPm2Status: vi.fn(),
|
||||
},
|
||||
}));
|
||||
// 2. Mock Geocoding
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
geocodingService: {
|
||||
@@ -65,44 +28,25 @@ vi.mock('../services/logger.server', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the router AFTER all mocks are defined to ensure systemService picks up the mocked util.promisify
|
||||
import { systemService } from '../services/systemService';
|
||||
import systemRouter from './system.routes';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
describe('System Routes (/api/system)', () => {
|
||||
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
||||
|
||||
beforeEach(() => {
|
||||
// We cast here to get type-safe access to mock functions like .mockImplementation
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /pm2-status', () => {
|
||||
it('should return success: true when pm2 process is online', async () => {
|
||||
// Arrange: Simulate a successful `pm2 describe` output for an online process.
|
||||
const pm2OnlineOutput = `
|
||||
┌─ PM2 info ────────────────┐
|
||||
│ status │ online │
|
||||
└───────────┴───────────┘
|
||||
`;
|
||||
|
||||
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
|
||||
|
||||
// A robust mock for `exec` that handles its multiple overloads.
|
||||
// This avoids the complex and error-prone `...args` signature.
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?: ExecOptions | ExecCallback | null,
|
||||
callback?: ExecCallback | null,
|
||||
) => {
|
||||
// The actual callback can be the second or third argument.
|
||||
const actualCallback = (
|
||||
typeof options === 'function' ? options : callback
|
||||
) as ExecCallback;
|
||||
if (actualCallback) {
|
||||
actualCallback(null, pm2OnlineOutput, '');
|
||||
}
|
||||
// Return a minimal object that satisfies the ChildProcess type for .unref()
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Application is online and running under PM2.',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
@@ -116,28 +60,10 @@ describe('System Routes (/api/system)', () => {
|
||||
});
|
||||
|
||||
it('should return success: false when pm2 process is stopped or errored', async () => {
|
||||
const pm2StoppedOutput = `│ status │ stopped │`;
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(null, pm2StoppedOutput, '');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Application process exists but is not online.',
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
@@ -148,33 +74,10 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
it('should return success: false when pm2 process does not exist', async () => {
|
||||
// Arrange: Simulate `pm2 describe` failing because the process isn't found.
|
||||
const processNotFoundOutput =
|
||||
"[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
||||
const processNotFoundError = new Error(
|
||||
'Command failed: pm2 describe flyer-crawler-api',
|
||||
) as ExecException;
|
||||
processNotFoundError.code = 1;
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(processNotFoundError, processNotFoundOutput, '');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Application process is not running under PM2.',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
@@ -189,55 +92,17 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
it('should return 500 if pm2 command produces stderr output', async () => {
|
||||
// Arrange: Simulate a successful exit code but with content in stderr.
|
||||
const stderrOutput = 'A non-fatal warning occurred.';
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(null, 'Some stdout', stderrOutput);
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
const serviceError = new Error('PM2 command produced an error: A non-fatal warning occurred.');
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe(`PM2 command produced an error: ${stderrOutput}`);
|
||||
expect(response.body.message).toBe(serviceError.message);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic exec error', async () => {
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(new Error('System error') as ExecException, '', 'stderr output');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
const serviceError = new Error('System error');
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
153
src/services/analyticsService.server.test.ts
Normal file
153
src/services/analyticsService.server.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// src/services/analyticsService.server.test.ts
|
||||
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..."
|
||||
|
||||
// Get the promise from the service method.
|
||||
const promise = service.processWeeklyReportJob(job);
|
||||
|
||||
// Capture the expectation promise BEFORE triggering the rejection.
|
||||
const expectation = expect(promise).rejects.toThrow('Processing failed');
|
||||
|
||||
// Advance timers to trigger the part of the code that throws.
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
|
||||
// Await the expectation to ensure assertions ran.
|
||||
await expectation;
|
||||
|
||||
// Verify the side effect (error logging) after the rejection is confirmed.
|
||||
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
attemptsMade: 1,
|
||||
}),
|
||||
'Weekly analytics job failed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
339
src/services/authService.test.ts
Normal file
339
src/services/authService.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { UserProfile } from '../types';
|
||||
import type * as jsonwebtoken from 'jsonwebtoken';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: typeof import('./authService').authService;
|
||||
let bcrypt: typeof import('bcrypt');
|
||||
let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken };
|
||||
let userRepo: typeof import('./db/index.db').userRepo;
|
||||
let adminRepo: typeof import('./db/index.db').adminRepo;
|
||||
let logger: typeof import('./logger.server').logger;
|
||||
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
|
||||
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
|
||||
|
||||
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(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Set environment variables before any modules are imported
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.FRONTEND_URL = 'http://localhost:3000';
|
||||
|
||||
// Mock all dependencies before dynamically importing the service
|
||||
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
|
||||
vi.mock('bcrypt');
|
||||
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() }));
|
||||
|
||||
// Dynamically import modules to get the mocked versions and the service instance
|
||||
authService = (await import('./authService')).authService;
|
||||
bcrypt = await import('bcrypt');
|
||||
jwt = (await import('jsonwebtoken')) as typeof jwt;
|
||||
const dbModule = await import('./db/index.db');
|
||||
userRepo = dbModule.userRepo;
|
||||
adminRepo = dbModule.adminRepo;
|
||||
logger = (await import('./logger.server')).logger;
|
||||
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
||||
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
||||
});
|
||||
|
||||
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);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
// We must mock `jwt.default.sign` to affect the code under test.
|
||||
vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');
|
||||
|
||||
const result = await authService.registerAndLoginUser(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'Test User',
|
||||
undefined,
|
||||
reqLog,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
newUserProfile: mockUserProfile,
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'mocked_random_id',
|
||||
});
|
||||
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'mocked_random_id',
|
||||
reqLog,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAuthTokens', () => {
|
||||
it('should generate access and refresh tokens', () => {
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
// We must mock `jwt.default.sign` to affect the code under test.
|
||||
vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');
|
||||
|
||||
const result = authService.generateAuthTokens(mockUserProfile);
|
||||
|
||||
expect(vi.mocked(jwt.default.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_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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_id'),
|
||||
reqLog,
|
||||
);
|
||||
expect(result).toBe('mocked_random_id');
|
||||
});
|
||||
|
||||
it('should log warning and return undefined for non-existent user', async () => {
|
||||
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(undefined);
|
||||
|
||||
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(undefined);
|
||||
|
||||
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);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
// We must mock `jwt.default.sign` to affect the code under test.
|
||||
vi.mocked(jwt.default.sign).mockImplementation(() => 'new-access-token');
|
||||
|
||||
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(undefined);
|
||||
const result = await authService.refreshAccessToken('invalid-token', reqLog);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/services/brandService.test.ts
Normal file
51
src/services/brandService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
],
|
||||
@@ -409,24 +409,18 @@ describe('FlyerProcessingService', () => {
|
||||
mockFileHandler.prepareImageInputs.mockRejectedValue(fileTypeError);
|
||||
const { logger } = await import('./logger.server');
|
||||
|
||||
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError);
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'UNSUPPORTED_FILE_TYPE',
|
||||
message: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.' },
|
||||
{ name: 'Extracting Data with AI', status: 'skipped', critical: true, detail: 'Communicating with AI model...' },
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(fileTypeError, job, expect.any(Object), expect.any(Array));
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error and not enqueue cleanup if icon generation fails', async () => {
|
||||
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
|
||||
const job = createMockJob({});
|
||||
const { logger } = await import('./logger.server');
|
||||
const iconError = new Error('Icon generation failed.');
|
||||
@@ -435,18 +429,11 @@ describe('FlyerProcessingService', () => {
|
||||
// bubbling up from the icon generation step.
|
||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(iconError);
|
||||
|
||||
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
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: 'Transforming AI Data', status: 'failed', critical: true, detail: 'Icon generation failed.' },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
],
|
||||
}); // This was a duplicate, fixed.
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(iconError, job, expect.any(Object), expect.any(Array));
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||
@@ -454,8 +441,58 @@ describe('FlyerProcessingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('_reportErrorAndThrow (private method)', () => {
|
||||
it('should update progress and throw UnrecoverableError for quota messages', async () => {
|
||||
describe('_reportErrorAndThrow (Error Reporting Logic)', () => {
|
||||
it('should update progress with a generic error and re-throw', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const genericError = new Error('A standard failure');
|
||||
const initialStages = [
|
||||
{ name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' },
|
||||
{ name: 'Stage 2', status: 'in-progress', critical: true, detail: 'Working...' },
|
||||
{ name: 'Stage 3', status: 'pending', critical: true, detail: 'Waiting...' },
|
||||
];
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(genericError, job, logger, initialStages)).rejects.toThrow(genericError);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'A standard failure',
|
||||
stages: [
|
||||
{ name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' },
|
||||
{ name: 'Stage 2', status: 'failed', critical: true, detail: 'A standard failure' },
|
||||
{ name: 'Stage 3', status: 'skipped', critical: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use toErrorPayload for FlyerProcessingError instances', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const validationError = new AiDataValidationError(
|
||||
'Validation failed',
|
||||
{ foo: 'bar' },
|
||||
{ raw: 'data' },
|
||||
);
|
||||
const initialStages = [
|
||||
{ name: 'Extracting Data with AI', status: 'in-progress', critical: true, detail: '...' },
|
||||
];
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(validationError, job, logger, initialStages)).rejects.toThrow(validationError);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'AI_VALIDATION_FAILED',
|
||||
message: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
validationErrors: { foo: 'bar' },
|
||||
rawData: { raw: 'data' },
|
||||
stages: [
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer." },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnrecoverableError for quota messages', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const quotaError = new Error('RESOURCE_EXHAUSTED');
|
||||
@@ -472,53 +509,35 @@ describe('FlyerProcessingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use toErrorPayload for FlyerProcessingError instances', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const validationError = new AiDataValidationError(
|
||||
'Validation failed',
|
||||
{ foo: 'bar' },
|
||||
{ raw: 'data' },
|
||||
);
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(validationError, job, logger, [])).rejects.toThrow(
|
||||
validationError,
|
||||
);
|
||||
|
||||
// The payload should now come from the error's `toErrorPayload` method
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'AI_VALIDATION_FAILED',
|
||||
message:
|
||||
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
validationErrors: { foo: 'bar' },
|
||||
rawData: { raw: 'data' },
|
||||
stages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should update progress and re-throw standard errors', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const genericError = new Error('A standard failure');
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(genericError, job, logger, [])).rejects.toThrow(genericError);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'A standard failure', // This was a duplicate, fixed.
|
||||
stages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap and throw non-Error objects', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const nonError = 'just a string error';
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow('just a string error');
|
||||
await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow(
|
||||
'just a string error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly identify the failed stage based on error code', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const job = createMockJob({});
|
||||
const pdfError = new PdfConversionError('PDF failed');
|
||||
const initialStages = [
|
||||
{ name: 'Preparing Inputs', status: 'in-progress', critical: true, detail: '...' },
|
||||
{ name: 'Extracting Data with AI', status: 'pending', critical: true, detail: '...' },
|
||||
];
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(pdfError, job, logger, initialStages)).rejects.toThrow(pdfError);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'failed', critical: true, detail: expect.any(String) },
|
||||
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
|
||||
],
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -242,7 +242,10 @@ export class FlyerProcessingService {
|
||||
// Mark subsequent critical stages as skipped
|
||||
for (let i = errorStageIndex + 1; i < stagesToReport.length; i++) {
|
||||
if (stagesToReport[i].critical) {
|
||||
stagesToReport[i] = { ...stagesToReport[i], status: 'skipped' };
|
||||
// When a stage is skipped, we don't need its previous 'detail' property.
|
||||
// This creates a clean 'skipped' state object by removing `detail` and keeping the rest.
|
||||
const { detail, ...restOfStage } = stagesToReport[i];
|
||||
stagesToReport[i] = { ...restOfStage, status: 'skipped' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,10 @@ describe('Worker Service Lifecycle', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processExitSpy.mockRestore();
|
||||
if (processExitSpy && typeof processExitSpy.mockRestore === 'function') {
|
||||
console.log('[DEBUG] queueService.server.test.ts: Restoring process.exit spy');
|
||||
processExitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should close all workers, queues, the redis connection, and exit the process', async () => {
|
||||
|
||||
93
src/services/systemService.test.ts
Normal file
93
src/services/systemService.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/services/systemService.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { logger } from './logger.server';
|
||||
import type { ExecException } from 'child_process';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the class, not the singleton instance, to apply Dependency Injection
|
||||
import { SystemService } from './systemService';
|
||||
|
||||
describe('SystemService', () => {
|
||||
let systemService: SystemService;
|
||||
let mockExecAsync: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Create a mock function for our dependency
|
||||
mockExecAsync = vi.fn();
|
||||
// Instantiate the service with the mock dependency
|
||||
systemService = new SystemService(mockExecAsync);
|
||||
});
|
||||
|
||||
describe('getPm2Status', () => {
|
||||
it('should return success: true when process is online', async () => {
|
||||
const stdout = `
|
||||
┌────┬──────────────────────┬──────────┐
|
||||
│ id │ name │ status │
|
||||
├────┼──────────────────────┼──────────┤
|
||||
│ 0 │ flyer-crawler-api │ online │
|
||||
└────┴──────────────────────┴──────────┘
|
||||
`;
|
||||
mockExecAsync.mockResolvedValue({ stdout, stderr: '' });
|
||||
|
||||
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 │
|
||||
└────┴──────────────────────┴──────────┘
|
||||
`;
|
||||
mockExecAsync.mockResolvedValue({ stdout, stderr: '' });
|
||||
|
||||
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 () => {
|
||||
mockExecAsync.mockResolvedValue({ stdout: 'some stdout', stderr: 'some stderr warning' });
|
||||
|
||||
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";
|
||||
|
||||
mockExecAsync.mockRejectedValue(error);
|
||||
|
||||
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,24 @@
|
||||
// src/services/systemService.ts
|
||||
import { exec } from 'child_process';
|
||||
import { exec as nodeExec, type ExecException } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
// Define a type for the exec function for better type safety and testability.
|
||||
// It matches the signature of a promisified child_process.exec.
|
||||
export type ExecAsync = (
|
||||
command: string,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
export class SystemService {
|
||||
private execAsync: ExecAsync;
|
||||
|
||||
constructor(execAsync: ExecAsync) {
|
||||
this.execAsync = execAsync;
|
||||
}
|
||||
|
||||
class SystemService {
|
||||
async getPm2Status(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('pm2 describe flyer-crawler-api');
|
||||
const { stdout, stderr } = await this.execAsync('pm2 describe flyer-crawler-api');
|
||||
|
||||
// If the command runs but produces output on stderr, treat it as an error.
|
||||
// This handles cases where pm2 might issue warnings but still exit 0.
|
||||
@@ -21,7 +31,7 @@ class SystemService {
|
||||
? 'Application is online and running under PM2.'
|
||||
: 'Application process exists but is not online.';
|
||||
return { success: isOnline, message };
|
||||
} catch (error: any) {
|
||||
} catch (error: ExecException | any) {
|
||||
// If the command fails (non-zero exit code), check if it's because the process doesn't exist.
|
||||
// This is a normal "not found" case, not a system error.
|
||||
// The error message can be in stdout or stderr depending on the pm2 version.
|
||||
@@ -40,4 +50,6 @@ class SystemService {
|
||||
}
|
||||
}
|
||||
|
||||
export const systemService = new SystemService();
|
||||
// Instantiate the service with the real dependency for the application
|
||||
const realExecAsync = promisify(nodeExec);
|
||||
export const systemService = new SystemService(realExecAsync);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,65 @@ afterEach(cleanup);
|
||||
// By placing mocks here, they are guaranteed to be hoisted and applied
|
||||
// before any test files are executed, preventing initialization errors.
|
||||
|
||||
// --- Centralized Core Node/NPM Module Mocks ---
|
||||
|
||||
// Mock 'util' to correctly handle the (err, stdout, stderr) signature of child_process.exec
|
||||
// when it's promisified. The standard util.promisify doesn't work on a simple vi.fn() mock.
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
const mocked = {
|
||||
...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 the error object to mimic child_process.exec behavior
|
||||
Object.assign(err, { stdout, stderr });
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
default: mocked,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock 'jsonwebtoken'. The `default` key is crucial because the code under test
|
||||
// uses `import jwt from 'jsonwebtoken'`, which imports the default export.
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: {
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
},
|
||||
// Also mock named exports for completeness.
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock 'bcrypt'. The service uses `import * as bcrypt from 'bcrypt'`.
|
||||
vi.mock('bcrypt');
|
||||
|
||||
// Mock 'crypto'. The service uses `import crypto from 'crypto'`.
|
||||
vi.mock('crypto', () => ({
|
||||
default: {
|
||||
randomBytes: vi.fn().mockReturnValue({
|
||||
toString: vi.fn().mockImplementation((encoding) => {
|
||||
const id = 'mocked_random_id';
|
||||
console.log(`[DEBUG] tests-setup-unit.ts: crypto.randomBytes mock returning "${id}" for encoding "${encoding}"`);
|
||||
return id;
|
||||
}),
|
||||
}),
|
||||
randomUUID: vi.fn().mockReturnValue('mocked_random_id'),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Global Mocks ---
|
||||
|
||||
// 1. Define the mock pool instance logic OUTSIDE the factory so it can be used
|
||||
|
||||
Reference in New Issue
Block a user