Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
934 lines
28 KiB
TypeScript
934 lines
28 KiB
TypeScript
// src/services/expiryService.server.test.ts
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Logger } from 'pino';
|
|
import type { Job } from 'bullmq';
|
|
import type { ExpiryAlertJobData } from '../types/job-data';
|
|
import { createMockLogger } from '../tests/utils/mockLogger';
|
|
import type {
|
|
InventorySource,
|
|
StorageLocation,
|
|
ExpiryStatus,
|
|
ExpiryRangeSource,
|
|
AlertMethod,
|
|
UserInventoryItem,
|
|
ReceiptStatus,
|
|
ReceiptItemStatus,
|
|
ExpiryAlertLogRecord,
|
|
ExpiryAlertType,
|
|
} from '../types/expiry';
|
|
|
|
// Mock dependencies
|
|
vi.mock('./db/index.db', () => ({
|
|
expiryRepo: {
|
|
addInventoryItem: vi.fn(),
|
|
updateInventoryItem: vi.fn(),
|
|
markAsConsumed: vi.fn(),
|
|
deleteInventoryItem: vi.fn(),
|
|
getInventoryItemById: vi.fn(),
|
|
getInventory: vi.fn(),
|
|
getExpiringItems: vi.fn(),
|
|
getExpiredItems: vi.fn(),
|
|
getExpiryRangeForItem: vi.fn(),
|
|
getExpiryRanges: vi.fn(),
|
|
addExpiryRange: vi.fn(),
|
|
getUserAlertSettings: vi.fn(),
|
|
upsertAlertSettings: vi.fn(),
|
|
getUsersWithExpiringItems: vi.fn(),
|
|
logAlert: vi.fn(),
|
|
markAlertSent: vi.fn(),
|
|
getRecipesForExpiringItems: vi.fn(),
|
|
},
|
|
receiptRepo: {
|
|
getReceiptById: vi.fn(),
|
|
getReceiptItems: vi.fn(),
|
|
updateReceiptItem: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('./emailService.server', () => ({
|
|
sendEmail: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./logger.server', () => ({
|
|
logger: {
|
|
child: vi.fn().mockReturnThis(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Import after mocks are set up
|
|
import {
|
|
addInventoryItem,
|
|
updateInventoryItem,
|
|
markItemConsumed,
|
|
deleteInventoryItem,
|
|
getInventoryItemById,
|
|
getInventory,
|
|
getExpiringItemsGrouped,
|
|
getExpiringItems,
|
|
getExpiredItems,
|
|
calculateExpiryDate,
|
|
getExpiryRanges,
|
|
addExpiryRange,
|
|
getAlertSettings,
|
|
updateAlertSettings,
|
|
processExpiryAlerts,
|
|
addItemsFromReceipt,
|
|
getRecipeSuggestionsForExpiringItems,
|
|
processExpiryAlertJob,
|
|
} from './expiryService.server';
|
|
|
|
import { expiryRepo, receiptRepo } from './db/index.db';
|
|
import * as emailService from './emailService.server';
|
|
|
|
// Helper to create mock alert log record
|
|
function createMockAlertLogRecord(
|
|
overrides: Partial<ExpiryAlertLogRecord> = {},
|
|
): ExpiryAlertLogRecord {
|
|
return {
|
|
alert_log_id: 1,
|
|
user_id: 'user-1',
|
|
pantry_item_id: null,
|
|
alert_type: 'expiring_soon' as ExpiryAlertType,
|
|
alert_method: 'email' as AlertMethod,
|
|
item_name: 'Test Item',
|
|
expiry_date: null,
|
|
days_until_expiry: null,
|
|
sent_at: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('expiryService.server', () => {
|
|
let mockLogger: Logger;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockLogger = createMockLogger();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('addInventoryItem', () => {
|
|
it('should add item to inventory without expiry date', async () => {
|
|
const mockItem: UserInventoryItem = {
|
|
inventory_id: 1,
|
|
user_id: 'user-1',
|
|
product_id: null,
|
|
master_item_id: null,
|
|
item_name: 'Milk',
|
|
quantity: 1,
|
|
unit: 'gallon',
|
|
purchase_date: null,
|
|
expiry_date: null,
|
|
source: 'manual',
|
|
location: 'fridge',
|
|
notes: null,
|
|
is_consumed: false,
|
|
consumed_at: null,
|
|
expiry_source: null,
|
|
receipt_item_id: null,
|
|
pantry_location_id: null,
|
|
notification_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
days_until_expiry: null,
|
|
expiry_status: 'unknown',
|
|
};
|
|
|
|
vi.mocked(expiryRepo.addInventoryItem).mockResolvedValueOnce(mockItem);
|
|
|
|
const result = await addInventoryItem(
|
|
'user-1',
|
|
{ item_name: 'Milk', quantity: 1, source: 'manual', location: 'fridge' },
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.inventory_id).toBe(1);
|
|
expect(result.item_name).toBe('Milk');
|
|
});
|
|
|
|
it('should calculate expiry date when purchase date and location provided', async () => {
|
|
const mockItem: UserInventoryItem = {
|
|
inventory_id: 2,
|
|
user_id: 'user-1',
|
|
product_id: null,
|
|
master_item_id: 5,
|
|
item_name: 'Milk',
|
|
quantity: 1,
|
|
unit: 'gallon',
|
|
purchase_date: '2024-01-15',
|
|
expiry_date: '2024-01-22', // calculated
|
|
source: 'manual',
|
|
location: 'fridge',
|
|
notes: null,
|
|
is_consumed: false,
|
|
consumed_at: null,
|
|
expiry_source: 'calculated',
|
|
receipt_item_id: null,
|
|
pantry_location_id: null,
|
|
notification_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
days_until_expiry: 7,
|
|
expiry_status: 'fresh',
|
|
};
|
|
|
|
vi.mocked(expiryRepo.getExpiryRangeForItem).mockResolvedValueOnce({
|
|
expiry_range_id: 1,
|
|
master_item_id: 5,
|
|
category_id: null,
|
|
item_pattern: null,
|
|
storage_location: 'fridge',
|
|
min_days: 5,
|
|
max_days: 10,
|
|
typical_days: 7,
|
|
notes: null,
|
|
source: 'usda',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
vi.mocked(expiryRepo.addInventoryItem).mockResolvedValueOnce(mockItem);
|
|
|
|
const result = await addInventoryItem(
|
|
'user-1',
|
|
{
|
|
item_name: 'Milk',
|
|
master_item_id: 5,
|
|
quantity: 1,
|
|
source: 'manual',
|
|
location: 'fridge',
|
|
purchase_date: '2024-01-15',
|
|
},
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.expiry_date).toBe('2024-01-22');
|
|
});
|
|
});
|
|
|
|
describe('updateInventoryItem', () => {
|
|
it('should update inventory item', async () => {
|
|
const mockUpdatedItem: UserInventoryItem = {
|
|
inventory_id: 1,
|
|
user_id: 'user-1',
|
|
product_id: null,
|
|
master_item_id: null,
|
|
item_name: 'Milk',
|
|
quantity: 2, // updated
|
|
unit: 'gallon',
|
|
purchase_date: null,
|
|
expiry_date: '2024-01-25',
|
|
source: 'manual',
|
|
location: 'fridge',
|
|
notes: 'Almost gone',
|
|
is_consumed: false,
|
|
consumed_at: null,
|
|
expiry_source: null,
|
|
receipt_item_id: null,
|
|
pantry_location_id: null,
|
|
notification_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
days_until_expiry: 5,
|
|
expiry_status: 'expiring_soon',
|
|
};
|
|
|
|
vi.mocked(expiryRepo.updateInventoryItem).mockResolvedValueOnce(mockUpdatedItem);
|
|
|
|
const result = await updateInventoryItem(
|
|
1,
|
|
'user-1',
|
|
{ quantity: 2, notes: 'Almost gone' },
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.quantity).toBe(2);
|
|
expect(result.notes).toBe('Almost gone');
|
|
});
|
|
});
|
|
|
|
describe('markItemConsumed', () => {
|
|
it('should mark item as consumed', async () => {
|
|
vi.mocked(expiryRepo.markAsConsumed).mockResolvedValueOnce(undefined);
|
|
|
|
await markItemConsumed(1, 'user-1', mockLogger);
|
|
|
|
expect(expiryRepo.markAsConsumed).toHaveBeenCalledWith(1, 'user-1', mockLogger);
|
|
});
|
|
});
|
|
|
|
describe('deleteInventoryItem', () => {
|
|
it('should delete inventory item', async () => {
|
|
vi.mocked(expiryRepo.deleteInventoryItem).mockResolvedValueOnce(undefined);
|
|
|
|
await deleteInventoryItem(1, 'user-1', mockLogger);
|
|
|
|
expect(expiryRepo.deleteInventoryItem).toHaveBeenCalledWith(1, 'user-1', mockLogger);
|
|
});
|
|
});
|
|
|
|
describe('getInventoryItemById', () => {
|
|
it('should return inventory item by ID', async () => {
|
|
const mockItem: UserInventoryItem = {
|
|
inventory_id: 1,
|
|
user_id: 'user-1',
|
|
product_id: null,
|
|
master_item_id: null,
|
|
item_name: 'Eggs',
|
|
quantity: 12,
|
|
unit: null,
|
|
purchase_date: null,
|
|
expiry_date: null,
|
|
source: 'manual',
|
|
location: 'fridge',
|
|
notes: null,
|
|
is_consumed: false,
|
|
consumed_at: null,
|
|
expiry_source: null,
|
|
receipt_item_id: null,
|
|
pantry_location_id: null,
|
|
notification_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
days_until_expiry: null,
|
|
expiry_status: 'unknown',
|
|
};
|
|
|
|
vi.mocked(expiryRepo.getInventoryItemById).mockResolvedValueOnce(mockItem);
|
|
|
|
const result = await getInventoryItemById(1, 'user-1', mockLogger);
|
|
|
|
expect(result.item_name).toBe('Eggs');
|
|
});
|
|
});
|
|
|
|
describe('getInventory', () => {
|
|
it('should return paginated inventory', async () => {
|
|
const mockInventory = {
|
|
items: [
|
|
{
|
|
inventory_id: 1,
|
|
user_id: 'user-1',
|
|
product_id: null,
|
|
master_item_id: null,
|
|
item_name: 'Butter',
|
|
quantity: 1,
|
|
unit: null,
|
|
purchase_date: null,
|
|
expiry_date: null,
|
|
source: 'manual' as InventorySource,
|
|
location: 'fridge' as StorageLocation,
|
|
notes: null,
|
|
is_consumed: false,
|
|
consumed_at: null,
|
|
expiry_source: null,
|
|
receipt_item_id: null,
|
|
pantry_location_id: null,
|
|
notification_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
days_until_expiry: null,
|
|
expiry_status: 'unknown' as ExpiryStatus,
|
|
},
|
|
],
|
|
total: 1,
|
|
};
|
|
|
|
vi.mocked(expiryRepo.getInventory).mockResolvedValueOnce(mockInventory);
|
|
|
|
const result = await getInventory({ user_id: 'user-1', limit: 10, offset: 0 }, mockLogger);
|
|
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.total).toBe(1);
|
|
});
|
|
|
|
it('should filter by location', async () => {
|
|
vi.mocked(expiryRepo.getInventory).mockResolvedValueOnce({ items: [], total: 0 });
|
|
|
|
await getInventory({ user_id: 'user-1', location: 'freezer' }, mockLogger);
|
|
|
|
expect(expiryRepo.getInventory).toHaveBeenCalledWith(
|
|
{ user_id: 'user-1', location: 'freezer' },
|
|
mockLogger,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getExpiringItemsGrouped', () => {
|
|
it('should return items grouped by expiry urgency', async () => {
|
|
const expiringItems = [
|
|
createMockInventoryItem({ days_until_expiry: 0 }), // today
|
|
createMockInventoryItem({ days_until_expiry: 3 }), // this week
|
|
createMockInventoryItem({ days_until_expiry: 15 }), // this month
|
|
];
|
|
const expiredItems = [createMockInventoryItem({ days_until_expiry: -2 })];
|
|
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce(expiringItems);
|
|
vi.mocked(expiryRepo.getExpiredItems).mockResolvedValueOnce(expiredItems);
|
|
|
|
const result = await getExpiringItemsGrouped('user-1', mockLogger);
|
|
|
|
expect(result.expiring_today).toHaveLength(1);
|
|
expect(result.expiring_this_week).toHaveLength(1);
|
|
expect(result.expiring_this_month).toHaveLength(1);
|
|
expect(result.already_expired).toHaveLength(1);
|
|
expect(result.counts.total).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe('getExpiringItems', () => {
|
|
it('should return items expiring within specified days', async () => {
|
|
const mockItems = [createMockInventoryItem({ days_until_expiry: 5 })];
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce(mockItems);
|
|
|
|
const result = await getExpiringItems('user-1', 7, mockLogger);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(expiryRepo.getExpiringItems).toHaveBeenCalledWith('user-1', 7, mockLogger);
|
|
});
|
|
});
|
|
|
|
describe('getExpiredItems', () => {
|
|
it('should return expired items', async () => {
|
|
const mockItems = [createMockInventoryItem({ days_until_expiry: -3 })];
|
|
vi.mocked(expiryRepo.getExpiredItems).mockResolvedValueOnce(mockItems);
|
|
|
|
const result = await getExpiredItems('user-1', mockLogger);
|
|
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('calculateExpiryDate', () => {
|
|
it('should calculate expiry date based on storage location', async () => {
|
|
vi.mocked(expiryRepo.getExpiryRangeForItem).mockResolvedValueOnce({
|
|
expiry_range_id: 1,
|
|
master_item_id: null,
|
|
category_id: 1,
|
|
item_pattern: null,
|
|
storage_location: 'fridge',
|
|
min_days: 7,
|
|
max_days: 14,
|
|
typical_days: 10,
|
|
notes: null,
|
|
source: 'usda',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
const result = await calculateExpiryDate(
|
|
{
|
|
item_name: 'Cheese',
|
|
storage_location: 'fridge',
|
|
purchase_date: '2024-01-15',
|
|
},
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result).toBe('2024-01-25'); // 10 days after purchase
|
|
});
|
|
|
|
it('should return null when no expiry range found', async () => {
|
|
vi.mocked(expiryRepo.getExpiryRangeForItem).mockResolvedValueOnce(null);
|
|
|
|
const result = await calculateExpiryDate(
|
|
{
|
|
item_name: 'Unknown Item',
|
|
storage_location: 'pantry',
|
|
purchase_date: '2024-01-15',
|
|
},
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getExpiryRanges', () => {
|
|
it('should return paginated expiry ranges', async () => {
|
|
const mockRanges = {
|
|
ranges: [
|
|
{
|
|
expiry_range_id: 1,
|
|
master_item_id: null,
|
|
category_id: 1,
|
|
item_pattern: null,
|
|
storage_location: 'fridge' as StorageLocation,
|
|
min_days: 7,
|
|
max_days: 14,
|
|
typical_days: 10,
|
|
notes: null,
|
|
source: 'usda' as ExpiryRangeSource,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
],
|
|
total: 1,
|
|
};
|
|
|
|
vi.mocked(expiryRepo.getExpiryRanges).mockResolvedValueOnce(mockRanges);
|
|
|
|
const result = await getExpiryRanges({}, mockLogger);
|
|
|
|
expect(result.ranges).toHaveLength(1);
|
|
expect(result.total).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('addExpiryRange', () => {
|
|
it('should add new expiry range', async () => {
|
|
const mockRange = {
|
|
expiry_range_id: 2,
|
|
master_item_id: null,
|
|
category_id: 2,
|
|
item_pattern: null,
|
|
storage_location: 'freezer' as StorageLocation,
|
|
min_days: 30,
|
|
max_days: 90,
|
|
typical_days: 60,
|
|
notes: 'Best stored in back of freezer',
|
|
source: 'manual' as ExpiryRangeSource,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
vi.mocked(expiryRepo.addExpiryRange).mockResolvedValueOnce(mockRange);
|
|
|
|
const result = await addExpiryRange(
|
|
{
|
|
category_id: 2,
|
|
storage_location: 'freezer',
|
|
min_days: 30,
|
|
max_days: 90,
|
|
typical_days: 60,
|
|
notes: 'Best stored in back of freezer',
|
|
},
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.typical_days).toBe(60);
|
|
});
|
|
});
|
|
|
|
describe('getAlertSettings', () => {
|
|
it('should return user alert settings', async () => {
|
|
const mockSettings = [
|
|
{
|
|
expiry_alert_id: 1,
|
|
user_id: 'user-1',
|
|
days_before_expiry: 3,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
vi.mocked(expiryRepo.getUserAlertSettings).mockResolvedValueOnce(mockSettings);
|
|
|
|
const result = await getAlertSettings('user-1', mockLogger);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].alert_method).toBe('email');
|
|
});
|
|
});
|
|
|
|
describe('updateAlertSettings', () => {
|
|
it('should update alert settings', async () => {
|
|
const mockUpdatedSettings = {
|
|
expiry_alert_id: 1,
|
|
user_id: 'user-1',
|
|
days_before_expiry: 5,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
vi.mocked(expiryRepo.upsertAlertSettings).mockResolvedValueOnce(mockUpdatedSettings);
|
|
|
|
const result = await updateAlertSettings(
|
|
'user-1',
|
|
'email',
|
|
{ days_before_expiry: 5 },
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.days_before_expiry).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('processExpiryAlerts', () => {
|
|
it('should process alerts for users with expiring items', async () => {
|
|
vi.mocked(expiryRepo.getUsersWithExpiringItems).mockResolvedValueOnce([
|
|
{
|
|
user_id: 'user-1',
|
|
email: 'user1@example.com',
|
|
alert_method: 'email' as AlertMethod,
|
|
days_before_expiry: 3,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([
|
|
createMockInventoryItem({ days_until_expiry: 2 }),
|
|
]);
|
|
|
|
vi.mocked(emailService.sendEmail).mockResolvedValueOnce(undefined);
|
|
vi.mocked(expiryRepo.logAlert).mockResolvedValue(createMockAlertLogRecord());
|
|
vi.mocked(expiryRepo.markAlertSent).mockResolvedValue(undefined);
|
|
|
|
const alertsSent = await processExpiryAlerts(mockLogger);
|
|
|
|
expect(alertsSent).toBe(1);
|
|
});
|
|
|
|
it('should skip users with no expiring items', async () => {
|
|
vi.mocked(expiryRepo.getUsersWithExpiringItems).mockResolvedValueOnce([
|
|
{
|
|
user_id: 'user-1',
|
|
email: 'user1@example.com',
|
|
alert_method: 'email' as AlertMethod,
|
|
days_before_expiry: 3,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([]);
|
|
|
|
const alertsSent = await processExpiryAlerts(mockLogger);
|
|
|
|
expect(alertsSent).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('addItemsFromReceipt', () => {
|
|
it('should add items from receipt to inventory', async () => {
|
|
const mockReceipt = {
|
|
receipt_id: 1,
|
|
user_id: 'user-1',
|
|
store_id: null,
|
|
receipt_image_url: '/uploads/receipt.jpg',
|
|
transaction_date: '2024-01-15',
|
|
total_amount_cents: 2500,
|
|
status: 'completed' as ReceiptStatus,
|
|
raw_text: 'test text',
|
|
store_confidence: null,
|
|
ocr_provider: null,
|
|
error_details: null,
|
|
retry_count: 0,
|
|
ocr_confidence: null,
|
|
currency: 'USD',
|
|
created_at: new Date().toISOString(),
|
|
processed_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
const mockReceiptItems = [
|
|
{
|
|
receipt_item_id: 1,
|
|
receipt_id: 1,
|
|
raw_item_description: 'MILK 2%',
|
|
quantity: 1,
|
|
price_paid_cents: 399,
|
|
master_item_id: 5,
|
|
product_id: null,
|
|
status: 'matched' as ReceiptItemStatus,
|
|
line_number: 1,
|
|
match_confidence: 0.95,
|
|
is_discount: false,
|
|
unit_price_cents: null,
|
|
unit_type: null,
|
|
added_to_pantry: false,
|
|
pantry_item_id: null,
|
|
upc_code: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
|
|
vi.mocked(receiptRepo.getReceiptItems).mockResolvedValueOnce(mockReceiptItems);
|
|
vi.mocked(expiryRepo.addInventoryItem).mockResolvedValueOnce(
|
|
createMockInventoryItem({ inventory_id: 10 }),
|
|
);
|
|
vi.mocked(receiptRepo.updateReceiptItem).mockResolvedValueOnce(mockReceiptItems[0] as any);
|
|
|
|
const result = await addItemsFromReceipt(
|
|
'user-1',
|
|
1,
|
|
[{ receipt_item_id: 1, location: 'fridge', include: true }],
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(receiptRepo.updateReceiptItem).toHaveBeenCalledWith(
|
|
1,
|
|
expect.objectContaining({ added_to_pantry: true }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should skip items with include: false', async () => {
|
|
const mockReceipt = {
|
|
receipt_id: 1,
|
|
user_id: 'user-1',
|
|
store_id: null,
|
|
receipt_image_url: '/uploads/receipt.jpg',
|
|
transaction_date: '2024-01-15',
|
|
total_amount_cents: 2500,
|
|
status: 'completed' as ReceiptStatus,
|
|
raw_text: 'test text',
|
|
store_confidence: null,
|
|
ocr_provider: null,
|
|
error_details: null,
|
|
retry_count: 0,
|
|
ocr_confidence: null,
|
|
currency: 'USD',
|
|
created_at: new Date().toISOString(),
|
|
processed_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
|
|
|
|
const result = await addItemsFromReceipt(
|
|
'user-1',
|
|
1,
|
|
[{ receipt_item_id: 1, include: false }],
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result).toHaveLength(0);
|
|
expect(expiryRepo.addInventoryItem).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getRecipeSuggestionsForExpiringItems', () => {
|
|
it('should return recipes using expiring items', async () => {
|
|
const expiringItems = [
|
|
createMockInventoryItem({ master_item_id: 5, days_until_expiry: 2 }),
|
|
createMockInventoryItem({ master_item_id: 10, days_until_expiry: 4 }),
|
|
];
|
|
|
|
const mockRecipes = {
|
|
recipes: [
|
|
{
|
|
recipe_id: 1,
|
|
recipe_name: 'Quick Breakfast',
|
|
description: 'Easy breakfast recipe',
|
|
prep_time_minutes: 10,
|
|
cook_time_minutes: 15,
|
|
servings: 2,
|
|
photo_url: null,
|
|
matching_master_item_ids: [5],
|
|
match_count: 1,
|
|
},
|
|
],
|
|
total: 1,
|
|
};
|
|
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce(expiringItems);
|
|
vi.mocked(expiryRepo.getRecipesForExpiringItems).mockResolvedValueOnce(mockRecipes);
|
|
|
|
const result = await getRecipeSuggestionsForExpiringItems('user-1', 7, mockLogger);
|
|
|
|
expect(result.recipes).toHaveLength(1);
|
|
expect(result.recipes[0].matching_items).toHaveLength(1);
|
|
expect(result.considered_items).toHaveLength(2);
|
|
});
|
|
|
|
it('should return empty results when no expiring items', async () => {
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([]);
|
|
|
|
const result = await getRecipeSuggestionsForExpiringItems('user-1', 7, mockLogger);
|
|
|
|
expect(result.recipes).toHaveLength(0);
|
|
expect(result.total).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('processExpiryAlertJob', () => {
|
|
it('should process user-specific alert job', async () => {
|
|
vi.mocked(expiryRepo.getUserAlertSettings).mockResolvedValueOnce([
|
|
{
|
|
expiry_alert_id: 1,
|
|
user_id: 'user-1',
|
|
days_before_expiry: 7,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([
|
|
createMockInventoryItem({ days_until_expiry: 3 }),
|
|
]);
|
|
|
|
vi.mocked(expiryRepo.logAlert).mockResolvedValue(createMockAlertLogRecord());
|
|
vi.mocked(expiryRepo.upsertAlertSettings).mockResolvedValue({
|
|
expiry_alert_id: 1,
|
|
user_id: 'user-1',
|
|
days_before_expiry: 7,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: new Date().toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
const mockJob = {
|
|
id: 'job-1',
|
|
data: {
|
|
alertType: 'user_specific' as const,
|
|
userId: 'user-1',
|
|
daysAhead: 7,
|
|
meta: { requestId: 'req-1' },
|
|
},
|
|
} as Job<ExpiryAlertJobData>;
|
|
|
|
const result = await processExpiryAlertJob(mockJob, mockLogger);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.alertsSent).toBe(1);
|
|
expect(result.usersNotified).toBe(1);
|
|
});
|
|
|
|
it('should process daily check job for all users', async () => {
|
|
vi.mocked(expiryRepo.getUsersWithExpiringItems).mockResolvedValueOnce([
|
|
{
|
|
user_id: 'user-1',
|
|
email: 'user1@example.com',
|
|
alert_method: 'email' as AlertMethod,
|
|
days_before_expiry: 7,
|
|
},
|
|
{
|
|
user_id: 'user-2',
|
|
email: 'user2@example.com',
|
|
alert_method: 'email' as AlertMethod,
|
|
days_before_expiry: 7,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(expiryRepo.getUserAlertSettings)
|
|
.mockResolvedValueOnce([
|
|
{
|
|
expiry_alert_id: 1,
|
|
user_id: 'user-1',
|
|
days_before_expiry: 7,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
expiry_alert_id: 2,
|
|
user_id: 'user-2',
|
|
days_before_expiry: 7,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
vi.mocked(expiryRepo.getExpiringItems)
|
|
.mockResolvedValueOnce([createMockInventoryItem({ days_until_expiry: 3 })])
|
|
.mockResolvedValueOnce([createMockInventoryItem({ days_until_expiry: 5 })]);
|
|
|
|
vi.mocked(expiryRepo.logAlert).mockResolvedValue(createMockAlertLogRecord());
|
|
vi.mocked(expiryRepo.upsertAlertSettings).mockResolvedValue({
|
|
expiry_alert_id: 1,
|
|
user_id: 'user-1',
|
|
days_before_expiry: 7,
|
|
alert_method: 'email' as AlertMethod,
|
|
is_enabled: true,
|
|
last_alert_sent_at: new Date().toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
const mockJob = {
|
|
id: 'job-2',
|
|
data: {
|
|
alertType: 'daily_check' as const,
|
|
daysAhead: 7,
|
|
},
|
|
} as Job<ExpiryAlertJobData>;
|
|
|
|
const result = await processExpiryAlertJob(mockJob, mockLogger);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.usersNotified).toBe(2);
|
|
});
|
|
|
|
it('should handle job processing errors', async () => {
|
|
vi.mocked(expiryRepo.getUserAlertSettings).mockRejectedValueOnce(new Error('DB error'));
|
|
|
|
const mockJob = {
|
|
id: 'job-3',
|
|
data: {
|
|
alertType: 'user_specific' as const,
|
|
userId: 'user-1',
|
|
},
|
|
} as Job<ExpiryAlertJobData>;
|
|
|
|
await expect(processExpiryAlertJob(mockJob, mockLogger)).rejects.toThrow('DB error');
|
|
});
|
|
});
|
|
});
|
|
|
|
// Helper function to create mock inventory items
|
|
function createMockInventoryItem(
|
|
overrides: Partial<{
|
|
inventory_id: number;
|
|
master_item_id: number | null;
|
|
days_until_expiry: number | null;
|
|
}>,
|
|
): UserInventoryItem {
|
|
const daysUntilExpiry = overrides.days_until_expiry ?? 5;
|
|
const expiryStatus: ExpiryStatus =
|
|
daysUntilExpiry !== null && daysUntilExpiry < 0
|
|
? 'expired'
|
|
: daysUntilExpiry !== null && daysUntilExpiry <= 7
|
|
? 'expiring_soon'
|
|
: 'fresh';
|
|
return {
|
|
inventory_id: overrides.inventory_id ?? 1,
|
|
user_id: 'user-1',
|
|
product_id: null,
|
|
master_item_id: overrides.master_item_id ?? null,
|
|
item_name: 'Test Item',
|
|
quantity: 1,
|
|
unit: null,
|
|
purchase_date: null,
|
|
expiry_date: '2024-01-25',
|
|
source: 'manual' as InventorySource,
|
|
location: 'fridge' as StorageLocation,
|
|
notes: null,
|
|
is_consumed: false,
|
|
consumed_at: null,
|
|
expiry_source: null,
|
|
receipt_item_id: null,
|
|
pantry_location_id: null,
|
|
notification_sent_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
days_until_expiry: daysUntilExpiry,
|
|
expiry_status: expiryStatus,
|
|
};
|
|
}
|