Files
flyer-crawler.projectium.com/src/routes/inventory.routes.test.ts
Torben Sorensen 2a5cc5bb51
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m17s
unit test repairs
2026-01-12 08:10:37 -08:00

666 lines
22 KiB
TypeScript

// src/routes/inventory.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { createTestApp } from '../tests/utils/createTestApp';
import { NotFoundError } from '../services/db/errors.db';
import type { UserInventoryItem, ExpiringItemsResponse } from '../types/expiry';
// Mock the expiryService module
vi.mock('../services/expiryService.server', () => ({
getInventory: vi.fn(),
addInventoryItem: vi.fn(),
getInventoryItemById: vi.fn(),
updateInventoryItem: vi.fn(),
deleteInventoryItem: vi.fn(),
markItemConsumed: vi.fn(),
getExpiringItemsGrouped: vi.fn(),
getExpiringItems: vi.fn(),
getExpiredItems: vi.fn(),
getAlertSettings: vi.fn(),
updateAlertSettings: vi.fn(),
getRecipeSuggestionsForExpiringItems: vi.fn(),
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the router and mocked service AFTER all mocks are defined.
import inventoryRouter from './inventory.routes';
import * as expiryService from '../services/expiryService.server';
const mockUser = createMockUserProfile({
user: { user_id: 'user-123', email: 'test@test.com' },
});
// Standardized mock for passport
vi.mock('../config/passport', () => ({
default: {
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
req.user = mockUser;
next();
}),
initialize: () => (req: Request, res: Response, next: NextFunction) => next(),
},
}));
// Define a reusable matcher for the logger object.
const expectLogger = expect.objectContaining({
info: expect.any(Function),
error: expect.any(Function),
});
// Helper to create mock inventory item
function createMockInventoryItem(overrides: Partial<UserInventoryItem> = {}): UserInventoryItem {
return {
inventory_id: 1,
user_id: 'user-123',
product_id: null,
master_item_id: 100,
item_name: 'Milk',
quantity: 1,
unit: 'liters',
purchase_date: '2024-01-10',
expiry_date: '2024-02-10',
source: 'manual',
location: 'fridge',
notes: null,
is_consumed: false,
consumed_at: null,
expiry_source: 'manual',
receipt_item_id: null,
pantry_location_id: 1,
notification_sent_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
days_until_expiry: 10,
expiry_status: 'fresh',
...overrides,
};
}
describe('Inventory Routes (/api/inventory)', () => {
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'test@test.com' },
});
beforeEach(() => {
vi.clearAllMocks();
// Provide default mock implementations
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
vi.mocked(expiryService.getExpiringItems).mockResolvedValue([]);
vi.mocked(expiryService.getExpiredItems).mockResolvedValue([]);
vi.mocked(expiryService.getAlertSettings).mockResolvedValue([]);
});
const app = createTestApp({
router: inventoryRouter,
basePath: '/api/inventory',
authenticatedUser: mockUserProfile,
});
// ============================================================================
// INVENTORY ITEM ENDPOINTS
// ============================================================================
describe('GET /', () => {
it('should return paginated inventory items', async () => {
const mockItems = [createMockInventoryItem()];
vi.mocked(expiryService.getInventory).mockResolvedValue({
items: mockItems,
total: 1,
});
const response = await supertest(app).get('/api/inventory');
expect(response.status).toBe(200);
expect(response.body.data.items).toHaveLength(1);
expect(response.body.data.total).toBe(1);
});
it('should support filtering by location', async () => {
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
const response = await supertest(app).get('/api/inventory?location=fridge');
expect(response.status).toBe(200);
expect(expiryService.getInventory).toHaveBeenCalledWith(
expect.objectContaining({ location: 'fridge' }),
expectLogger,
);
});
it('should support filtering by expiring_within_days', async () => {
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
const response = await supertest(app).get('/api/inventory?expiring_within_days=7');
expect(response.status).toBe(200);
expect(expiryService.getInventory).toHaveBeenCalledWith(
expect.objectContaining({ expiring_within_days: 7 }),
expectLogger,
);
});
it('should support search filter', async () => {
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
const response = await supertest(app).get('/api/inventory?search=milk');
expect(response.status).toBe(200);
expect(expiryService.getInventory).toHaveBeenCalledWith(
expect.objectContaining({ search: 'milk' }),
expectLogger,
);
});
it('should support sorting', async () => {
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
const response = await supertest(app).get(
'/api/inventory?sort_by=expiry_date&sort_order=asc',
);
expect(response.status).toBe(200);
expect(expiryService.getInventory).toHaveBeenCalledWith(
expect.objectContaining({
sort_by: 'expiry_date',
sort_order: 'asc',
}),
expectLogger,
);
});
it('should return 400 for invalid location', async () => {
const response = await supertest(app).get('/api/inventory?location=invalid');
expect(response.status).toBe(400);
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.getInventory).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/inventory');
expect(response.status).toBe(500);
});
});
describe('POST /', () => {
it('should add a new inventory item', async () => {
const mockItem = createMockInventoryItem();
vi.mocked(expiryService.addInventoryItem).mockResolvedValue(mockItem);
const response = await supertest(app).post('/api/inventory').send({
item_name: 'Milk',
source: 'manual',
quantity: 1,
location: 'fridge',
expiry_date: '2024-02-10',
});
expect(response.status).toBe(201);
expect(response.body.data.item_name).toBe('Milk');
expect(expiryService.addInventoryItem).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expect.objectContaining({
item_name: 'Milk',
source: 'manual',
}),
expectLogger,
);
});
it('should return 400 if item_name is missing', async () => {
const response = await supertest(app).post('/api/inventory').send({
source: 'manual',
});
expect(response.status).toBe(400);
// Zod returns a type error message when a required field is undefined
expect(response.body.error.details[0].message).toMatch(/expected string|required/i);
});
it('should return 400 for invalid source', async () => {
const response = await supertest(app).post('/api/inventory').send({
item_name: 'Milk',
source: 'invalid_source',
});
expect(response.status).toBe(400);
});
it('should return 400 for invalid expiry_date format', async () => {
const response = await supertest(app).post('/api/inventory').send({
item_name: 'Milk',
source: 'manual',
expiry_date: '01-10-2024',
});
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toMatch(/YYYY-MM-DD/);
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.addInventoryItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/inventory').send({
item_name: 'Milk',
source: 'manual',
});
expect(response.status).toBe(500);
});
});
describe('GET /:inventoryId', () => {
it('should return a specific inventory item', async () => {
const mockItem = createMockInventoryItem();
vi.mocked(expiryService.getInventoryItemById).mockResolvedValue(mockItem);
const response = await supertest(app).get('/api/inventory/1');
expect(response.status).toBe(200);
expect(response.body.data.inventory_id).toBe(1);
expect(expiryService.getInventoryItemById).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
expectLogger,
);
});
it('should return 404 when item not found', async () => {
vi.mocked(expiryService.getInventoryItemById).mockRejectedValue(
new NotFoundError('Item not found'),
);
const response = await supertest(app).get('/api/inventory/999');
expect(response.status).toBe(404);
});
it('should return 400 for invalid inventory ID', async () => {
const response = await supertest(app).get('/api/inventory/abc');
expect(response.status).toBe(400);
});
});
describe('PUT /:inventoryId', () => {
it('should update an inventory item', async () => {
const mockItem = createMockInventoryItem({ quantity: 2 });
vi.mocked(expiryService.updateInventoryItem).mockResolvedValue(mockItem);
const response = await supertest(app).put('/api/inventory/1').send({
quantity: 2,
});
expect(response.status).toBe(200);
expect(response.body.data.quantity).toBe(2);
});
it('should update expiry_date', async () => {
const mockItem = createMockInventoryItem({ expiry_date: '2024-03-01' });
vi.mocked(expiryService.updateInventoryItem).mockResolvedValue(mockItem);
const response = await supertest(app).put('/api/inventory/1').send({
expiry_date: '2024-03-01',
});
expect(response.status).toBe(200);
expect(expiryService.updateInventoryItem).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
expect.objectContaining({ expiry_date: '2024-03-01' }),
expectLogger,
);
});
it('should return 400 if no update fields provided', async () => {
const response = await supertest(app).put('/api/inventory/1').send({});
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toMatch(/At least one field/);
});
it('should return 404 when item not found', async () => {
vi.mocked(expiryService.updateInventoryItem).mockRejectedValue(
new NotFoundError('Item not found'),
);
const response = await supertest(app).put('/api/inventory/999').send({
quantity: 2,
});
expect(response.status).toBe(404);
});
});
describe('DELETE /:inventoryId', () => {
it('should delete an inventory item', async () => {
vi.mocked(expiryService.deleteInventoryItem).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/inventory/1');
expect(response.status).toBe(204);
expect(expiryService.deleteInventoryItem).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
expectLogger,
);
});
it('should return 404 when item not found', async () => {
vi.mocked(expiryService.deleteInventoryItem).mockRejectedValue(
new NotFoundError('Item not found'),
);
const response = await supertest(app).delete('/api/inventory/999');
expect(response.status).toBe(404);
});
});
describe('POST /:inventoryId/consume', () => {
it('should mark item as consumed', async () => {
vi.mocked(expiryService.markItemConsumed).mockResolvedValue(undefined);
const response = await supertest(app).post('/api/inventory/1/consume');
expect(response.status).toBe(204);
expect(expiryService.markItemConsumed).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
expectLogger,
);
});
it('should return 404 when item not found', async () => {
vi.mocked(expiryService.markItemConsumed).mockRejectedValue(
new NotFoundError('Item not found'),
);
const response = await supertest(app).post('/api/inventory/999/consume');
expect(response.status).toBe(404);
});
});
// ============================================================================
// EXPIRING ITEMS ENDPOINTS
// ============================================================================
describe('GET /expiring/summary', () => {
it('should return expiring items grouped by urgency', async () => {
const mockSummary: ExpiringItemsResponse = {
expiring_today: [createMockInventoryItem({ days_until_expiry: 0 })],
expiring_this_week: [createMockInventoryItem({ days_until_expiry: 3 })],
expiring_this_month: [createMockInventoryItem({ days_until_expiry: 20 })],
already_expired: [createMockInventoryItem({ days_until_expiry: -5 })],
counts: {
today: 1,
this_week: 1,
this_month: 1,
expired: 1,
total: 4,
},
};
vi.mocked(expiryService.getExpiringItemsGrouped).mockResolvedValue(mockSummary);
const response = await supertest(app).get('/api/inventory/expiring/summary');
expect(response.status).toBe(200);
expect(response.body.data.counts.total).toBe(4);
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.getExpiringItemsGrouped).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/inventory/expiring/summary');
expect(response.status).toBe(500);
});
});
describe('GET /expiring', () => {
it('should return items expiring within default 7 days', async () => {
const mockItems = [createMockInventoryItem({ days_until_expiry: 5 })];
vi.mocked(expiryService.getExpiringItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/inventory/expiring');
expect(response.status).toBe(200);
expect(response.body.data.items).toHaveLength(1);
expect(expiryService.getExpiringItems).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
7,
expectLogger,
);
});
it('should accept custom days parameter', async () => {
vi.mocked(expiryService.getExpiringItems).mockResolvedValue([]);
const response = await supertest(app).get('/api/inventory/expiring?days=14');
expect(response.status).toBe(200);
expect(expiryService.getExpiringItems).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
14,
expectLogger,
);
});
it('should return 400 for invalid days parameter', async () => {
const response = await supertest(app).get('/api/inventory/expiring?days=100');
expect(response.status).toBe(400);
});
});
describe('GET /expired', () => {
it('should return already expired items', async () => {
const mockItems = [
createMockInventoryItem({ days_until_expiry: -3, expiry_status: 'expired' }),
];
vi.mocked(expiryService.getExpiredItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/inventory/expired');
expect(response.status).toBe(200);
expect(response.body.data.items).toHaveLength(1);
expect(expiryService.getExpiredItems).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expectLogger,
);
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.getExpiredItems).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/inventory/expired');
expect(response.status).toBe(500);
});
});
// ============================================================================
// ALERT SETTINGS ENDPOINTS
// ============================================================================
describe('GET /alerts', () => {
it('should return user alert settings', async () => {
const mockSettings = [
{
expiry_alert_id: 1,
user_id: 'user-123',
alert_method: 'email' as const,
days_before_expiry: 3,
is_enabled: true,
last_alert_sent_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
vi.mocked(expiryService.getAlertSettings).mockResolvedValue(mockSettings);
const response = await supertest(app).get('/api/inventory/alerts');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].alert_method).toBe('email');
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.getAlertSettings).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/inventory/alerts');
expect(response.status).toBe(500);
});
});
describe('PUT /alerts/:alertMethod', () => {
it('should update alert settings for email', async () => {
const mockSettings = {
expiry_alert_id: 1,
user_id: 'user-123',
alert_method: 'email' as const,
days_before_expiry: 5,
is_enabled: true,
last_alert_sent_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
vi.mocked(expiryService.updateAlertSettings).mockResolvedValue(mockSettings);
const response = await supertest(app).put('/api/inventory/alerts/email').send({
days_before_expiry: 5,
is_enabled: true,
});
expect(response.status).toBe(200);
expect(response.body.data.days_before_expiry).toBe(5);
expect(expiryService.updateAlertSettings).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
'email',
{ days_before_expiry: 5, is_enabled: true },
expectLogger,
);
});
it('should return 400 for invalid alert method', async () => {
const response = await supertest(app).put('/api/inventory/alerts/sms').send({
is_enabled: true,
});
expect(response.status).toBe(400);
});
it('should return 400 for invalid days_before_expiry', async () => {
const response = await supertest(app).put('/api/inventory/alerts/email').send({
days_before_expiry: 0,
});
expect(response.status).toBe(400);
});
it('should return 400 if days_before_expiry exceeds maximum', async () => {
const response = await supertest(app).put('/api/inventory/alerts/email').send({
days_before_expiry: 31,
});
expect(response.status).toBe(400);
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.updateAlertSettings).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put('/api/inventory/alerts/email').send({
is_enabled: false,
});
expect(response.status).toBe(500);
});
});
// ============================================================================
// RECIPE SUGGESTIONS ENDPOINT
// ============================================================================
describe('GET /recipes/suggestions', () => {
it('should return recipe suggestions for expiring items', async () => {
const mockInventoryItem = createMockInventoryItem({ inventory_id: 1, item_name: 'Milk' });
const mockResult = {
recipes: [
{
recipe_id: 1,
recipe_name: 'Milk Smoothie',
description: 'A healthy smoothie',
prep_time_minutes: 5,
cook_time_minutes: 0,
servings: 2,
photo_url: null,
matching_items: [mockInventoryItem],
match_count: 1,
},
],
total: 1,
considered_items: [mockInventoryItem],
};
vi.mocked(expiryService.getRecipeSuggestionsForExpiringItems).mockResolvedValue(
mockResult as any,
);
const response = await supertest(app).get('/api/inventory/recipes/suggestions');
expect(response.status).toBe(200);
expect(response.body.data.recipes).toHaveLength(1);
expect(response.body.data.total).toBe(1);
});
it('should accept days, limit, and offset parameters', async () => {
vi.mocked(expiryService.getRecipeSuggestionsForExpiringItems).mockResolvedValue({
recipes: [],
total: 0,
considered_items: [],
});
const response = await supertest(app).get(
'/api/inventory/recipes/suggestions?days=14&limit=5&offset=10',
);
expect(response.status).toBe(200);
expect(expiryService.getRecipeSuggestionsForExpiringItems).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
14,
expectLogger,
{ limit: 5, offset: 10 },
);
});
it('should return 400 for invalid days parameter', async () => {
const response = await supertest(app).get('/api/inventory/recipes/suggestions?days=100');
expect(response.status).toBe(400);
});
it('should return 500 if service fails', async () => {
vi.mocked(expiryService.getRecipeSuggestionsForExpiringItems).mockRejectedValue(
new Error('DB Error'),
);
const response = await supertest(app).get('/api/inventory/recipes/suggestions');
expect(response.status).toBe(500);
});
});
});