complete project using prettier!
This commit is contained in:
@@ -36,25 +36,35 @@ describe('Address DB Service', () => {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockAddress] });
|
||||
|
||||
|
||||
const result = await addressRepo.getAddressById(1, mockLogger);
|
||||
|
||||
expect(result).toEqual(mockAddress);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.addresses WHERE address_id = $1', [1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.addresses WHERE address_id = $1',
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if no address is found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
await expect(addressRepo.getAddressById(999, mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(addressRepo.getAddressById(999, mockLogger)).rejects.toThrow('Address with ID 999 not found.');
|
||||
await expect(addressRepo.getAddressById(999, mockLogger)).rejects.toThrow(
|
||||
'Address with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.getAddressById(1, mockLogger)).rejects.toThrow('Failed to retrieve address.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, addressId: 1 }, 'Database error in getAddressById');
|
||||
await expect(addressRepo.getAddressById(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve address.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, addressId: 1 },
|
||||
'Database error in getAddressById',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,8 +101,13 @@ describe('Address DB Service', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow('Failed to upsert address.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, address: newAddressData }, 'Database error in upsertAddress');
|
||||
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow(
|
||||
'Failed to upsert address.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: newAddressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error on UPDATE failure', async () => {
|
||||
@@ -100,19 +115,33 @@ describe('Address DB Service', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(existingAddressData, mockLogger)).rejects.toThrow('Failed to upsert address.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, address: existingAddressData }, 'Database error in upsertAddress');
|
||||
await expect(addressRepo.upsertAddress(existingAddressData, mockLogger)).rejects.toThrow(
|
||||
'Failed to upsert address.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: existingAddressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UniqueConstraintError on duplicate address insert', async () => {
|
||||
const newAddressData = { address_line_1: '123 Main St', city: 'Anytown' };
|
||||
const dbError = new Error('duplicate key value violates unique constraint') as Error & { code: string };
|
||||
const dbError = new Error('duplicate key value violates unique constraint') as Error & {
|
||||
code: string;
|
||||
};
|
||||
dbError.code = '23505';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow(UniqueConstraintError);
|
||||
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow('An identical address already exists.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, address: newAddressData }, 'Database error in upsertAddress');
|
||||
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow(
|
||||
UniqueConstraintError,
|
||||
);
|
||||
await expect(addressRepo.upsertAddress(newAddressData, mockLogger)).rejects.toThrow(
|
||||
'An identical address already exists.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: newAddressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/services/db/address.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { getPool } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { UniqueConstraintError, NotFoundError } from './errors.db';
|
||||
import { Address } from '../../types';
|
||||
@@ -19,7 +19,10 @@ export class AddressRepository {
|
||||
*/
|
||||
async getAddressById(addressId: number, logger: Logger): Promise<Address> {
|
||||
try {
|
||||
const res = await this.db.query<Address>('SELECT * FROM public.addresses WHERE address_id = $1', [addressId]);
|
||||
const res = await this.db.query<Address>(
|
||||
'SELECT * FROM public.addresses WHERE address_id = $1',
|
||||
[addressId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError(`Address with ID ${addressId} not found.`);
|
||||
}
|
||||
@@ -57,8 +60,8 @@ export class AddressRepository {
|
||||
// Dynamically build the SET clause for the UPDATE part.
|
||||
// EXCLUDED refers to the values from the failed INSERT attempt.
|
||||
const updateSetClauses = columns
|
||||
.filter(col => col !== 'address_id') // Don't update the primary key
|
||||
.map(col => `${col} = EXCLUDED.${col}`)
|
||||
.filter((col) => col !== 'address_id') // Don't update the primary key
|
||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||
.join(', ');
|
||||
|
||||
const query = `
|
||||
@@ -72,11 +75,11 @@ export class AddressRepository {
|
||||
|
||||
const res = await this.db.query<{ address_id: number }>(query, values);
|
||||
return res.rows[0].address_id;
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ err: error, address }, 'Database error in upsertAddress');
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') throw new UniqueConstraintError('An identical address already exists.');
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505')
|
||||
throw new UniqueConstraintError('An identical address already exists.');
|
||||
throw new Error('Failed to upsert address.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import type { Pool, PoolClient } from 'pg';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { AdminRepository } from './admin.db';
|
||||
import type { SuggestedCorrection, AdminUserView, Profile } from '../../types';
|
||||
import { createMockSuggestedCorrection, createMockAdminUserView, createMockProfile } from '../../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockSuggestedCorrection,
|
||||
createMockAdminUserView,
|
||||
createMockProfile,
|
||||
} from '../../tests/utils/mockFactories';
|
||||
// Un-mock the module we are testing
|
||||
vi.unmock('./admin.db');
|
||||
|
||||
@@ -45,20 +49,29 @@ describe('Admin DB Service', () => {
|
||||
|
||||
describe('getSuggestedCorrections', () => {
|
||||
it('should execute the correct query and return corrections', async () => {
|
||||
const mockCorrections: SuggestedCorrection[] = [createMockSuggestedCorrection({ suggested_correction_id: 1 })];
|
||||
const mockCorrections: SuggestedCorrection[] = [
|
||||
createMockSuggestedCorrection({ suggested_correction_id: 1 }),
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections });
|
||||
|
||||
const result = await adminRepo.getSuggestedCorrections(mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc"));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.suggested_corrections sc'),
|
||||
);
|
||||
expect(result).toEqual(mockCorrections);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.getSuggestedCorrections(mockLogger)).rejects.toThrow('Failed to retrieve suggested corrections.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getSuggestedCorrections');
|
||||
await expect(adminRepo.getSuggestedCorrections(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve suggested corrections.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getSuggestedCorrections',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,14 +80,22 @@ describe('Admin DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // Mock the function call
|
||||
await adminRepo.approveCorrection(123, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT public.approve_correction($1)',
|
||||
[123],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database function fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.approveCorrection(123, mockLogger)).rejects.toThrow('Failed to approve correction.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, correctionId: 123 }, 'Database transaction error in approveCorrection');
|
||||
await expect(adminRepo.approveCorrection(123, mockLogger)).rejects.toThrow(
|
||||
'Failed to approve correction.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, correctionId: 123 },
|
||||
'Database transaction error in approveCorrection',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,47 +106,66 @@ describe('Admin DB Service', () => {
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE public.suggested_corrections SET status = 'rejected'"),
|
||||
[123]
|
||||
[123],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if the correction is not found or not pending', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow('Correction with ID 123 not found or not in \'pending\' state.');
|
||||
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow(
|
||||
"Correction with ID 123 not found or not in 'pending' state.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
|
||||
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow('Failed to reject correction.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), correctionId: 123 }, 'Database error in rejectCorrection');
|
||||
await expect(adminRepo.rejectCorrection(123, mockLogger)).rejects.toThrow(
|
||||
'Failed to reject correction.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), correctionId: 123 },
|
||||
'Database error in rejectCorrection',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSuggestedCorrection', () => {
|
||||
it('should update the suggested value and return the updated correction', async () => {
|
||||
const mockCorrection = createMockSuggestedCorrection({ suggested_correction_id: 1, suggested_value: '300' });
|
||||
const mockCorrection = createMockSuggestedCorrection({
|
||||
suggested_correction_id: 1,
|
||||
suggested_value: '300',
|
||||
});
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
|
||||
|
||||
const result = await adminRepo.updateSuggestedCorrection(1, '300', mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE public.suggested_corrections SET suggested_value = $1"),
|
||||
['300', 1]
|
||||
expect.stringContaining('UPDATE public.suggested_corrections SET suggested_value = $1'),
|
||||
['300', 1],
|
||||
);
|
||||
expect(result).toEqual(mockCorrection);
|
||||
});
|
||||
|
||||
it('should throw an error if the correction is not found (rowCount is 0)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
await expect(adminRepo.updateSuggestedCorrection(999, 'new value', mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.updateSuggestedCorrection(999, 'new value', mockLogger)).rejects.toThrow('Correction with ID 999 not found or is not in \'pending\' state.');
|
||||
await expect(
|
||||
adminRepo.updateSuggestedCorrection(999, 'new value', mockLogger),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
await expect(
|
||||
adminRepo.updateSuggestedCorrection(999, 'new value', mockLogger),
|
||||
).rejects.toThrow("Correction with ID 999 not found or is not in 'pending' state.");
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
|
||||
await expect(adminRepo.updateSuggestedCorrection(1, 'new value', mockLogger)).rejects.toThrow('Failed to update suggested correction.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), correctionId: 1 }, 'Database error in updateSuggestedCorrection');
|
||||
await expect(adminRepo.updateSuggestedCorrection(1, 'new value', mockLogger)).rejects.toThrow(
|
||||
'Failed to update suggested correction.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), correctionId: 1 },
|
||||
'Database error in updateSuggestedCorrection',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +201,10 @@ describe('Admin DB Service', () => {
|
||||
|
||||
// The Promise.all should reject, and the function should re-throw the error
|
||||
await expect(adminRepo.getApplicationStats(mockLogger)).rejects.toThrow('DB Read Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in getApplicationStats');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Database error in getApplicationStats',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,15 +215,22 @@ describe('Admin DB Service', () => {
|
||||
|
||||
const result = await adminRepo.getDailyStatsForLast30Days(mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS"));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WITH date_series AS'),
|
||||
);
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.getDailyStatsForLast30Days(mockLogger)).rejects.toThrow('Failed to retrieve daily statistics.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getDailyStatsForLast30Days');
|
||||
await expect(adminRepo.getDailyStatsForLast30Days(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve daily statistics.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getDailyStatsForLast30Days',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,8 +241,8 @@ describe('Admin DB Service', () => {
|
||||
await adminRepo.logActivity(logData, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("INSERT INTO public.activity_log"),
|
||||
[logData.userId, logData.action, logData.displayText, null, null]
|
||||
expect.stringContaining('INSERT INTO public.activity_log'),
|
||||
[logData.userId, logData.action, logData.displayText, null, null],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -200,7 +250,10 @@ describe('Admin DB Service', () => {
|
||||
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
|
||||
const logData = { action: 'test_action', displayText: 'Test activity' };
|
||||
await expect(adminRepo.logActivity(logData, mockLogger)).resolves.toBeUndefined();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), logData }, 'Database error in logActivity');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), logData },
|
||||
'Database error in logActivity',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,14 +261,22 @@ describe('Admin DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await adminRepo.getMostFrequentSaleItems(30, 10, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.flyer_items fi'),
|
||||
[30, 10],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.getMostFrequentSaleItems(30, 10, mockLogger)).rejects.toThrow('Failed to get most frequent sale items.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getMostFrequentSaleItems');
|
||||
await expect(adminRepo.getMostFrequentSaleItems(30, 10, mockLogger)).rejects.toThrow(
|
||||
'Failed to get most frequent sale items.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getMostFrequentSaleItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,20 +285,30 @@ describe('Admin DB Service', () => {
|
||||
const mockComment = { comment_id: 1, status: 'hidden' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockComment], rowCount: 1 });
|
||||
const result = await adminRepo.updateRecipeCommentStatus(1, 'hidden', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.recipe_comments'),
|
||||
['hidden', 1],
|
||||
);
|
||||
expect(result).toEqual(mockComment);
|
||||
});
|
||||
|
||||
it('should throw an error if the comment is not found (rowCount is 0)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
await expect(adminRepo.updateRecipeCommentStatus(999, 'hidden', mockLogger)).rejects.toThrow('Recipe comment with ID 999 not found.');
|
||||
await expect(adminRepo.updateRecipeCommentStatus(999, 'hidden', mockLogger)).rejects.toThrow(
|
||||
'Recipe comment with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.updateRecipeCommentStatus(1, 'hidden', mockLogger)).rejects.toThrow('Failed to update recipe comment status.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, commentId: 1, status: 'hidden' }, 'Database error in updateRecipeCommentStatus');
|
||||
await expect(adminRepo.updateRecipeCommentStatus(1, 'hidden', mockLogger)).rejects.toThrow(
|
||||
'Failed to update recipe comment status.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, commentId: 1, status: 'hidden' },
|
||||
'Database error in updateRecipeCommentStatus',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,14 +316,21 @@ describe('Admin DB Service', () => {
|
||||
it('should execute the correct query to get unmatched items', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await adminRepo.getUnmatchedFlyerItems(mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi'));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.unmatched_flyer_items ufi'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.getUnmatchedFlyerItems(mockLogger)).rejects.toThrow('Failed to retrieve unmatched flyer items.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getUnmatchedFlyerItems');
|
||||
await expect(adminRepo.getUnmatchedFlyerItems(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve unmatched flyer items.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getUnmatchedFlyerItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,21 +339,33 @@ describe('Admin DB Service', () => {
|
||||
const mockRecipe = { recipe_id: 1, status: 'public' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
|
||||
const result = await adminRepo.updateRecipeStatus(1, 'public', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.recipes'),
|
||||
['public', 1],
|
||||
);
|
||||
expect(result).toEqual(mockRecipe);
|
||||
});
|
||||
|
||||
it('should throw an error if the recipe is not found (rowCount is 0)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
await expect(adminRepo.updateRecipeStatus(999, 'public', mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.updateRecipeStatus(999, 'public', mockLogger)).rejects.toThrow('Recipe with ID 999 not found.');
|
||||
await expect(adminRepo.updateRecipeStatus(999, 'public', mockLogger)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(adminRepo.updateRecipeStatus(999, 'public', mockLogger)).rejects.toThrow(
|
||||
'Recipe with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.updateRecipeStatus(1, 'public', mockLogger)).rejects.toThrow('Failed to update recipe status.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1, status: 'public' }, 'Database error in updateRecipeStatus');
|
||||
await expect(adminRepo.updateRecipeStatus(1, 'public', mockLogger)).rejects.toThrow(
|
||||
'Failed to update recipe status.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, recipeId: 1, status: 'public' },
|
||||
'Database error in updateRecipeStatus',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,12 +381,21 @@ describe('Admin DB Service', () => {
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
return callback(mockClient as unknown as PoolClient);
|
||||
});
|
||||
|
||||
|
||||
await adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT flyer_item_id FROM public.unmatched_flyer_items'), [1]);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.flyer_items'), [101, 55]);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'resolved'"), [1]);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT flyer_item_id FROM public.unmatched_flyer_items'),
|
||||
[1],
|
||||
);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.flyer_items'),
|
||||
[101, 55],
|
||||
);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'resolved'"),
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if the unmatched item is not found', async () => {
|
||||
@@ -307,8 +406,12 @@ describe('Admin DB Service', () => {
|
||||
throw new NotFoundError(`Unmatched flyer item with ID 999 not found.`); // Re-throw for the outer expect
|
||||
});
|
||||
|
||||
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101, mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101, mockLogger)).rejects.toThrow('Unmatched flyer item with ID 999 not found.');
|
||||
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101, mockLogger)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101, mockLogger)).rejects.toThrow(
|
||||
'Unmatched flyer item with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should rollback transaction on generic error', async () => {
|
||||
@@ -322,8 +425,13 @@ describe('Admin DB Service', () => {
|
||||
throw dbError; // Re-throw for the outer expect
|
||||
});
|
||||
|
||||
await expect(adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger)).rejects.toThrow('Failed to resolve unmatched flyer item.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, unmatchedFlyerItemId: 1, masterItemId: 101 }, 'Database transaction error in resolveUnmatchedFlyerItem');
|
||||
await expect(adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger)).rejects.toThrow(
|
||||
'Failed to resolve unmatched flyer item.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, unmatchedFlyerItemId: 1, masterItemId: 101 },
|
||||
'Database transaction error in resolveUnmatchedFlyerItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,21 +439,36 @@ describe('Admin DB Service', () => {
|
||||
it('should update the status of an unmatched item to "ignored"', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 1 });
|
||||
await adminRepo.ignoreUnmatchedFlyerItem(1, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'", [1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
"UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'",
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if the unmatched item is not found or not pending', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(adminRepo.ignoreUnmatchedFlyerItem(999, mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.ignoreUnmatchedFlyerItem(999, mockLogger)).rejects.toThrow('Unmatched flyer item with ID 999 not found or not in \'pending\' state.');
|
||||
await expect(adminRepo.ignoreUnmatchedFlyerItem(999, mockLogger)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(adminRepo.ignoreUnmatchedFlyerItem(999, mockLogger)).rejects.toThrow(
|
||||
"Unmatched flyer item with ID 999 not found or not in 'pending' state.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.ignoreUnmatchedFlyerItem(1, mockLogger)).rejects.toThrow('Failed to ignore unmatched flyer item.');
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'ignored'"), [1]);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, unmatchedFlyerItemId: 1 }, 'Database error in ignoreUnmatchedFlyerItem');
|
||||
await expect(adminRepo.ignoreUnmatchedFlyerItem(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to ignore unmatched flyer item.',
|
||||
);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'ignored'"),
|
||||
[1],
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, unmatchedFlyerItemId: 1 },
|
||||
'Database error in ignoreUnmatchedFlyerItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,29 +485,37 @@ describe('Admin DB Service', () => {
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
// The test now verifies the full structure of the query.
|
||||
expect.stringMatching(expectedQueryRegex),
|
||||
['user-123', '127.0.0.1']
|
||||
['user-123', '127.0.0.1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw an error if the database query fails (non-critical)', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.resetFailedLoginAttempts('user-123', '127.0.0.1', mockLogger)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
adminRepo.resetFailedLoginAttempts('user-123', '127.0.0.1', mockLogger),
|
||||
).resolves.toBeUndefined();
|
||||
const { logger } = await import('../logger.server');
|
||||
expect(logger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in resetFailedLoginAttempts');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in resetFailedLoginAttempts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementFailedLoginAttempts', () => {
|
||||
it('should execute an UPDATE query and return the new attempt count', async () => {
|
||||
// Mock the DB to return the new count
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ failed_login_attempts: 3 }], rowCount: 1 });
|
||||
mockPoolInstance.query.mockResolvedValue({
|
||||
rows: [{ failed_login_attempts: 3 }],
|
||||
rowCount: 1,
|
||||
});
|
||||
const newCount = await adminRepo.incrementFailedLoginAttempts('user-123', mockLogger);
|
||||
|
||||
expect(newCount).toBe(3);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('RETURNING failed_login_attempts'),
|
||||
['user-123']
|
||||
['user-123'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -392,7 +523,10 @@ describe('Admin DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
const newCount = await adminRepo.incrementFailedLoginAttempts('user-not-found', mockLogger);
|
||||
expect(newCount).toBe(0);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith({ userId: 'user-not-found' }, 'Attempted to increment failed login attempts for a non-existent user.');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{ userId: 'user-not-found' },
|
||||
'Attempted to increment failed login attempts for a non-existent user.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return -1 if the database query fails', async () => {
|
||||
@@ -401,7 +535,10 @@ describe('Admin DB Service', () => {
|
||||
const newCount = await adminRepo.incrementFailedLoginAttempts('user-123', mockLogger);
|
||||
|
||||
expect(newCount).toBe(-1);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in incrementFailedLoginAttempts');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in incrementFailedLoginAttempts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,21 +546,36 @@ describe('Admin DB Service', () => {
|
||||
it('should execute an UPDATE query for the brand logo', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await adminRepo.updateBrandLogo(1, '/logo.png', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2',
|
||||
['/logo.png', 1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if the brand is not found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(adminRepo.updateBrandLogo(999, '/logo.png', mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.updateBrandLogo(999, '/logo.png', mockLogger)).rejects.toThrow('Brand with ID 999 not found.');
|
||||
await expect(adminRepo.updateBrandLogo(999, '/logo.png', mockLogger)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(adminRepo.updateBrandLogo(999, '/logo.png', mockLogger)).rejects.toThrow(
|
||||
'Brand with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.updateBrandLogo(1, '/logo.png', mockLogger)).rejects.toThrow('Failed to update brand logo in database.');
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.brands SET logo_url'), ['/logo.png', 1]);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, brandId: 1 }, 'Database error in updateBrandLogo');
|
||||
await expect(adminRepo.updateBrandLogo(1, '/logo.png', mockLogger)).rejects.toThrow(
|
||||
'Failed to update brand logo in database.',
|
||||
);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.brands SET logo_url'),
|
||||
['/logo.png', 1],
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, brandId: 1 },
|
||||
'Database error in updateBrandLogo',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,21 +584,33 @@ describe('Admin DB Service', () => {
|
||||
const mockReceipt = { receipt_id: 1, status: 'completed' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 });
|
||||
const result = await adminRepo.updateReceiptStatus(1, 'completed', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.receipts'),
|
||||
['completed', 1],
|
||||
);
|
||||
expect(result).toEqual(mockReceipt);
|
||||
});
|
||||
|
||||
it('should throw an error if the receipt is not found (rowCount is 0)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
await expect(adminRepo.updateReceiptStatus(999, 'completed', mockLogger)).rejects.toThrow(NotFoundError);
|
||||
await expect(adminRepo.updateReceiptStatus(999, 'completed', mockLogger)).rejects.toThrow('Receipt with ID 999 not found.');
|
||||
await expect(adminRepo.updateReceiptStatus(999, 'completed', mockLogger)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(adminRepo.updateReceiptStatus(999, 'completed', mockLogger)).rejects.toThrow(
|
||||
'Receipt with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.updateReceiptStatus(1, 'completed', mockLogger)).rejects.toThrow('Failed to update receipt status.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1, status: 'completed' }, 'Database error in updateReceiptStatus');
|
||||
await expect(adminRepo.updateReceiptStatus(1, 'completed', mockLogger)).rejects.toThrow(
|
||||
'Failed to update receipt status.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, receiptId: 1, status: 'completed' },
|
||||
'Database error in updateReceiptStatus',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -454,31 +618,48 @@ describe('Admin DB Service', () => {
|
||||
it('should call the get_activity_log database function', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await adminRepo.getActivityLog(50, 0, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_activity_log($1, $2)', [50, 0]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_activity_log($1, $2)',
|
||||
[50, 0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.getActivityLog(50, 0, mockLogger)).rejects.toThrow('Failed to retrieve activity log.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, limit: 50, offset: 0 }, 'Database error in getActivityLog');
|
||||
await expect(adminRepo.getActivityLog(50, 0, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve activity log.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, limit: 50, offset: 0 },
|
||||
'Database error in getActivityLog',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllUsers', () => {
|
||||
it('should return a list of all users for the admin view', async () => {
|
||||
const mockUsers: AdminUserView[] = [createMockAdminUserView({ user_id: '1', email: 'test@test.com' })];
|
||||
const mockUsers: AdminUserView[] = [
|
||||
createMockAdminUserView({ user_id: '1', email: 'test@test.com' }),
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockUsers });
|
||||
const result = await adminRepo.getAllUsers(mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p'));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.users u JOIN public.profiles p'),
|
||||
);
|
||||
expect(result).toEqual(mockUsers);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.getAllUsers(mockLogger)).rejects.toThrow('Failed to retrieve all users.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllUsers');
|
||||
await expect(adminRepo.getAllUsers(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve all users.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getAllUsers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -487,20 +668,28 @@ describe('Admin DB Service', () => {
|
||||
const mockProfile: Profile = createMockProfile({ role: 'admin' });
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile], rowCount: 1 });
|
||||
const result = await adminRepo.updateUserRole('1', 'admin', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', ['admin', '1']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
|
||||
['admin', '1'],
|
||||
);
|
||||
expect(result).toEqual(mockProfile);
|
||||
});
|
||||
|
||||
it('should throw an error if the user is not found (rowCount is 0)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
await expect(adminRepo.updateUserRole('999', 'admin', mockLogger)).rejects.toThrow('User with ID 999 not found.');
|
||||
await expect(adminRepo.updateUserRole('999', 'admin', mockLogger)).rejects.toThrow(
|
||||
'User with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-throw a generic error if the database query fails for other reasons', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.updateUserRole('1', 'admin', mockLogger)).rejects.toThrow('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '1', role: 'admin' }, 'Database error in updateUserRole');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: '1', role: 'admin' },
|
||||
'Database error in updateUserRole',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -510,8 +699,15 @@ describe('Admin DB Service', () => {
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger)).rejects.toThrow('The specified user does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', role: 'admin' }, 'Database error in updateUserRole');
|
||||
await expect(
|
||||
adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(
|
||||
adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger),
|
||||
).rejects.toThrow('The specified user does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'non-existent-user', role: 'admin' },
|
||||
'Database error in updateUserRole',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,17 @@ import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, AdminUserView, Profile } from '../../types';
|
||||
import {
|
||||
SuggestedCorrection,
|
||||
MostFrequentSaleItem,
|
||||
Recipe,
|
||||
RecipeComment,
|
||||
UnmatchedFlyerItem,
|
||||
ActivityLogItem,
|
||||
Receipt,
|
||||
AdminUserView,
|
||||
Profile,
|
||||
} from '../../types';
|
||||
|
||||
export class AdminRepository {
|
||||
private db: Pool | PoolClient;
|
||||
@@ -210,7 +220,11 @@ export class AdminRepository {
|
||||
* @param limit The maximum number of items to return.
|
||||
* @returns A promise that resolves to an array of the most frequent sale items.
|
||||
*/
|
||||
async getMostFrequentSaleItems(days: number, limit: number, logger: Logger): Promise<MostFrequentSaleItem[]> {
|
||||
async getMostFrequentSaleItems(
|
||||
days: number,
|
||||
limit: number,
|
||||
logger: Logger,
|
||||
): Promise<MostFrequentSaleItem[]> {
|
||||
// This is a secure parameterized query. The values for `days` and `limit` are passed
|
||||
// separately from the query string. The database driver safely substitutes the `$1` and `$2`
|
||||
// placeholders, preventing SQL injection attacks.
|
||||
@@ -248,23 +262,30 @@ export class AdminRepository {
|
||||
* @param status The new status ('visible', 'hidden', 'reported').
|
||||
* @returns A promise that resolves to the updated RecipeComment object.
|
||||
*/
|
||||
async updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported', logger: Logger): Promise<RecipeComment> {
|
||||
try {
|
||||
const res = await this.db.query<RecipeComment>(
|
||||
'UPDATE public.recipe_comments SET status = $1 WHERE recipe_comment_id = $2 RETURNING *',
|
||||
[status, commentId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError(`Recipe comment with ID ${commentId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error, commentId, status }, 'Database error in updateRecipeCommentStatus');
|
||||
throw new Error('Failed to update recipe comment status.');
|
||||
async updateRecipeCommentStatus(
|
||||
commentId: number,
|
||||
status: 'visible' | 'hidden' | 'reported',
|
||||
logger: Logger,
|
||||
): Promise<RecipeComment> {
|
||||
try {
|
||||
const res = await this.db.query<RecipeComment>(
|
||||
'UPDATE public.recipe_comments SET status = $1 WHERE recipe_comment_id = $2 RETURNING *',
|
||||
[status, commentId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError(`Recipe comment with ID ${commentId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error(
|
||||
{ err: error, commentId, status },
|
||||
'Database error in updateRecipeCommentStatus',
|
||||
);
|
||||
throw new Error('Failed to update recipe comment status.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,21 +325,25 @@ export class AdminRepository {
|
||||
* @param status The new status ('private', 'pending_review', 'public', 'rejected').
|
||||
* @returns A promise that resolves to the updated Recipe object.
|
||||
*/
|
||||
async updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected', logger: Logger): Promise<Recipe> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>(
|
||||
'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *',
|
||||
[status, recipeId]
|
||||
);
|
||||
if (res.rowCount === 0) throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error, recipeId, status }, 'Database error in updateRecipeStatus');
|
||||
throw new Error('Failed to update recipe status.'); // Keep generic for other DB errors
|
||||
async updateRecipeStatus(
|
||||
recipeId: number,
|
||||
status: 'private' | 'pending_review' | 'public' | 'rejected',
|
||||
logger: Logger,
|
||||
): Promise<Recipe> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>(
|
||||
'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *',
|
||||
[status, recipeId],
|
||||
);
|
||||
if (res.rowCount === 0) throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error, recipeId, status }, 'Database error in updateRecipeStatus');
|
||||
throw new Error('Failed to update recipe status.'); // Keep generic for other DB errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,34 +352,52 @@ export class AdminRepository {
|
||||
* @param unmatchedFlyerItemId The ID from the `unmatched_flyer_items` table.
|
||||
* @param masterItemId The ID of the `master_grocery_items` to link to.
|
||||
*/
|
||||
async resolveUnmatchedFlyerItem(unmatchedFlyerItemId: number, masterItemId: number, logger: Logger): Promise<void> {
|
||||
async resolveUnmatchedFlyerItem(
|
||||
unmatchedFlyerItemId: number,
|
||||
masterItemId: number,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withTransaction(async (client) => {
|
||||
// First, get the flyer_item_id from the unmatched record
|
||||
const unmatchedRes = await client.query<{
|
||||
flyer_item_id: number;
|
||||
}>('SELECT flyer_item_id FROM public.unmatched_flyer_items WHERE unmatched_flyer_item_id = $1 FOR UPDATE', [
|
||||
unmatchedFlyerItemId,
|
||||
]);
|
||||
}>(
|
||||
'SELECT flyer_item_id FROM public.unmatched_flyer_items WHERE unmatched_flyer_item_id = $1 FOR UPDATE',
|
||||
[unmatchedFlyerItemId],
|
||||
);
|
||||
|
||||
if (unmatchedRes.rowCount === 0) {
|
||||
throw new NotFoundError(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`);
|
||||
throw new NotFoundError(
|
||||
`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`,
|
||||
);
|
||||
}
|
||||
const { flyer_item_id } = unmatchedRes.rows[0];
|
||||
|
||||
// Next, update the original flyer_items table with the correct master_item_id
|
||||
await client.query('UPDATE public.flyer_items SET master_item_id = $1 WHERE flyer_item_id = $2', [masterItemId, flyer_item_id]);
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET master_item_id = $1 WHERE flyer_item_id = $2',
|
||||
[masterItemId, flyer_item_id],
|
||||
);
|
||||
|
||||
// Finally, update the status of the unmatched record to 'resolved'
|
||||
await client.query("UPDATE public.unmatched_flyer_items SET status = 'resolved' WHERE unmatched_flyer_item_id = $1", [unmatchedFlyerItemId]);
|
||||
await client.query(
|
||||
"UPDATE public.unmatched_flyer_items SET status = 'resolved' WHERE unmatched_flyer_item_id = $1",
|
||||
[unmatchedFlyerItemId],
|
||||
);
|
||||
|
||||
logger.info(`Successfully resolved unmatched item ${unmatchedFlyerItemId} to master item ${masterItemId}.`);
|
||||
logger.info(
|
||||
`Successfully resolved unmatched item ${unmatchedFlyerItemId} to master item ${masterItemId}.`,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error, unmatchedFlyerItemId, masterItemId }, 'Database transaction error in resolveUnmatchedFlyerItem');
|
||||
logger.error(
|
||||
{ err: error, unmatchedFlyerItemId, masterItemId },
|
||||
'Database transaction error in resolveUnmatchedFlyerItem',
|
||||
);
|
||||
throw new Error('Failed to resolve unmatched flyer item.');
|
||||
}
|
||||
}
|
||||
@@ -365,13 +408,21 @@ export class AdminRepository {
|
||||
*/
|
||||
async ignoreUnmatchedFlyerItem(unmatchedFlyerItemId: number, logger: Logger): Promise<void> {
|
||||
try {
|
||||
const res = await this.db.query("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'", [unmatchedFlyerItemId]);
|
||||
const res = await this.db.query(
|
||||
"UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'",
|
||||
[unmatchedFlyerItemId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found or not in 'pending' state.`);
|
||||
throw new NotFoundError(
|
||||
`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found or not in 'pending' state.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error({ err: error, unmatchedFlyerItemId }, 'Database error in ignoreUnmatchedFlyerItem');
|
||||
logger.error(
|
||||
{ err: error, unmatchedFlyerItemId },
|
||||
'Database error in ignoreUnmatchedFlyerItem',
|
||||
);
|
||||
throw new Error('Failed to ignore unmatched flyer item.');
|
||||
}
|
||||
}
|
||||
@@ -397,13 +448,16 @@ export class AdminRepository {
|
||||
* Defines a type for JSON-compatible data structures, allowing for nested objects and arrays.
|
||||
* This provides a safer alternative to `any` for objects intended for JSON serialization.
|
||||
*/
|
||||
async logActivity(logData: {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
displayText: string;
|
||||
icon?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
}, logger: Logger): Promise<void> {
|
||||
async logActivity(
|
||||
logData: {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
displayText: string;
|
||||
icon?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
},
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const { userId, action, displayText, icon, details } = logData;
|
||||
try {
|
||||
await this.db.query(
|
||||
@@ -415,7 +469,7 @@ export class AdminRepository {
|
||||
displayText,
|
||||
icon || null,
|
||||
details ? JSON.stringify(details) : null,
|
||||
]
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, logData }, 'Database error in logActivity');
|
||||
@@ -435,10 +489,13 @@ export class AdminRepository {
|
||||
SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING failed_login_attempts`,
|
||||
[userId]
|
||||
[userId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
logger.warn({ userId }, 'Attempted to increment failed login attempts for a non-existent user.');
|
||||
logger.warn(
|
||||
{ userId },
|
||||
'Attempted to increment failed login attempts for a non-existent user.',
|
||||
);
|
||||
return 0; // Should not happen if called after user lookup, but safe to handle.
|
||||
}
|
||||
return res.rows[0].failed_login_attempts;
|
||||
@@ -459,7 +516,7 @@ export class AdminRepository {
|
||||
`UPDATE public.users
|
||||
SET failed_login_attempts = 0, last_failed_login = NULL, last_login_ip = $2, last_login_at = NOW()
|
||||
WHERE user_id = $1 AND failed_login_attempts > 0`,
|
||||
[userId, ipAddress]
|
||||
[userId, ipAddress],
|
||||
);
|
||||
} catch (error) {
|
||||
// This is a non-critical operation, so we just log the error and continue.
|
||||
@@ -495,11 +552,15 @@ export class AdminRepository {
|
||||
* @param status The new status for the receipt.
|
||||
* @returns A promise that resolves to the updated Receipt object.
|
||||
*/
|
||||
async updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed', logger: Logger): Promise<Receipt> {
|
||||
async updateReceiptStatus(
|
||||
receiptId: number,
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed',
|
||||
logger: Logger,
|
||||
): Promise<Receipt> {
|
||||
try {
|
||||
const res = await this.db.query<Receipt>(
|
||||
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE receipt_id = $2 RETURNING *`,
|
||||
[status, receiptId]
|
||||
[status, receiptId],
|
||||
);
|
||||
if (res.rowCount === 0) throw new NotFoundError(`Receipt with ID ${receiptId} not found.`);
|
||||
return res.rows[0];
|
||||
@@ -522,7 +583,7 @@ export class AdminRepository {
|
||||
logger.error({ err: error }, 'Database error in getAllUsers');
|
||||
throw new Error('Failed to retrieve all users.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the role of a specific user.
|
||||
@@ -534,7 +595,7 @@ export class AdminRepository {
|
||||
try {
|
||||
const res = await this.db.query<Profile>(
|
||||
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
|
||||
[role, userId]
|
||||
[role, userId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError(`User with ID ${userId} not found.`);
|
||||
@@ -551,4 +612,4 @@ export class AdminRepository {
|
||||
throw error; // Re-throw to be handled by the route
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,26 @@ describe('Budget DB Service', () => {
|
||||
|
||||
describe('getBudgetsForUser', () => {
|
||||
it('should execute the correct SELECT query and return budgets', async () => {
|
||||
const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }];
|
||||
const mockBudgets: Budget[] = [
|
||||
{
|
||||
budget_id: 1,
|
||||
user_id: 'user-123',
|
||||
name: 'Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly',
|
||||
start_date: '2024-01-01',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockBudgets });
|
||||
|
||||
|
||||
const result = await budgetRepo.getBudgetsForUser('user-123', mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC', ['user-123']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
|
||||
['user-123'],
|
||||
);
|
||||
expect(result).toEqual(mockBudgets);
|
||||
});
|
||||
|
||||
@@ -70,16 +84,32 @@ describe('Budget DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(budgetRepo.getBudgetsForUser('user-123', mockLogger)).rejects.toThrow('Failed to retrieve budgets.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getBudgetsForUser');
|
||||
await expect(budgetRepo.getBudgetsForUser('user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve budgets.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getBudgetsForUser',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBudget', () => {
|
||||
it('should execute an INSERT query and return the new budget', async () => {
|
||||
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
|
||||
const mockCreatedBudget: Budget = { budget_id: 1, user_id: 'user-123', ...budgetData, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
|
||||
const budgetData = {
|
||||
name: 'Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly' as const,
|
||||
start_date: '2024-01-01',
|
||||
};
|
||||
const mockCreatedBudget: Budget = {
|
||||
budget_id: 1,
|
||||
user_id: 'user-123',
|
||||
...budgetData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Create a mock client that we can reference both inside and outside the transaction mock.
|
||||
const mockClient = { query: vi.fn() };
|
||||
|
||||
@@ -90,18 +120,30 @@ describe('Budget DB Service', () => {
|
||||
.mockResolvedValueOnce({ rows: [] }); // For award_achievement
|
||||
return callback(mockClient as unknown as PoolClient);
|
||||
});
|
||||
|
||||
|
||||
const result = await budgetRepo.createBudget('user-123', budgetData, mockLogger);
|
||||
|
||||
|
||||
// Now we can assert directly on the mockClient we created.
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.budgets'), expect.any(Array));
|
||||
expect(mockedAwardAchievement).toHaveBeenCalledWith('user-123', 'First Budget Created', mockLogger);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.budgets'),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockedAwardAchievement).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'First Budget Created',
|
||||
mockLogger,
|
||||
);
|
||||
expect(result).toEqual(mockCreatedBudget);
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
||||
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
|
||||
const budgetData = {
|
||||
name: 'Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly' as const,
|
||||
start_date: '2024-01-01',
|
||||
};
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
|
||||
@@ -112,32 +154,58 @@ describe('Budget DB Service', () => {
|
||||
throw dbError; // Re-throw for the outer expect
|
||||
});
|
||||
|
||||
await expect(budgetRepo.createBudget('non-existent-user', budgetData, mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(budgetRepo.createBudget('non-existent-user', budgetData, mockLogger)).rejects.toThrow('The specified user does not exist.');
|
||||
await expect(
|
||||
budgetRepo.createBudget('non-existent-user', budgetData, mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(
|
||||
budgetRepo.createBudget('non-existent-user', budgetData, mockLogger),
|
||||
).rejects.toThrow('The specified user does not exist.');
|
||||
});
|
||||
|
||||
it('should rollback the transaction if awarding an achievement fails', async () => {
|
||||
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
|
||||
const mockCreatedBudget: Budget = { budget_id: 1, user_id: 'user-123', ...budgetData, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
const budgetData = {
|
||||
name: 'Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly' as const,
|
||||
start_date: '2024-01-01',
|
||||
};
|
||||
const mockCreatedBudget: Budget = {
|
||||
budget_id: 1,
|
||||
user_id: 'user-123',
|
||||
...budgetData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const achievementError = new Error('Achievement award failed');
|
||||
|
||||
mockedAwardAchievement.mockRejectedValueOnce(achievementError);
|
||||
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
const mockClient = { query: vi.fn() };
|
||||
(mockClient.query as Mock)
|
||||
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }); // INSERT...RETURNING
|
||||
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(achievementError);
|
||||
(mockClient.query as Mock).mockResolvedValueOnce({ rows: [mockCreatedBudget] }); // INSERT...RETURNING
|
||||
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
|
||||
achievementError,
|
||||
);
|
||||
throw achievementError; // Re-throw for the outer expect
|
||||
});
|
||||
|
||||
await expect(budgetRepo.createBudget('user-123', budgetData, mockLogger)).rejects.toThrow('Failed to create budget.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: achievementError, budgetData, userId: 'user-123' }, 'Database error in createBudget');
|
||||
await expect(budgetRepo.createBudget('user-123', budgetData, mockLogger)).rejects.toThrow(
|
||||
'Failed to create budget.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: achievementError, budgetData, userId: 'user-123' },
|
||||
'Database error in createBudget',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
|
||||
const budgetData = {
|
||||
name: 'Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly' as const,
|
||||
start_date: '2024-01-01',
|
||||
};
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
const mockClient = { query: vi.fn() };
|
||||
@@ -145,22 +213,36 @@ describe('Budget DB Service', () => {
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(dbError);
|
||||
throw dbError; // Re-throw for the outer expect
|
||||
});
|
||||
await expect(budgetRepo.createBudget('user-123', budgetData, mockLogger)).rejects.toThrow('Failed to create budget.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, budgetData, userId: 'user-123' }, 'Database error in createBudget');
|
||||
await expect(budgetRepo.createBudget('user-123', budgetData, mockLogger)).rejects.toThrow(
|
||||
'Failed to create budget.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, budgetData, userId: 'user-123' },
|
||||
'Database error in createBudget',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBudget', () => {
|
||||
it('should execute an UPDATE query with COALESCE and return the updated budget', async () => {
|
||||
const budgetUpdates = { name: 'Updated Groceries', amount_cents: 55000 };
|
||||
const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Updated Groceries', amount_cents: 55000, period: 'monthly', start_date: '2024-01-01', created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
const mockUpdatedBudget: Budget = {
|
||||
budget_id: 1,
|
||||
user_id: 'user-123',
|
||||
name: 'Updated Groceries',
|
||||
amount_cents: 55000,
|
||||
period: 'monthly',
|
||||
start_date: '2024-01-01',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockUpdatedBudget], rowCount: 1 });
|
||||
|
||||
const result = await budgetRepo.updateBudget(1, 'user-123', budgetUpdates, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.budgets SET'),
|
||||
[budgetUpdates.name, budgetUpdates.amount_cents, undefined, undefined, 1, 'user-123']
|
||||
[budgetUpdates.name, budgetUpdates.amount_cents, undefined, undefined, 1, 'user-123'],
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedBudget);
|
||||
});
|
||||
@@ -168,15 +250,22 @@ describe('Budget DB Service', () => {
|
||||
it('should throw an error if no rows are updated', async () => {
|
||||
// Arrange: Mock the query to return 0 rows affected
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
await expect(budgetRepo.updateBudget(999, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Budget not found or user does not have permission to update.');
|
||||
|
||||
await expect(
|
||||
budgetRepo.updateBudget(999, 'user-123', { name: 'Fail' }, mockLogger),
|
||||
).rejects.toThrow('Budget not found or user does not have permission to update.');
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(budgetRepo.updateBudget(1, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Failed to update budget.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, budgetId: 1, userId: 'user-123' }, 'Database error in updateBudget');
|
||||
await expect(
|
||||
budgetRepo.updateBudget(1, 'user-123', { name: 'Fail' }, mockLogger),
|
||||
).rejects.toThrow('Failed to update budget.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, budgetId: 1, userId: 'user-123' },
|
||||
'Database error in updateBudget',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,46 +273,76 @@ describe('Budget DB Service', () => {
|
||||
it('should execute a DELETE query with user ownership check', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, command: 'DELETE', rows: [] });
|
||||
await budgetRepo.deleteBudget(1, 'user-123', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [1, 'user-123']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2',
|
||||
[1, 'user-123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if no rows are deleted', async () => {
|
||||
// Arrange: Mock the query to return 0 rows affected
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
await expect(budgetRepo.deleteBudget(999, 'user-123', mockLogger)).rejects.toThrow('Budget not found or user does not have permission to delete.');
|
||||
|
||||
await expect(budgetRepo.deleteBudget(999, 'user-123', mockLogger)).rejects.toThrow(
|
||||
'Budget not found or user does not have permission to delete.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(budgetRepo.deleteBudget(1, 'user-123', mockLogger)).rejects.toThrow('Failed to delete budget.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, budgetId: 1, userId: 'user-123' }, 'Database error in deleteBudget');
|
||||
await expect(budgetRepo.deleteBudget(1, 'user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to delete budget.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, budgetId: 1, userId: 'user-123' },
|
||||
'Database error in deleteBudget',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpendingByCategory', () => {
|
||||
it('should call the correct database function and return spending data', async () => {
|
||||
const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }];
|
||||
const mockSpendingData: SpendingByCategory[] = [
|
||||
{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 },
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockSpendingData });
|
||||
|
||||
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger);
|
||||
const result = await budgetRepo.getSpendingByCategory(
|
||||
'user-123',
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_spending_by_category($1, $2, $3)', ['user-123', '2024-01-01', '2024-01-31']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_spending_by_category($1, $2, $3)',
|
||||
['user-123', '2024-01-01', '2024-01-31'],
|
||||
);
|
||||
expect(result).toEqual(mockSpendingData);
|
||||
});
|
||||
|
||||
it('should return an empty array if there is no spending data', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger);
|
||||
const result = await budgetRepo.getSpendingByCategory(
|
||||
'user-123',
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
mockLogger,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger)).rejects.toThrow('Failed to get spending analysis.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', startDate: '2024-01-01', endDate: '2024-01-31' }, 'Database error in getSpendingByCategory');
|
||||
await expect(
|
||||
budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31', mockLogger),
|
||||
).rejects.toThrow('Failed to get spending analysis.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', startDate: '2024-01-01', endDate: '2024-01-31' },
|
||||
'Database error in getSpendingByCategory',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export class BudgetRepository {
|
||||
try {
|
||||
const res = await this.db.query<Budget>(
|
||||
'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
|
||||
[userId]
|
||||
[userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
@@ -37,13 +37,17 @@ export class BudgetRepository {
|
||||
* @param budgetData The data for the new budget.
|
||||
* @returns A promise that resolves to the newly created Budget object.
|
||||
*/
|
||||
async createBudget(userId: string, budgetData: Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>, logger: Logger): Promise<Budget> {
|
||||
async createBudget(
|
||||
userId: string,
|
||||
budgetData: Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>,
|
||||
logger: Logger,
|
||||
): Promise<Budget> {
|
||||
const { name, amount_cents, period, start_date } = budgetData;
|
||||
try {
|
||||
return await withTransaction(async (client) => {
|
||||
const res = await client.query<Budget>(
|
||||
'INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
||||
[userId, name, amount_cents, period, start_date]
|
||||
[userId, name, amount_cents, period, start_date],
|
||||
);
|
||||
|
||||
// After successfully creating the budget, try to award the 'First Budget Created' achievement.
|
||||
@@ -71,7 +75,12 @@ export class BudgetRepository {
|
||||
* @param budgetData The data to update.
|
||||
* @returns A promise that resolves to the updated Budget object.
|
||||
*/
|
||||
async updateBudget(budgetId: number, userId: string, budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>>, logger: Logger): Promise<Budget> {
|
||||
async updateBudget(
|
||||
budgetId: number,
|
||||
userId: string,
|
||||
budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>>,
|
||||
logger: Logger,
|
||||
): Promise<Budget> {
|
||||
const { name, amount_cents, period, start_date } = budgetData;
|
||||
try {
|
||||
const res = await this.db.query<Budget>(
|
||||
@@ -83,7 +92,8 @@ export class BudgetRepository {
|
||||
WHERE budget_id = $5 AND user_id = $6 RETURNING *`,
|
||||
[name, amount_cents, period, start_date, budgetId, userId],
|
||||
);
|
||||
if (res.rowCount === 0) throw new NotFoundError('Budget not found or user does not have permission to update.');
|
||||
if (res.rowCount === 0)
|
||||
throw new NotFoundError('Budget not found or user does not have permission to update.');
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
@@ -99,7 +109,10 @@ export class BudgetRepository {
|
||||
*/
|
||||
async deleteBudget(budgetId: number, userId: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
const result = await this.db.query('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [budgetId, userId]);
|
||||
const result = await this.db.query(
|
||||
'DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2',
|
||||
[budgetId, userId],
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new NotFoundError('Budget not found or user does not have permission to delete.');
|
||||
}
|
||||
@@ -117,13 +130,24 @@ export class BudgetRepository {
|
||||
* @param endDate The end of the date range.
|
||||
* @returns A promise that resolves to an array of spending data.
|
||||
*/
|
||||
async getSpendingByCategory(userId: string, startDate: string, endDate: string, logger: Logger): Promise<SpendingByCategory[]> {
|
||||
async getSpendingByCategory(
|
||||
userId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
logger: Logger,
|
||||
): Promise<SpendingByCategory[]> {
|
||||
try {
|
||||
const res = await this.db.query<SpendingByCategory>('SELECT * FROM public.get_spending_by_category($1, $2, $3)', [userId, startDate, endDate]);
|
||||
const res = await this.db.query<SpendingByCategory>(
|
||||
'SELECT * FROM public.get_spending_by_category($1, $2, $3)',
|
||||
[userId, startDate, endDate],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, startDate, endDate }, 'Database error in getSpendingByCategory');
|
||||
logger.error(
|
||||
{ err: error, userId, startDate, endDate },
|
||||
'Database error in getSpendingByCategory',
|
||||
);
|
||||
throw new Error('Failed to get spending analysis.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
// FIX: Use a standard function expression so it can be called with 'new'
|
||||
const MockPool = vi.fn(function() {
|
||||
const MockPool = vi.fn(function () {
|
||||
return mockPoolInstance;
|
||||
});
|
||||
|
||||
@@ -45,14 +45,14 @@ import { logger } from '../logger.server';
|
||||
describe('DB Connection Service', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
||||
// We reset modules to ensure 'connection.db.ts' re-evaluates and calls the (mocked) Pool constructor again
|
||||
// Reset implementations to default for shared mocks
|
||||
// This is crucial because 'should throw an error' test overrides implementation
|
||||
mocks.MockPool.mockImplementation(function() {
|
||||
mocks.MockPool.mockImplementation(function () {
|
||||
return mocks.mockPoolInstance;
|
||||
});
|
||||
|
||||
|
||||
// Reset specific method behaviors
|
||||
mocks.mockPoolInstance.query.mockReset();
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('DB Connection Service', () => {
|
||||
// Re-import to ensure fresh state after resetModules
|
||||
const { getPool } = await import('./connection.db');
|
||||
const { Pool } = await import('pg'); // Get the current mock
|
||||
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// Verify Pool constructor was called
|
||||
@@ -86,7 +86,7 @@ describe('DB Connection Service', () => {
|
||||
|
||||
it('should return the same pool instance on subsequent calls', async () => {
|
||||
const { getPool } = await import('./connection.db');
|
||||
|
||||
|
||||
const pool1 = getPool();
|
||||
const pool2 = getPool();
|
||||
|
||||
@@ -104,10 +104,18 @@ describe('DB Connection Service', () => {
|
||||
const mockClient = { query: vi.fn(), release: vi.fn() } as unknown as PoolClient;
|
||||
|
||||
// Manually invoke the captured error handler
|
||||
const errorHandler = vi.mocked(mocks.mockPoolInstance.on).mock.calls.find(call => call[0] === 'error')?.[1] as ((err: Error, client: PoolClient) => void);
|
||||
const errorHandler = vi
|
||||
.mocked(mocks.mockPoolInstance.on)
|
||||
.mock.calls.find((call) => call[0] === 'error')?.[1] as (
|
||||
err: Error,
|
||||
client: PoolClient,
|
||||
) => void;
|
||||
errorHandler(unexpectedError, mockClient);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({ err: unexpectedError, client: mockClient }, 'Unexpected error on idle client in pool');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: unexpectedError, client: mockClient },
|
||||
'Unexpected error on idle client in pool',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,9 +123,9 @@ describe('DB Connection Service', () => {
|
||||
// Arrange: Mock the Pool constructor to throw an error
|
||||
const { Pool } = await import('pg');
|
||||
const constructorError = new Error('Invalid credentials');
|
||||
|
||||
|
||||
// FIX: Use standard function for mock implementation
|
||||
vi.mocked(Pool).mockImplementation(function() {
|
||||
vi.mocked(Pool).mockImplementation(function () {
|
||||
throw constructorError;
|
||||
});
|
||||
|
||||
@@ -131,9 +139,11 @@ describe('DB Connection Service', () => {
|
||||
it('should return an empty array if all tables exist', async () => {
|
||||
const { checkTablesExist } = await import('./connection.db');
|
||||
const pool = mocks.mockPoolInstance;
|
||||
|
||||
|
||||
// Use vi.mocked() to get a type-safe mock of the query function
|
||||
(pool.query as Mock).mockResolvedValue({ rows: [{ table_name: 'users' }, { table_name: 'flyers' }] });
|
||||
(pool.query as Mock).mockResolvedValue({
|
||||
rows: [{ table_name: 'users' }, { table_name: 'flyers' }],
|
||||
});
|
||||
|
||||
const tableNames = ['users', 'flyers'];
|
||||
const missingTables = await checkTablesExist(tableNames);
|
||||
@@ -150,14 +160,19 @@ describe('DB Connection Service', () => {
|
||||
|
||||
const tableNames = ['users'];
|
||||
|
||||
await expect(checkTablesExist(tableNames)).rejects.toThrow('Failed to check for tables in database.');
|
||||
expect(logger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in checkTablesExist');
|
||||
await expect(checkTablesExist(tableNames)).rejects.toThrow(
|
||||
'Failed to check for tables in database.',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in checkTablesExist',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an array of missing tables', async () => {
|
||||
const { checkTablesExist } = await import('./connection.db');
|
||||
const pool = mocks.mockPoolInstance;
|
||||
|
||||
|
||||
(pool.query as Mock).mockResolvedValue({ rows: [{ table_name: 'users' }] });
|
||||
|
||||
const tableNames = ['users', 'flyers', 'products'];
|
||||
@@ -171,7 +186,7 @@ describe('DB Connection Service', () => {
|
||||
it('should return the status counts from the pool instance', async () => {
|
||||
const { getPoolStatus } = await import('./connection.db');
|
||||
const status = getPoolStatus();
|
||||
|
||||
|
||||
expect(status).toEqual({
|
||||
totalCount: 10,
|
||||
idleCount: 5,
|
||||
@@ -216,8 +231,11 @@ describe('DB Connection Service', () => {
|
||||
expect(callback).toHaveBeenCalledWith(mockClient);
|
||||
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||
expect(mockClient.query).not.toHaveBeenCalledWith('COMMIT');
|
||||
expect(logger.error).toHaveBeenCalledWith({ err: dbError }, 'Transaction failed, rolling back.');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Transaction failed, rolling back.',
|
||||
);
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,15 +24,15 @@ export const getPool = (): Pool => {
|
||||
|
||||
logger.info('[DB Connection] Creating new PostgreSQL connection pool...');
|
||||
const poolConfig: PoolConfig = {
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
// Recommended pool settings for a typical web application
|
||||
max: 20, // Max number of clients in the pool
|
||||
idleTimeoutMillis: 30000, // How long a client is allowed to remain idle before being closed
|
||||
connectionTimeoutMillis: 2000, // How long to wait for a client to connect
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
// Recommended pool settings for a typical web application
|
||||
max: 20, // Max number of clients in the pool
|
||||
idleTimeoutMillis: 30000, // How long a client is allowed to remain idle before being closed
|
||||
connectionTimeoutMillis: 2000, // How long to wait for a client to connect
|
||||
};
|
||||
|
||||
pool = new Pool(poolConfig);
|
||||
@@ -69,10 +69,13 @@ export async function withTransaction<T>(callback: (client: PoolClient) => Promi
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error({
|
||||
// Safely access error message
|
||||
err: error,
|
||||
}, 'Transaction failed, rolling back.');
|
||||
logger.error(
|
||||
{
|
||||
// Safely access error message
|
||||
err: error,
|
||||
},
|
||||
'Transaction failed, rolling back.',
|
||||
);
|
||||
throw error; // Re-throw the original error to be handled by the caller
|
||||
} finally {
|
||||
// Always release the client back to the pool
|
||||
@@ -80,7 +83,6 @@ export async function withTransaction<T>(callback: (client: PoolClient) => Promi
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks for the existence of a list of tables in the public schema.
|
||||
* @param tableNames An array of table names to check.
|
||||
@@ -123,4 +125,4 @@ export function getPoolStatus() {
|
||||
idleCount: pool.idleCount,
|
||||
waitingCount: pool.waitingCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,22 @@ describe('Deals DB Service', () => {
|
||||
it('should execute the correct query and return deals', async () => {
|
||||
// Arrange
|
||||
const mockDeals: WatchedItemDeal[] = [
|
||||
{ master_item_id: 1, item_name: 'Apples', best_price_in_cents: 199, store_name: 'Good Food', flyer_id: 10, valid_to: '2025-12-25' },
|
||||
{ master_item_id: 2, item_name: 'Milk', best_price_in_cents: 350, store_name: 'Super Grocer', flyer_id: 11, valid_to: '2025-12-24' },
|
||||
{
|
||||
master_item_id: 1,
|
||||
item_name: 'Apples',
|
||||
best_price_in_cents: 199,
|
||||
store_name: 'Good Food',
|
||||
flyer_id: 10,
|
||||
valid_to: '2025-12-25',
|
||||
},
|
||||
{
|
||||
master_item_id: 2,
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 350,
|
||||
store_name: 'Super Grocer',
|
||||
flyer_id: 11,
|
||||
valid_to: '2025-12-24',
|
||||
},
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockDeals });
|
||||
|
||||
@@ -43,14 +57,23 @@ describe('Deals DB Service', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockDeals);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM flyer_items fi'), ['user-123']);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith({ userId: 'user-123' }, 'Finding best prices for watched items.');
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM flyer_items fi'),
|
||||
['user-123'],
|
||||
);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
{ userId: 'user-123' },
|
||||
'Finding best prices for watched items.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no deals are found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await dealsRepo.findBestPricesForWatchedItems('user-with-no-deals', mockLogger);
|
||||
const result = await dealsRepo.findBestPricesForWatchedItems(
|
||||
'user-with-no-deals',
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -59,8 +82,13 @@ describe('Deals DB Service', () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(dealsRepo.findBestPricesForWatchedItems('user-1', mockLogger)).rejects.toThrow(dbError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in findBestPricesForWatchedItems');
|
||||
await expect(dealsRepo.findBestPricesForWatchedItems('user-1', mockLogger)).rejects.toThrow(
|
||||
dbError,
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in findBestPricesForWatchedItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,10 @@ export class DealsRepository {
|
||||
* @param logger - The logger instance for context-specific logging.
|
||||
* @returns A promise that resolves to an array of WatchedItemDeal objects.
|
||||
*/
|
||||
async findBestPricesForWatchedItems(userId: string, logger: Logger = globalLogger): Promise<WatchedItemDeal[]> {
|
||||
async findBestPricesForWatchedItems(
|
||||
userId: string,
|
||||
logger: Logger = globalLogger,
|
||||
): Promise<WatchedItemDeal[]> {
|
||||
logger.debug({ userId }, 'Finding best prices for watched items.');
|
||||
const query = `
|
||||
WITH UserWatchedItems AS (
|
||||
@@ -70,4 +73,4 @@ export class DealsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
export const dealsRepo = new DealsRepository();
|
||||
export const dealsRepo = new DealsRepository();
|
||||
|
||||
@@ -114,4 +114,4 @@ describe('Custom Database and Application Errors', () => {
|
||||
expect(error.name).toBe('FileUploadError');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,11 +66,10 @@ export class ValidationError extends DatabaseError {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class FileUploadError extends Error {
|
||||
public status = 400;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FileUploadError';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { createMockFlyer, createMockFlyerItem, createMockBrand } from '../../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockFlyer,
|
||||
createMockFlyerItem,
|
||||
createMockBrand,
|
||||
} from '../../tests/utils/mockFactories';
|
||||
|
||||
// Un-mock the module we are testing to ensure we use the real implementation
|
||||
vi.unmock('./flyer.db');
|
||||
|
||||
import { FlyerRepository, createFlyerAndItems } from './flyer.db';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import type { FlyerInsert, FlyerItemInsert, Brand, Flyer, FlyerItem, FlyerDbInsert } from '../../types';
|
||||
import type {
|
||||
FlyerInsert,
|
||||
FlyerItemInsert,
|
||||
Brand,
|
||||
Flyer,
|
||||
FlyerItem,
|
||||
FlyerDbInsert,
|
||||
} from '../../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../logger.server', () => ({
|
||||
@@ -44,7 +55,10 @@ describe('Flyer DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
|
||||
const result = await flyerRepo.findOrCreateStore('Existing Store', mockLogger);
|
||||
expect(result).toBe(1);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT store_id FROM public.stores WHERE name = $1', ['Existing Store']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT store_id FROM public.stores WHERE name = $1',
|
||||
['Existing Store'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new store if it does not exist', async () => {
|
||||
@@ -53,7 +67,10 @@ describe('Flyer DB Service', () => {
|
||||
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] }); // INSERT returns new ID
|
||||
const result = await flyerRepo.findOrCreateStore('New Store', mockLogger);
|
||||
expect(result).toBe(2);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', ['New Store']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id',
|
||||
['New Store'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle race condition where store is created between SELECT and INSERT', async () => {
|
||||
@@ -73,8 +90,13 @@ describe('Flyer DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.findOrCreateStore('Any Store', mockLogger)).rejects.toThrow('Failed to find or create store in database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, storeName: 'Any Store' }, 'Database error in findOrCreateStore');
|
||||
await expect(flyerRepo.findOrCreateStore('Any Store', mockLogger)).rejects.toThrow(
|
||||
'Failed to find or create store in database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, storeName: 'Any Store' },
|
||||
'Database error in findOrCreateStore',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if race condition recovery fails', async () => {
|
||||
@@ -86,8 +108,13 @@ describe('Flyer DB Service', () => {
|
||||
.mockRejectedValueOnce(uniqueConstraintError) // INSERT fails
|
||||
.mockRejectedValueOnce(new Error('Second select fails')); // Recovery SELECT fails
|
||||
|
||||
await expect(flyerRepo.findOrCreateStore('Racy Store', mockLogger)).rejects.toThrow('Failed to find or create store in database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), storeName: 'Racy Store' }, 'Database error in findOrCreateStore');
|
||||
await expect(flyerRepo.findOrCreateStore('Racy Store', mockLogger)).rejects.toThrow(
|
||||
'Failed to find or create store in database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), storeName: 'Racy Store' },
|
||||
'Database error in findOrCreateStore',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,36 +152,68 @@ describe('Flyer DB Service', () => {
|
||||
'123 Test St',
|
||||
10,
|
||||
'user-1',
|
||||
]
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UniqueConstraintError on duplicate checksum', async () => {
|
||||
const flyerData: FlyerDbInsert = { checksum: 'duplicate-checksum' } as FlyerDbInsert;
|
||||
const dbError = new Error('duplicate key value violates unique constraint "flyers_checksum_key"');
|
||||
const dbError = new Error(
|
||||
'duplicate key value violates unique constraint "flyers_checksum_key"',
|
||||
);
|
||||
(dbError as Error & { code: string }).code = '23505';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(UniqueConstraintError);
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow('A flyer with this checksum already exists.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerData }, 'Database error in insertFlyer');
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
|
||||
UniqueConstraintError,
|
||||
);
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
|
||||
'A flyer with this checksum already exists.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerData },
|
||||
'Database error in insertFlyer',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const flyerData: FlyerDbInsert = { checksum: 'fail-checksum' } as FlyerDbInsert;
|
||||
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow('Failed to insert flyer into database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), flyerData }, 'Database error in insertFlyer');
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
|
||||
'Failed to insert flyer into database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), flyerData },
|
||||
'Database error in insertFlyer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertFlyerItems', () => {
|
||||
it('should build a bulk INSERT query and return the new items', async () => {
|
||||
const itemsData: FlyerItemInsert[] = [
|
||||
{ item: 'Milk', price_display: '$3.99', price_in_cents: 399, quantity: '1L', category_name: 'Dairy', view_count: 0, click_count: 0 },
|
||||
{ item: 'Bread', price_display: '$2.49', price_in_cents: 249, quantity: '1 loaf', category_name: 'Bakery', view_count: 0, click_count: 0 },
|
||||
{
|
||||
item: 'Milk',
|
||||
price_display: '$3.99',
|
||||
price_in_cents: 399,
|
||||
quantity: '1L',
|
||||
category_name: 'Dairy',
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
},
|
||||
{
|
||||
item: 'Bread',
|
||||
price_display: '$2.49',
|
||||
price_in_cents: 249,
|
||||
quantity: '1 loaf',
|
||||
category_name: 'Bakery',
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
},
|
||||
];
|
||||
const mockItems = itemsData.map((item, i) => createMockFlyerItem({ ...item, flyer_item_id: i + 1, flyer_id: 1 }));
|
||||
const mockItems = itemsData.map((item, i) =>
|
||||
createMockFlyerItem({ ...item, flyer_item_id: i + 1, flyer_id: 1 }),
|
||||
);
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
|
||||
|
||||
const result = await flyerRepo.insertFlyerItems(1, itemsData, mockLogger);
|
||||
@@ -162,11 +221,27 @@ describe('Flyer DB Service', () => {
|
||||
expect(result).toEqual(mockItems);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
|
||||
// Check that the query string has two value placeholders
|
||||
expect(mockPoolInstance.query.mock.calls[0][0]).toContain('VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16)');
|
||||
expect(mockPoolInstance.query.mock.calls[0][0]).toContain(
|
||||
'VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16)',
|
||||
);
|
||||
// Check that the values array is correctly flattened
|
||||
expect(mockPoolInstance.query.mock.calls[0][1]).toEqual([
|
||||
1, 'Milk', '$3.99', 399, '1L', 'Dairy', 0, 0,
|
||||
1, 'Bread', '$2.49', 249, '1 loaf', 'Bakery', 0, 0,
|
||||
1,
|
||||
'Milk',
|
||||
'$3.99',
|
||||
399,
|
||||
'1L',
|
||||
'Dairy',
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
'Bread',
|
||||
'$2.49',
|
||||
249,
|
||||
'1 loaf',
|
||||
'Bakery',
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -177,37 +252,73 @@ describe('Flyer DB Service', () => {
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if flyerId is invalid', async () => {
|
||||
const itemsData: FlyerItemInsert[] = [{ item: 'Test', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Test', view_count: 0, click_count: 0 }];
|
||||
const dbError = new Error('insert or update on table "flyer_items" violates foreign key constraint "flyer_items_flyer_id_fkey"');
|
||||
const itemsData: FlyerItemInsert[] = [
|
||||
{
|
||||
item: 'Test',
|
||||
price_display: '$1',
|
||||
price_in_cents: 100,
|
||||
quantity: '1',
|
||||
category_name: 'Test',
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
},
|
||||
];
|
||||
const dbError = new Error(
|
||||
'insert or update on table "flyer_items" violates foreign key constraint "flyer_items_flyer_id_fkey"',
|
||||
);
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow('The specified flyer does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 999 }, 'Database error in insertFlyerItems');
|
||||
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow(
|
||||
ForeignKeyConstraintError,
|
||||
);
|
||||
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow(
|
||||
'The specified flyer does not exist.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerId: 999 },
|
||||
'Database error in insertFlyerItems',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
// The implementation now re-throws the original error, so we should expect that.
|
||||
await expect(flyerRepo.insertFlyerItems(1, [{ item: 'Test' } as FlyerItemInsert], mockLogger)).rejects.toThrow(dbError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 1 }, 'Database error in insertFlyerItems');
|
||||
await expect(
|
||||
flyerRepo.insertFlyerItems(1, [{ item: 'Test' } as FlyerItemInsert], mockLogger),
|
||||
).rejects.toThrow(dbError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerId: 1 },
|
||||
'Database error in insertFlyerItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFlyerAndItems', () => {
|
||||
it('should use withTransaction to create a flyer and items', async () => {
|
||||
const flyerData: FlyerInsert = { file_name: 'transact.jpg', store_name: 'Transaction Store' } as FlyerInsert;
|
||||
const itemsData: FlyerItemInsert[] = [{
|
||||
item: 'Transactional Item',
|
||||
price_in_cents: 199,
|
||||
quantity: 'each',
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
} as FlyerItemInsert];
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: 'transact.jpg',
|
||||
store_name: 'Transaction Store',
|
||||
} as FlyerInsert;
|
||||
const itemsData: FlyerItemInsert[] = [
|
||||
{
|
||||
item: 'Transactional Item',
|
||||
price_in_cents: 199,
|
||||
quantity: 'each',
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
} as FlyerItemInsert,
|
||||
];
|
||||
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 99, store_id: 1 });
|
||||
const mockItems = [createMockFlyerItem({ ...itemsData[0], flyer_id: 99, flyer_item_id: 101, master_item_id: undefined })];
|
||||
const mockItems = [
|
||||
createMockFlyerItem({
|
||||
...itemsData[0],
|
||||
flyer_id: 99,
|
||||
flyer_item_id: 101,
|
||||
master_item_id: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
// Mock the withTransaction to execute the callback with a mock client
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
@@ -224,22 +335,37 @@ describe('Flyer DB Service', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
flyer: mockFlyer,
|
||||
items: mockItems
|
||||
items: mockItems,
|
||||
});
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the individual functions were called with the client
|
||||
const callback = (vi.mocked(withTransaction) as Mock).mock.calls[0][0];
|
||||
const mockClient = { query: vi.fn() };
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }).mockResolvedValueOnce({ rows: [mockFlyer] }).mockResolvedValueOnce({ rows: mockItems });
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
||||
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
||||
.mockResolvedValueOnce({ rows: mockItems });
|
||||
await callback(mockClient as unknown as PoolClient);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT store_id FROM public.stores'), ['Transaction Store']);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyers'), expect.any(Array));
|
||||
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyer_items'), expect.any(Array));
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT store_id FROM public.stores'),
|
||||
['Transaction Store'],
|
||||
);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO flyers'),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO flyer_items'),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ROLLBACK the transaction if an error occurs', async () => {
|
||||
const flyerData: FlyerInsert = { file_name: 'fail.jpg', store_name: 'Fail Store' } as FlyerInsert;
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: 'fail.jpg',
|
||||
store_name: 'Fail Store',
|
||||
} as FlyerInsert;
|
||||
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
|
||||
const dbError = new Error('DB connection lost');
|
||||
|
||||
@@ -251,16 +377,23 @@ describe('Flyer DB Service', () => {
|
||||
.mockRejectedValueOnce(dbError); // insertFlyer fails
|
||||
// The withTransaction helper will catch this and roll back.
|
||||
// Since insertFlyer wraps the DB error, we expect the wrapped error message here.
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow('Failed to insert flyer into database.');
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
|
||||
'Failed to insert flyer into database.',
|
||||
);
|
||||
// re-throw because withTransaction re-throws (simulating the wrapped error propagating up)
|
||||
throw new Error('Failed to insert flyer into database.');
|
||||
});
|
||||
|
||||
// The transactional function re-throws the original error from the failed step.
|
||||
// Since insertFlyer wraps errors, we expect the wrapped error message.
|
||||
await expect(createFlyerAndItems(flyerData, itemsData, mockLogger)).rejects.toThrow('Failed to insert flyer into database.');
|
||||
await expect(createFlyerAndItems(flyerData, itemsData, mockLogger)).rejects.toThrow(
|
||||
'Failed to insert flyer into database.',
|
||||
);
|
||||
// The error object passed to the logger will be the wrapped Error object, not the original dbError
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database transaction error in createFlyerAndItems');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Database transaction error in createFlyerAndItems',
|
||||
);
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -273,14 +406,21 @@ describe('Flyer DB Service', () => {
|
||||
const result = await flyerRepo.getAllBrands(mockLogger);
|
||||
|
||||
expect(result).toEqual(mockBrands);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.stores s'));
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.stores s'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.getAllBrands(mockLogger)).rejects.toThrow('Failed to retrieve brands from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllBrands');
|
||||
await expect(flyerRepo.getAllBrands(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve brands from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getAllBrands',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +432,10 @@ describe('Flyer DB Service', () => {
|
||||
const result = await flyerRepo.getFlyerById(123);
|
||||
|
||||
expect(result).toEqual(mockFlyer);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE flyer_id = $1', [123]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.flyers WHERE flyer_id = $1',
|
||||
[123],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if flyer is not found', async () => {
|
||||
@@ -322,11 +465,14 @@ describe('Flyer DB Service', () => {
|
||||
|
||||
await flyerRepo.getFlyers(mockLogger);
|
||||
|
||||
console.log('[TEST DEBUG] mockPoolInstance.query calls:', JSON.stringify(mockPoolInstance.query.mock.calls, null, 2));
|
||||
console.log(
|
||||
'[TEST DEBUG] mockPoolInstance.query calls:',
|
||||
JSON.stringify(mockPoolInstance.query.mock.calls, null, 2),
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expectedQuery,
|
||||
[20, 0] // Default values
|
||||
[20, 0], // Default values
|
||||
);
|
||||
});
|
||||
|
||||
@@ -337,19 +483,27 @@ describe('Flyer DB Service', () => {
|
||||
|
||||
await flyerRepo.getFlyers(mockLogger, 10, 5);
|
||||
|
||||
console.log('[TEST DEBUG] mockPoolInstance.query calls:', JSON.stringify(mockPoolInstance.query.mock.calls, null, 2));
|
||||
console.log(
|
||||
'[TEST DEBUG] mockPoolInstance.query calls:',
|
||||
JSON.stringify(mockPoolInstance.query.mock.calls, null, 2),
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expectedQuery,
|
||||
[10, 5] // Provided values
|
||||
[10, 5], // Provided values
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.getFlyers(mockLogger)).rejects.toThrow('Failed to retrieve flyers from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, limit: 20, offset: 0 }, 'Database error in getFlyers');
|
||||
await expect(flyerRepo.getFlyers(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve flyers from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, limit: 20, offset: 0 },
|
||||
'Database error in getFlyers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,7 +515,10 @@ describe('Flyer DB Service', () => {
|
||||
const result = await flyerRepo.getFlyerItems(456, mockLogger);
|
||||
|
||||
expect(result).toEqual(mockItems);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE flyer_id = $1'), [456]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WHERE flyer_id = $1'),
|
||||
[456],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if flyer has no items', async () => {
|
||||
@@ -373,8 +530,13 @@ describe('Flyer DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.getFlyerItems(456, mockLogger)).rejects.toThrow('Failed to retrieve flyer items from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 456 }, 'Database error in getFlyerItems');
|
||||
await expect(flyerRepo.getFlyerItems(456, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve flyer items from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerId: 456 },
|
||||
'Database error in getFlyerItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +544,10 @@ describe('Flyer DB Service', () => {
|
||||
it('should return items for multiple flyers using ANY', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await flyerRepo.getFlyerItemsForFlyers([1, 2, 3], mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('flyer_id = ANY($1::int[])'), [[1, 2, 3]]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('flyer_id = ANY($1::int[])'),
|
||||
[[1, 2, 3]],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no items are found for the given flyer IDs', async () => {
|
||||
@@ -394,8 +559,13 @@ describe('Flyer DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.getFlyerItemsForFlyers([1, 2, 3], mockLogger)).rejects.toThrow('Failed to retrieve flyer items in batch from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerIds: [1, 2, 3] }, 'Database error in getFlyerItemsForFlyers');
|
||||
await expect(flyerRepo.getFlyerItemsForFlyers([1, 2, 3], mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve flyer items in batch from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerIds: [1, 2, 3] },
|
||||
'Database error in getFlyerItemsForFlyers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,7 +574,10 @@ describe('Flyer DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ count: '42' }] });
|
||||
const result = await flyerRepo.countFlyerItemsForFlyers([1, 2], mockLogger);
|
||||
expect(result).toBe(42);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT COUNT(*)'), [[1, 2]]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT COUNT(*)'),
|
||||
[[1, 2]],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 if the flyerIds array is empty', async () => {
|
||||
@@ -416,8 +589,13 @@ describe('Flyer DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.countFlyerItemsForFlyers([1, 2], mockLogger)).rejects.toThrow('Failed to count flyer items in batch from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerIds: [1, 2] }, 'Database error in countFlyerItemsForFlyers');
|
||||
await expect(flyerRepo.countFlyerItemsForFlyers([1, 2], mockLogger)).rejects.toThrow(
|
||||
'Failed to count flyer items in batch from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerIds: [1, 2] },
|
||||
'Database error in countFlyerItemsForFlyers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -427,7 +605,10 @@ describe('Flyer DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
|
||||
const result = await flyerRepo.findFlyerByChecksum('abc', mockLogger);
|
||||
expect(result).toEqual(mockFlyer);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE checksum = $1', ['abc']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.flyers WHERE checksum = $1',
|
||||
['abc'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined if no flyer is found for the checksum', async () => {
|
||||
@@ -439,8 +620,13 @@ describe('Flyer DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(flyerRepo.findFlyerByChecksum('abc', mockLogger)).rejects.toThrow('Failed to find flyer by checksum in database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, checksum: 'abc' }, 'Database error in findFlyerByChecksum');
|
||||
await expect(flyerRepo.findFlyerByChecksum('abc', mockLogger)).rejects.toThrow(
|
||||
'Failed to find flyer by checksum in database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, checksum: 'abc' },
|
||||
'Database error in findFlyerByChecksum',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -448,21 +634,32 @@ describe('Flyer DB Service', () => {
|
||||
it('should increment view_count for a "view" interaction', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await flyerRepo.trackFlyerItemInteraction(101, 'view', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SET view_count = view_count + 1'), [101]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SET view_count = view_count + 1'),
|
||||
[101],
|
||||
);
|
||||
});
|
||||
|
||||
it('should increment click_count for a "click" interaction', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
await flyerRepo.trackFlyerItemInteraction(102, 'click', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SET click_count = click_count + 1'), [102]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SET click_count = click_count + 1'),
|
||||
[102],
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw an error if the database query fails (fire-and-forget)', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
// The function is designed to swallow errors, so we expect it to resolve.
|
||||
await expect(flyerRepo.trackFlyerItemInteraction(103, 'view', mockLogger)).resolves.toBeUndefined();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerItemId: 103, interactionType: 'view' }, 'Database error in trackFlyerItemInteraction (non-critical)');
|
||||
await expect(
|
||||
flyerRepo.trackFlyerItemInteraction(103, 'view', mockLogger),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerItemId: 103, interactionType: 'view' },
|
||||
'Database error in trackFlyerItemInteraction (non-critical)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -479,16 +676,26 @@ describe('Flyer DB Service', () => {
|
||||
|
||||
await flyerRepo.deleteFlyer(42, mockLogger);
|
||||
|
||||
expect(mockClientQuery).toHaveBeenCalledWith('DELETE FROM public.flyers WHERE flyer_id = $1', [42]);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.flyers WHERE flyer_id = $1',
|
||||
[42],
|
||||
);
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the flyer to delete is not found', async () => {
|
||||
const mockClient = { query: vi.fn().mockResolvedValue({ rowCount: 0 }) };
|
||||
vi.mocked(withTransaction).mockImplementation(cb => cb(mockClient as unknown as PoolClient));
|
||||
vi.mocked(withTransaction).mockImplementation((cb) =>
|
||||
cb(mockClient as unknown as PoolClient),
|
||||
);
|
||||
|
||||
await expect(flyerRepo.deleteFlyer(999, mockLogger)).rejects.toThrow('Failed to delete flyer.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(NotFoundError), flyerId: 999 }, 'Database transaction error in deleteFlyer');
|
||||
await expect(flyerRepo.deleteFlyer(999, mockLogger)).rejects.toThrow(
|
||||
'Failed to delete flyer.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(NotFoundError), flyerId: 999 },
|
||||
'Database transaction error in deleteFlyer',
|
||||
);
|
||||
});
|
||||
|
||||
it('should rollback transaction on generic error', async () => {
|
||||
@@ -497,8 +704,13 @@ describe('Flyer DB Service', () => {
|
||||
throw dbError; // Simulate error during transaction
|
||||
});
|
||||
|
||||
await expect(flyerRepo.deleteFlyer(42, mockLogger)).rejects.toThrow('Failed to delete flyer.'); // This was a duplicate, fixed.
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, flyerId: 42 }, 'Database transaction error in deleteFlyer');
|
||||
await expect(flyerRepo.deleteFlyer(42, mockLogger)).rejects.toThrow(
|
||||
'Failed to delete flyer.',
|
||||
); // This was a duplicate, fixed.
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerId: 42 },
|
||||
'Database transaction error in deleteFlyer',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import type { Flyer, FlyerItem, FlyerInsert, FlyerItemInsert, Brand, FlyerDbInsert } from '../../types';
|
||||
import type {
|
||||
Flyer,
|
||||
FlyerItem,
|
||||
FlyerInsert,
|
||||
FlyerItemInsert,
|
||||
Brand,
|
||||
FlyerDbInsert,
|
||||
} from '../../types';
|
||||
|
||||
export class FlyerRepository {
|
||||
private db: Pool | PoolClient;
|
||||
@@ -23,13 +30,19 @@ export class FlyerRepository {
|
||||
// needs to ensure atomicity with other operations.
|
||||
try {
|
||||
// First, try to find the store.
|
||||
let result = await this.db.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [storeName]);
|
||||
let result = await this.db.query<{ store_id: number }>(
|
||||
'SELECT store_id FROM public.stores WHERE name = $1',
|
||||
[storeName],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
return result.rows[0].store_id;
|
||||
} else {
|
||||
// If not found, create it.
|
||||
result = await this.db.query<{ store_id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', [storeName]);
|
||||
result = await this.db.query<{ store_id: number }>(
|
||||
'INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id',
|
||||
[storeName],
|
||||
);
|
||||
return result.rows[0].store_id;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -37,8 +50,14 @@ export class FlyerRepository {
|
||||
// if two processes try to create the same store at the same time.
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') {
|
||||
try {
|
||||
logger.warn({ storeName }, `Race condition avoided: Store was created by another process. Refetching.`);
|
||||
const result = await this.db.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [storeName]);
|
||||
logger.warn(
|
||||
{ storeName },
|
||||
`Race condition avoided: Store was created by another process. Refetching.`,
|
||||
);
|
||||
const result = await this.db.query<{ store_id: number }>(
|
||||
'SELECT store_id FROM public.stores WHERE name = $1',
|
||||
[storeName],
|
||||
);
|
||||
if (result.rows.length > 0) return result.rows[0].store_id;
|
||||
} catch (recoveryError) {
|
||||
// If recovery fails, log a warning and fall through to the generic error handler
|
||||
@@ -68,14 +87,14 @@ export class FlyerRepository {
|
||||
const values = [
|
||||
flyerData.file_name, // $1
|
||||
flyerData.image_url, // $2
|
||||
flyerData.icon_url, // $3
|
||||
flyerData.checksum, // $4
|
||||
flyerData.store_id, // $5
|
||||
flyerData.valid_from,// $6
|
||||
flyerData.valid_to, // $7
|
||||
flyerData.icon_url, // $3
|
||||
flyerData.checksum, // $4
|
||||
flyerData.store_id, // $5
|
||||
flyerData.valid_from, // $6
|
||||
flyerData.valid_to, // $7
|
||||
flyerData.store_address, // $8
|
||||
flyerData.item_count, // $9
|
||||
flyerData.uploaded_by, // $10
|
||||
flyerData.item_count, // $9
|
||||
flyerData.uploaded_by, // $10
|
||||
];
|
||||
|
||||
const result = await this.db.query<Flyer>(query, values);
|
||||
@@ -96,7 +115,11 @@ export class FlyerRepository {
|
||||
* @param items - An array of item data to insert.
|
||||
* @returns An array of the newly created flyer item records.
|
||||
*/
|
||||
async insertFlyerItems(flyerId: number, items: FlyerItemInsert[], logger: Logger): Promise<FlyerItem[]> {
|
||||
async insertFlyerItems(
|
||||
flyerId: number,
|
||||
items: FlyerItemInsert[],
|
||||
logger: Logger,
|
||||
): Promise<FlyerItem[]> {
|
||||
try {
|
||||
if (!items || items.length === 0) {
|
||||
return [];
|
||||
@@ -107,8 +130,19 @@ export class FlyerRepository {
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const item of items) {
|
||||
valueStrings.push(`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`);
|
||||
values.push(flyerId, item.item, item.price_display, item.price_in_cents ?? null, item.quantity ?? '', item.category_name ?? null, item.view_count, item.click_count);
|
||||
valueStrings.push(
|
||||
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`,
|
||||
);
|
||||
values.push(
|
||||
flyerId,
|
||||
item.item,
|
||||
item.price_display,
|
||||
item.price_in_cents ?? null,
|
||||
item.quantity ?? '',
|
||||
item.category_name ?? null,
|
||||
item.view_count,
|
||||
item.click_count,
|
||||
);
|
||||
}
|
||||
|
||||
const query = `
|
||||
@@ -160,7 +194,9 @@ export class FlyerRepository {
|
||||
* @returns A promise that resolves to the Flyer object or undefined if not found.
|
||||
*/
|
||||
async getFlyerById(flyerId: number): Promise<Flyer> {
|
||||
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [flyerId]);
|
||||
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [
|
||||
flyerId,
|
||||
]);
|
||||
if (res.rowCount === 0) throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
|
||||
return res.rows[0];
|
||||
}
|
||||
@@ -199,7 +235,10 @@ export class FlyerRepository {
|
||||
*/
|
||||
async getFlyerItems(flyerId: number, logger: Logger): Promise<FlyerItem[]> {
|
||||
try {
|
||||
const res = await this.db.query<FlyerItem>('SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC', [flyerId]);
|
||||
const res = await this.db.query<FlyerItem>(
|
||||
'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
|
||||
[flyerId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, flyerId }, 'Database error in getFlyerItems');
|
||||
@@ -214,7 +253,10 @@ export class FlyerRepository {
|
||||
*/
|
||||
async getFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<FlyerItem[]> {
|
||||
try {
|
||||
const res = await this.db.query<FlyerItem>('SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC', [flyerIds]);
|
||||
const res = await this.db.query<FlyerItem>(
|
||||
'SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC',
|
||||
[flyerIds],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, flyerIds }, 'Database error in getFlyerItemsForFlyers');
|
||||
@@ -232,7 +274,10 @@ export class FlyerRepository {
|
||||
if (flyerIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const res = await this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])', [flyerIds]);
|
||||
const res = await this.db.query<{ count: string }>(
|
||||
'SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])',
|
||||
[flyerIds],
|
||||
);
|
||||
return parseInt(res.rows[0].count, 10);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, flyerIds }, 'Database error in countFlyerItemsForFlyers');
|
||||
@@ -247,7 +292,9 @@ export class FlyerRepository {
|
||||
*/
|
||||
async findFlyerByChecksum(checksum: string, logger: Logger): Promise<Flyer | undefined> {
|
||||
try {
|
||||
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
|
||||
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [
|
||||
checksum,
|
||||
]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error({ err: error, checksum }, 'Database error in findFlyerByChecksum');
|
||||
@@ -261,7 +308,11 @@ export class FlyerRepository {
|
||||
* @param flyerItemId The ID of the flyer item.
|
||||
* @param interactionType The type of interaction, either 'view' or 'click'.
|
||||
*/
|
||||
async trackFlyerItemInteraction(flyerItemId: number, interactionType: 'view' | 'click', logger: Logger): Promise<void> {
|
||||
async trackFlyerItemInteraction(
|
||||
flyerItemId: number,
|
||||
interactionType: 'view' | 'click',
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Choose the column to increment based on the interaction type.
|
||||
// This is safe from SQL injection as the input is strictly controlled to be 'view' or 'click'.
|
||||
@@ -274,7 +325,10 @@ export class FlyerRepository {
|
||||
`;
|
||||
await this.db.query(query, [flyerItemId]);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, flyerItemId, interactionType }, 'Database error in trackFlyerItemInteraction (non-critical)');
|
||||
logger.error(
|
||||
{ err: error, flyerItemId, interactionType },
|
||||
'Database error in trackFlyerItemInteraction (non-critical)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +363,11 @@ export class FlyerRepository {
|
||||
* @param itemsForDb - An array of item data to associate with the flyer.
|
||||
* @returns An object containing the new flyer and its items.
|
||||
*/
|
||||
export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: FlyerItemInsert[], logger: Logger) {
|
||||
export async function createFlyerAndItems(
|
||||
flyerData: FlyerInsert,
|
||||
itemsForDb: FlyerItemInsert[],
|
||||
logger: Logger,
|
||||
) {
|
||||
try {
|
||||
return await withTransaction(async (client) => {
|
||||
const flyerRepo = new FlyerRepository(client);
|
||||
@@ -332,4 +390,4 @@ export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: Fl
|
||||
logger.error({ err: error }, 'Database transaction error in createFlyerAndItems');
|
||||
throw error; // Re-throw the error to be handled by the calling service.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,42 +33,73 @@ describe('Gamification DB Service', () => {
|
||||
describe('getAllAchievements', () => {
|
||||
it('should execute the correct SELECT query and return achievements', async () => {
|
||||
const mockAchievements: Achievement[] = [
|
||||
{ achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10, created_at: new Date().toISOString() },
|
||||
{
|
||||
achievement_id: 1,
|
||||
name: 'First Steps',
|
||||
description: '...',
|
||||
icon: 'footprints',
|
||||
points_value: 10,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockAchievements });
|
||||
|
||||
const result = await gamificationRepo.getAllAchievements(mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC',
|
||||
);
|
||||
expect(result).toEqual(mockAchievements);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(gamificationRepo.getAllAchievements(mockLogger)).rejects.toThrow('Failed to retrieve achievements.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllAchievements');
|
||||
await expect(gamificationRepo.getAllAchievements(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve achievements.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getAllAchievements',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAchievements', () => {
|
||||
it('should execute the correct SELECT query with a JOIN and return user achievements', async () => {
|
||||
const mockUserAchievements: (UserAchievement & Achievement)[] = [
|
||||
{ achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10, created_at: new Date().toISOString() },
|
||||
{
|
||||
achievement_id: 1,
|
||||
user_id: 'user-123',
|
||||
achieved_at: '2024-01-01',
|
||||
name: 'First Steps',
|
||||
description: '...',
|
||||
icon: 'footprints',
|
||||
points_value: 10,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockUserAchievements });
|
||||
|
||||
const result = await gamificationRepo.getUserAchievements('user-123', mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.user_achievements ua'),
|
||||
['user-123'],
|
||||
);
|
||||
expect(result).toEqual(mockUserAchievements);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(gamificationRepo.getUserAchievements('user-123', mockLogger)).rejects.toThrow('Failed to retrieve user achievements.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserAchievements');
|
||||
await expect(gamificationRepo.getUserAchievements('user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve user achievements.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getUserAchievements',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,22 +108,39 @@ describe('Gamification DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // The function returns void
|
||||
await gamificationRepo.awardAchievement('user-123', 'Test Achievement', mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT public.award_achievement($1, $2)',
|
||||
['user-123', 'Test Achievement'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if user or achievement does not exist', async () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(gamificationRepo.awardAchievement('non-existent-user', 'Non-existent Achievement', mockLogger)).rejects.toThrow('The specified user or achievement does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'non-existent-user', achievementName: 'Non-existent Achievement' }, 'Database error in awardAchievement');
|
||||
await expect(
|
||||
gamificationRepo.awardAchievement(
|
||||
'non-existent-user',
|
||||
'Non-existent Achievement',
|
||||
mockLogger,
|
||||
),
|
||||
).rejects.toThrow('The specified user or achievement does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'non-existent-user', achievementName: 'Non-existent Achievement' },
|
||||
'Database error in awardAchievement',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(gamificationRepo.awardAchievement('user-123', 'Test Achievement', mockLogger)).rejects.toThrow('Failed to award achievement.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', achievementName: 'Test Achievement' }, 'Database error in awardAchievement');
|
||||
await expect(
|
||||
gamificationRepo.awardAchievement('user-123', 'Test Achievement', mockLogger),
|
||||
).rejects.toThrow('Failed to award achievement.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', achievementName: 'Test Achievement' },
|
||||
'Database error in awardAchievement',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,15 +155,23 @@ describe('Gamification DB Service', () => {
|
||||
const result = await gamificationRepo.getLeaderboard(10, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('RANK() OVER (ORDER BY points DESC)'),
|
||||
[10],
|
||||
);
|
||||
expect(result).toEqual(mockLeaderboard);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(gamificationRepo.getLeaderboard(10, mockLogger)).rejects.toThrow('Failed to retrieve leaderboard.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, limit: 10 }, 'Database error in getLeaderboard');
|
||||
await expect(gamificationRepo.getLeaderboard(10, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve leaderboard.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, limit: 10 },
|
||||
'Database error in getLeaderboard',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ export class GamificationRepository {
|
||||
*/
|
||||
async getAllAchievements(logger: Logger): Promise<Achievement[]> {
|
||||
try {
|
||||
const res = await this.db.query<Achievement>('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
|
||||
const res = await this.db.query<Achievement>(
|
||||
'SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC',
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in getAllAchievements');
|
||||
@@ -32,7 +34,10 @@ export class GamificationRepository {
|
||||
* @param userId The UUID of the user.
|
||||
* @returns A promise that resolves to an array of Achievement objects earned by the user.
|
||||
*/
|
||||
async getUserAchievements(userId: string, logger: Logger): Promise<(UserAchievement & Achievement)[]> {
|
||||
async getUserAchievements(
|
||||
userId: string,
|
||||
logger: Logger,
|
||||
): Promise<(UserAchievement & Achievement)[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -48,7 +53,7 @@ export class GamificationRepository {
|
||||
WHERE ua.user_id = $1
|
||||
ORDER BY ua.achieved_at DESC;
|
||||
`;
|
||||
const res = await this.db.query<(UserAchievement & Achievement)>(query, [userId]);
|
||||
const res = await this.db.query<UserAchievement & Achievement>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getUserAchievements');
|
||||
@@ -66,7 +71,7 @@ export class GamificationRepository {
|
||||
*/
|
||||
async awardAchievement(userId: string, achievementName: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await this.db.query("SELECT public.award_achievement($1, $2)", [userId, achievementName]); // This was a duplicate, fixed.
|
||||
await this.db.query('SELECT public.award_achievement($1, $2)', [userId, achievementName]); // This was a duplicate, fixed.
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, achievementName }, 'Database error in awardAchievement');
|
||||
// Check for a foreign key violation, which would mean the user or achievement name is invalid.
|
||||
@@ -102,4 +107,4 @@ export class GamificationRepository {
|
||||
throw new Error('Failed to retrieve leaderboard.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,16 @@ const budgetRepo = new BudgetRepository();
|
||||
const gamificationRepo = new GamificationRepository();
|
||||
const adminRepo = new AdminRepository();
|
||||
|
||||
export { userRepo, flyerRepo, addressRepo, shoppingRepo, personalizationRepo, recipeRepo, notificationRepo, budgetRepo, gamificationRepo, adminRepo, withTransaction };
|
||||
export {
|
||||
userRepo,
|
||||
flyerRepo,
|
||||
addressRepo,
|
||||
shoppingRepo,
|
||||
personalizationRepo,
|
||||
recipeRepo,
|
||||
notificationRepo,
|
||||
budgetRepo,
|
||||
gamificationRepo,
|
||||
adminRepo,
|
||||
withTransaction,
|
||||
};
|
||||
|
||||
@@ -34,7 +34,12 @@ describe('Notification DB Service', () => {
|
||||
describe('getNotificationsForUser', () => {
|
||||
it('should execute the correct query with limit and offset and return notifications', async () => {
|
||||
const mockNotifications: Notification[] = [
|
||||
createMockNotification({ notification_id: 1, user_id: 'user-123', content: 'Test 1', is_read: false }),
|
||||
createMockNotification({
|
||||
notification_id: 1,
|
||||
user_id: 'user-123',
|
||||
content: 'Test 1',
|
||||
is_read: false,
|
||||
}),
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockNotifications });
|
||||
|
||||
@@ -42,7 +47,7 @@ describe('Notification DB Service', () => {
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM public.notifications'),
|
||||
['user-123', 10, 5]
|
||||
['user-123', 10, 5],
|
||||
);
|
||||
expect(result).toEqual(mockNotifications);
|
||||
});
|
||||
@@ -57,14 +62,24 @@ describe('Notification DB Service', () => {
|
||||
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, mockLogger)).rejects.toThrow('Failed to retrieve notifications.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', limit: 10, offset: 5 }, 'Database error in getNotificationsForUser');
|
||||
await expect(
|
||||
notificationRepo.getNotificationsForUser('user-123', 10, 5, mockLogger),
|
||||
).rejects.toThrow('Failed to retrieve notifications.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', limit: 10, offset: 5 },
|
||||
'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 });
|
||||
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);
|
||||
@@ -72,13 +87,24 @@ describe('Notification DB Service', () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
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');
|
||||
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']
|
||||
['user-123', 'Test with link', '/some/link'],
|
||||
);
|
||||
expect(result).toEqual(mockNotification);
|
||||
});
|
||||
@@ -87,28 +113,38 @@ describe('Notification DB Service', () => {
|
||||
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 }, 'Database error in createNotification');
|
||||
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 },
|
||||
'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');
|
||||
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" }];
|
||||
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]]
|
||||
[['u1'], ['msg'], [null]],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -121,23 +157,38 @@ describe('Notification DB Service', () => {
|
||||
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 }, 'Database error in createBulkNotifications');
|
||||
const notificationsToCreate = [{ user_id: 'non-existent', content: 'msg' }];
|
||||
await expect(
|
||||
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'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 }, 'Database error in createBulkNotifications');
|
||||
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 },
|
||||
'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 });
|
||||
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);
|
||||
@@ -147,26 +198,36 @@ describe('Notification DB Service', () => {
|
||||
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);
|
||||
|
||||
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.');
|
||||
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);
|
||||
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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,20 +235,25 @@ describe('Notification DB Service', () => {
|
||||
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']
|
||||
['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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,7 +263,7 @@ describe('Notification DB Service', () => {
|
||||
const result = await notificationRepo.deleteOldNotifications(30, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
|
||||
[30]
|
||||
[30],
|
||||
);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
@@ -211,8 +277,13 @@ describe('Notification DB Service', () => {
|
||||
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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,123 +6,149 @@ import type { Logger } from 'pino';
|
||||
import type { Notification } from '../../types';
|
||||
|
||||
export class NotificationRepository {
|
||||
private db: Pool | PoolClient;
|
||||
private db: Pool | PoolClient;
|
||||
|
||||
constructor(db: Pool | PoolClient = getPool()) {
|
||||
this.db = db;
|
||||
constructor(db: Pool | PoolClient = getPool()) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a single notification into the database.
|
||||
* @param userId The ID of the user to notify.
|
||||
* @param content The text content of the notification.
|
||||
* @param linkUrl An optional URL for the notification to link to.
|
||||
* @returns A promise that resolves to the newly created Notification object.
|
||||
*/
|
||||
async createNotification(
|
||||
userId: string,
|
||||
content: string,
|
||||
logger: Logger,
|
||||
linkUrl?: string,
|
||||
): Promise<Notification> {
|
||||
try {
|
||||
const res = await this.db.query<Notification>(
|
||||
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[userId, content, linkUrl || null],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, userId, content, linkUrl },
|
||||
'Database error in createNotification',
|
||||
);
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('The specified user does not exist.');
|
||||
}
|
||||
throw new Error('Failed to create notification.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a single notification into the database.
|
||||
* @param userId The ID of the user to notify.
|
||||
* @param content The text content of the notification.
|
||||
* @param linkUrl An optional URL for the notification to link to.
|
||||
* @returns A promise that resolves to the newly created Notification object.
|
||||
*/
|
||||
async createNotification(userId: string, content: string, logger: Logger, linkUrl?: string): Promise<Notification> {
|
||||
try {
|
||||
const res = await this.db.query<Notification>(
|
||||
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[userId, content, linkUrl || null]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, content, linkUrl }, 'Database error in createNotification');
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('The specified user does not exist.');
|
||||
}
|
||||
throw new Error('Failed to create notification.');
|
||||
}
|
||||
/**
|
||||
* Inserts multiple notifications into the database in a single query.
|
||||
* This is more efficient than inserting one by one.
|
||||
* @param notifications An array of notification objects to be inserted.
|
||||
*/
|
||||
async createBulkNotifications(
|
||||
notifications: Omit<
|
||||
Notification,
|
||||
'notification_id' | 'is_read' | 'created_at' | 'updated_at'
|
||||
>[],
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
if (notifications.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts multiple notifications into the database in a single query.
|
||||
* This is more efficient than inserting one by one.
|
||||
* @param notifications An array of notification objects to be inserted.
|
||||
*/
|
||||
async createBulkNotifications(notifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'>[], logger: Logger): Promise<void> {
|
||||
if (notifications.length === 0) {
|
||||
return;
|
||||
}
|
||||
// This method assumes it might be part of a larger transaction, so it uses `this.db`.
|
||||
// The calling service is responsible for acquiring and releasing a client if needed.
|
||||
try {
|
||||
// This is the secure way to perform bulk inserts.
|
||||
// We use the `unnest` function in PostgreSQL to turn arrays of parameters
|
||||
// into a set of rows that can be inserted. This avoids string concatenation
|
||||
// and completely prevents SQL injection.
|
||||
const query = `
|
||||
// This method assumes it might be part of a larger transaction, so it uses `this.db`.
|
||||
// The calling service is responsible for acquiring and releasing a client if needed.
|
||||
try {
|
||||
// This is the secure way to perform bulk inserts.
|
||||
// We use the `unnest` function in PostgreSQL to turn arrays of parameters
|
||||
// into a set of rows that can be inserted. This avoids string concatenation
|
||||
// and completely prevents SQL injection.
|
||||
const query = `
|
||||
INSERT INTO public.notifications (user_id, content, link_url)
|
||||
SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])
|
||||
`;
|
||||
|
||||
const userIds = notifications.map(n => n.user_id);
|
||||
const contents = notifications.map(n => n.content);
|
||||
const linkUrls = notifications.map(n => n.link_url || null);
|
||||
const userIds = notifications.map((n) => n.user_id);
|
||||
const contents = notifications.map((n) => n.content);
|
||||
const linkUrls = notifications.map((n) => n.link_url || null);
|
||||
|
||||
await this.db.query(query, [userIds, contents, linkUrls]);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in createBulkNotifications');
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('One or more of the specified users do not exist.');
|
||||
}
|
||||
throw new Error('Failed to create bulk notifications.');
|
||||
}
|
||||
await this.db.query(query, [userIds, contents, linkUrls]);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in createBulkNotifications');
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('One or more of the specified users do not exist.');
|
||||
}
|
||||
throw new Error('Failed to create bulk notifications.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of notifications for a specific user.
|
||||
* @param userId The ID of the user whose notifications are to be retrieved.
|
||||
* @param limit The maximum number of notifications to return.
|
||||
* @param offset The number of notifications to skip for pagination.
|
||||
* @returns A promise that resolves to an array of Notification objects.
|
||||
*/
|
||||
async getNotificationsForUser(userId: string, limit: number, offset: number, logger: Logger): Promise<Notification[]> {
|
||||
try {
|
||||
const res = await this.db.query<Notification>(
|
||||
`SELECT * FROM public.notifications
|
||||
/**
|
||||
* Retrieves a paginated list of notifications for a specific user.
|
||||
* @param userId The ID of the user whose notifications are to be retrieved.
|
||||
* @param limit The maximum number of notifications to return.
|
||||
* @param offset The number of notifications to skip for pagination.
|
||||
* @returns A promise that resolves to an array of Notification objects.
|
||||
*/
|
||||
async getNotificationsForUser(
|
||||
userId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
logger: Logger,
|
||||
): Promise<Notification[]> {
|
||||
try {
|
||||
const res = await this.db.query<Notification>(
|
||||
`SELECT * FROM public.notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[userId, limit, offset]
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, limit, offset }, 'Database error in getNotificationsForUser');
|
||||
throw new Error('Failed to retrieve notifications.');
|
||||
}
|
||||
[userId, limit, offset],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, userId, limit, offset },
|
||||
'Database error in getNotificationsForUser',
|
||||
);
|
||||
throw new Error('Failed to retrieve notifications.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all unread notifications for a user as read.
|
||||
* @param userId The ID of the user whose notifications should be marked as read.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
async markAllNotificationsAsRead(userId: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await this.db.query(
|
||||
`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
|
||||
[userId]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in markAllNotificationsAsRead');
|
||||
throw new Error('Failed to mark notifications as read.');
|
||||
}
|
||||
/**
|
||||
* Marks all unread notifications for a user as read.
|
||||
* @param userId The ID of the user whose notifications should be marked as read.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
async markAllNotificationsAsRead(userId: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await this.db.query(
|
||||
`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
|
||||
[userId],
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in markAllNotificationsAsRead');
|
||||
throw new Error('Failed to mark notifications as read.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a single notification as read for a specific user.
|
||||
* Ensures that a user can only mark their own notifications.
|
||||
* @param notificationId The ID of the notification to mark as read.
|
||||
* @param userId The ID of the user who owns the notification.
|
||||
* @returns A promise that resolves to the updated Notification object.
|
||||
* @throws An error if the notification is not found or does not belong to the user.
|
||||
*/
|
||||
async markNotificationAsRead(notificationId: number, userId: string, logger: Logger): Promise<Notification> {
|
||||
/**
|
||||
* Marks a single notification as read for a specific user.
|
||||
* Ensures that a user can only mark their own notifications.
|
||||
* @param notificationId The ID of the notification to mark as read.
|
||||
* @param userId The ID of the user who owns the notification.
|
||||
* @returns A promise that resolves to the updated Notification object.
|
||||
* @throws An error if the notification is not found or does not belong to the user.
|
||||
*/
|
||||
async markNotificationAsRead(
|
||||
notificationId: number,
|
||||
userId: string,
|
||||
logger: Logger,
|
||||
): Promise<Notification> {
|
||||
try {
|
||||
const res = await this.db.query<Notification>(
|
||||
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
|
||||
[notificationId, userId]
|
||||
[notificationId, userId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Notification not found or user does not have permission.');
|
||||
@@ -130,27 +156,30 @@ export class NotificationRepository {
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error({ err: error, notificationId, userId }, 'Database error in markNotificationAsRead');
|
||||
logger.error(
|
||||
{ err: error, notificationId, userId },
|
||||
'Database error in markNotificationAsRead',
|
||||
);
|
||||
throw new Error('Failed to mark notification as read.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes notifications that are older than a specified number of days.
|
||||
* This is intended for a periodic cleanup job.
|
||||
* @param daysOld The minimum age in days for a notification to be deleted.
|
||||
* @returns A promise that resolves to the number of deleted notifications.
|
||||
*/
|
||||
async deleteOldNotifications(daysOld: number, logger: Logger): Promise<number> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
|
||||
[daysOld]
|
||||
);
|
||||
return res.rowCount ?? 0;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, daysOld }, 'Database error in deleteOldNotifications');
|
||||
throw new Error('Failed to delete old notifications.');
|
||||
}
|
||||
/**
|
||||
* Deletes notifications that are older than a specified number of days.
|
||||
* This is intended for a periodic cleanup job.
|
||||
* @param daysOld The minimum age in days for a notification to be deleted.
|
||||
* @returns A promise that resolves to the number of deleted notifications.
|
||||
*/
|
||||
async deleteOldNotifications(daysOld: number, logger: Logger): Promise<number> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
|
||||
[daysOld],
|
||||
);
|
||||
return res.rowCount ?? 0;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, daysOld }, 'Database error in deleteOldNotifications');
|
||||
throw new Error('Failed to delete old notifications.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { withTransaction } from './connection.db';
|
||||
import {
|
||||
PersonalizationRepository} from './personalization.db';
|
||||
import { PersonalizationRepository } from './personalization.db';
|
||||
import type { MasterGroceryItem, UserAppliance, DietaryRestriction, Appliance } from '../../types';
|
||||
import { createMockMasterGroceryItem } from '../../tests/utils/mockFactories';
|
||||
|
||||
@@ -47,10 +46,14 @@ describe('Personalization DB Service', () => {
|
||||
|
||||
describe('getAllMasterItems', () => {
|
||||
it('should execute the correct query and return master items', async () => {
|
||||
console.log('[TEST DEBUG] Running test: getAllMasterItems > should execute the correct query');
|
||||
const mockItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
|
||||
console.log(
|
||||
'[TEST DEBUG] Running test: getAllMasterItems > should execute the correct query',
|
||||
);
|
||||
const mockItems: MasterGroceryItem[] = [
|
||||
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
|
||||
];
|
||||
mockQuery.mockResolvedValue({ rows: mockItems });
|
||||
|
||||
|
||||
const result = await personalizationRepo.getAllMasterItems(mockLogger);
|
||||
|
||||
const expectedQuery = `
|
||||
@@ -78,19 +81,29 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getAllMasterItems(mockLogger)).rejects.toThrow('Failed to retrieve master grocery items.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAllMasterItems');
|
||||
await expect(personalizationRepo.getAllMasterItems(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve master grocery items.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getAllMasterItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWatchedItems', () => {
|
||||
it('should execute the correct query and return watched items', async () => {
|
||||
const mockItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
|
||||
const mockItems: MasterGroceryItem[] = [
|
||||
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
|
||||
];
|
||||
mockQuery.mockResolvedValue({ rows: mockItems });
|
||||
|
||||
const result = await personalizationRepo.getWatchedItems('user-123', mockLogger);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items mgi'), ['user-123']);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.master_grocery_items mgi'),
|
||||
['user-123'],
|
||||
);
|
||||
expect(result).toEqual(mockItems);
|
||||
});
|
||||
|
||||
@@ -103,8 +116,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getWatchedItems('user-123', mockLogger)).rejects.toThrow('Failed to retrieve watched items.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getWatchedItems');
|
||||
await expect(personalizationRepo.getWatchedItems('user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve watched items.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getWatchedItems',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,14 +142,26 @@ describe('Personalization DB Service', () => {
|
||||
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce', mockLogger);
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT category_id FROM public.categories'), ['Produce']);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM public.master_grocery_items'), ['New Item']);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_watched_items'), ['user-123', 1]);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT category_id FROM public.categories'),
|
||||
['Produce'],
|
||||
);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM public.master_grocery_items'),
|
||||
['New Item'],
|
||||
);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.user_watched_items'),
|
||||
['user-123', 1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new master item if it does not exist', async () => {
|
||||
const mockClientQuery = vi.fn();
|
||||
const mockNewItem = createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Brand New Item' });
|
||||
const mockNewItem = createMockMasterGroceryItem({
|
||||
master_grocery_item_id: 2,
|
||||
name: 'Brand New Item',
|
||||
});
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
const mockClient = { query: mockClientQuery };
|
||||
mockClientQuery
|
||||
@@ -142,15 +172,26 @@ describe('Personalization DB Service', () => {
|
||||
return callback(mockClient as unknown as PoolClient);
|
||||
});
|
||||
|
||||
const result = await personalizationRepo.addWatchedItem('user-123', 'Brand New Item', 'Produce', mockLogger);
|
||||
const result = await personalizationRepo.addWatchedItem(
|
||||
'user-123',
|
||||
'Brand New Item',
|
||||
'Produce',
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.master_grocery_items'), ['Brand New Item', 1]);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.master_grocery_items'),
|
||||
['Brand New Item', 1],
|
||||
);
|
||||
expect(result).toEqual(mockNewItem);
|
||||
});
|
||||
|
||||
it('should not throw an error if the item is already in the watchlist (ON CONFLICT DO NOTHING)', async () => {
|
||||
const mockClientQuery = vi.fn();
|
||||
const mockExistingItem = createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Existing Item' });
|
||||
const mockExistingItem = createMockMasterGroceryItem({
|
||||
master_grocery_item_id: 1,
|
||||
name: 'Existing Item',
|
||||
});
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
const mockClient = { query: mockClientQuery };
|
||||
mockClientQuery
|
||||
@@ -161,32 +202,56 @@ describe('Personalization DB Service', () => {
|
||||
});
|
||||
|
||||
// The function should resolve successfully without throwing an error.
|
||||
await expect(personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce', mockLogger)).resolves.toEqual(mockExistingItem);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_watched_items'), ['user-123', 1]);
|
||||
await expect(
|
||||
personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce', mockLogger),
|
||||
).resolves.toEqual(mockExistingItem);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.user_watched_items'),
|
||||
['user-123', 1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the category is not found', async () => {
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow("Category 'Fake Category' not found.");
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
|
||||
"Category 'Fake Category' not found.",
|
||||
);
|
||||
throw new Error("Category 'Fake Category' not found.");
|
||||
});
|
||||
|
||||
await expect(personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category', mockLogger)).rejects.toThrow("Failed to add item to watchlist.");
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: 'user-123', itemName: 'Some Item', categoryName: 'Fake Category' }, 'Transaction error in addWatchedItem');
|
||||
await expect(
|
||||
personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category', mockLogger),
|
||||
).rejects.toThrow('Failed to add item to watchlist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
userId: 'user-123',
|
||||
itemName: 'Some Item',
|
||||
categoryName: 'Fake Category',
|
||||
},
|
||||
'Transaction error in addWatchedItem',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error on failure', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||
const mockClient = { query: vi.fn() };
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }).mockRejectedValueOnce(dbError);
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] })
|
||||
.mockRejectedValueOnce(dbError);
|
||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(dbError);
|
||||
throw dbError;
|
||||
});
|
||||
|
||||
await expect(personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce', mockLogger)).rejects.toThrow('Failed to add item to watchlist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryName: 'Produce' }, 'Transaction error in addWatchedItem');
|
||||
await expect(
|
||||
personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce', mockLogger),
|
||||
).rejects.toThrow('Failed to add item to watchlist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryName: 'Produce' },
|
||||
'Transaction error in addWatchedItem',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError on invalid user or category', async () => {
|
||||
@@ -194,7 +259,9 @@ describe('Personalization DB Service', () => {
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||
|
||||
await expect(personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce', mockLogger)).rejects.toThrow('The specified user or category does not exist.');
|
||||
await expect(
|
||||
personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce', mockLogger),
|
||||
).rejects.toThrow('The specified user or category does not exist.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,31 +269,44 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getDietaryRestrictions(mockLogger)).rejects.toThrow('Failed to get dietary restrictions.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getDietaryRestrictions');
|
||||
await expect(personalizationRepo.getDietaryRestrictions(mockLogger)).rejects.toThrow(
|
||||
'Failed to get dietary restrictions.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getDietaryRestrictions',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('removeWatchedItem', () => {
|
||||
it('should execute a DELETE query', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.removeWatchedItem('user-123', 1, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2',
|
||||
['user-123', 1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should complete without error if the item to remove is not in the watchlist', async () => {
|
||||
// Simulate the DB returning 0 rows affected
|
||||
mockQuery.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(personalizationRepo.removeWatchedItem('user-123', 999, mockLogger)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
personalizationRepo.removeWatchedItem('user-123', 999, mockLogger),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.removeWatchedItem('user-123', 1, mockLogger)).rejects.toThrow('Failed to remove item from watchlist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', masterItemId: 1 }, 'Database error in removeWatchedItem');
|
||||
await expect(
|
||||
personalizationRepo.removeWatchedItem('user-123', 1, mockLogger),
|
||||
).rejects.toThrow('Failed to remove item from watchlist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', masterItemId: 1 },
|
||||
'Database error in removeWatchedItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +314,9 @@ describe('Personalization DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.findRecipesFromPantry('user-123', mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', [
|
||||
'user-123',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if no recipes are found', async () => {
|
||||
@@ -246,8 +328,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.findRecipesFromPantry('user-123', mockLogger)).rejects.toThrow('Failed to find recipes from pantry.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in findRecipesFromPantry');
|
||||
await expect(
|
||||
personalizationRepo.findRecipesFromPantry('user-123', mockLogger),
|
||||
).rejects.toThrow('Failed to find recipes from pantry.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in findRecipesFromPantry',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,7 +342,10 @@ describe('Personalization DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.recommendRecipesForUser('user-123', 5, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.recommend_recipes_for_user($1, $2)',
|
||||
['user-123', 5],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no recipes are recommended', async () => {
|
||||
@@ -267,8 +357,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.recommendRecipesForUser('user-123', 5, mockLogger)).rejects.toThrow('Failed to recommend recipes.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', limit: 5 }, 'Database error in recommendRecipesForUser');
|
||||
await expect(
|
||||
personalizationRepo.recommendRecipesForUser('user-123', 5, mockLogger),
|
||||
).rejects.toThrow('Failed to recommend recipes.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', limit: 5 },
|
||||
'Database error in recommendRecipesForUser',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,7 +371,10 @@ describe('Personalization DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.getBestSalePricesForUser('user-123', mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_best_sale_prices_for_user($1)',
|
||||
['user-123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no deals are found for the user', async () => {
|
||||
@@ -288,8 +386,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getBestSalePricesForUser('user-123', mockLogger)).rejects.toThrow('Failed to get best sale prices.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getBestSalePricesForUser');
|
||||
await expect(
|
||||
personalizationRepo.getBestSalePricesForUser('user-123', mockLogger),
|
||||
).rejects.toThrow('Failed to get best sale prices.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getBestSalePricesForUser',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,7 +400,9 @@ describe('Personalization DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.getBestSalePricesForAllUsers(mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()');
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_best_sale_prices_for_all_users()',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no deals are found for any user', async () => {
|
||||
@@ -309,8 +414,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getBestSalePricesForAllUsers(mockLogger)).rejects.toThrow('Failed to get best sale prices for all users.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getBestSalePricesForAllUsers');
|
||||
await expect(personalizationRepo.getBestSalePricesForAllUsers(mockLogger)).rejects.toThrow(
|
||||
'Failed to get best sale prices for all users.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getBestSalePricesForAllUsers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,7 +428,10 @@ describe('Personalization DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.suggestPantryItemConversions(1, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.suggest_pantry_item_conversions($1)',
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no conversions are suggested', async () => {
|
||||
@@ -330,8 +443,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.suggestPantryItemConversions(1, mockLogger)).rejects.toThrow('Failed to suggest pantry item conversions.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, pantryItemId: 1 }, 'Database error in suggestPantryItemConversions');
|
||||
await expect(personalizationRepo.suggestPantryItemConversions(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to suggest pantry item conversions.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, pantryItemId: 1 },
|
||||
'Database error in suggestPantryItemConversions',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,8 +457,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getUserDietaryRestrictions('user-123', mockLogger)).rejects.toThrow('Failed to get user dietary restrictions.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserDietaryRestrictions');
|
||||
await expect(
|
||||
personalizationRepo.getUserDietaryRestrictions('user-123', mockLogger),
|
||||
).rejects.toThrow('Failed to get user dietary restrictions.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getUserDietaryRestrictions',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if user has no restrictions', async () => {
|
||||
@@ -354,7 +477,10 @@ describe('Personalization DB Service', () => {
|
||||
it('should execute a SELECT query to find the owner', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ user_id: 'user-123' }] });
|
||||
const result = await personalizationRepo.findPantryItemOwner(1, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1',
|
||||
[1],
|
||||
);
|
||||
expect(result?.user_id).toBe('user-123');
|
||||
});
|
||||
|
||||
@@ -367,8 +493,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.findPantryItemOwner(1, mockLogger)).rejects.toThrow('Failed to retrieve pantry item owner from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, pantryItemId: 1 }, 'Database error in findPantryItemOwner');
|
||||
await expect(personalizationRepo.findPantryItemOwner(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve pantry item owner from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, pantryItemId: 1 },
|
||||
'Database error in findPantryItemOwner',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,7 +507,9 @@ describe('Personalization DB Service', () => {
|
||||
it('should execute a SELECT query to get all restrictions', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
|
||||
await personalizationRepo.getDietaryRestrictions(mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.dietary_restrictions ORDER BY type, name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no restrictions exist', async () => {
|
||||
@@ -388,8 +521,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getDietaryRestrictions(mockLogger)).rejects.toThrow('Failed to get dietary restrictions.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getDietaryRestrictions');
|
||||
await expect(personalizationRepo.getDietaryRestrictions(mockLogger)).rejects.toThrow(
|
||||
'Failed to get dietary restrictions.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getDietaryRestrictions',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,8 +542,14 @@ describe('Personalization DB Service', () => {
|
||||
await personalizationRepo.setUserDietaryRestrictions('user-123', [1, 2], mockLogger);
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_dietary_restrictions'), ['user-123', [1, 2]]);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.user_dietary_restrictions WHERE user_id = $1',
|
||||
['user-123'],
|
||||
);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.user_dietary_restrictions'),
|
||||
['user-123', [1, 2]],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if a restriction ID is invalid', async () => {
|
||||
@@ -418,7 +562,9 @@ describe('Personalization DB Service', () => {
|
||||
throw dbError;
|
||||
});
|
||||
|
||||
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [999], mockLogger)).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
|
||||
await expect(
|
||||
personalizationRepo.setUserDietaryRestrictions('user-123', [999], mockLogger),
|
||||
).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
|
||||
});
|
||||
|
||||
it('should handle an empty array of restriction IDs', async () => {
|
||||
@@ -431,13 +577,18 @@ describe('Personalization DB Service', () => {
|
||||
await personalizationRepo.setUserDietaryRestrictions('user-123', [], mockLogger);
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.user_dietary_restrictions WHERE user_id = $1',
|
||||
['user-123'],
|
||||
);
|
||||
expect(mockClientQuery).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'));
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
vi.mocked(withTransaction).mockRejectedValue(new Error('DB Error'));
|
||||
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [1], mockLogger)).rejects.toThrow('Failed to set user dietary restrictions.');
|
||||
await expect(
|
||||
personalizationRepo.setUserDietaryRestrictions('user-123', [1], mockLogger),
|
||||
).rejects.toThrow('Failed to set user dietary restrictions.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,8 +608,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getAppliances(mockLogger)).rejects.toThrow('Failed to get appliances.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getAppliances');
|
||||
await expect(personalizationRepo.getAppliances(mockLogger)).rejects.toThrow(
|
||||
'Failed to get appliances.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getAppliances',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,7 +622,9 @@ describe('Personalization DB Service', () => {
|
||||
it('should execute a SELECT query with a JOIN', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
|
||||
await personalizationRepo.getUserAppliances('user-123', mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']);
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), [
|
||||
'user-123',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if the user has no appliances', async () => {
|
||||
@@ -478,8 +636,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getUserAppliances('user-123', mockLogger)).rejects.toThrow('Failed to get user appliances.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserAppliances');
|
||||
await expect(personalizationRepo.getUserAppliances('user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to get user appliances.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getUserAppliances',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -501,8 +664,14 @@ describe('Personalization DB Service', () => {
|
||||
const result = await personalizationRepo.setUserAppliances('user-123', [1, 2], mockLogger);
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances'), ['user-123', [1, 2]]);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.user_appliances WHERE user_id = $1',
|
||||
['user-123'],
|
||||
);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.user_appliances'),
|
||||
['user-123', [1, 2]],
|
||||
);
|
||||
expect(result).toEqual(mockNewAppliances);
|
||||
});
|
||||
|
||||
@@ -516,7 +685,9 @@ describe('Personalization DB Service', () => {
|
||||
throw dbError;
|
||||
});
|
||||
|
||||
await expect(personalizationRepo.setUserAppliances('user-123', [999], mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(
|
||||
personalizationRepo.setUserAppliances('user-123', [999], mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
});
|
||||
|
||||
it('should handle an empty array of appliance IDs', async () => {
|
||||
@@ -529,7 +700,10 @@ describe('Personalization DB Service', () => {
|
||||
const result = await personalizationRepo.setUserAppliances('user-123', [], mockLogger);
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.user_appliances WHERE user_id = $1',
|
||||
['user-123'],
|
||||
);
|
||||
// The INSERT query should NOT be called
|
||||
expect(mockClientQuery).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'));
|
||||
expect(result).toEqual([]);
|
||||
@@ -543,8 +717,13 @@ describe('Personalization DB Service', () => {
|
||||
throw dbError;
|
||||
});
|
||||
|
||||
await expect(personalizationRepo.setUserAppliances('user-123', [1], mockLogger)).rejects.toThrow('Failed to set user appliances.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', applianceIds: [1] }, 'Database error in setUserAppliances');
|
||||
await expect(
|
||||
personalizationRepo.setUserAppliances('user-123', [1], mockLogger),
|
||||
).rejects.toThrow('Failed to set user appliances.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', applianceIds: [1] },
|
||||
'Database error in setUserAppliances',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -552,7 +731,10 @@ describe('Personalization DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await personalizationRepo.getRecipesForUserDiets('user-123', mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_recipes_for_user_diets($1)',
|
||||
['user-123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no recipes match the diet', async () => {
|
||||
@@ -564,8 +746,13 @@ describe('Personalization DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(personalizationRepo.getRecipesForUserDiets('user-123', mockLogger)).rejects.toThrow('Failed to get recipes compatible with user diet.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getRecipesForUserDiets');
|
||||
await expect(
|
||||
personalizationRepo.getRecipesForUserDiets('user-123', mockLogger),
|
||||
).rejects.toThrow('Failed to get recipes compatible with user diet.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getRecipesForUserDiets',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/services/db/personalization.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import { ForeignKeyConstraintError } from './errors.db';
|
||||
import type { Logger } from 'pino';
|
||||
import {
|
||||
@@ -72,7 +72,10 @@ export class PersonalizationRepository {
|
||||
*/
|
||||
async removeWatchedItem(userId: string, masterItemId: number, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await this.db.query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]);
|
||||
await this.db.query(
|
||||
'DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2',
|
||||
[userId, masterItemId],
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, masterItemId }, 'Database error in removeWatchedItem');
|
||||
throw new Error('Failed to remove item from watchlist.');
|
||||
@@ -84,11 +87,14 @@ export class PersonalizationRepository {
|
||||
* @param pantryItemId The ID of the pantry item.
|
||||
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
|
||||
*/
|
||||
async findPantryItemOwner(pantryItemId: number, logger: Logger): Promise<{ user_id: string } | undefined> {
|
||||
async findPantryItemOwner(
|
||||
pantryItemId: number,
|
||||
logger: Logger,
|
||||
): Promise<{ user_id: string } | undefined> {
|
||||
try {
|
||||
const res = await this.db.query<{ user_id: string }>(
|
||||
'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1',
|
||||
[pantryItemId]
|
||||
[pantryItemId],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -105,11 +111,19 @@ export class PersonalizationRepository {
|
||||
* @param categoryName The category of the item.
|
||||
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
|
||||
*/
|
||||
async addWatchedItem(userId: string, itemName: string, categoryName: string, logger: Logger): Promise<MasterGroceryItem> {
|
||||
async addWatchedItem(
|
||||
userId: string,
|
||||
itemName: string,
|
||||
categoryName: string,
|
||||
logger: Logger,
|
||||
): Promise<MasterGroceryItem> {
|
||||
try {
|
||||
return await withTransaction(async (client) => {
|
||||
// Find category ID
|
||||
const categoryRes = await client.query<{ category_id: number }>('SELECT category_id FROM public.categories WHERE name = $1', [categoryName]);
|
||||
const categoryRes = await client.query<{ category_id: number }>(
|
||||
'SELECT category_id FROM public.categories WHERE name = $1',
|
||||
[categoryName],
|
||||
);
|
||||
const categoryId = categoryRes.rows[0]?.category_id;
|
||||
if (!categoryId) {
|
||||
throw new Error(`Category '${categoryName}' not found.`);
|
||||
@@ -117,13 +131,16 @@ export class PersonalizationRepository {
|
||||
|
||||
// Find or create master item
|
||||
let masterItem: MasterGroceryItem;
|
||||
const masterItemRes = await client.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]);
|
||||
const masterItemRes = await client.query<MasterGroceryItem>(
|
||||
'SELECT * FROM public.master_grocery_items WHERE name = $1',
|
||||
[itemName],
|
||||
);
|
||||
if (masterItemRes.rows.length > 0) {
|
||||
masterItem = masterItemRes.rows[0];
|
||||
} else {
|
||||
const newMasterItemRes = await client.query<MasterGroceryItem>(
|
||||
'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *',
|
||||
[itemName, categoryId]
|
||||
[itemName, categoryId],
|
||||
);
|
||||
masterItem = newMasterItemRes.rows[0];
|
||||
}
|
||||
@@ -131,7 +148,7 @@ export class PersonalizationRepository {
|
||||
// Add to user's watchlist, ignoring if it's already there.
|
||||
await client.query(
|
||||
'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING',
|
||||
[userId, masterItem.master_grocery_item_id]
|
||||
[userId, masterItem.master_grocery_item_id],
|
||||
);
|
||||
|
||||
return masterItem;
|
||||
@@ -139,11 +156,15 @@ export class PersonalizationRepository {
|
||||
} catch (error) {
|
||||
// The withTransaction helper will handle rollback. We just need to handle specific errors.
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
if (error.code === '23503') { // foreign_key_violation
|
||||
if (error.code === '23503') {
|
||||
// foreign_key_violation
|
||||
throw new ForeignKeyConstraintError('The specified user or category does not exist.');
|
||||
}
|
||||
}
|
||||
logger.error({ err: error, userId, itemName, categoryName }, 'Transaction error in addWatchedItem');
|
||||
logger.error(
|
||||
{ err: error, userId, itemName, categoryName },
|
||||
'Transaction error in addWatchedItem',
|
||||
);
|
||||
throw new Error('Failed to add item to watchlist.');
|
||||
}
|
||||
}
|
||||
@@ -153,10 +174,14 @@ export class PersonalizationRepository {
|
||||
* This is much more efficient than calling getBestSalePricesForUser for each user individually.
|
||||
* @returns A promise that resolves to an array of deals, each augmented with user information.
|
||||
*/
|
||||
async getBestSalePricesForAllUsers(logger: Logger): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> {
|
||||
async getBestSalePricesForAllUsers(
|
||||
logger: Logger,
|
||||
): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> {
|
||||
try {
|
||||
// This function assumes a corresponding PostgreSQL function `get_best_sale_prices_for_all_users` exists.
|
||||
const res = await this.db.query<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
|
||||
const res = await this.db.query<
|
||||
WatchedItemDeal & { user_id: string; email: string; full_name: string | null }
|
||||
>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in getBestSalePricesForAllUsers');
|
||||
@@ -184,7 +209,9 @@ export class PersonalizationRepository {
|
||||
*/
|
||||
async getDietaryRestrictions(logger: Logger): Promise<DietaryRestriction[]> {
|
||||
try {
|
||||
const res = await this.db.query<DietaryRestriction>('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
|
||||
const res = await this.db.query<DietaryRestriction>(
|
||||
'SELECT * FROM public.dietary_restrictions ORDER BY type, name',
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in getDietaryRestrictions');
|
||||
@@ -218,11 +245,17 @@ export class PersonalizationRepository {
|
||||
* @param restrictionIds An array of IDs for the selected dietary restrictions.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
async setUserDietaryRestrictions(userId: string, restrictionIds: number[], logger: Logger): Promise<void> {
|
||||
async setUserDietaryRestrictions(
|
||||
userId: string,
|
||||
restrictionIds: number[],
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withTransaction(async (client) => {
|
||||
// 1. Clear existing restrictions for the user
|
||||
await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [userId]);
|
||||
await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [
|
||||
userId,
|
||||
]);
|
||||
// 2. Insert new ones if any are provided
|
||||
if (restrictionIds.length > 0) {
|
||||
// Using unnest is safer than string concatenation and prevents SQL injection.
|
||||
@@ -233,9 +266,14 @@ export class PersonalizationRepository {
|
||||
} catch (error) {
|
||||
// Check for a foreign key violation, which would mean an invalid ID was provided.
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('One or more of the specified restriction IDs are invalid.');
|
||||
throw new ForeignKeyConstraintError(
|
||||
'One or more of the specified restriction IDs are invalid.',
|
||||
);
|
||||
}
|
||||
logger.error({ err: error, userId, restrictionIds }, 'Database error in setUserDietaryRestrictions');
|
||||
logger.error(
|
||||
{ err: error, userId, restrictionIds },
|
||||
'Database error in setUserDietaryRestrictions',
|
||||
);
|
||||
throw new Error('Failed to set user dietary restrictions.');
|
||||
}
|
||||
}
|
||||
@@ -246,7 +284,11 @@ export class PersonalizationRepository {
|
||||
* @param applianceIds An array of IDs for the selected appliances.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
async setUserAppliances(userId: string, applianceIds: number[], logger: Logger): Promise<UserAppliance[]> {
|
||||
async setUserAppliances(
|
||||
userId: string,
|
||||
applianceIds: number[],
|
||||
logger: Logger,
|
||||
): Promise<UserAppliance[]> {
|
||||
try {
|
||||
return await withTransaction(async (client) => {
|
||||
// 1. Clear existing appliances for the user
|
||||
@@ -297,13 +339,16 @@ export class PersonalizationRepository {
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
async findRecipesFromPantry(userId: string, logger: Logger): Promise<PantryRecipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<PantryRecipe>('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in findRecipesFromPantry');
|
||||
throw new Error('Failed to find recipes from pantry.');
|
||||
}
|
||||
try {
|
||||
const res = await this.db.query<PantryRecipe>(
|
||||
'SELECT * FROM public.find_recipes_from_pantry($1)',
|
||||
[userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in findRecipesFromPantry');
|
||||
throw new Error('Failed to find recipes from pantry.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,14 +357,21 @@ export class PersonalizationRepository {
|
||||
* @param limit The maximum number of recipes to recommend.
|
||||
* @returns A promise that resolves to an array of recommended recipes.
|
||||
*/
|
||||
async recommendRecipesForUser(userId: string, limit: number, logger: Logger): Promise<RecommendedRecipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<RecommendedRecipe>('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, limit }, 'Database error in recommendRecipesForUser');
|
||||
throw new Error('Failed to recommend recipes.');
|
||||
}
|
||||
async recommendRecipesForUser(
|
||||
userId: string,
|
||||
limit: number,
|
||||
logger: Logger,
|
||||
): Promise<RecommendedRecipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<RecommendedRecipe>(
|
||||
'SELECT * FROM public.recommend_recipes_for_user($1, $2)',
|
||||
[userId, limit],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, limit }, 'Database error in recommendRecipesForUser');
|
||||
throw new Error('Failed to recommend recipes.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,13 +380,16 @@ export class PersonalizationRepository {
|
||||
* @returns A promise that resolves to an array of the best deals.
|
||||
*/
|
||||
async getBestSalePricesForUser(userId: string, logger: Logger): Promise<WatchedItemDeal[]> {
|
||||
try {
|
||||
const res = await this.db.query<WatchedItemDeal>('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getBestSalePricesForUser');
|
||||
throw new Error('Failed to get best sale prices.');
|
||||
}
|
||||
try {
|
||||
const res = await this.db.query<WatchedItemDeal>(
|
||||
'SELECT * FROM public.get_best_sale_prices_for_user($1)',
|
||||
[userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getBestSalePricesForUser');
|
||||
throw new Error('Failed to get best sale prices.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,14 +397,20 @@ export class PersonalizationRepository {
|
||||
* @param pantryItemId The ID of the pantry item.
|
||||
* @returns A promise that resolves to an array of suggested conversions.
|
||||
*/
|
||||
async suggestPantryItemConversions(pantryItemId: number, logger: Logger): Promise<PantryItemConversion[]> {
|
||||
try {
|
||||
const res = await this.db.query<PantryItemConversion>('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, pantryItemId }, 'Database error in suggestPantryItemConversions');
|
||||
throw new Error('Failed to suggest pantry item conversions.');
|
||||
}
|
||||
async suggestPantryItemConversions(
|
||||
pantryItemId: number,
|
||||
logger: Logger,
|
||||
): Promise<PantryItemConversion[]> {
|
||||
try {
|
||||
const res = await this.db.query<PantryItemConversion>(
|
||||
'SELECT * FROM public.suggest_pantry_item_conversions($1)',
|
||||
[pantryItemId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, pantryItemId }, 'Database error in suggestPantryItemConversions');
|
||||
throw new Error('Failed to suggest pantry item conversions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,12 +419,15 @@ export class PersonalizationRepository {
|
||||
* @returns A promise that resolves to an array of compatible Recipe objects.
|
||||
*/
|
||||
async getRecipesForUserDiets(userId: string, logger: Logger): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]); // This is a standalone function, no change needed here.
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getRecipesForUserDiets');
|
||||
throw new Error('Failed to get recipes compatible with user diet.');
|
||||
}
|
||||
try {
|
||||
const res = await this.db.query<Recipe>(
|
||||
'SELECT * FROM public.get_recipes_for_user_diets($1)',
|
||||
[userId],
|
||||
); // This is a standalone function, no change needed here.
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getRecipesForUserDiets');
|
||||
throw new Error('Failed to get recipes compatible with user diet.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ describe('Recipe DB Service', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await recipeRepo.getRecipesBySalePercentage(50, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [50]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_recipes_by_sale_percentage($1)',
|
||||
[50],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no recipes are found', async () => {
|
||||
@@ -48,17 +51,24 @@ describe('Recipe DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.getRecipesBySalePercentage(50, mockLogger)).rejects.toThrow('Failed to get recipes by sale percentage.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, minPercentage: 50 }, 'Database error in getRecipesBySalePercentage');
|
||||
await expect(recipeRepo.getRecipesBySalePercentage(50, mockLogger)).rejects.toThrow(
|
||||
'Failed to get recipes by sale percentage.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, minPercentage: 50 },
|
||||
'Database error in getRecipesBySalePercentage',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('getRecipesByMinSaleIngredients', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [3]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)',
|
||||
[3],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no recipes are found', async () => {
|
||||
@@ -70,17 +80,24 @@ describe('Recipe DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger)).rejects.toThrow('Failed to get recipes by minimum sale ingredients.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, minIngredients: 3 }, 'Database error in getRecipesByMinSaleIngredients');
|
||||
await expect(recipeRepo.getRecipesByMinSaleIngredients(3, mockLogger)).rejects.toThrow(
|
||||
'Failed to get recipes by minimum sale ingredients.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, minIngredients: 3 },
|
||||
'Database error in getRecipesByMinSaleIngredients',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('findRecipesByIngredientAndTag', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', ['chicken', 'quick']);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)',
|
||||
['chicken', 'quick'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if no recipes are found', async () => {
|
||||
@@ -92,17 +109,23 @@ describe('Recipe DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger)).rejects.toThrow('Failed to find recipes by ingredient and tag.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, ingredient: 'chicken', tag: 'quick' }, 'Database error in findRecipesByIngredientAndTag');
|
||||
await expect(
|
||||
recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick', mockLogger),
|
||||
).rejects.toThrow('Failed to find recipes by ingredient and tag.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, ingredient: 'chicken', tag: 'quick' },
|
||||
'Database error in findRecipesByIngredientAndTag',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('getUserFavoriteRecipes', () => {
|
||||
it('should call the correct database function', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await recipeRepo.getUserFavoriteRecipes('user-123', mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', ['user-123']);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', [
|
||||
'user-123',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if user has no favorites', async () => {
|
||||
@@ -114,19 +137,31 @@ describe('Recipe DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.getUserFavoriteRecipes('user-123', mockLogger)).rejects.toThrow('Failed to get favorite recipes.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123' }, 'Database error in getUserFavoriteRecipes');
|
||||
await expect(recipeRepo.getUserFavoriteRecipes('user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to get favorite recipes.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123' },
|
||||
'Database error in getUserFavoriteRecipes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFavoriteRecipe', () => {
|
||||
it('should execute an INSERT query and return the new favorite', async () => {
|
||||
const mockFavorite: FavoriteRecipe = { user_id: 'user-123', recipe_id: 1, created_at: new Date().toISOString() }; // This line is correct.
|
||||
const mockFavorite: FavoriteRecipe = {
|
||||
user_id: 'user-123',
|
||||
recipe_id: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}; // This line is correct.
|
||||
mockQuery.mockResolvedValue({ rows: [mockFavorite], rowCount: 1 });
|
||||
|
||||
const result = await recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.favorite_recipes'), ['user-123', 1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.favorite_recipes'),
|
||||
['user-123', 1],
|
||||
);
|
||||
expect(result).toEqual(mockFavorite);
|
||||
});
|
||||
|
||||
@@ -134,23 +169,34 @@ describe('Recipe DB Service', () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.addFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow('The specified user or recipe does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', recipeId: 999 }, 'Database error in addFavoriteRecipe');
|
||||
await expect(recipeRepo.addFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow(
|
||||
'The specified user or recipe does not exist.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', recipeId: 999 },
|
||||
'Database error in addFavoriteRecipe',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UniqueConstraintError if the favorite already exists (ON CONFLICT)', async () => {
|
||||
// When ON CONFLICT DO NOTHING happens, the RETURNING clause does not execute, so rows is empty.
|
||||
// The implementation now throws an error when rowCount is 0.
|
||||
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger))
|
||||
.rejects.toThrow(UniqueConstraintError);
|
||||
await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow(
|
||||
UniqueConstraintError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow('Failed to add favorite recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', recipeId: 1 }, 'Database error in addFavoriteRecipe');
|
||||
await expect(recipeRepo.addFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow(
|
||||
'Failed to add favorite recipe.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', recipeId: 1 },
|
||||
'Database error in addFavoriteRecipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,20 +204,30 @@ describe('Recipe DB Service', () => {
|
||||
it('should execute a DELETE query', async () => {
|
||||
mockQuery.mockResolvedValue({ rowCount: 1 });
|
||||
await recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2',
|
||||
['user-123', 1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the favorite recipe is not found', async () => {
|
||||
// Simulate the DB returning 0 rows affected
|
||||
mockQuery.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(recipeRepo.removeFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow('Favorite recipe not found for this user.');
|
||||
await expect(recipeRepo.removeFavoriteRecipe('user-123', 999, mockLogger)).rejects.toThrow(
|
||||
'Favorite recipe not found for this user.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow('Failed to remove favorite recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', recipeId: 1 }, 'Database error in removeFavoriteRecipe');
|
||||
await expect(recipeRepo.removeFavoriteRecipe('user-123', 1, mockLogger)).rejects.toThrow(
|
||||
'Failed to remove favorite recipe.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', recipeId: 1 },
|
||||
'Database error in removeFavoriteRecipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,55 +235,83 @@ describe('Recipe DB Service', () => {
|
||||
it('should execute a DELETE query with user ownership check', async () => {
|
||||
mockQuery.mockResolvedValue({ rowCount: 1 });
|
||||
await recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2', [1, 'user-123']);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2',
|
||||
[1, 'user-123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute a DELETE query without user ownership check if isAdmin is true', async () => {
|
||||
mockQuery.mockResolvedValue({ rowCount: 1 });
|
||||
await recipeRepo.deleteRecipe(1, 'admin-user', true, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.recipes WHERE recipe_id = $1', [1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.recipes WHERE recipe_id = $1',
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the recipe is not found or not owned by the user', async () => {
|
||||
mockQuery.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(recipeRepo.deleteRecipe(999, 'user-123', false, mockLogger)).rejects.toThrow('Recipe not found or user does not have permission to delete.');
|
||||
await expect(recipeRepo.deleteRecipe(999, 'user-123', false, mockLogger)).rejects.toThrow(
|
||||
'Recipe not found or user does not have permission to delete.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
mockQuery.mockRejectedValue(new Error('DB Error'));
|
||||
await expect(recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger)).rejects.toThrow('Failed to delete recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), recipeId: 1, userId: 'user-123', isAdmin: false }, 'Database error in deleteRecipe');
|
||||
await expect(recipeRepo.deleteRecipe(1, 'user-123', false, mockLogger)).rejects.toThrow(
|
||||
'Failed to delete recipe.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), recipeId: 1, userId: 'user-123', isAdmin: false },
|
||||
'Database error in deleteRecipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecipe', () => {
|
||||
it('should execute an UPDATE query with the correct fields', async () => {
|
||||
const mockRecipe = createMockRecipe({ recipe_id: 1, name: 'Updated Recipe', description: 'New desc' });
|
||||
const mockRecipe = createMockRecipe({
|
||||
recipe_id: 1,
|
||||
name: 'Updated Recipe',
|
||||
description: 'New desc',
|
||||
});
|
||||
mockQuery.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
|
||||
|
||||
const updates = { name: 'Updated Recipe', description: 'New desc' };
|
||||
const result = await recipeRepo.updateRecipe(1, 'user-123', updates, mockLogger);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.recipes'),
|
||||
['Updated Recipe', 'New desc', 1, 'user-123']
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), [
|
||||
'Updated Recipe',
|
||||
'New desc',
|
||||
1,
|
||||
'user-123',
|
||||
]);
|
||||
expect(result).toEqual(mockRecipe);
|
||||
});
|
||||
|
||||
it('should throw an error if no fields are provided to update', async () => {
|
||||
await expect(recipeRepo.updateRecipe(1, 'user-123', {}, mockLogger)).rejects.toThrow('No fields provided to update.');
|
||||
await expect(recipeRepo.updateRecipe(1, 'user-123', {}, mockLogger)).rejects.toThrow(
|
||||
'No fields provided to update.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the recipe is not found or not owned by the user', async () => {
|
||||
mockQuery.mockResolvedValue({ rowCount: 0 });
|
||||
await expect(recipeRepo.updateRecipe(999, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Recipe not found or user does not have permission to update.');
|
||||
await expect(
|
||||
recipeRepo.updateRecipe(999, 'user-123', { name: 'Fail' }, mockLogger),
|
||||
).rejects.toThrow('Recipe not found or user does not have permission to update.');
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
mockQuery.mockRejectedValue(new Error('DB Error'));
|
||||
await expect(recipeRepo.updateRecipe(1, 'user-123', { name: 'Fail' }, mockLogger)).rejects.toThrow('Failed to update recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), recipeId: 1, userId: 'user-123', updates: { name: 'Fail' } }, 'Database error in updateRecipe');
|
||||
await expect(
|
||||
recipeRepo.updateRecipe(1, 'user-123', { name: 'Fail' }, mockLogger),
|
||||
).rejects.toThrow('Failed to update recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error), recipeId: 1, userId: 'user-123', updates: { name: 'Fail' } },
|
||||
'Database error in updateRecipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,14 +326,21 @@ describe('Recipe DB Service', () => {
|
||||
|
||||
it('should throw NotFoundError if recipe is not found', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
await expect(recipeRepo.getRecipeById(999, mockLogger)).rejects.toThrow('Recipe with ID 999 not found');
|
||||
await expect(recipeRepo.getRecipeById(999, mockLogger)).rejects.toThrow(
|
||||
'Recipe with ID 999 not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.getRecipeById(1, mockLogger)).rejects.toThrow('Failed to retrieve recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1 }, 'Database error in getRecipeById');
|
||||
await expect(recipeRepo.getRecipeById(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve recipe.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, recipeId: 1 },
|
||||
'Database error in getRecipeById',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,7 +348,10 @@ describe('Recipe DB Service', () => {
|
||||
it('should execute a SELECT query with a JOIN', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
await recipeRepo.getRecipeComments(1, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipe_comments rc'), [1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.recipe_comments rc'),
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array if recipe has no comments', async () => {
|
||||
@@ -269,19 +363,34 @@ describe('Recipe DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.getRecipeComments(1, mockLogger)).rejects.toThrow('Failed to get recipe comments.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1 }, 'Database error in getRecipeComments');
|
||||
await expect(recipeRepo.getRecipeComments(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to get recipe comments.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, recipeId: 1 },
|
||||
'Database error in getRecipeComments',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRecipeComment', () => {
|
||||
it('should execute an INSERT query and return the new comment', async () => {
|
||||
const mockComment: RecipeComment = { recipe_comment_id: 1, recipe_id: 1, user_id: 'user-123', content: 'Great!', status: 'visible', created_at: new Date().toISOString() };
|
||||
const mockComment: RecipeComment = {
|
||||
recipe_comment_id: 1,
|
||||
recipe_id: 1,
|
||||
user_id: 'user-123',
|
||||
content: 'Great!',
|
||||
status: 'visible',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
mockQuery.mockResolvedValue({ rows: [mockComment] });
|
||||
|
||||
const result = await recipeRepo.addRecipeComment(1, 'user-123', 'Great!', mockLogger);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.recipe_comments'), [1, 'user-123', 'Great!', undefined]);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.recipe_comments'),
|
||||
[1, 'user-123', 'Great!', undefined],
|
||||
);
|
||||
expect(result).toEqual(mockComment);
|
||||
});
|
||||
|
||||
@@ -289,15 +398,25 @@ describe('Recipe DB Service', () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.addRecipeComment(999, 'user-123', 'Fail', mockLogger)).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 999, userId: 'user-123', parentCommentId: undefined }, 'Database error in addRecipeComment');
|
||||
await expect(
|
||||
recipeRepo.addRecipeComment(999, 'user-123', 'Fail', mockLogger),
|
||||
).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, recipeId: 999, userId: 'user-123', parentCommentId: undefined },
|
||||
'Database error in addRecipeComment',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.addRecipeComment(1, 'user-123', 'Fail', mockLogger)).rejects.toThrow('Failed to add recipe comment.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, recipeId: 1, userId: 'user-123', parentCommentId: undefined }, 'Database error in addRecipeComment');
|
||||
await expect(recipeRepo.addRecipeComment(1, 'user-123', 'Fail', mockLogger)).rejects.toThrow(
|
||||
'Failed to add recipe comment.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, recipeId: 1, userId: 'user-123', parentCommentId: undefined },
|
||||
'Database error in addRecipeComment',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -307,7 +426,10 @@ describe('Recipe DB Service', () => {
|
||||
mockQuery.mockResolvedValue({ rows: [mockRecipe] });
|
||||
|
||||
const result = await recipeRepo.forkRecipe('user-123', 1, mockLogger);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', ['user-123', 1]);
|
||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', [
|
||||
'user-123',
|
||||
1,
|
||||
]);
|
||||
expect(result).toEqual(mockRecipe);
|
||||
});
|
||||
|
||||
@@ -316,15 +438,25 @@ describe('Recipe DB Service', () => {
|
||||
(dbError as Error & { code: string }).code = 'P0001'; // raise_exception
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
|
||||
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow('Recipe is not public and cannot be forked.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', originalRecipeId: 1 }, 'Database error in forkRecipe');
|
||||
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow(
|
||||
'Recipe is not public and cannot be forked.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', originalRecipeId: 1 },
|
||||
'Database error in forkRecipe',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockQuery.mockRejectedValue(dbError);
|
||||
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow('Failed to fork recipe.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', originalRecipeId: 1 }, 'Database error in forkRecipe');
|
||||
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow(
|
||||
'Failed to fork recipe.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', originalRecipeId: 1 },
|
||||
'Database error in forkRecipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,10 @@ export class RecipeRepository {
|
||||
*/
|
||||
async getRecipesBySalePercentage(minPercentage: number, logger: Logger): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
|
||||
const res = await this.db.query<Recipe>(
|
||||
'SELECT * FROM public.get_recipes_by_sale_percentage($1)',
|
||||
[minPercentage],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, minPercentage }, 'Database error in getRecipesBySalePercentage');
|
||||
@@ -34,10 +37,16 @@ export class RecipeRepository {
|
||||
*/
|
||||
async getRecipesByMinSaleIngredients(minIngredients: number, logger: Logger): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
|
||||
const res = await this.db.query<Recipe>(
|
||||
'SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)',
|
||||
[minIngredients],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, minIngredients }, 'Database error in getRecipesByMinSaleIngredients');
|
||||
logger.error(
|
||||
{ err: error, minIngredients },
|
||||
'Database error in getRecipesByMinSaleIngredients',
|
||||
);
|
||||
throw new Error('Failed to get recipes by minimum sale ingredients.');
|
||||
}
|
||||
}
|
||||
@@ -48,12 +57,22 @@ export class RecipeRepository {
|
||||
* @param tag The name of the tag to search for.
|
||||
* @returns A promise that resolves to an array of matching recipes.
|
||||
*/
|
||||
async findRecipesByIngredientAndTag(ingredient: string, tag: string, logger: Logger): Promise<Recipe[]> {
|
||||
async findRecipesByIngredientAndTag(
|
||||
ingredient: string,
|
||||
tag: string,
|
||||
logger: Logger,
|
||||
): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
|
||||
const res = await this.db.query<Recipe>(
|
||||
'SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)',
|
||||
[ingredient, tag],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, ingredient, tag }, 'Database error in findRecipesByIngredientAndTag');
|
||||
logger.error(
|
||||
{ err: error, ingredient, tag },
|
||||
'Database error in findRecipesByIngredientAndTag',
|
||||
);
|
||||
throw new Error('Failed to find recipes by ingredient and tag.');
|
||||
}
|
||||
}
|
||||
@@ -65,7 +84,10 @@ export class RecipeRepository {
|
||||
*/
|
||||
async getUserFavoriteRecipes(userId: string, logger: Logger): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.get_user_favorite_recipes($1)', [userId]);
|
||||
const res = await this.db.query<Recipe>(
|
||||
'SELECT * FROM public.get_user_favorite_recipes($1)',
|
||||
[userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getUserFavoriteRecipes');
|
||||
@@ -79,16 +101,20 @@ export class RecipeRepository {
|
||||
* @param recipeId The ID of the recipe to favorite.
|
||||
* @returns A promise that resolves to the created favorite record.
|
||||
*/
|
||||
async addFavoriteRecipe(userId: string, recipeId: number, logger: Logger): Promise<FavoriteRecipe> {
|
||||
async addFavoriteRecipe(
|
||||
userId: string,
|
||||
recipeId: number,
|
||||
logger: Logger,
|
||||
): Promise<FavoriteRecipe> {
|
||||
// The ON CONFLICT DO NOTHING clause prevents duplicates but also causes the query to return
|
||||
// zero rows if the favorite already exists. We need to handle this case.
|
||||
try {
|
||||
const res = await this.db.query<FavoriteRecipe>(
|
||||
'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *',
|
||||
[userId, recipeId]
|
||||
[userId, recipeId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new UniqueConstraintError('This recipe is already in the user\'s favorites.');
|
||||
throw new UniqueConstraintError("This recipe is already in the user's favorites.");
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -110,7 +136,10 @@ export class RecipeRepository {
|
||||
*/
|
||||
async removeFavoriteRecipe(userId: string, recipeId: number, logger: Logger): Promise<void> {
|
||||
try {
|
||||
const res = await this.db.query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
|
||||
const res = await this.db.query(
|
||||
'DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2',
|
||||
[userId, recipeId],
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Favorite recipe not found for this user.');
|
||||
}
|
||||
@@ -129,7 +158,12 @@ export class RecipeRepository {
|
||||
* @param userId The ID of the user attempting to delete the recipe.
|
||||
* @param isAdmin A boolean indicating if the user is an administrator.
|
||||
*/
|
||||
async deleteRecipe(recipeId: number, userId: string, isAdmin: boolean, logger: Logger): Promise<void> {
|
||||
async deleteRecipe(
|
||||
recipeId: number,
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let query = 'DELETE FROM public.recipes WHERE recipe_id = $1 AND user_id = $2';
|
||||
const params = [recipeId, userId];
|
||||
@@ -157,7 +191,23 @@ export class RecipeRepository {
|
||||
* @param updates An object containing the fields to update.
|
||||
* @returns A promise that resolves to the updated Recipe object.
|
||||
*/
|
||||
async updateRecipe(recipeId: number, userId: string, updates: Partial<Pick<Recipe, 'name' | 'description' | 'instructions' | 'prep_time_minutes' | 'cook_time_minutes' | 'servings' | 'photo_url'>>, logger: Logger): Promise<Recipe> {
|
||||
async updateRecipe(
|
||||
recipeId: number,
|
||||
userId: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
Recipe,
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'instructions'
|
||||
| 'prep_time_minutes'
|
||||
| 'cook_time_minutes'
|
||||
| 'servings'
|
||||
| 'photo_url'
|
||||
>
|
||||
>,
|
||||
logger: Logger,
|
||||
): Promise<Recipe> {
|
||||
try {
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
@@ -190,7 +240,10 @@ export class RecipeRepository {
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// Re-throw specific, known errors to allow for more precise error handling in the calling code.
|
||||
if (error instanceof NotFoundError || (error instanceof Error && error.message.includes('No fields provided'))) {
|
||||
if (
|
||||
error instanceof NotFoundError ||
|
||||
(error instanceof Error && error.message.includes('No fields provided'))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error, recipeId, userId, updates }, 'Database error in updateRecipe');
|
||||
@@ -265,18 +318,30 @@ export class RecipeRepository {
|
||||
* @param parentCommentId Optional ID of the parent comment for threaded replies.
|
||||
* @returns A promise that resolves to the newly created RecipeComment object.
|
||||
*/
|
||||
async addRecipeComment(recipeId: number, userId: string, content: string, logger: Logger, parentCommentId?: number): Promise<RecipeComment> {
|
||||
async addRecipeComment(
|
||||
recipeId: number,
|
||||
userId: string,
|
||||
content: string,
|
||||
logger: Logger,
|
||||
parentCommentId?: number,
|
||||
): Promise<RecipeComment> {
|
||||
try {
|
||||
const res = await this.db.query<RecipeComment>(
|
||||
'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[recipeId, userId, content, parentCommentId]
|
||||
[recipeId, userId, content, parentCommentId],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error({ err: error, recipeId, userId, parentCommentId }, 'Database error in addRecipeComment');
|
||||
logger.error(
|
||||
{ err: error, recipeId, userId, parentCommentId },
|
||||
'Database error in addRecipeComment',
|
||||
);
|
||||
// Check for specific PostgreSQL error codes
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') { // foreign_key_violation
|
||||
throw new ForeignKeyConstraintError('The specified recipe, user, or parent comment does not exist.');
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
// foreign_key_violation
|
||||
throw new ForeignKeyConstraintError(
|
||||
'The specified recipe, user, or parent comment does not exist.',
|
||||
);
|
||||
}
|
||||
throw new Error('Failed to add recipe comment.');
|
||||
}
|
||||
@@ -289,16 +354,20 @@ export class RecipeRepository {
|
||||
* @returns A promise that resolves to the newly created forked Recipe object.
|
||||
*/
|
||||
async forkRecipe(userId: string, originalRecipeId: number, logger: Logger): Promise<Recipe> {
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [userId, originalRecipeId]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, originalRecipeId }, 'Database error in forkRecipe');
|
||||
// The fork_recipe function could fail if the original recipe doesn't exist or isn't public.
|
||||
if (error instanceof Error && 'code' in error && error.code === 'P0001') { // raise_exception
|
||||
throw new Error(error.message); // Re-throw the user-friendly message from the DB function.
|
||||
}
|
||||
throw new Error('Failed to fork recipe.');
|
||||
try {
|
||||
const res = await this.db.query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [
|
||||
userId,
|
||||
originalRecipeId,
|
||||
]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId, originalRecipeId }, 'Database error in forkRecipe');
|
||||
// The fork_recipe function could fail if the original recipe doesn't exist or isn't public.
|
||||
if (error instanceof Error && 'code' in error && error.code === 'P0001') {
|
||||
// raise_exception
|
||||
throw new Error(error.message); // Re-throw the user-friendly message from the DB function.
|
||||
}
|
||||
throw new Error('Failed to fork recipe.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { withTransaction } from './connection.db';
|
||||
import { createMockShoppingList, createMockShoppingListItem } from '../../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockShoppingList,
|
||||
createMockShoppingListItem,
|
||||
} from '../../tests/utils/mockFactories';
|
||||
|
||||
// Un-mock the module we are testing to ensure we use the real implementation.
|
||||
vi.unmock('./shopping.db');
|
||||
@@ -41,25 +44,36 @@ describe('Shopping DB Service', () => {
|
||||
it('should execute the correct query and return shopping lists', async () => {
|
||||
const mockLists = [createMockShoppingList({ user_id: 'user-1' })];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockLists });
|
||||
|
||||
|
||||
const result = await shoppingRepo.getShoppingLists('user-1', mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-1']);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.shopping_lists sl'),
|
||||
['user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockLists);
|
||||
});
|
||||
|
||||
it('should return an empty array if a user has no shopping lists', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
||||
const result = await shoppingRepo.getShoppingLists('user-with-no-lists', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-with-no-lists']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.shopping_lists sl'),
|
||||
['user-with-no-lists'],
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.getShoppingLists('user-1', mockLogger)).rejects.toThrow('Failed to retrieve shopping lists.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1' }, 'Database error in getShoppingLists');
|
||||
await expect(shoppingRepo.getShoppingLists('user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve shopping lists.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-1' },
|
||||
'Database error in getShoppingLists',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +86,7 @@ describe('Shopping DB Service', () => {
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WHERE sl.shopping_list_id = $1 AND sl.user_id = $2'),
|
||||
[1, 'user-1']
|
||||
[1, 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockList);
|
||||
});
|
||||
@@ -80,15 +94,22 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw NotFoundError if the shopping list is not found or not owned by the user', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
await expect(shoppingRepo.getShoppingListById(999, 'user-1', mockLogger)).rejects.toThrow('Shopping list not found or you do not have permission to view it.');
|
||||
await expect(shoppingRepo.getShoppingListById(999, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Shopping list not found or you do not have permission to view it.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(shoppingRepo.getShoppingListById(1, 'user-1', mockLogger)).rejects.toThrow('Failed to retrieve shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 1, userId: 'user-1' }, 'Database error in getShoppingListById');
|
||||
await expect(shoppingRepo.getShoppingListById(1, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve shopping list.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, listId: 1, userId: 'user-1' },
|
||||
'Database error in getShoppingListById',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,43 +119,67 @@ describe('Shopping DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockList] });
|
||||
|
||||
const result = await shoppingRepo.createShoppingList('user-1', 'New List', mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_lists'), ['user-1', 'New List']);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_lists'),
|
||||
['user-1', 'New List'],
|
||||
);
|
||||
expect(result).toEqual(mockList);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
||||
const dbError = new Error('insert or update on table "shopping_lists" violates foreign key constraint "shopping_lists_user_id_fkey"');
|
||||
const dbError = new Error(
|
||||
'insert or update on table "shopping_lists" violates foreign key constraint "shopping_lists_user_id_fkey"',
|
||||
);
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createShoppingList('non-existent-user', 'Wont work', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(
|
||||
shoppingRepo.createShoppingList('non-existent-user', 'Wont work', mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails for other reasons', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createShoppingList('user-1', 'New List', mockLogger)).rejects.toThrow('Failed to create shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', name: 'New List' }, 'Database error in createShoppingList');
|
||||
await expect(
|
||||
shoppingRepo.createShoppingList('user-1', 'New List', mockLogger),
|
||||
).rejects.toThrow('Failed to create shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-1', name: 'New List' },
|
||||
'Database error in createShoppingList',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteShoppingList', () => {
|
||||
it('should delete a shopping list if rowCount is 1', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
|
||||
await expect(shoppingRepo.deleteShoppingList(1, 'user-1', mockLogger)).resolves.toBeUndefined();
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [1, 'user-1']);
|
||||
await expect(
|
||||
shoppingRepo.deleteShoppingList(1, 'user-1', mockLogger),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2',
|
||||
[1, 'user-1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if no rows are deleted (list not found or wrong user)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
|
||||
await expect(shoppingRepo.deleteShoppingList(999, 'user-1', mockLogger)).rejects.toThrow('Failed to delete shopping list.');
|
||||
await expect(shoppingRepo.deleteShoppingList(999, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to delete shopping list.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.deleteShoppingList(1, 'user-1', mockLogger)).rejects.toThrow('Failed to delete shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 1, userId: 'user-1' }, 'Database error in deleteShoppingList');
|
||||
await expect(shoppingRepo.deleteShoppingList(1, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to delete shopping list.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, listId: 1, userId: 'user-1' },
|
||||
'Database error in deleteShoppingList',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,9 +188,16 @@ describe('Shopping DB Service', () => {
|
||||
const mockItem = createMockShoppingListItem({ custom_item_name: 'Custom Item' });
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
|
||||
|
||||
const result = await shoppingRepo.addShoppingListItem(1, { customItemName: 'Custom Item' }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, null, 'Custom Item']);
|
||||
const result = await shoppingRepo.addShoppingListItem(
|
||||
1,
|
||||
{ customItemName: 'Custom Item' },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_list_items'),
|
||||
[1, null, 'Custom Item'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
|
||||
@@ -154,37 +206,59 @@ describe('Shopping DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
|
||||
|
||||
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123 }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, 123, null]);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_list_items'),
|
||||
[1, 123, null],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
|
||||
it('should add an item with both masterItemId and customItemName', async () => {
|
||||
const mockItem = createMockShoppingListItem({ master_item_id: 123, custom_item_name: 'Organic Apples' });
|
||||
const mockItem = createMockShoppingListItem({
|
||||
master_item_id: 123,
|
||||
custom_item_name: 'Organic Apples',
|
||||
});
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
|
||||
|
||||
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123, customItemName: 'Organic Apples' }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, 123, 'Organic Apples']);
|
||||
const result = await shoppingRepo.addShoppingListItem(
|
||||
1,
|
||||
{ masterItemId: 123, customItemName: 'Organic Apples' },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_list_items'),
|
||||
[1, 123, 'Organic Apples'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
|
||||
it('should throw an error if both masterItemId and customItemName are missing', async () => {
|
||||
await expect(shoppingRepo.addShoppingListItem(1, {}, mockLogger)).rejects.toThrow('Either masterItemId or customItemName must be provided.');
|
||||
await expect(shoppingRepo.addShoppingListItem(1, {}, mockLogger)).rejects.toThrow(
|
||||
'Either masterItemId or customItemName must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if list or master item does not exist', async () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.addShoppingListItem(999, { masterItemId: 999 }, mockLogger)).rejects.toThrow('Referenced list or item does not exist.');
|
||||
await expect(
|
||||
shoppingRepo.addShoppingListItem(999, { masterItemId: 999 }, mockLogger),
|
||||
).rejects.toThrow('Referenced list or item does not exist.');
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.addShoppingListItem(1, { customItemName: 'Test' }, mockLogger)).rejects.toThrow('Failed to add item to shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, listId: 1, item: { customItemName: 'Test' } }, 'Database error in addShoppingListItem');
|
||||
await expect(
|
||||
shoppingRepo.addShoppingListItem(1, { customItemName: 'Test' }, mockLogger),
|
||||
).rejects.toThrow('Failed to add item to shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, listId: 1, item: { customItemName: 'Test' } },
|
||||
'Database error in addShoppingListItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,11 +267,15 @@ describe('Shopping DB Service', () => {
|
||||
const mockItem = createMockShoppingListItem({ shopping_list_item_id: 1, is_purchased: true });
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
|
||||
|
||||
const result = await shoppingRepo.updateShoppingListItem(1, { is_purchased: true }, mockLogger);
|
||||
|
||||
const result = await shoppingRepo.updateShoppingListItem(
|
||||
1,
|
||||
{ is_purchased: true },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.shopping_list_items SET is_purchased = $1 WHERE shopping_list_item_id = $2 RETURNING *',
|
||||
[true, 1]
|
||||
[true, 1],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
@@ -208,29 +286,38 @@ describe('Shopping DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
|
||||
|
||||
const result = await shoppingRepo.updateShoppingListItem(1, updates, mockLogger);
|
||||
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.shopping_list_items SET quantity = $1, is_purchased = $2, notes = $3 WHERE shopping_list_item_id = $4 RETURNING *',
|
||||
[updates.quantity, updates.is_purchased, updates.notes, 1]
|
||||
[updates.quantity, updates.is_purchased, updates.notes, 1],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
|
||||
it('should throw an error if the item to update is not found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'UPDATE' });
|
||||
await expect(shoppingRepo.updateShoppingListItem(999, { quantity: 5 }, mockLogger)).rejects.toThrow('Shopping list item not found.');
|
||||
await expect(
|
||||
shoppingRepo.updateShoppingListItem(999, { quantity: 5 }, mockLogger),
|
||||
).rejects.toThrow('Shopping list item not found.');
|
||||
});
|
||||
|
||||
it('should throw an error if no valid fields are provided to update', async () => {
|
||||
// The function should throw before even querying the database.
|
||||
await expect(shoppingRepo.updateShoppingListItem(1, {}, mockLogger)).rejects.toThrow('No valid fields to update.');
|
||||
await expect(shoppingRepo.updateShoppingListItem(1, {}, mockLogger)).rejects.toThrow(
|
||||
'No valid fields to update.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.updateShoppingListItem(1, { is_purchased: true }, mockLogger)).rejects.toThrow('Failed to update shopping list item.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, itemId: 1, updates: { is_purchased: true } }, 'Database error in updateShoppingListItem');
|
||||
await expect(
|
||||
shoppingRepo.updateShoppingListItem(1, { is_purchased: true }, mockLogger),
|
||||
).rejects.toThrow('Failed to update shopping list item.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, itemId: 1, updates: { is_purchased: true } },
|
||||
'Database error in updateShoppingListItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,19 +325,29 @@ describe('Shopping DB Service', () => {
|
||||
it('should delete an item if rowCount is 1', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).resolves.toBeUndefined();
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
|
||||
[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if no rows are deleted (item not found)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
|
||||
await expect(shoppingRepo.removeShoppingListItem(999, mockLogger)).rejects.toThrow('Shopping list item not found.');
|
||||
await expect(shoppingRepo.removeShoppingListItem(999, mockLogger)).rejects.toThrow(
|
||||
'Shopping list item not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).rejects.toThrow('Failed to remove item from shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, itemId: 1 }, 'Database error in removeShoppingListItem');
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to remove item from shopping list.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, itemId: 1 },
|
||||
'Database error in removeShoppingListItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,21 +356,31 @@ describe('Shopping DB Service', () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [{ complete_shopping_list: 1 }] });
|
||||
const result = await shoppingRepo.completeShoppingList(1, 'user-123', mockLogger, 5000);
|
||||
expect(result).toBe(1);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.complete_shopping_list($1, $2, $3)', [1, 'user-123', 5000]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT public.complete_shopping_list($1, $2, $3)',
|
||||
[1, 'user-123', 5000],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if the shopping list does not exist', async () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.completeShoppingList(999, 'user-123', mockLogger)).rejects.toThrow('The specified shopping list does not exist.');
|
||||
await expect(shoppingRepo.completeShoppingList(999, 'user-123', mockLogger)).rejects.toThrow(
|
||||
'The specified shopping list does not exist.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Function Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.completeShoppingList(1, 'user-123', mockLogger)).rejects.toThrow('Failed to complete shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, shoppingListId: 1, userId: 'user-123' }, 'Database error in completeShoppingList');
|
||||
await expect(shoppingRepo.completeShoppingList(1, 'user-123', mockLogger)).rejects.toThrow(
|
||||
'Failed to complete shopping list.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, shoppingListId: 1, userId: 'user-123' },
|
||||
'Database error in completeShoppingList',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,7 +389,10 @@ describe('Shopping DB Service', () => {
|
||||
const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
|
||||
const result = await shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [1, 'user-1']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)',
|
||||
[1, 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItems);
|
||||
});
|
||||
|
||||
@@ -295,8 +405,13 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1', mockLogger)).rejects.toThrow('Failed to generate shopping list for menu plan.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, menuPlanId: 1, userId: 'user-1' }, 'Database error in generateShoppingListForMenuPlan');
|
||||
await expect(
|
||||
shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1', mockLogger),
|
||||
).rejects.toThrow('Failed to generate shopping list for menu plan.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, menuPlanId: 1, userId: 'user-1' },
|
||||
'Database error in generateShoppingListForMenuPlan',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,7 +420,10 @@ describe('Shopping DB Service', () => {
|
||||
const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
|
||||
const result = await shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [1, 10, 'user-1']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)',
|
||||
[1, 10, 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItems);
|
||||
});
|
||||
|
||||
@@ -318,8 +436,13 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1', mockLogger)).rejects.toThrow('Failed to add menu plan to shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, menuPlanId: 1, shoppingListId: 10, userId: 'user-1' }, 'Database error in addMenuPlanToShoppingList');
|
||||
await expect(
|
||||
shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1', mockLogger),
|
||||
).rejects.toThrow('Failed to add menu plan to shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, menuPlanId: 1, shoppingListId: 10, userId: 'user-1' },
|
||||
'Database error in addMenuPlanToShoppingList',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,7 +451,10 @@ describe('Shopping DB Service', () => {
|
||||
const mockLocations = [{ pantry_location_id: 1, name: 'Fridge', user_id: 'user-1' }];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockLocations });
|
||||
const result = await shoppingRepo.getPantryLocations('user-1', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', ['user-1']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name',
|
||||
['user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockLocations);
|
||||
});
|
||||
|
||||
@@ -341,8 +467,13 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.getPantryLocations('user-1', mockLogger)).rejects.toThrow('Failed to get pantry locations.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1' }, 'Database error in getPantryLocations');
|
||||
await expect(shoppingRepo.getPantryLocations('user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to get pantry locations.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-1' },
|
||||
'Database error in getPantryLocations',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,7 +482,10 @@ describe('Shopping DB Service', () => {
|
||||
const mockLocation = { pantry_location_id: 1, name: 'Freezer', user_id: 'user-1' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockLocation] });
|
||||
const result = await shoppingRepo.createPantryLocation('user-1', 'Freezer', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *', ['user-1', 'Freezer']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
|
||||
['user-1', 'Freezer'],
|
||||
);
|
||||
expect(result).toEqual(mockLocation);
|
||||
});
|
||||
|
||||
@@ -359,21 +493,30 @@ describe('Shopping DB Service', () => {
|
||||
const dbError = new Error('duplicate key value violates unique constraint');
|
||||
(dbError as Error & { code: string }).code = '23505';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createPantryLocation('user-1', 'Fridge', mockLogger)).rejects.toThrow(UniqueConstraintError);
|
||||
await expect(
|
||||
shoppingRepo.createPantryLocation('user-1', 'Fridge', mockLogger),
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
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.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createPantryLocation('non-existent-user', 'Pantry', mockLogger)).rejects.toThrow(ForeignKeyConstraintError);
|
||||
await expect(
|
||||
shoppingRepo.createPantryLocation('non-existent-user', 'Pantry', mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createPantryLocation('user-1', 'Pantry', mockLogger)).rejects.toThrow('Failed to create pantry location.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', name: 'Pantry' }, 'Database error in createPantryLocation');
|
||||
await expect(
|
||||
shoppingRepo.createPantryLocation('user-1', 'Pantry', mockLogger),
|
||||
).rejects.toThrow('Failed to create pantry location.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-1', name: 'Pantry' },
|
||||
'Database error in createPantryLocation',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +525,10 @@ describe('Shopping DB Service', () => {
|
||||
const mockTrips = [{ shopping_trip_id: 1, user_id: 'user-1', items: [] }];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockTrips });
|
||||
const result = await shoppingRepo.getShoppingTripHistory('user-1', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_trips st'), ['user-1']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.shopping_trips st'),
|
||||
['user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockTrips);
|
||||
});
|
||||
|
||||
@@ -395,17 +541,30 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.getShoppingTripHistory('user-1', mockLogger)).rejects.toThrow('Failed to retrieve shopping trip history.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1' }, 'Database error in getShoppingTripHistory');
|
||||
await expect(shoppingRepo.getShoppingTripHistory('user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve shopping trip history.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-1' },
|
||||
'Database error in getShoppingTripHistory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createReceipt', () => {
|
||||
it('should insert a new receipt and return it', async () => {
|
||||
const mockReceipt = { receipt_id: 1, user_id: 'user-1', receipt_image_url: 'url', status: 'pending' };
|
||||
const mockReceipt = {
|
||||
receipt_id: 1,
|
||||
user_id: 'user-1',
|
||||
receipt_image_url: 'url',
|
||||
status: 'pending',
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });
|
||||
const result = await shoppingRepo.createReceipt('user-1', 'url', mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.receipts'), ['user-1', 'url']);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.receipts'),
|
||||
['user-1', 'url'],
|
||||
);
|
||||
expect(result).toEqual(mockReceipt);
|
||||
});
|
||||
|
||||
@@ -413,14 +572,21 @@ describe('Shopping DB Service', () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createReceipt('non-existent-user', 'url', mockLogger)).rejects.toThrow('User not found');
|
||||
await expect(
|
||||
shoppingRepo.createReceipt('non-existent-user', 'url', mockLogger),
|
||||
).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.createReceipt('user-1', 'url', mockLogger)).rejects.toThrow('Failed to create receipt record.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-1', receiptImageUrl: 'url' }, 'Database error in createReceipt');
|
||||
await expect(shoppingRepo.createReceipt('user-1', 'url', mockLogger)).rejects.toThrow(
|
||||
'Failed to create receipt record.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-1', receiptImageUrl: 'url' },
|
||||
'Database error in createReceipt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,7 +595,10 @@ describe('Shopping DB Service', () => {
|
||||
const mockOwner = { user_id: 'owner-123' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockOwner] });
|
||||
const result = await shoppingRepo.findReceiptOwner(1, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT user_id FROM public.receipts WHERE receipt_id = $1', [1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT user_id FROM public.receipts WHERE receipt_id = $1',
|
||||
[1],
|
||||
);
|
||||
expect(result).toEqual(mockOwner);
|
||||
});
|
||||
|
||||
@@ -442,8 +611,13 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.findReceiptOwner(1, mockLogger)).rejects.toThrow('Failed to retrieve receipt owner from database.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database error in findReceiptOwner');
|
||||
await expect(shoppingRepo.findReceiptOwner(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve receipt owner from database.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, receiptId: 1 },
|
||||
'Database error in findReceiptOwner',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,13 +630,16 @@ describe('Shopping DB Service', () => {
|
||||
});
|
||||
|
||||
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
|
||||
|
||||
|
||||
await shoppingRepo.processReceiptItems(1, items, mockLogger);
|
||||
|
||||
const expectedItemsWithQuantity = [{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 }];
|
||||
|
||||
const expectedItemsWithQuantity = [
|
||||
{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 },
|
||||
];
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientQuery).toHaveBeenCalledWith(
|
||||
'SELECT public.process_receipt_items($1, $2, $3)', [1, JSON.stringify(expectedItemsWithQuantity), JSON.stringify(expectedItemsWithQuantity)]
|
||||
'SELECT public.process_receipt_items($1, $2, $3)',
|
||||
[1, JSON.stringify(expectedItemsWithQuantity), JSON.stringify(expectedItemsWithQuantity)],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -476,21 +653,43 @@ describe('Shopping DB Service', () => {
|
||||
});
|
||||
|
||||
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
|
||||
await expect(shoppingRepo.processReceiptItems(1, items, mockLogger)).rejects.toThrow('Failed to process and save receipt items.');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database transaction error in processReceiptItems');
|
||||
await expect(shoppingRepo.processReceiptItems(1, items, mockLogger)).rejects.toThrow(
|
||||
'Failed to process and save receipt items.',
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, receiptId: 1 },
|
||||
'Database transaction error in processReceiptItems',
|
||||
);
|
||||
// Verify that the status was updated to 'failed' in the catch block
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
"UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1",
|
||||
[1],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDealsForReceipt', () => {
|
||||
it('should call the find_deals_for_receipt_items database function', async () => {
|
||||
const mockDeals = [{ receipt_item_id: 1, master_item_id: 10, item_name: 'Milk', price_paid_cents: 399, current_best_price_in_cents: 350, potential_savings_cents: 49, deal_store_name: 'Grocer', flyer_id: 101 }];
|
||||
const mockDeals = [
|
||||
{
|
||||
receipt_item_id: 1,
|
||||
master_item_id: 10,
|
||||
item_name: 'Milk',
|
||||
price_paid_cents: 399,
|
||||
current_best_price_in_cents: 350,
|
||||
potential_savings_cents: 49,
|
||||
deal_store_name: 'Grocer',
|
||||
flyer_id: 101,
|
||||
},
|
||||
];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockDeals });
|
||||
|
||||
const result = await shoppingRepo.findDealsForReceipt(1, mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.find_deals_for_receipt_items($1)', [1]);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'SELECT * FROM public.find_deals_for_receipt_items($1)',
|
||||
[1],
|
||||
);
|
||||
expect(result).toEqual(mockDeals);
|
||||
});
|
||||
|
||||
@@ -503,8 +702,13 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.findDealsForReceipt(1, mockLogger)).rejects.toThrow('Failed to find deals for receipt.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database error in findDealsForReceipt');
|
||||
await expect(shoppingRepo.findDealsForReceipt(1, mockLogger)).rejects.toThrow(
|
||||
'Failed to find deals for receipt.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, receiptId: 1 },
|
||||
'Database error in findDealsForReceipt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,10 +726,18 @@ describe('Shopping DB Service', () => {
|
||||
mockPoolInstance.query.mockRejectedValueOnce(updateStatusError);
|
||||
|
||||
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
|
||||
await expect(shoppingRepo.processReceiptItems(1, items, mockLogger)).rejects.toThrow('Failed to process and save receipt items.');
|
||||
await expect(shoppingRepo.processReceiptItems(1, items, mockLogger)).rejects.toThrow(
|
||||
'Failed to process and save receipt items.',
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: transactionError, receiptId: 1 }, 'Database transaction error in processReceiptItems');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ updateError: updateStatusError, receiptId: 1 }, 'Failed to update receipt status to "failed" after transaction rollback.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: transactionError, receiptId: 1 },
|
||||
'Database transaction error in processReceiptItems',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ updateError: updateStatusError, receiptId: 1 },
|
||||
'Failed to update receipt status to "failed" after transaction rollback.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,8 @@ export class ShoppingRepository {
|
||||
* @returns A promise that resolves to an array of ShoppingList objects.
|
||||
*/
|
||||
async getShoppingLists(userId: string, logger: Logger): Promise<ShoppingList[]> {
|
||||
try {
|
||||
const query = `
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
sl.shopping_list_id, sl.name, sl.created_at,
|
||||
COALESCE(json_agg(
|
||||
@@ -50,12 +50,12 @@ export class ShoppingRepository {
|
||||
GROUP BY sl.shopping_list_id
|
||||
ORDER BY sl.created_at ASC;
|
||||
`;
|
||||
const res = await this.db.query<ShoppingList>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getShoppingLists');
|
||||
throw new Error('Failed to retrieve shopping lists.');
|
||||
}
|
||||
const res = await this.db.query<ShoppingList>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getShoppingLists');
|
||||
throw new Error('Failed to retrieve shopping lists.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ export class ShoppingRepository {
|
||||
try {
|
||||
const res = await this.db.query<ShoppingList>(
|
||||
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id, user_id, name, created_at',
|
||||
[userId, name]
|
||||
[userId, name],
|
||||
);
|
||||
return { ...res.rows[0], items: [] };
|
||||
} catch (error) {
|
||||
@@ -113,7 +113,9 @@ export class ShoppingRepository {
|
||||
`;
|
||||
const res = await this.db.query<ShoppingList>(query, [listId, userId]);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list not found or you do not have permission to view it.');
|
||||
throw new NotFoundError(
|
||||
'Shopping list not found or you do not have permission to view it.',
|
||||
);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -130,10 +132,15 @@ export class ShoppingRepository {
|
||||
*/
|
||||
async deleteShoppingList(listId: number, userId: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
const res = await this.db.query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
|
||||
const res = await this.db.query(
|
||||
'DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2',
|
||||
[listId, userId],
|
||||
);
|
||||
// The patch requested this specific error handling.
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list not found or user does not have permission to delete.');
|
||||
throw new NotFoundError(
|
||||
'Shopping list not found or user does not have permission to delete.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, listId, userId }, 'Database error in deleteShoppingList');
|
||||
@@ -147,7 +154,11 @@ export class ShoppingRepository {
|
||||
* @param item An object containing either a `masterItemId` or a `customItemName`.
|
||||
* @returns A promise that resolves to the newly created ShoppingListItem object.
|
||||
*/
|
||||
async addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }, logger: Logger): Promise<ShoppingListItem> {
|
||||
async addShoppingListItem(
|
||||
listId: number,
|
||||
item: { masterItemId?: number; customItemName?: string },
|
||||
logger: Logger,
|
||||
): Promise<ShoppingListItem> {
|
||||
// The patch requested this specific error handling.
|
||||
if (!item.masterItemId && !item.customItemName) {
|
||||
throw new Error('Either masterItemId or customItemName must be provided.');
|
||||
@@ -156,7 +167,7 @@ export class ShoppingRepository {
|
||||
try {
|
||||
const res = await this.db.query<ShoppingListItem>(
|
||||
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
|
||||
[listId, item.masterItemId ?? null, item.customItemName ?? null]
|
||||
[listId, item.masterItemId ?? null, item.customItemName ?? null],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -175,7 +186,10 @@ export class ShoppingRepository {
|
||||
*/
|
||||
async removeShoppingListItem(itemId: number, logger: Logger): Promise<void> {
|
||||
try {
|
||||
const res = await this.db.query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
|
||||
const res = await this.db.query(
|
||||
'DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
|
||||
[itemId],
|
||||
);
|
||||
// The patch requested this specific error handling.
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list item not found.');
|
||||
@@ -192,12 +206,22 @@ export class ShoppingRepository {
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of items for the shopping list.
|
||||
*/
|
||||
async generateShoppingListForMenuPlan(menuPlanId: number, userId: string, logger: Logger): Promise<MenuPlanShoppingListItem[]> {
|
||||
async generateShoppingListForMenuPlan(
|
||||
menuPlanId: number,
|
||||
userId: string,
|
||||
logger: Logger,
|
||||
): Promise<MenuPlanShoppingListItem[]> {
|
||||
try {
|
||||
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
|
||||
const res = await this.db.query<MenuPlanShoppingListItem>(
|
||||
'SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)',
|
||||
[menuPlanId, userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, menuPlanId, userId }, 'Database error in generateShoppingListForMenuPlan');
|
||||
logger.error(
|
||||
{ err: error, menuPlanId, userId },
|
||||
'Database error in generateShoppingListForMenuPlan',
|
||||
);
|
||||
throw new Error('Failed to generate shopping list for menu plan.');
|
||||
}
|
||||
}
|
||||
@@ -209,12 +233,23 @@ export class ShoppingRepository {
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the items that were added.
|
||||
*/
|
||||
async addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string, logger: Logger): Promise<MenuPlanShoppingListItem[]> {
|
||||
async addMenuPlanToShoppingList(
|
||||
menuPlanId: number,
|
||||
shoppingListId: number,
|
||||
userId: string,
|
||||
logger: Logger,
|
||||
): Promise<MenuPlanShoppingListItem[]> {
|
||||
try {
|
||||
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
|
||||
const res = await this.db.query<MenuPlanShoppingListItem>(
|
||||
'SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)',
|
||||
[menuPlanId, shoppingListId, userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, menuPlanId, shoppingListId, userId }, 'Database error in addMenuPlanToShoppingList');
|
||||
logger.error(
|
||||
{ err: error, menuPlanId, shoppingListId, userId },
|
||||
'Database error in addMenuPlanToShoppingList',
|
||||
);
|
||||
throw new Error('Failed to add menu plan to shopping list.');
|
||||
}
|
||||
}
|
||||
@@ -225,13 +260,16 @@ export class ShoppingRepository {
|
||||
* @returns A promise that resolves to an array of PantryLocation objects.
|
||||
*/
|
||||
async getPantryLocations(userId: string, logger: Logger): Promise<PantryLocation[]> {
|
||||
try {
|
||||
const res = await this.db.query<PantryLocation>('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getPantryLocations');
|
||||
throw new Error('Failed to get pantry locations.');
|
||||
}
|
||||
try {
|
||||
const res = await this.db.query<PantryLocation>(
|
||||
'SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name',
|
||||
[userId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getPantryLocations');
|
||||
throw new Error('Failed to get pantry locations.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,22 +278,26 @@ export class ShoppingRepository {
|
||||
* @param name The name of the new location (e.g., "Fridge").
|
||||
* @returns A promise that resolves to the newly created PantryLocation object.
|
||||
*/
|
||||
async createPantryLocation(userId: string, name: string, logger: Logger): Promise<PantryLocation> {
|
||||
try {
|
||||
const res = await this.db.query<PantryLocation>(
|
||||
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
|
||||
[userId, name]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') {
|
||||
throw new UniqueConstraintError('A pantry location with this name already exists.');
|
||||
} else if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('User not found');
|
||||
}
|
||||
logger.error({ err: error, userId, name }, 'Database error in createPantryLocation');
|
||||
throw new Error('Failed to create pantry location.');
|
||||
async createPantryLocation(
|
||||
userId: string,
|
||||
name: string,
|
||||
logger: Logger,
|
||||
): Promise<PantryLocation> {
|
||||
try {
|
||||
const res = await this.db.query<PantryLocation>(
|
||||
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
|
||||
[userId, name],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') {
|
||||
throw new UniqueConstraintError('A pantry location with this name already exists.');
|
||||
} else if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('User not found');
|
||||
}
|
||||
logger.error({ err: error, userId, name }, 'Database error in createPantryLocation');
|
||||
throw new Error('Failed to create pantry location.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,7 +306,11 @@ export class ShoppingRepository {
|
||||
* @param updates A partial object of the fields to update (e.g., quantity, is_purchased).
|
||||
* @returns A promise that resolves to the updated ShoppingListItem object.
|
||||
*/
|
||||
async updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>, logger: Logger): Promise<ShoppingListItem> {
|
||||
async updateShoppingListItem(
|
||||
itemId: number,
|
||||
updates: Partial<ShoppingListItem>,
|
||||
logger: Logger,
|
||||
): Promise<ShoppingListItem> {
|
||||
try {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error('No valid fields to update.');
|
||||
@@ -274,20 +320,20 @@ export class ShoppingRepository {
|
||||
let valueIndex = 1;
|
||||
|
||||
if ('quantity' in updates) {
|
||||
setClauses.push(`quantity = $${valueIndex++}`);
|
||||
values.push(updates.quantity);
|
||||
setClauses.push(`quantity = $${valueIndex++}`);
|
||||
values.push(updates.quantity);
|
||||
}
|
||||
if ('is_purchased' in updates) {
|
||||
setClauses.push(`is_purchased = $${valueIndex++}`);
|
||||
values.push(updates.is_purchased);
|
||||
setClauses.push(`is_purchased = $${valueIndex++}`);
|
||||
values.push(updates.is_purchased);
|
||||
}
|
||||
if ('notes' in updates) {
|
||||
setClauses.push(`notes = $${valueIndex++}`);
|
||||
values.push(updates.notes);
|
||||
setClauses.push(`notes = $${valueIndex++}`);
|
||||
values.push(updates.notes);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error("No valid fields to update.");
|
||||
throw new Error('No valid fields to update.');
|
||||
}
|
||||
|
||||
values.push(itemId);
|
||||
@@ -301,7 +347,10 @@ export class ShoppingRepository {
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// Re-throw specific, known errors to allow for more precise error handling in the calling code.
|
||||
if (error instanceof NotFoundError || (error instanceof Error && error.message.startsWith('No valid fields'))) {
|
||||
if (
|
||||
error instanceof NotFoundError ||
|
||||
(error instanceof Error && error.message.startsWith('No valid fields'))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error, itemId, updates }, 'Database error in updateShoppingListItem');
|
||||
@@ -316,21 +365,29 @@ export class ShoppingRepository {
|
||||
* @param totalSpentCents Optional total amount spent on the trip.
|
||||
* @returns A promise that resolves to the ID of the newly created shopping trip.
|
||||
*/
|
||||
async completeShoppingList(shoppingListId: number, userId: string, logger: Logger, totalSpentCents?: number): Promise<number> {
|
||||
try {
|
||||
const res = await this.db.query<{ complete_shopping_list: number }>(
|
||||
'SELECT public.complete_shopping_list($1, $2, $3)',
|
||||
[shoppingListId, userId, totalSpentCents]
|
||||
);
|
||||
return res.rows[0].complete_shopping_list;
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('The specified shopping list does not exist.');
|
||||
}
|
||||
logger.error({ err: error, shoppingListId, userId }, 'Database error in completeShoppingList');
|
||||
throw new Error('Failed to complete shopping list.');
|
||||
async completeShoppingList(
|
||||
shoppingListId: number,
|
||||
userId: string,
|
||||
logger: Logger,
|
||||
totalSpentCents?: number,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const res = await this.db.query<{ complete_shopping_list: number }>(
|
||||
'SELECT public.complete_shopping_list($1, $2, $3)',
|
||||
[shoppingListId, userId, totalSpentCents],
|
||||
);
|
||||
return res.rows[0].complete_shopping_list;
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('The specified shopping list does not exist.');
|
||||
}
|
||||
logger.error(
|
||||
{ err: error, shoppingListId, userId },
|
||||
'Database error in completeShoppingList',
|
||||
);
|
||||
throw new Error('Failed to complete shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,8 +396,8 @@ export class ShoppingRepository {
|
||||
* @returns A promise that resolves to an array of ShoppingTrip objects.
|
||||
*/
|
||||
async getShoppingTripHistory(userId: string, logger: Logger): Promise<ShoppingTrip[]> {
|
||||
try {
|
||||
const query = `
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
st.shopping_trip_id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents,
|
||||
COALESCE(
|
||||
@@ -363,12 +420,12 @@ export class ShoppingRepository {
|
||||
GROUP BY st.shopping_trip_id
|
||||
ORDER BY st.completed_at DESC;
|
||||
`;
|
||||
const res = await this.db.query<ShoppingTrip>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getShoppingTripHistory');
|
||||
throw new Error('Failed to retrieve shopping trip history.');
|
||||
}
|
||||
const res = await this.db.query<ShoppingTrip>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId }, 'Database error in getShoppingTripHistory');
|
||||
throw new Error('Failed to retrieve shopping trip history.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,22 +435,22 @@ export class ShoppingRepository {
|
||||
* @returns A promise that resolves to the newly created Receipt object.
|
||||
*/
|
||||
async createReceipt(userId: string, receiptImageUrl: string, logger: Logger): Promise<Receipt> {
|
||||
try {
|
||||
const res = await this.db.query<Receipt>(
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
||||
try {
|
||||
const res = await this.db.query<Receipt>(
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
RETURNING *`,
|
||||
[userId, receiptImageUrl]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('User not found');
|
||||
}
|
||||
logger.error({ err: error, userId, receiptImageUrl }, 'Database error in createReceipt');
|
||||
throw new Error('Failed to create receipt record.');
|
||||
[userId, receiptImageUrl],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('User not found');
|
||||
}
|
||||
logger.error({ err: error, userId, receiptImageUrl }, 'Database error in createReceipt');
|
||||
throw new Error('Failed to create receipt record.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,14 +461,21 @@ export class ShoppingRepository {
|
||||
*/
|
||||
async processReceiptItems(
|
||||
receiptId: number,
|
||||
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[],
|
||||
logger: Logger
|
||||
items: Omit<
|
||||
ReceiptItem,
|
||||
'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'
|
||||
>[],
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withTransaction(async (client) => {
|
||||
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));
|
||||
const itemsWithQuantity = items.map((item) => ({ ...item, quantity: 1 }));
|
||||
// Use the transactional client for this operation
|
||||
await client.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]);
|
||||
await client.query('SELECT public.process_receipt_items($1, $2, $3)', [
|
||||
receiptId,
|
||||
JSON.stringify(itemsWithQuantity),
|
||||
JSON.stringify(itemsWithQuantity),
|
||||
]);
|
||||
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -419,9 +483,14 @@ export class ShoppingRepository {
|
||||
// After the transaction fails and is rolled back by withTransaction,
|
||||
// update the receipt status in a separate, non-transactional query.
|
||||
try {
|
||||
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [receiptId]);
|
||||
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [
|
||||
receiptId,
|
||||
]);
|
||||
} catch (updateError) {
|
||||
logger.error({ updateError, receiptId }, 'Failed to update receipt status to "failed" after transaction rollback.');
|
||||
logger.error(
|
||||
{ updateError, receiptId },
|
||||
'Failed to update receipt status to "failed" after transaction rollback.',
|
||||
);
|
||||
}
|
||||
throw new Error('Failed to process and save receipt items.');
|
||||
}
|
||||
@@ -434,7 +503,10 @@ export class ShoppingRepository {
|
||||
*/
|
||||
async findDealsForReceipt(receiptId: number, logger: Logger): Promise<ReceiptDeal[]> {
|
||||
try {
|
||||
const res = await this.db.query<ReceiptDeal>('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]);
|
||||
const res = await this.db.query<ReceiptDeal>(
|
||||
'SELECT * FROM public.find_deals_for_receipt_items($1)',
|
||||
[receiptId],
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, receiptId }, 'Database error in findDealsForReceipt');
|
||||
@@ -447,11 +519,14 @@ export class ShoppingRepository {
|
||||
* @param receiptId The ID of the receipt.
|
||||
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
|
||||
*/
|
||||
async findReceiptOwner(receiptId: number, logger: Logger): Promise<{ user_id: string } | undefined> {
|
||||
async findReceiptOwner(
|
||||
receiptId: number,
|
||||
logger: Logger,
|
||||
): Promise<{ user_id: string } | undefined> {
|
||||
try {
|
||||
const res = await this.db.query<{ user_id: string }>(
|
||||
'SELECT user_id FROM public.receipts WHERE receipt_id = $1',
|
||||
[receiptId]
|
||||
[receiptId],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -459,4 +534,4 @@ export class ShoppingRepository {
|
||||
throw new Error('Failed to retrieve receipt owner from database.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// src/services/db/user.db.ts
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { getPool } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile, SearchQuery } from '../../types';
|
||||
import {
|
||||
Profile,
|
||||
MasterGroceryItem,
|
||||
ShoppingList,
|
||||
ActivityLogItem,
|
||||
UserProfile,
|
||||
SearchQuery,
|
||||
} from '../../types';
|
||||
import { ShoppingRepository } from './shopping.db';
|
||||
import { PersonalizationRepository } from './personalization.db';
|
||||
import { withTransaction } from './connection.db';
|
||||
@@ -12,13 +19,13 @@ import { withTransaction } from './connection.db';
|
||||
* Defines the structure of a user object as returned from the database.
|
||||
*/
|
||||
interface DbUser {
|
||||
user_id: string;
|
||||
email: string;
|
||||
// The password_hash can be null for users who signed up via OAuth.
|
||||
password_hash: string | null;
|
||||
refresh_token?: string | null;
|
||||
failed_login_attempts: number;
|
||||
last_failed_login: string | null; // This will be a date string from the DB
|
||||
user_id: string;
|
||||
email: string;
|
||||
// The password_hash can be null for users who signed up via OAuth.
|
||||
password_hash: string | null;
|
||||
refresh_token?: string | null;
|
||||
failed_login_attempts: number;
|
||||
last_failed_login: string | null; // This will be a date string from the DB
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
@@ -37,10 +44,12 @@ export class UserRepository {
|
||||
try {
|
||||
const res = await this.db.query<DbUser>(
|
||||
'SELECT user_id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1',
|
||||
[email]
|
||||
[email],
|
||||
);
|
||||
const userFound = res.rows[0];
|
||||
logger.debug(`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`);
|
||||
logger.debug(
|
||||
`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`,
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error({ err: error, email }, 'Database error in findUserByEmail');
|
||||
@@ -60,19 +69,21 @@ export class UserRepository {
|
||||
email: string,
|
||||
passwordHash: string | null,
|
||||
profileData: { full_name?: string; avatar_url?: string },
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
): Promise<UserProfile> {
|
||||
return withTransaction(async (client: PoolClient) => {
|
||||
logger.debug(`[DB createUser] Starting transaction for email: ${email}`);
|
||||
|
||||
// Use 'set_config' to safely pass parameters to a configuration variable.
|
||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify(profileData)]);
|
||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
||||
JSON.stringify(profileData),
|
||||
]);
|
||||
logger.debug(`[DB createUser] Session metadata set for ${email}.`);
|
||||
|
||||
// Insert the new user into the 'users' table. This will fire the trigger.
|
||||
const userInsertRes = await client.query<{ user_id: string }>(
|
||||
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||
[email, passwordHash]
|
||||
[email, passwordHash],
|
||||
);
|
||||
const newUserId = userInsertRes.rows[0].user_id;
|
||||
logger.debug(`[DB createUser] Inserted into users table. New user ID: ${newUserId}`);
|
||||
@@ -109,7 +120,7 @@ export class UserRepository {
|
||||
|
||||
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
|
||||
return fullUserProfile;
|
||||
}).catch(error => {
|
||||
}).catch((error) => {
|
||||
// Check for specific PostgreSQL error codes
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') {
|
||||
logger.warn(`Attempted to create a user with an existing email: ${email}`);
|
||||
@@ -127,7 +138,10 @@ export class UserRepository {
|
||||
* @param email The email of the user to find.
|
||||
* @returns A promise that resolves to the combined user and profile object or undefined if not found.
|
||||
*/
|
||||
async findUserWithProfileByEmail(email: string, logger: Logger): Promise<(UserProfile & Omit<DbUser, 'user_id' | 'email'>) | undefined> {
|
||||
async findUserWithProfileByEmail(
|
||||
email: string,
|
||||
logger: Logger,
|
||||
): Promise<(UserProfile & Omit<DbUser, 'user_id' | 'email'>) | undefined> {
|
||||
logger.debug({ email }, `[DB findUserWithProfileByEmail] Searching for user.`);
|
||||
try {
|
||||
const query = `
|
||||
@@ -420,7 +434,9 @@ export class UserRepository {
|
||||
*/
|
||||
async deleteRefreshToken(refreshToken: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await this.db.query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [refreshToken]);
|
||||
await this.db.query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [
|
||||
refreshToken,
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in deleteRefreshToken');
|
||||
}
|
||||
@@ -488,9 +504,11 @@ export class UserRepository {
|
||||
async deleteExpiredResetTokens(logger: Logger): Promise<number> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
"DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()"
|
||||
'DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()',
|
||||
);
|
||||
logger.info(
|
||||
`[DB deleteExpiredResetTokens] Deleted ${res.rowCount ?? 0} expired password reset tokens.`,
|
||||
);
|
||||
logger.info(`[DB deleteExpiredResetTokens] Deleted ${res.rowCount ?? 0} expired password reset tokens.`);
|
||||
return res.rowCount ?? 0;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Database error in deleteExpiredResetTokens');
|
||||
@@ -506,7 +524,7 @@ export class UserRepository {
|
||||
try {
|
||||
await this.db.query(
|
||||
'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT (follower_id, following_id) DO NOTHING',
|
||||
[followerId, followingId]
|
||||
[followerId, followingId],
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
@@ -524,7 +542,10 @@ export class UserRepository {
|
||||
*/
|
||||
async unfollowUser(followerId: string, followingId: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await this.db.query('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', [followerId, followingId]);
|
||||
await this.db.query(
|
||||
'DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2',
|
||||
[followerId, followingId],
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, followerId, followingId }, 'Database error in unfollowUser');
|
||||
throw new Error('Failed to unfollow user.');
|
||||
@@ -538,7 +559,12 @@ export class UserRepository {
|
||||
* @param offset The number of feed items to skip for pagination.
|
||||
* @returns A promise that resolves to an array of ActivityLogItem objects.
|
||||
*/
|
||||
async getUserFeed(userId: string, limit: number, offset: number, logger: Logger): Promise<ActivityLogItem[]> {
|
||||
async getUserFeed(
|
||||
userId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
logger: Logger,
|
||||
): Promise<ActivityLogItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT al.*, p.full_name as user_full_name, p.avatar_url as user_avatar_url
|
||||
@@ -562,12 +588,15 @@ export class UserRepository {
|
||||
* @param queryData The search query data to log.
|
||||
* @returns A promise that resolves to the created SearchQuery object.
|
||||
*/
|
||||
async logSearchQuery(queryData: Omit<SearchQuery, 'search_query_id' | 'created_at'>, logger: Logger): Promise<SearchQuery> {
|
||||
async logSearchQuery(
|
||||
queryData: Omit<SearchQuery, 'search_query_id' | 'created_at'>,
|
||||
logger: Logger,
|
||||
): Promise<SearchQuery> {
|
||||
const { user_id, query_text, result_count, was_successful } = queryData;
|
||||
try {
|
||||
const res = await this.db.query<SearchQuery>(
|
||||
'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[user_id, query_text, result_count, was_successful]
|
||||
[user_id, query_text, result_count, was_successful],
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -608,4 +637,4 @@ export async function exportUserData(userId: string, logger: Logger): Promise<{
|
||||
logger.error({ err: error, userId }, 'Database error in exportUserData');
|
||||
throw new Error('Failed to export user data.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user