All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m59s
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
// src/services/db/notification.db.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { Pool } from 'pg';
|
|
|
|
vi.unmock('./notification.db');
|
|
|
|
import { NotificationRepository } from './notification.db';
|
|
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
|
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
|
import type { Notification } from '../../types';
|
|
import { createMockNotification } from '../../tests/utils/mockFactories';
|
|
|
|
|
|
// Mock the logger to prevent console output during tests
|
|
vi.mock('../logger.server', () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
import { logger as mockLogger } from '../logger.server';
|
|
|
|
describe('Notification DB Service', () => {
|
|
let notificationRepo: NotificationRepository;
|
|
const mockDb = {
|
|
query: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Instantiate the repository with the mock pool for each test
|
|
|
|
notificationRepo = new NotificationRepository(mockPoolInstance as unknown as Pool);
|
|
});
|
|
|
|
describe('getNotificationsForUser', () => {
|
|
it('should only return unread notifications by default', async () => {
|
|
const mockNotifications: Notification[] = [
|
|
createMockNotification({
|
|
notification_id: 1,
|
|
user_id: 'user-123',
|
|
content: 'Test 1',
|
|
is_read: false,
|
|
}),
|
|
];
|
|
mockPoolInstance.query.mockResolvedValue({ rows: mockNotifications });
|
|
|
|
const result = await notificationRepo.getNotificationsForUser(
|
|
'user-123',
|
|
10,
|
|
5,
|
|
false,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
expect.stringContaining('is_read = false'),
|
|
['user-123', 10, 5],
|
|
);
|
|
expect(result).toEqual(mockNotifications);
|
|
});
|
|
|
|
it('should return all notifications when includeRead is true', async () => {
|
|
const mockNotifications: Notification[] = [
|
|
createMockNotification({ is_read: true }),
|
|
createMockNotification({ is_read: false }),
|
|
];
|
|
mockPoolInstance.query.mockResolvedValue({ rows: mockNotifications });
|
|
|
|
await notificationRepo.getNotificationsForUser('user-123', 10, 0, true, mockLogger);
|
|
|
|
// The query should NOT contain the is_read filter
|
|
expect(mockPoolInstance.query.mock.calls[0][0]).not.toContain('is_read = false');
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), ['user-123', 10, 0]);
|
|
});
|
|
|
|
it('should return an empty array if the user has no notifications', async () => {
|
|
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
|
const result = await notificationRepo.getNotificationsForUser(
|
|
'user-456',
|
|
10,
|
|
0,
|
|
false,
|
|
mockLogger,
|
|
);
|
|
expect(result).toEqual([]);
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
expect.stringContaining('is_read = false'),
|
|
['user-456', 10, 0],
|
|
);
|
|
});
|
|
|
|
it('should throw an error if the database query fails', async () => {
|
|
const dbError = new Error('DB Error');
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
await expect(
|
|
notificationRepo.getNotificationsForUser('user-123', 10, 5, false, mockLogger),
|
|
).rejects.toThrow('Failed to retrieve notifications.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, userId: 'user-123', limit: 10, offset: 5, includeRead: false },
|
|
'Database error in getNotificationsForUser',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createNotification', () => {
|
|
it('should insert a new notification and return it', async () => {
|
|
const mockNotification = createMockNotification({
|
|
notification_id: 1,
|
|
user_id: 'user-123',
|
|
content: 'Test',
|
|
is_read: false,
|
|
});
|
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification] });
|
|
|
|
const result = await notificationRepo.createNotification('user-123', 'Test', mockLogger);
|
|
expect(result).toEqual(mockNotification);
|
|
});
|
|
|
|
it('should insert a notification with a linkUrl', async () => {
|
|
const mockNotification = createMockNotification({
|
|
notification_id: 2,
|
|
user_id: 'user-123',
|
|
content: 'Test with link',
|
|
link_url: '/some/link',
|
|
is_read: false,
|
|
});
|
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification] });
|
|
|
|
const result = await notificationRepo.createNotification(
|
|
'user-123',
|
|
'Test with link',
|
|
mockLogger,
|
|
'/some/link',
|
|
);
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
expect.stringContaining('INSERT INTO public.notifications'),
|
|
['user-123', 'Test with link', '/some/link'],
|
|
);
|
|
expect(result).toEqual(mockNotification);
|
|
});
|
|
|
|
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
|
const dbError = new Error('violates foreign key constraint');
|
|
(dbError as Error & { code: string }).code = '23503';
|
|
mockPoolInstance.query.mockRejectedValueOnce(dbError);
|
|
await expect(
|
|
notificationRepo.createNotification('non-existent-user', 'Test', mockLogger),
|
|
).rejects.toThrow('The specified user does not exist.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{
|
|
err: dbError,
|
|
userId: 'non-existent-user',
|
|
content: 'Test',
|
|
linkUrl: undefined,
|
|
code: '23503',
|
|
constraint: undefined,
|
|
detail: undefined,
|
|
},
|
|
'Database error in createNotification',
|
|
);
|
|
});
|
|
|
|
it('should throw a generic error if the database query fails', async () => {
|
|
const dbError = new Error('DB Error');
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
await expect(
|
|
notificationRepo.createNotification('user-123', 'Test', mockLogger),
|
|
).rejects.toThrow('Failed to create notification.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, userId: 'user-123', content: 'Test', linkUrl: undefined },
|
|
'Database error in createNotification',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createBulkNotifications', () => {
|
|
it('should build a correct bulk insert query and release the client', async () => {
|
|
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
|
const notificationsToCreate = [{ user_id: 'u1', content: 'msg' }];
|
|
|
|
await notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger);
|
|
// Check that the query was called with the correct unnest structure
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
expect.stringContaining('SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])'),
|
|
[['u1'], ['msg'], [null]],
|
|
);
|
|
});
|
|
|
|
it('should not query the database if the notifications array is empty', async () => {
|
|
await notificationRepo.createBulkNotifications([], mockLogger);
|
|
expect(mockPoolInstance.query).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw ForeignKeyConstraintError if any user does not exist', async () => {
|
|
const dbError = new Error('violates foreign key constraint');
|
|
(dbError as Error & { code: string }).code = '23503';
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
const notificationsToCreate = [{ user_id: 'non-existent', content: 'msg' }];
|
|
await expect(
|
|
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
|
).rejects.toThrow(ForeignKeyConstraintError);
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{
|
|
err: dbError,
|
|
notifications: notificationsToCreate,
|
|
code: '23503',
|
|
constraint: undefined,
|
|
detail: undefined,
|
|
},
|
|
'Database error in createBulkNotifications',
|
|
);
|
|
});
|
|
|
|
it('should throw a generic error if the database query fails', async () => {
|
|
const dbError = new Error('DB Error');
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
const notificationsToCreate = [{ user_id: 'u1', content: 'msg' }];
|
|
await expect(
|
|
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
|
).rejects.toThrow('Failed to create bulk notifications.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, notifications: notificationsToCreate },
|
|
'Database error in createBulkNotifications',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('markNotificationAsRead', () => {
|
|
it('should update a single notification and return the updated record', async () => {
|
|
const mockNotification = createMockNotification({
|
|
notification_id: 123,
|
|
user_id: 'abc',
|
|
content: 'msg',
|
|
is_read: true,
|
|
});
|
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification], rowCount: 1 });
|
|
|
|
const result = await notificationRepo.markNotificationAsRead(123, 'abc', mockLogger);
|
|
expect(result).toEqual(mockNotification);
|
|
});
|
|
|
|
it('should throw an error if the notification is not found or does not belong to the user', async () => {
|
|
// FIX: Ensure rowCount is 0
|
|
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
|
|
await expect(notificationRepo.markNotificationAsRead(999, 'abc', mockLogger)).rejects.toThrow(
|
|
NotFoundError,
|
|
);
|
|
});
|
|
|
|
it('should re-throw the specific "not found" error if it occurs', async () => {
|
|
// This tests the `if (error instanceof NotFoundError)` line
|
|
const notFoundError = new NotFoundError(
|
|
'Notification not found or user does not have permission.',
|
|
);
|
|
mockPoolInstance.query.mockImplementation(() => {
|
|
throw notFoundError;
|
|
});
|
|
|
|
await expect(
|
|
notificationRepo.markNotificationAsRead(999, 'user-abc', mockLogger),
|
|
).rejects.toThrow(notFoundError);
|
|
});
|
|
|
|
it('should throw a generic error if the database query fails', async () => {
|
|
const dbError = new Error('DB Error');
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
await expect(notificationRepo.markNotificationAsRead(123, 'abc', mockLogger)).rejects.toThrow(
|
|
'Failed to mark notification as read.',
|
|
);
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, notificationId: 123, userId: 'abc' },
|
|
'Database error in markNotificationAsRead',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('markNotificationAsRead - Ownership Check', () => {
|
|
it('should not mark a notification as read if the user does not own it', async () => {
|
|
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
|
|
|
await expect(notificationRepo.markNotificationAsRead(1, 'wrong-user', mockLogger)).rejects.toThrow(
|
|
'Notification not found or user does not have permission.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('markAllNotificationsAsRead', () => {
|
|
it('should execute an UPDATE query to mark all notifications as read for a user', async () => {
|
|
mockPoolInstance.query.mockResolvedValue({ rowCount: 3 });
|
|
await notificationRepo.markAllNotificationsAsRead('user-xyz', mockLogger);
|
|
|
|
// Fix expected arguments to match what the implementation actually sends
|
|
// The implementation likely passes the user ID
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
expect.stringContaining('UPDATE public.notifications'),
|
|
['user-xyz'],
|
|
);
|
|
});
|
|
|
|
it('should throw an error if the database query fails', async () => {
|
|
const dbError = new Error('DB Error');
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
await expect(
|
|
notificationRepo.markAllNotificationsAsRead('user-xyz', mockLogger),
|
|
).rejects.toThrow('Failed to mark notifications as read.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, userId: 'user-xyz' },
|
|
'Database error in markAllNotificationsAsRead',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deleteOldNotifications', () => {
|
|
it('should execute a DELETE query and return the number of deleted rows', async () => {
|
|
mockPoolInstance.query.mockResolvedValue({ rowCount: 5 });
|
|
const result = await notificationRepo.deleteOldNotifications(30, mockLogger);
|
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
|
|
[30],
|
|
);
|
|
expect(result).toBe(5);
|
|
});
|
|
|
|
it('should return 0 if rowCount is null or undefined', async () => {
|
|
mockPoolInstance.query.mockResolvedValue({ rowCount: null });
|
|
const result = await notificationRepo.deleteOldNotifications(30, mockLogger);
|
|
expect(result).toBe(0);
|
|
});
|
|
|
|
it('should throw an error if the database query fails', async () => {
|
|
const dbError = new Error('DB Error');
|
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
|
await expect(notificationRepo.deleteOldNotifications(30, mockLogger)).rejects.toThrow(
|
|
'Failed to delete old notifications.',
|
|
);
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ err: dbError, daysOld: 30 },
|
|
'Database error in deleteOldNotifications',
|
|
);
|
|
});
|
|
});
|
|
});
|