ADR-022 - websocket notificaitons - also more test fixes with stores
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s

This commit is contained in:
2026-01-19 10:50:19 -08:00
parent 7b7a8d0f35
commit cf476e7afc
40 changed files with 3626 additions and 20437 deletions

View File

@@ -0,0 +1,131 @@
// src/components/NotificationBell.tsx
/**
* Real-time notification bell component
* Displays WebSocket connection status and unread notification count
* Integrates with useWebSocket hook for real-time updates
*/
import { useState, useCallback } from 'react';
import { Bell, Wifi, WifiOff } from 'lucide-react';
import { useWebSocket } from '../hooks/useWebSocket';
import { useEventBus } from '../hooks/useEventBus';
import type { DealNotificationData } from '../types/websocket';
interface NotificationBellProps {
/**
* Callback when bell is clicked
*/
onClick?: () => void;
/**
* Whether to show the connection status indicator
* @default true
*/
showConnectionStatus?: boolean;
/**
* Custom CSS classes for the bell container
*/
className?: string;
}
export function NotificationBell({
onClick,
showConnectionStatus = true,
className = '',
}: NotificationBellProps) {
const [unreadCount, setUnreadCount] = useState(0);
const { isConnected, error } = useWebSocket({ autoConnect: true });
// Handle incoming deal notifications
const handleDealNotification = useCallback((data?: DealNotificationData) => {
if (data) {
setUnreadCount((prev) => prev + 1);
}
}, []);
// Listen for deal notifications via event bus
useEventBus('notification:deal', handleDealNotification);
// Reset count when clicked
const handleClick = () => {
setUnreadCount(0);
onClick?.();
};
return (
<div className={`relative inline-block ${className}`}>
{/* Notification Bell Button */}
<button
onClick={handleClick}
className="relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
title={
error
? `WebSocket error: ${error}`
: isConnected
? 'Connected to live notifications'
: 'Connecting...'
}
>
<Bell
className={`w-6 h-6 ${unreadCount > 0 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`}
/>
{/* Unread Badge */}
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-600 rounded-full transform translate-x-1 -translate-y-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{/* Connection Status Indicator */}
{showConnectionStatus && (
<span
className="absolute bottom-0 right-0 inline-block w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 transform translate-x-1 translate-y-1"
style={{
backgroundColor: isConnected ? '#10b981' : error ? '#ef4444' : '#f59e0b',
}}
title={isConnected ? 'Connected' : error ? 'Disconnected' : 'Connecting'}
/>
)}
</button>
{/* Connection Status Tooltip (shown on hover when disconnected) */}
{!isConnected && error && (
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg shadow-lg whitespace-nowrap z-50 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-red-400" />
<span>Live notifications unavailable</span>
</div>
</div>
)}
</div>
);
}
/**
* Simple connection status indicator (no bell, just status)
*/
export function ConnectionStatus() {
const { isConnected, error } = useWebSocket({ autoConnect: true });
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-100 dark:bg-gray-800 text-sm">
{isConnected ? (
<>
<Wifi className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-gray-700 dark:text-gray-300">Live</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-gray-700 dark:text-gray-300">
{error ? 'Offline' : 'Connecting...'}
</span>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
// src/components/NotificationToastHandler.tsx
/**
* Global notification toast handler
* Listens for WebSocket notifications and displays them as toasts
* Should be rendered once at the app root level
*/
import { useCallback, useEffect } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { useEventBus } from '../hooks/useEventBus';
import toast from 'react-hot-toast';
import type { DealNotificationData, SystemMessageData } from '../types/websocket';
import { formatCurrency } from '../utils/formatUtils';
interface NotificationToastHandlerProps {
/**
* Whether to enable toast notifications
* @default true
*/
enabled?: boolean;
/**
* Whether to play a sound when notifications arrive
* @default false
*/
playSound?: boolean;
/**
* Custom sound URL (if playSound is true)
*/
soundUrl?: string;
}
export function NotificationToastHandler({
enabled = true,
playSound = false,
soundUrl = '/notification-sound.mp3',
}: NotificationToastHandlerProps) {
// Connect to WebSocket
const { isConnected, error } = useWebSocket({
autoConnect: true,
onConnect: () => {
if (enabled) {
toast.success('Connected to live notifications', {
duration: 2000,
icon: '🟢',
});
}
},
onDisconnect: () => {
if (enabled && error) {
toast.error('Disconnected from live notifications', {
duration: 3000,
icon: '🔴',
});
}
},
});
// Play notification sound
const playNotificationSound = useCallback(() => {
if (!playSound) return;
try {
const audio = new Audio(soundUrl);
audio.volume = 0.3;
audio.play().catch((error) => {
console.warn('Failed to play notification sound:', error);
});
} catch (error) {
console.warn('Failed to play notification sound:', error);
}
}, [playSound, soundUrl]);
// Handle deal notifications
const handleDealNotification = useCallback(
(data?: DealNotificationData) => {
if (!enabled || !data) return;
playNotificationSound();
const dealsCount = data.deals.length;
const firstDeal = data.deals[0];
// Show toast with deal information
toast.success(
<div className="flex flex-col gap-1">
<div className="font-semibold">
{dealsCount === 1 ? 'New Deal Found!' : `${dealsCount} New Deals Found!`}
</div>
{dealsCount === 1 && firstDeal && (
<div className="text-sm text-gray-600 dark:text-gray-400">
{firstDeal.item_name} for {formatCurrency(firstDeal.best_price_in_cents)} at{' '}
{firstDeal.store_name}
</div>
)}
{dealsCount > 1 && (
<div className="text-sm text-gray-600 dark:text-gray-400">
Check your deals page to see all offers
</div>
)}
</div>,
{
duration: 5000,
icon: '🎉',
position: 'top-right',
},
);
},
[enabled, playNotificationSound],
);
// Handle system messages
const handleSystemMessage = useCallback(
(data?: SystemMessageData) => {
if (!enabled || !data) return;
const toastOptions = {
duration: data.severity === 'error' ? 6000 : 4000,
position: 'top-center' as const,
};
switch (data.severity) {
case 'error':
toast.error(data.message, { ...toastOptions, icon: '❌' });
break;
case 'warning':
toast(data.message, { ...toastOptions, icon: '⚠️' });
break;
case 'info':
default:
toast(data.message, { ...toastOptions, icon: '' });
break;
}
},
[enabled],
);
// Handle errors
const handleError = useCallback(
(data?: { message: string; code?: string }) => {
if (!enabled || !data) return;
toast.error(`Error: ${data.message}`, {
duration: 5000,
icon: '🚨',
});
},
[enabled],
);
// Subscribe to event bus
useEventBus('notification:deal', handleDealNotification);
useEventBus('notification:system', handleSystemMessage);
useEventBus('notification:error', handleError);
// Show connection error if persistent
useEffect(() => {
if (error && !isConnected) {
// Only show after a delay to avoid showing on initial connection
const timer = setTimeout(() => {
if (error && !isConnected && enabled) {
toast.error('Unable to connect to live notifications. Some features may be limited.', {
duration: 5000,
icon: '⚠️',
});
}
}, 5000);
return () => clearTimeout(timer);
}
}, [error, isConnected, enabled]);
// This component doesn't render anything - it just handles side effects
return null;
}

41
src/hooks/useEventBus.ts Normal file
View File

@@ -0,0 +1,41 @@
// src/hooks/useEventBus.ts
/**
* React hook for subscribing to event bus events
* Automatically handles cleanup on unmount
*
* Based on ADR-036: Event Bus and Pub/Sub Pattern
*/
import { useEffect, useCallback, useRef } from 'react';
import { eventBus } from '../services/eventBus';
/**
* Hook to subscribe to event bus events
* @param event The event name to listen for
* @param callback The callback function to execute when the event is dispatched
*/
export function useEventBus<T = unknown>(event: string, callback: (data?: T) => void): void {
// Use a ref to store the latest callback to avoid unnecessary re-subscriptions
const callbackRef = useRef(callback);
// Update the ref when callback changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Stable callback that calls the latest version
const stableCallback = useCallback((data?: unknown) => {
callbackRef.current(data as T);
}, []);
useEffect(() => {
// Subscribe to the event
eventBus.on(event, stableCallback);
// Cleanup: unsubscribe on unmount
return () => {
eventBus.off(event, stableCallback);
};
}, [event, stableCallback]);
}

284
src/hooks/useWebSocket.ts Normal file
View File

@@ -0,0 +1,284 @@
// src/hooks/useWebSocket.ts
/**
* React hook for WebSocket connections with automatic reconnection
* and integration with the event bus for cross-component notifications
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { eventBus } from '../services/eventBus';
import type { WebSocketMessage, DealNotificationData, SystemMessageData } from '../types/websocket';
interface UseWebSocketOptions {
/**
* Whether to automatically connect on mount
* @default true
*/
autoConnect?: boolean;
/**
* Maximum number of reconnection attempts
* @default 5
*/
maxReconnectAttempts?: number;
/**
* Base delay for exponential backoff (in ms)
* @default 1000
*/
reconnectDelay?: number;
/**
* Callback when connection is established
*/
onConnect?: () => void;
/**
* Callback when connection is closed
*/
onDisconnect?: () => void;
/**
* Callback when an error occurs
*/
onError?: (error: Event) => void;
}
interface WebSocketState {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
}
/**
* Hook for managing WebSocket connections to receive real-time notifications
*/
export function useWebSocket(options: UseWebSocketOptions = {}) {
const {
autoConnect = true,
maxReconnectAttempts = 5,
reconnectDelay = 1000,
onConnect,
onDisconnect,
onError,
} = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldReconnectRef = useRef(true);
const [state, setState] = useState<WebSocketState>({
isConnected: false,
isConnecting: false,
error: null,
});
/**
* Get the WebSocket URL based on current location
*/
const getWebSocketUrl = useCallback((): string => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
// Get access token from cookie
const token = document.cookie
.split('; ')
.find((row) => row.startsWith('accessToken='))
?.split('=')[1];
if (!token) {
throw new Error('No access token found. Please log in.');
}
return `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`;
}, []);
/**
* Handle incoming WebSocket messages
*/
const handleMessage = useCallback((event: MessageEvent) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
// Handle different message types
switch (message.type) {
case 'connection-established':
console.log('[WebSocket] Connection established:', message.data);
break;
case 'deal-notification':
// Emit to event bus for components to listen
eventBus.dispatch('notification:deal', message.data as DealNotificationData);
break;
case 'system-message':
// Emit to event bus for system-wide notifications
eventBus.dispatch('notification:system', message.data as SystemMessageData);
break;
case 'error':
console.error('[WebSocket] Server error:', message.data);
eventBus.dispatch('notification:error', message.data);
break;
case 'ping':
// Respond to ping with pong
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: 'pong', data: {}, timestamp: new Date().toISOString() }),
);
}
break;
case 'pong':
// Server acknowledged our ping
break;
default:
console.warn('[WebSocket] Unknown message type:', message.type);
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error);
}
}, []);
/**
* Connect to the WebSocket server
*/
const connect = useCallback(() => {
if (
wsRef.current?.readyState === WebSocket.OPEN ||
wsRef.current?.readyState === WebSocket.CONNECTING
) {
console.warn('[WebSocket] Already connected or connecting');
return;
}
try {
setState((prev) => ({ ...prev, isConnecting: true, error: null }));
const url = getWebSocketUrl();
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('[WebSocket] Connected');
reconnectAttemptsRef.current = 0; // Reset reconnect attempts on successful connection
setState({ isConnected: true, isConnecting: false, error: null });
onConnect?.();
};
ws.onmessage = handleMessage;
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
setState((prev) => ({
...prev,
error: 'WebSocket connection error',
}));
onError?.(error);
};
ws.onclose = (event) => {
console.log('[WebSocket] Disconnected:', event.code, event.reason);
setState({
isConnected: false,
isConnecting: false,
error: event.reason || 'Connection closed',
});
onDisconnect?.();
// Attempt to reconnect with exponential backoff
if (shouldReconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = reconnectDelay * Math.pow(2, reconnectAttemptsRef.current);
console.log(
`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current += 1;
connect();
}, delay);
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
console.error('[WebSocket] Max reconnection attempts reached');
setState((prev) => ({
...prev,
error: 'Failed to reconnect after multiple attempts',
}));
}
};
wsRef.current = ws;
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
setState({
isConnected: false,
isConnecting: false,
error: error instanceof Error ? error.message : 'Failed to connect',
});
}
}, [
getWebSocketUrl,
handleMessage,
maxReconnectAttempts,
reconnectDelay,
onConnect,
onDisconnect,
onError,
]);
/**
* Disconnect from the WebSocket server
*/
const disconnect = useCallback(() => {
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close(1000, 'Client disconnecting');
wsRef.current = null;
}
setState({
isConnected: false,
isConnecting: false,
error: null,
});
}, []);
/**
* Send a message to the server
*/
const send = useCallback((message: WebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WebSocket] Cannot send message: not connected');
}
}, []);
/**
* Auto-connect on mount if enabled
*/
useEffect(() => {
if (autoConnect) {
shouldReconnectRef.current = true;
connect();
}
return () => {
disconnect();
};
}, [autoConnect, connect, disconnect]);
return {
...state,
connect,
disconnect,
send,
};
}

View File

@@ -1229,6 +1229,54 @@ router.get(
},
);
/**
* @openapi
* /admin/websocket/stats:
* get:
* tags: [Admin]
* summary: Get WebSocket connection statistics
* description: Get real-time WebSocket connection stats including total users and connections. Requires admin role. (ADR-022)
* security:
* - bearerAuth: []
* responses:
* 200:
* description: WebSocket connection statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* totalUsers:
* type: number
* description: Number of unique users with active connections
* totalConnections:
* type: number
* description: Total number of active WebSocket connections
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.get(
'/websocket/stats',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { websocketService } = await import('../services/websocketService.server');
const stats = websocketService.getConnectionStats();
sendSuccess(res, stats);
} catch (error) {
req.log.error({ error }, 'Error fetching WebSocket stats');
next(error);
}
},
);
/**
* @openapi
* /admin/jobs/{queueName}/{jobId}/retry:

View File

@@ -63,7 +63,7 @@ const _receiptItemIdParamSchema = numericIdParam(
*/
const uploadReceiptSchema = z.object({
body: z.object({
store_id: z
store_location_id: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined))
@@ -80,7 +80,7 @@ const receiptQuerySchema = z.object({
limit: optionalNumeric({ default: 50, min: 1, max: 100, integer: true }),
offset: optionalNumeric({ default: 0, min: 0, integer: true }),
status: receiptStatusSchema.optional(),
store_id: z
store_location_id: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined))
@@ -167,7 +167,7 @@ router.use(passport.authenticate('jwt', { session: false }));
* type: string
* enum: [pending, processing, completed, failed]
* - in: query
* name: store_id
* name: store_location_id
* schema:
* type: integer
* - in: query
@@ -199,7 +199,7 @@ router.get(
{
user_id: userProfile.user.user_id,
status: query.status,
store_id: query.store_id,
store_location_id: query.store_location_id,
from_date: query.from_date,
to_date: query.to_date,
limit: query.limit,
@@ -237,9 +237,9 @@ router.get(
* type: string
* format: binary
* description: Receipt image file
* store_id:
* store_location_id:
* type: integer
* description: Store ID if known
* description: Store location ID if known
* transaction_date:
* type: string
* format: date
@@ -275,7 +275,7 @@ router.post(
file.path, // Use the actual file path from multer
req.log,
{
storeId: body.store_id,
storeLocationId: body.store_location_id,
transactionDate: body.transaction_date,
},
);

View File

@@ -5,30 +5,70 @@ import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
import type { Store, StoreWithLocations } from '../types';
// Mock the Store repositories
// Create mock implementations
const mockStoreRepoMethods = {
getAllStores: vi.fn(),
getStoreById: vi.fn(),
createStore: vi.fn(),
updateStore: vi.fn(),
deleteStore: vi.fn(),
};
const mockStoreLocationRepoMethods = {
getAllStoresWithLocations: vi.fn(),
getStoreWithLocations: vi.fn(),
createStoreLocation: vi.fn(),
deleteStoreLocation: vi.fn(),
};
const mockAddressRepoMethods = {
upsertAddress: vi.fn(),
};
// Mock the Store repositories - Use methods instead of field initializers to avoid hoisting issues
vi.mock('../services/db/store.db', () => ({
StoreRepository: vi.fn().mockImplementation(() => ({
getAllStores: vi.fn(),
getStoreById: vi.fn(),
createStore: vi.fn(),
updateStore: vi.fn(),
deleteStore: vi.fn(),
})),
StoreRepository: class MockStoreRepository {
getAllStores(...args: any[]) {
return mockStoreRepoMethods.getAllStores(...args);
}
getStoreById(...args: any[]) {
return mockStoreRepoMethods.getStoreById(...args);
}
createStore(...args: any[]) {
return mockStoreRepoMethods.createStore(...args);
}
updateStore(...args: any[]) {
return mockStoreRepoMethods.updateStore(...args);
}
deleteStore(...args: any[]) {
return mockStoreRepoMethods.deleteStore(...args);
}
},
}));
vi.mock('../services/db/storeLocation.db', () => ({
StoreLocationRepository: vi.fn().mockImplementation(() => ({
getAllStoresWithLocations: vi.fn(),
getStoreWithLocations: vi.fn(),
createStoreLocation: vi.fn(),
deleteStoreLocation: vi.fn(),
})),
StoreLocationRepository: class MockStoreLocationRepository {
getAllStoresWithLocations(...args: any[]) {
return mockStoreLocationRepoMethods.getAllStoresWithLocations(...args);
}
getStoreWithLocations(...args: any[]) {
return mockStoreLocationRepoMethods.getStoreWithLocations(...args);
}
createStoreLocation(...args: any[]) {
return mockStoreLocationRepoMethods.createStoreLocation(...args);
}
deleteStoreLocation(...args: any[]) {
return mockStoreLocationRepoMethods.deleteStoreLocation(...args);
}
},
}));
vi.mock('../services/db/address.db', () => ({
AddressRepository: vi.fn().mockImplementation(() => ({
upsertAddress: vi.fn(),
})),
AddressRepository: class MockAddressRepository {
upsertAddress(...args: any[]) {
return mockAddressRepoMethods.upsertAddress(...args);
}
},
}));
// Mock connection pool
@@ -43,9 +83,6 @@ vi.mock('../services/db/connection.db', () => ({
// Import after mocks
import storeRouter from './store.routes';
import { StoreRepository } from '../services/db/store.db';
import { StoreLocationRepository } from '../services/db/storeLocation.db';
import { AddressRepository } from '../services/db/address.db';
import { getPool } from '../services/db/connection.db';
// Mock the logger
@@ -53,11 +90,17 @@ vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock authentication
// Mock authentication - UserProfile has nested user object
vi.mock('../config/passport', () => ({
default: {
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
req.user = { user_id: 'test-user-id', role: 'admin' };
req.user = {
user: {
user_id: 'test-user-id',
email: 'test@example.com',
role: 'admin',
},
};
next();
}),
},
@@ -70,15 +113,8 @@ const expectLogger = expect.objectContaining({
});
describe('Store Routes (/api/stores)', () => {
let mockStoreRepo: any;
let mockStoreLocationRepo: any;
let mockAddressRepo: any;
beforeEach(() => {
vi.clearAllMocks();
mockStoreRepo = new (StoreRepository as any)();
mockStoreLocationRepo = new (StoreLocationRepository as any)();
mockAddressRepo = new (AddressRepository as any)();
});
const app = createTestApp({ router: storeRouter, basePath: '/api/stores' });
@@ -104,14 +140,14 @@ describe('Store Routes (/api/stores)', () => {
},
];
mockStoreRepo.getAllStores.mockResolvedValue(mockStores);
mockStoreRepoMethods.getAllStores.mockResolvedValue(mockStores);
const response = await supertest(app).get('/api/stores');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockStores);
expect(mockStoreRepo.getAllStores).toHaveBeenCalledWith(expectLogger);
expect(mockStoreLocationRepo.getAllStoresWithLocations).not.toHaveBeenCalled();
expect(mockStoreRepoMethods.getAllStores).toHaveBeenCalledWith(expectLogger);
expect(mockStoreLocationRepoMethods.getAllStoresWithLocations).not.toHaveBeenCalled();
});
it('should return stores with locations when includeLocations=true', async () => {
@@ -127,19 +163,23 @@ describe('Store Routes (/api/stores)', () => {
},
];
mockStoreLocationRepo.getAllStoresWithLocations.mockResolvedValue(mockStoresWithLocations);
mockStoreLocationRepoMethods.getAllStoresWithLocations.mockResolvedValue(
mockStoresWithLocations,
);
const response = await supertest(app).get('/api/stores?includeLocations=true');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockStoresWithLocations);
expect(mockStoreLocationRepo.getAllStoresWithLocations).toHaveBeenCalledWith(expectLogger);
expect(mockStoreRepo.getAllStores).not.toHaveBeenCalled();
expect(mockStoreLocationRepoMethods.getAllStoresWithLocations).toHaveBeenCalledWith(
expectLogger,
);
expect(mockStoreRepoMethods.getAllStores).not.toHaveBeenCalled();
});
it('should return 500 if database call fails', async () => {
const dbError = new Error('DB Error');
mockStoreRepo.getAllStores.mockRejectedValue(dbError);
mockStoreRepoMethods.getAllStores.mockRejectedValue(dbError);
const response = await supertest(app).get('/api/stores');
@@ -181,17 +221,20 @@ describe('Store Routes (/api/stores)', () => {
],
};
mockStoreLocationRepo.getStoreWithLocations.mockResolvedValue(mockStore);
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
const response = await supertest(app).get('/api/stores/1');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockStore);
expect(mockStoreLocationRepo.getStoreWithLocations).toHaveBeenCalledWith(1, expectLogger);
expect(mockStoreLocationRepoMethods.getStoreWithLocations).toHaveBeenCalledWith(
1,
expectLogger,
);
});
it('should return 404 if store not found', async () => {
mockStoreLocationRepo.getStoreWithLocations.mockRejectedValue(
mockStoreLocationRepoMethods.getStoreWithLocations.mockRejectedValue(
new NotFoundError('Store with ID 999 not found.'),
);
@@ -217,7 +260,7 @@ describe('Store Routes (/api/stores)', () => {
connect: vi.fn().mockResolvedValue(mockClient),
} as any);
mockStoreRepo.createStore.mockResolvedValue(1);
mockStoreRepoMethods.createStore.mockResolvedValue(1);
const response = await supertest(app).post('/api/stores').send({
name: 'New Store',
@@ -240,9 +283,9 @@ describe('Store Routes (/api/stores)', () => {
connect: vi.fn().mockResolvedValue(mockClient),
} as any);
mockStoreRepo.createStore.mockResolvedValue(1);
mockAddressRepo.upsertAddress.mockResolvedValue(1);
mockStoreLocationRepo.createStoreLocation.mockResolvedValue(1);
mockStoreRepoMethods.createStore.mockResolvedValue(1);
mockAddressRepoMethods.upsertAddress.mockResolvedValue(1);
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(1);
const response = await supertest(app)
.post('/api/stores')
@@ -271,7 +314,7 @@ describe('Store Routes (/api/stores)', () => {
connect: vi.fn().mockResolvedValue(mockClient),
} as any);
mockStoreRepo.createStore.mockRejectedValue(new Error('DB Error'));
mockStoreRepoMethods.createStore.mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/stores').send({
name: 'New Store',
@@ -291,14 +334,14 @@ describe('Store Routes (/api/stores)', () => {
describe('PUT /:id', () => {
it('should update a store', async () => {
mockStoreRepo.updateStore.mockResolvedValue(undefined);
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
const response = await supertest(app).put('/api/stores/1').send({
name: 'Updated Store Name',
});
expect(response.status).toBe(204);
expect(mockStoreRepo.updateStore).toHaveBeenCalledWith(
expect(mockStoreRepoMethods.updateStore).toHaveBeenCalledWith(
1,
{ name: 'Updated Store Name' },
expectLogger,
@@ -306,7 +349,7 @@ describe('Store Routes (/api/stores)', () => {
});
it('should return 404 if store not found', async () => {
mockStoreRepo.updateStore.mockRejectedValue(
mockStoreRepoMethods.updateStore.mockRejectedValue(
new NotFoundError('Store with ID 999 not found.'),
);
@@ -318,7 +361,10 @@ describe('Store Routes (/api/stores)', () => {
});
it('should return 400 for invalid request body', async () => {
const response = await supertest(app).put('/api/stores/1').send({});
// Send invalid data: logo_url must be a valid URL
const response = await supertest(app).put('/api/stores/1').send({
logo_url: 'not-a-valid-url',
});
expect(response.status).toBe(400);
});
@@ -326,16 +372,16 @@ describe('Store Routes (/api/stores)', () => {
describe('DELETE /:id', () => {
it('should delete a store', async () => {
mockStoreRepo.deleteStore.mockResolvedValue(undefined);
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/stores/1');
expect(response.status).toBe(204);
expect(mockStoreRepo.deleteStore).toHaveBeenCalledWith(1, expectLogger);
expect(mockStoreRepoMethods.deleteStore).toHaveBeenCalledWith(1, expectLogger);
});
it('should return 404 if store not found', async () => {
mockStoreRepo.deleteStore.mockRejectedValue(
mockStoreRepoMethods.deleteStore.mockRejectedValue(
new NotFoundError('Store with ID 999 not found.'),
);
@@ -355,8 +401,8 @@ describe('Store Routes (/api/stores)', () => {
connect: vi.fn().mockResolvedValue(mockClient),
} as any);
mockAddressRepo.upsertAddress.mockResolvedValue(1);
mockStoreLocationRepo.createStoreLocation.mockResolvedValue(1);
mockAddressRepoMethods.upsertAddress.mockResolvedValue(1);
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(1);
const response = await supertest(app).post('/api/stores/1/locations').send({
address_line_1: '456 New St',
@@ -379,16 +425,19 @@ describe('Store Routes (/api/stores)', () => {
describe('DELETE /:id/locations/:locationId', () => {
it('should delete a store location', async () => {
mockStoreLocationRepo.deleteStoreLocation.mockResolvedValue(undefined);
mockStoreLocationRepoMethods.deleteStoreLocation.mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/stores/1/locations/1');
expect(response.status).toBe(204);
expect(mockStoreLocationRepo.deleteStoreLocation).toHaveBeenCalledWith(1, expectLogger);
expect(mockStoreLocationRepoMethods.deleteStoreLocation).toHaveBeenCalledWith(
1,
expectLogger,
);
});
it('should return 404 if location not found', async () => {
mockStoreLocationRepo.deleteStoreLocation.mockRejectedValue(
mockStoreLocationRepoMethods.deleteStoreLocation.mockRejectedValue(
new NotFoundError('Store location with ID 999 not found.'),
);

View File

@@ -133,6 +133,22 @@ export class BackgroundJobService {
// Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Send real-time WebSocket notification (ADR-022)
const { websocketService } = await import('./websocketService.server');
websocketService.broadcastDealNotification(userProfile.user_id, {
user_id: userProfile.user_id,
deals: deals.map((deal) => ({
item_name: deal.item_name,
best_price_in_cents: deal.best_price_in_cents,
store_name: deal.store.name,
store_id: deal.store.store_id,
})),
message: `You have ${deals.length} new deal(s) on your watched items!`,
});
this.logger.info(
`[BackgroundJob] Sent WebSocket notification to user ${userProfile.user_id}`,
);
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {

View File

@@ -44,6 +44,22 @@ vi.mock('../cacheService.server', () => ({
CACHE_PREFIX: { BRANDS: 'brands', FLYERS: 'flyers', FLYER_ITEMS: 'flyer_items' },
}));
// Mock flyerLocation.db to avoid real database calls during insertFlyer auto-linking
vi.mock('./flyerLocation.db', () => ({
FlyerLocationRepository: class MockFlyerLocationRepository {
constructor(private db: any) {}
async linkFlyerToAllStoreLocations(flyerId: number, storeId: number, _logger: any) {
// Delegate to the mock client's query method
const result = await this.db.query(
'INSERT INTO public.flyer_locations (flyer_id, store_location_id) SELECT $1, store_location_id FROM public.store_locations WHERE store_id = $2 ON CONFLICT (flyer_id, store_location_id) DO NOTHING RETURNING store_location_id',
[flyerId, storeId],
);
return result.rowCount || 0;
}
},
}));
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
@@ -161,7 +177,8 @@ describe('Flyer DB Service', () => {
const result = await flyerRepo.insertFlyer(flyerData, mockLogger);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
// Expect 2 queries: 1 for INSERT INTO flyers, 1 for linking to store_locations
expect(mockPoolInstance.query).toHaveBeenCalledTimes(2);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO flyers'),
[
@@ -509,7 +526,7 @@ describe('Flyer DB Service', () => {
}),
];
// Mock the sequence of 4 calls on the client
// Mock the sequence of 5 calls on the client (added linkFlyerToAllStoreLocations)
const mockClient = { query: vi.fn() };
mockClient.query
// 1. findOrCreateStore: INSERT ... ON CONFLICT
@@ -518,7 +535,9 @@ describe('Flyer DB Service', () => {
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
// 3. insertFlyer
.mockResolvedValueOnce({ rows: [mockFlyer] })
// 4. insertFlyerItems
// 4. linkFlyerToAllStoreLocations (auto-link to store locations)
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }], rowCount: 1 })
// 5. insertFlyerItems
.mockResolvedValueOnce({ rows: mockItems });
const result = await createFlyerAndItems(
@@ -567,7 +586,8 @@ describe('Flyer DB Service', () => {
mockClient.query
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore (insert)
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] }) // findOrCreateStore (select)
.mockResolvedValueOnce({ rows: [mockFlyer] }); // insertFlyer
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }], rowCount: 1 }); // linkFlyerToAllStoreLocations
const result = await createFlyerAndItems(
flyerData,
@@ -580,7 +600,8 @@ describe('Flyer DB Service', () => {
flyer: mockFlyer,
items: [],
});
expect(mockClient.query).toHaveBeenCalledTimes(3);
// Expect 4 queries: 2 for findOrCreateStore, 1 for insertFlyer, 1 for linkFlyerToAllStoreLocations
expect(mockClient.query).toHaveBeenCalledTimes(4);
});
it('should propagate an error if any step fails', async () => {
@@ -641,8 +662,9 @@ describe('Flyer DB Service', () => {
const result = await flyerRepo.getFlyerById(123);
expect(result).toEqual(mockFlyer);
// The query now includes JOINs through flyer_locations for many-to-many relationship
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers WHERE flyer_id = $1',
expect.stringContaining('FROM public.flyers f'),
[123],
);
});

View File

@@ -132,7 +132,30 @@ export class FlyerRepository {
);
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
const newFlyer = result.rows[0];
// Automatically populate flyer_locations if store_id is provided
if (flyerData.store_id) {
const { FlyerLocationRepository } = await import('./flyerLocation.db');
const { Pool } = await import('pg');
// Only pass the client if this.db is a PoolClient, not a Pool
const clientToPass = this.db instanceof Pool ? undefined : (this.db as PoolClient);
const flyerLocationRepo = new FlyerLocationRepository(clientToPass);
await flyerLocationRepo.linkFlyerToAllStoreLocations(
newFlyer.flyer_id,
flyerData.store_id,
logger,
);
logger.info(
{ flyerId: newFlyer.flyer_id, storeId: flyerData.store_id },
'Auto-linked flyer to all store locations',
);
}
return newFlyer;
} catch (error) {
console.error('[DB DEBUG] insertFlyer caught error:', error);
const errorMessage = error instanceof Error ? error.message : '';
@@ -293,6 +316,7 @@ export class FlyerRepository {
const query = `
SELECT
f.*,
-- Legacy store relationship (for backward compatibility)
json_build_object(
'store_id', s.store_id,
'name', s.name,
@@ -311,7 +335,35 @@ export class FlyerRepository {
WHERE sl.store_id = s.store_id),
'[]'::json
)
) as store
) as store,
-- Correct many-to-many relationship via flyer_locations
COALESCE(
(SELECT json_agg(
json_build_object(
'store_location_id', fl_sl.store_location_id,
'store', json_build_object(
'store_id', fl_s.store_id,
'name', fl_s.name,
'logo_url', fl_s.logo_url
),
'address', json_build_object(
'address_id', fl_a.address_id,
'address_line_1', fl_a.address_line_1,
'address_line_2', fl_a.address_line_2,
'city', fl_a.city,
'province_state', fl_a.province_state,
'postal_code', fl_a.postal_code,
'country', fl_a.country
)
)
)
FROM public.flyer_locations fl
JOIN public.store_locations fl_sl ON fl.store_location_id = fl_sl.store_location_id
JOIN public.stores fl_s ON fl_sl.store_id = fl_s.store_id
JOIN public.addresses fl_a ON fl_sl.address_id = fl_a.address_id
WHERE fl.flyer_id = f.flyer_id),
'[]'::json
) as locations
FROM public.flyers f
LEFT JOIN public.stores s ON f.store_id = s.store_id
WHERE f.flyer_id = $1
@@ -338,6 +390,7 @@ export class FlyerRepository {
const query = `
SELECT
f.*,
-- Legacy store relationship (for backward compatibility)
json_build_object(
'store_id', s.store_id,
'name', s.name,
@@ -356,7 +409,35 @@ export class FlyerRepository {
WHERE sl.store_id = s.store_id),
'[]'::json
)
) as store
) as store,
-- Correct many-to-many relationship via flyer_locations
COALESCE(
(SELECT json_agg(
json_build_object(
'store_location_id', fl_sl.store_location_id,
'store', json_build_object(
'store_id', fl_s.store_id,
'name', fl_s.name,
'logo_url', fl_s.logo_url
),
'address', json_build_object(
'address_id', fl_a.address_id,
'address_line_1', fl_a.address_line_1,
'address_line_2', fl_a.address_line_2,
'city', fl_a.city,
'province_state', fl_a.province_state,
'postal_code', fl_a.postal_code,
'country', fl_a.country
)
)
)
FROM public.flyer_locations fl
JOIN public.store_locations fl_sl ON fl.store_location_id = fl_sl.store_location_id
JOIN public.stores fl_s ON fl_sl.store_id = fl_s.store_id
JOIN public.addresses fl_a ON fl_sl.address_id = fl_a.address_id
WHERE fl.flyer_id = f.flyer_id),
'[]'::json
) as locations
FROM public.flyers f
JOIN public.stores s ON f.store_id = s.store_id
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;

View File

@@ -0,0 +1,209 @@
// src/services/db/flyerLocation.db.ts
/**
* Repository for managing flyer_locations (many-to-many relationship between flyers and store_locations).
*/
import type { Logger } from 'pino';
import type { PoolClient, Pool } from 'pg';
import { handleDbError } from './errors.db';
import type { FlyerLocation } from '../../types';
import { getPool } from './connection.db';
export class FlyerLocationRepository {
private db: Pool | PoolClient;
constructor(dbClient?: PoolClient) {
this.db = dbClient || getPool();
}
/**
* Links a flyer to one or more store locations.
* @param flyerId The ID of the flyer
* @param storeLocationIds Array of store_location_ids to associate with this flyer
* @param logger Logger instance
* @returns Promise that resolves when all links are created
*/
async linkFlyerToLocations(
flyerId: number,
storeLocationIds: number[],
logger: Logger,
): Promise<void> {
try {
if (storeLocationIds.length === 0) {
logger.warn({ flyerId }, 'No store locations provided for flyer linkage');
return;
}
// Use VALUES with multiple rows for efficient bulk insert
const values = storeLocationIds.map((_, index) => `($1, $${index + 2})`).join(', ');
const query = `
INSERT INTO public.flyer_locations (flyer_id, store_location_id)
VALUES ${values}
ON CONFLICT (flyer_id, store_location_id) DO NOTHING
`;
await this.db.query(query, [flyerId, ...storeLocationIds]);
logger.info(
{ flyerId, locationCount: storeLocationIds.length },
'Linked flyer to store locations',
);
} catch (error) {
handleDbError(
error,
logger,
'Database error in linkFlyerToLocations',
{ flyerId, storeLocationIds },
{
defaultMessage: 'Failed to link flyer to store locations.',
},
);
}
}
/**
* Links a flyer to all locations of a given store.
* This is a convenience method for the common case where a flyer is valid at all store locations.
* @param flyerId The ID of the flyer
* @param storeId The ID of the store
* @param logger Logger instance
* @returns Promise that resolves to the number of locations linked
*/
async linkFlyerToAllStoreLocations(
flyerId: number,
storeId: number,
logger: Logger,
): Promise<number> {
try {
const query = `
INSERT INTO public.flyer_locations (flyer_id, store_location_id)
SELECT $1, store_location_id
FROM public.store_locations
WHERE store_id = $2
ON CONFLICT (flyer_id, store_location_id) DO NOTHING
RETURNING store_location_id
`;
const res = await this.db.query(query, [flyerId, storeId]);
const linkedCount = res.rowCount || 0;
logger.info({ flyerId, storeId, linkedCount }, 'Linked flyer to all store locations');
return linkedCount;
} catch (error) {
handleDbError(
error,
logger,
'Database error in linkFlyerToAllStoreLocations',
{ flyerId, storeId },
{
defaultMessage: 'Failed to link flyer to all store locations.',
},
);
}
}
/**
* Removes all location links for a flyer.
* @param flyerId The ID of the flyer
* @param logger Logger instance
*/
async unlinkAllLocations(flyerId: number, logger: Logger): Promise<void> {
try {
await this.db.query('DELETE FROM public.flyer_locations WHERE flyer_id = $1', [flyerId]);
logger.info({ flyerId }, 'Unlinked all locations from flyer');
} catch (error) {
handleDbError(
error,
logger,
'Database error in unlinkAllLocations',
{ flyerId },
{
defaultMessage: 'Failed to unlink locations from flyer.',
},
);
}
}
/**
* Removes a specific location link from a flyer.
* @param flyerId The ID of the flyer
* @param storeLocationId The ID of the store location to unlink
* @param logger Logger instance
*/
async unlinkLocation(flyerId: number, storeLocationId: number, logger: Logger): Promise<void> {
try {
await this.db.query(
'DELETE FROM public.flyer_locations WHERE flyer_id = $1 AND store_location_id = $2',
[flyerId, storeLocationId],
);
logger.info({ flyerId, storeLocationId }, 'Unlinked location from flyer');
} catch (error) {
handleDbError(
error,
logger,
'Database error in unlinkLocation',
{ flyerId, storeLocationId },
{
defaultMessage: 'Failed to unlink location from flyer.',
},
);
}
}
/**
* Gets all location IDs associated with a flyer.
* @param flyerId The ID of the flyer
* @param logger Logger instance
* @returns Promise that resolves to an array of store_location_ids
*/
async getLocationIdsByFlyerId(flyerId: number, logger: Logger): Promise<number[]> {
try {
const res = await this.db.query<{ store_location_id: number }>(
'SELECT store_location_id FROM public.flyer_locations WHERE flyer_id = $1',
[flyerId],
);
return res.rows.map((row) => row.store_location_id);
} catch (error) {
handleDbError(
error,
logger,
'Database error in getLocationIdsByFlyerId',
{ flyerId },
{
defaultMessage: 'Failed to get location IDs for flyer.',
},
);
}
}
/**
* Gets all flyer_location records for a flyer.
* @param flyerId The ID of the flyer
* @param logger Logger instance
* @returns Promise that resolves to an array of FlyerLocation objects
*/
async getFlyerLocationsByFlyerId(flyerId: number, logger: Logger): Promise<FlyerLocation[]> {
try {
const res = await this.db.query<FlyerLocation>(
'SELECT * FROM public.flyer_locations WHERE flyer_id = $1',
[flyerId],
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getFlyerLocationsByFlyerId',
{ flyerId },
{
defaultMessage: 'Failed to get flyer locations.',
},
);
}
}
}

View File

@@ -59,7 +59,7 @@ describe('ReceiptRepository', () => {
{
user_id: 'user-1',
receipt_image_url: '/uploads/receipts/receipt-1.jpg',
store_id: 5,
store_location_id: 5,
transaction_date: '2024-01-15',
},
mockLogger,
@@ -237,10 +237,10 @@ describe('ReceiptRepository', () => {
mockQuery.mockResolvedValueOnce({ rows: [{ count: '3' }] });
mockQuery.mockResolvedValueOnce({ rows: [] });
await repo.getReceipts({ user_id: 'user-1', store_id: 5 }, mockLogger);
await repo.getReceipts({ user_id: 'user-1', store_location_id: 5 }, mockLogger);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('store_id = $2'),
expect.stringContaining('store_location_id = $2'),
expect.any(Array),
);
});

View File

@@ -82,7 +82,7 @@ interface StoreReceiptPatternRow {
export interface CreateReceiptRequest {
user_id: string;
receipt_image_url: string;
store_id?: number;
store_location_id?: number;
transaction_date?: string;
}
@@ -135,7 +135,7 @@ export interface UpdateReceiptItemRequest {
export interface ReceiptQueryOptions {
user_id: string;
status?: ReceiptStatus;
store_id?: number;
store_location_id?: number;
from_date?: string;
to_date?: string;
limit?: number;
@@ -166,13 +166,13 @@ export class ReceiptRepository {
const res = await this.db.query<ReceiptRow>(
`INSERT INTO public.receipts
(user_id, receipt_image_url, store_id, transaction_date, status)
(user_id, receipt_image_url, store_location_id, transaction_date, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING *`,
[
request.user_id,
request.receipt_image_url,
request.store_id || null,
request.store_location_id || null,
request.transaction_date || null,
],
);
@@ -228,7 +228,15 @@ export class ReceiptRepository {
options: ReceiptQueryOptions,
logger: Logger,
): Promise<{ receipts: ReceiptScan[]; total: number }> {
const { user_id, status, store_id, from_date, to_date, limit = 50, offset = 0 } = options;
const {
user_id,
status,
store_location_id,
from_date,
to_date,
limit = 50,
offset = 0,
} = options;
try {
// Build dynamic WHERE clause
@@ -241,9 +249,9 @@ export class ReceiptRepository {
params.push(status);
}
if (store_id) {
conditions.push(`store_id = $${paramIndex++}`);
params.push(store_id);
if (store_location_id) {
conditions.push(`store_location_id = $${paramIndex++}`);
params.push(store_location_id);
}
if (from_date) {

View File

@@ -181,16 +181,14 @@ describe('Email Service (Server)', () => {
// FIX: Use `stringContaining` to check for key parts of the HTML without being brittle about whitespace.
// The actual HTML is a multi-line template string with tags like <h1>, <ul>, and <li>.
expect(mailOptions.html).toEqual(expect.stringContaining('<h1>Hi Deal Hunter,</h1>'));
expect(mailOptions.html).toEqual(
expect.stringContaining(
'<li>\n <strong>Apples</strong> is on sale for \n <strong>$1.99</strong> \n at Green Grocer!\n </li>',
),
);
expect(mailOptions.html).toEqual(
expect.stringContaining(
'<li>\n <strong>Milk</strong> is on sale for \n <strong>$3.50</strong> \n at Dairy Farm!\n </li>',
),
);
// Check for key content without being brittle about exact whitespace/newlines
expect(mailOptions.html).toContain('<strong>Apples</strong>');
expect(mailOptions.html).toContain('is on sale for');
expect(mailOptions.html).toContain('<strong>$1.99</strong>');
expect(mailOptions.html).toContain('Green Grocer');
expect(mailOptions.html).toContain('<strong>Milk</strong>');
expect(mailOptions.html).toContain('<strong>$3.50</strong>');
expect(mailOptions.html).toContain('Dairy Farm');
expect(mailOptions.html).toEqual(
expect.stringContaining('<p>Check them out on the deals page!</p>'),
);

View File

@@ -223,7 +223,7 @@ describe('receiptService.server', () => {
);
const result = await createReceipt('user-1', '/uploads/receipt2.jpg', mockLogger, {
storeId: 5,
storeLocationId: 5,
transactionDate: '2024-01-15',
});

View File

@@ -40,7 +40,7 @@ export const createReceipt = async (
userId: string,
imageUrl: string,
logger: Logger,
options: { storeId?: number; transactionDate?: string } = {},
options: { storeLocationId?: number; transactionDate?: string } = {},
): Promise<ReceiptScan> => {
logger.info({ userId, imageUrl }, 'Creating new receipt for processing');
@@ -48,7 +48,7 @@ export const createReceipt = async (
{
user_id: userId,
receipt_image_url: imageUrl,
store_id: options.storeId,
store_location_id: options.storeLocationId,
transaction_date: options.transactionDate,
},
logger,

View File

@@ -0,0 +1,123 @@
// src/services/websocketService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebSocketService } from './websocketService.server';
import type { Logger } from 'pino';
import type { Server as HTTPServer } from 'http';
// Mock dependencies
vi.mock('jsonwebtoken', () => ({
default: {
verify: vi.fn(),
},
}));
describe('WebSocketService', () => {
let service: WebSocketService;
let mockLogger: Logger;
beforeEach(() => {
mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: vi.fn(() => mockLogger),
} as unknown as Logger;
service = new WebSocketService(mockLogger);
});
afterEach(() => {
service.shutdown();
vi.clearAllMocks();
});
describe('initialization', () => {
it('should initialize without errors', () => {
const mockServer = {} as HTTPServer;
expect(() => service.initialize(mockServer)).not.toThrow();
expect(mockLogger.info).toHaveBeenCalledWith('WebSocket server initialized on path /ws');
});
});
describe('connection stats', () => {
it('should return zero stats initially', () => {
const stats = service.getConnectionStats();
expect(stats).toEqual({
totalUsers: 0,
totalConnections: 0,
});
});
});
describe('broadcasting', () => {
it('should handle deal notification broadcast without active connections', () => {
// Should not throw when no clients are connected
expect(() =>
service.broadcastDealNotification('user-123', {
user_id: 'user-123',
deals: [
{
item_name: 'Milk',
best_price_in_cents: 299,
store_name: 'Test Store',
store_id: 1,
},
],
message: 'You have 1 new deal!',
}),
).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(
{ userId: 'user-123' },
'No active WebSocket connections for user',
);
});
it('should handle system message broadcast without active connections', () => {
expect(() =>
service.broadcastSystemMessage('user-123', {
message: 'Test system message',
severity: 'info',
}),
).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(
{ userId: 'user-123' },
'No active WebSocket connections for user',
);
});
it('should handle broadcast to all without active connections', () => {
expect(() =>
service.broadcastToAll({
message: 'Test broadcast',
severity: 'info',
}),
).not.toThrow();
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({
sentCount: 0,
totalUsers: 0,
}),
'Broadcast message to all users',
);
});
});
describe('shutdown', () => {
it('should shutdown gracefully', () => {
const mockServer = {} as HTTPServer;
service.initialize(mockServer);
expect(() => service.shutdown()).not.toThrow();
expect(mockLogger.info).toHaveBeenCalledWith('Shutting down WebSocket server');
});
it('should handle shutdown when not initialized', () => {
expect(() => service.shutdown()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,358 @@
// src/services/websocketService.server.ts
/**
* WebSocket service for real-time notifications
* Manages WebSocket connections and broadcasts messages to connected clients
*/
import { WebSocketServer, WebSocket } from 'ws';
import type { Server as HTTPServer } from 'http';
import jwt from 'jsonwebtoken';
import type { Logger } from 'pino';
import { logger as globalLogger } from './logger.server';
import {
createWebSocketMessage,
type WebSocketMessage,
type DealNotificationData,
type SystemMessageData,
} from '../types/websocket';
import type { IncomingMessage } from 'http';
const JWT_SECRET = process.env.JWT_SECRET!;
/**
* Extended WebSocket with user context
*/
interface AuthenticatedWebSocket extends WebSocket {
userId?: string;
isAlive?: boolean;
}
/**
* JWT payload structure
*/
interface JWTPayload {
user_id: string;
email: string;
role: string;
}
export class WebSocketService {
private wss: WebSocketServer | null = null;
private clients: Map<string, Set<AuthenticatedWebSocket>> = new Map();
private pingInterval: NodeJS.Timeout | null = null;
constructor(private logger: Logger) {}
/**
* Initialize the WebSocket server and attach it to an HTTP server
*/
initialize(server: HTTPServer): void {
this.wss = new WebSocketServer({
server,
path: '/ws',
});
this.logger.info('WebSocket server initialized on path /ws');
this.wss.on('connection', (ws: AuthenticatedWebSocket, request: IncomingMessage) => {
this.handleConnection(ws, request);
});
// Start heartbeat ping/pong to detect dead connections
this.startHeartbeat();
}
/**
* Handle new WebSocket connection
*/
private handleConnection(ws: AuthenticatedWebSocket, request: IncomingMessage): void {
const connectionLogger = this.logger.child({ context: 'ws-connection' });
// Extract JWT token from query string or cookie
const token = this.extractToken(request);
if (!token) {
connectionLogger.warn('WebSocket connection rejected: No token provided');
ws.close(1008, 'Authentication required');
return;
}
// Verify JWT token
let payload: JWTPayload;
try {
payload = jwt.verify(token, JWT_SECRET) as JWTPayload;
} catch (error) {
connectionLogger.warn({ error }, 'WebSocket connection rejected: Invalid token');
ws.close(1008, 'Invalid token');
return;
}
// Attach user ID to the WebSocket connection
ws.userId = payload.user_id;
ws.isAlive = true;
// Register the client
this.registerClient(ws);
connectionLogger.info(
{ userId: ws.userId },
`WebSocket client connected for user ${ws.userId}`,
);
// Send connection confirmation
const confirmationMessage = createWebSocketMessage.connectionEstablished({
user_id: ws.userId,
message: 'Connected to real-time notification service',
});
this.sendToClient(ws, confirmationMessage);
// Handle incoming messages
ws.on('message', (data: Buffer) => {
this.handleMessage(ws, data);
});
// Handle pong responses (heartbeat)
ws.on('pong', () => {
ws.isAlive = true;
});
// Handle disconnection
ws.on('close', () => {
this.unregisterClient(ws);
connectionLogger.info({ userId: ws.userId }, 'WebSocket client disconnected');
});
// Handle errors
ws.on('error', (error: Error) => {
connectionLogger.error({ error, userId: ws.userId }, 'WebSocket error');
});
}
/**
* Extract JWT token from request (query string or cookie)
*/
private extractToken(request: IncomingMessage): string | null {
// Try to extract from query string (?token=xxx)
const url = new URL(request.url || '', `http://${request.headers.host}`);
const tokenFromQuery = url.searchParams.get('token');
if (tokenFromQuery) {
return tokenFromQuery;
}
// Try to extract from cookie
const cookieHeader = request.headers.cookie;
if (cookieHeader) {
const cookies = cookieHeader.split(';').reduce(
(acc, cookie) => {
const [key, value] = cookie.trim().split('=');
acc[key] = value;
return acc;
},
{} as Record<string, string>,
);
return cookies['accessToken'] || null;
}
return null;
}
/**
* Register a WebSocket client
*/
private registerClient(ws: AuthenticatedWebSocket): void {
if (!ws.userId) return;
if (!this.clients.has(ws.userId)) {
this.clients.set(ws.userId, new Set());
}
this.clients.get(ws.userId)!.add(ws);
this.logger.info(
{ userId: ws.userId, totalConnections: this.clients.get(ws.userId)!.size },
'Client registered',
);
}
/**
* Unregister a WebSocket client
*/
private unregisterClient(ws: AuthenticatedWebSocket): void {
if (!ws.userId) return;
const userClients = this.clients.get(ws.userId);
if (userClients) {
userClients.delete(ws);
if (userClients.size === 0) {
this.clients.delete(ws.userId);
}
}
}
/**
* Handle incoming messages from clients
*/
private handleMessage(ws: AuthenticatedWebSocket, data: Buffer): void {
try {
const message = JSON.parse(data.toString()) as WebSocketMessage;
// Handle ping messages
if (message.type === 'ping') {
const pongMessage = createWebSocketMessage.pong();
this.sendToClient(ws, pongMessage);
}
// Log other message types for debugging
this.logger.debug(
{ userId: ws.userId, messageType: message.type },
'Received WebSocket message',
);
} catch (error) {
this.logger.error({ error }, 'Failed to parse WebSocket message');
}
}
/**
* Send a message to a specific WebSocket client
*/
private sendToClient(ws: AuthenticatedWebSocket, message: WebSocketMessage): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Broadcast a deal notification to a specific user
*/
broadcastDealNotification(userId: string, data: DealNotificationData): void {
const message = createWebSocketMessage.dealNotification(data);
this.broadcastToUser(userId, message);
}
/**
* Broadcast a system message to a specific user
*/
broadcastSystemMessage(userId: string, data: SystemMessageData): void {
const message = createWebSocketMessage.systemMessage(data);
this.broadcastToUser(userId, message);
}
/**
* Broadcast a message to all connections of a specific user
*/
private broadcastToUser(userId: string, message: WebSocketMessage): void {
const userClients = this.clients.get(userId);
if (!userClients || userClients.size === 0) {
this.logger.debug({ userId }, 'No active WebSocket connections for user');
return;
}
let sentCount = 0;
userClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
this.sendToClient(client, message);
sentCount++;
}
});
this.logger.info(
{ userId, messageType: message.type, sentCount, totalConnections: userClients.size },
'Broadcast message to user',
);
}
/**
* Broadcast a system message to all connected clients
*/
broadcastToAll(data: SystemMessageData): void {
const message = createWebSocketMessage.systemMessage(data);
let sentCount = 0;
this.clients.forEach((userClients) => {
userClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
this.sendToClient(client, message);
sentCount++;
}
});
});
this.logger.info(
{ messageType: message.type, sentCount, totalUsers: this.clients.size },
'Broadcast message to all users',
);
}
/**
* Start heartbeat ping/pong to detect dead connections
*/
private startHeartbeat(): void {
this.pingInterval = setInterval(() => {
if (!this.wss) return;
this.wss.clients.forEach((ws) => {
const authWs = ws as AuthenticatedWebSocket;
if (authWs.isAlive === false) {
this.logger.debug({ userId: authWs.userId }, 'Terminating dead connection');
return authWs.terminate();
}
authWs.isAlive = false;
authWs.ping();
});
}, 30000); // Ping every 30 seconds
this.logger.info('WebSocket heartbeat started (30s interval)');
}
/**
* Get count of active connections
*/
getConnectionStats(): { totalUsers: number; totalConnections: number } {
let totalConnections = 0;
this.clients.forEach((userClients) => {
totalConnections += userClients.size;
});
return {
totalUsers: this.clients.size,
totalConnections,
};
}
/**
* Shutdown the WebSocket server gracefully
*/
shutdown(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.wss) {
this.logger.info('Shutting down WebSocket server');
// Notify all clients about shutdown
this.broadcastToAll({
message: 'Server is shutting down. Please reconnect.',
severity: 'warning',
});
// Close all connections
this.wss.clients.forEach((client) => {
client.close(1001, 'Server shutting down');
});
this.wss.close(() => {
this.logger.info('WebSocket server closed');
});
this.clients.clear();
}
}
}
// Singleton instance
export const websocketService = new WebSocketService(globalLogger);

View File

@@ -126,7 +126,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
.post('/api/receipts')
.set('Authorization', `Bearer ${authToken}`)
.attach('receipt', testImageBuffer, 'test-receipt.png')
.field('store_id', '1')
.field('store_location_id', '1')
.field('transaction_date', '2024-01-15');
expect(response.status).toBe(201);
@@ -263,13 +263,12 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
postalCode: 'M5V 4A4',
});
createdStoreLocations.push(store);
const storeId = store.storeId;
const result = await pool.query(
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents)
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_location_id, total_amount_cents)
VALUES ($1, $2, 'completed', $3, 9999)
RETURNING receipt_id`,
[testUser.user.user_id, '/uploads/receipts/detail-test.jpg', storeId],
[testUser.user.user_id, '/uploads/receipts/detail-test.jpg', store.storeLocationId],
);
testReceiptId = result.rows[0].receipt_id;
createdReceiptIds.push(testReceiptId);
@@ -292,7 +291,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
expect(response.body.success).toBe(true);
expect(response.body.data.receipt).toBeDefined();
expect(response.body.data.receipt.receipt_id).toBe(testReceiptId);
expect(response.body.data.receipt.store_id).toBeDefined();
expect(response.body.data.receipt.store_location_id).toBeDefined();
expect(response.body.data.items).toBeDefined();
expect(response.body.data.items.length).toBe(2);
});

View File

@@ -1,7 +1,7 @@
// src/services/db/store.db.test.ts
// src/tests/integration/store.db.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { getPool } from './connection.db';
import { StoreRepository } from './store.db';
import { getPool } from '../../services/db/connection.db';
import { StoreRepository } from '../../services/db/store.db';
import { pino } from 'pino';
import type { Pool } from 'pg';
@@ -65,10 +65,10 @@ describe('StoreRepository', () => {
it('should create a store with created_by user ID', async () => {
// Create a test user first
const userResult = await pool.query(
`INSERT INTO public.users (email, password_hash, full_name)
VALUES ($1, $2, $3)
`INSERT INTO public.users (email, password_hash)
VALUES ($1, $2)
RETURNING user_id`,
['test@example.com', 'hash', 'Test User'],
['test@example.com', 'hash'],
);
const userId = userResult.rows[0].user_id;

View File

@@ -1,9 +1,9 @@
// src/services/db/storeLocation.db.test.ts
// src/tests/integration/storeLocation.db.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { getPool } from './connection.db';
import { StoreLocationRepository } from './storeLocation.db';
import { StoreRepository } from './store.db';
import { AddressRepository } from './address.db';
import { getPool } from '../../services/db/connection.db';
import { StoreLocationRepository } from '../../services/db/storeLocation.db';
import { StoreRepository } from '../../services/db/store.db';
import { AddressRepository } from '../../services/db/address.db';
import { pino } from 'pino';
import type { Pool } from 'pg';

View File

@@ -0,0 +1,452 @@
// src/tests/integration/websocket.integration.test.ts
/**
* Integration tests for WebSocket real-time notification system
* Tests the full flow from server to client including authentication
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Server as HTTPServer } from 'http';
import express from 'express';
import WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import { WebSocketService } from '../../services/websocketService.server';
import type { Logger } from 'pino';
import type { WebSocketMessage, DealNotificationData } from '../../types/websocket';
import { createServer } from 'http';
const JWT_SECRET = process.env.JWT_SECRET || 'test-secret';
let TEST_PORT = 0; // Use dynamic port (0 = let OS assign)
describe('WebSocket Integration Tests', () => {
let app: express.Application;
let server: HTTPServer;
let wsService: WebSocketService;
let mockLogger: Logger;
beforeAll(async () => {
// Create mock logger
mockLogger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
child: () => mockLogger,
} as unknown as Logger;
// Create Express app
app = express();
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// Create HTTP server (use port 0 for dynamic allocation)
server = createServer(app);
// Start server and wait for it to be listening
await new Promise<void>((resolve) => {
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === 'object') {
TEST_PORT = addr.port;
}
resolve();
});
});
// Initialize WebSocket service
wsService = new WebSocketService(mockLogger);
wsService.initialize(server);
// Wait for WebSocket server to be ready
await new Promise((resolve) => setTimeout(resolve, 200));
});
afterAll(async () => {
// Shutdown WebSocket service first
wsService.shutdown();
// Close HTTP server
await new Promise<void>((resolve) => {
server.close(() => {
resolve();
});
});
// Wait for cleanup
await new Promise((resolve) => setTimeout(resolve, 200));
});
describe('WebSocket Connection', () => {
it('should reject connection without authentication token', async () => {
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws`);
await new Promise<void>((resolve) => {
ws.on('close', (code, reason) => {
expect(code).toBe(1008); // Policy violation
expect(reason.toString()).toContain('Authentication required');
resolve();
});
});
});
it('should reject connection with invalid token', async () => {
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=invalid-token`);
await new Promise<void>((resolve) => {
ws.on('close', (code, reason) => {
expect(code).toBe(1008);
expect(reason.toString()).toContain('Invalid token');
resolve();
});
});
});
it('should accept connection with valid JWT token', async () => {
const token = jwt.sign(
{ user_id: 'test-user-1', email: 'test@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
await new Promise<void>((resolve, reject) => {
ws.on('open', () => {
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
resolve();
});
ws.on('error', (error) => {
reject(error);
});
});
});
it('should receive connection-established message on successful connection', async () => {
const token = jwt.sign(
{ user_id: 'test-user-2', email: 'test2@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
await new Promise<void>((resolve, reject) => {
ws.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
expect(message.type).toBe('connection-established');
expect(message.data).toHaveProperty('user_id', 'test-user-2');
expect(message.data).toHaveProperty('message');
expect(message.timestamp).toBeDefined();
ws.close();
resolve();
});
ws.on('error', (error) => {
reject(error);
});
});
});
});
describe('Deal Notifications', () => {
it('should broadcast deal notification to connected user', async () => {
const userId = 'test-user-3';
const token = jwt.sign(
{ user_id: userId, email: 'test3@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
await new Promise<void>((resolve, reject) => {
let messageCount = 0;
ws.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
messageCount++;
// First message should be connection-established
if (messageCount === 1) {
expect(message.type).toBe('connection-established');
return;
}
// Second message should be our deal notification
if (messageCount === 2) {
expect(message.type).toBe('deal-notification');
const dealData = message.data as DealNotificationData;
expect(dealData.user_id).toBe(userId);
expect(dealData.deals).toHaveLength(2);
expect(dealData.deals[0].item_name).toBe('Test Item 1');
expect(dealData.deals[0].best_price_in_cents).toBe(299);
expect(dealData.message).toContain('2 new deal');
ws.close();
resolve();
}
});
ws.on('open', () => {
// Wait a bit for connection-established message
setTimeout(() => {
// Broadcast a deal notification
wsService.broadcastDealNotification(userId, {
user_id: userId,
deals: [
{
item_name: 'Test Item 1',
best_price_in_cents: 299,
store_name: 'Test Store',
store_id: 1,
},
{
item_name: 'Test Item 2',
best_price_in_cents: 499,
store_name: 'Test Store 2',
store_id: 2,
},
],
message: 'You have 2 new deal(s) on your watched items!',
});
}, 100);
});
ws.on('error', (error) => {
reject(error);
});
});
});
it('should broadcast to multiple connections of same user', async () => {
const userId = 'test-user-4';
const token = jwt.sign(
{ user_id: userId, email: 'test4@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
// Open two WebSocket connections for the same user
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
await new Promise<void>((resolve, reject) => {
let ws1Ready = false;
let ws2Ready = false;
let ws1ReceivedDeal = false;
let ws2ReceivedDeal = false;
const checkComplete = () => {
if (ws1ReceivedDeal && ws2ReceivedDeal) {
ws1.close();
ws2.close();
resolve();
}
};
ws1.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
if (message.type === 'connection-established') {
ws1Ready = true;
} else if (message.type === 'deal-notification') {
ws1ReceivedDeal = true;
checkComplete();
}
});
ws2.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
if (message.type === 'connection-established') {
ws2Ready = true;
} else if (message.type === 'deal-notification') {
ws2ReceivedDeal = true;
checkComplete();
}
});
ws1.on('open', () => {
setTimeout(() => {
if (ws1Ready && ws2Ready) {
wsService.broadcastDealNotification(userId, {
user_id: userId,
deals: [
{
item_name: 'Test Item',
best_price_in_cents: 199,
store_name: 'Store',
store_id: 1,
},
],
message: 'You have 1 new deal!',
});
}
}, 200);
});
ws1.on('error', reject);
ws2.on('error', reject);
});
});
it('should not send notification to different user', async () => {
const user1Id = 'test-user-5';
const user2Id = 'test-user-6';
const token1 = jwt.sign(
{ user_id: user1Id, email: 'test5@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const token2 = jwt.sign(
{ user_id: user2Id, email: 'test6@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token1}`);
const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token2}`);
await new Promise<void>((resolve, reject) => {
let ws1Ready = false;
let ws2Ready = false;
let ws2ReceivedUnexpectedMessage = false;
ws1.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
if (message.type === 'connection-established') {
ws1Ready = true;
}
});
ws2.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
if (message.type === 'connection-established') {
ws2Ready = true;
} else if (message.type === 'deal-notification') {
// User 2 should NOT receive this message
ws2ReceivedUnexpectedMessage = true;
}
});
ws1.on('open', () => {
setTimeout(() => {
if (ws1Ready && ws2Ready) {
// Send notification only to user 1
wsService.broadcastDealNotification(user1Id, {
user_id: user1Id,
deals: [
{
item_name: 'Test Item',
best_price_in_cents: 199,
store_name: 'Store',
store_id: 1,
},
],
message: 'You have 1 new deal!',
});
// Wait a bit to ensure user 2 doesn't receive it
setTimeout(() => {
expect(ws2ReceivedUnexpectedMessage).toBe(false);
ws1.close();
ws2.close();
resolve();
}, 300);
}
}, 200);
});
ws1.on('error', reject);
ws2.on('error', reject);
});
});
});
describe('System Messages', () => {
it('should broadcast system message to specific user', async () => {
const userId = 'test-user-7';
const token = jwt.sign(
{ user_id: userId, email: 'test7@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
await new Promise<void>((resolve, reject) => {
let messageCount = 0;
ws.on('message', (data: Buffer) => {
const message = JSON.parse(data.toString()) as WebSocketMessage;
messageCount++;
if (messageCount === 2) {
expect(message.type).toBe('system-message');
expect(message.data).toHaveProperty('message', 'Test system message');
expect(message.data).toHaveProperty('severity', 'info');
ws.close();
resolve();
}
});
ws.on('open', () => {
setTimeout(() => {
wsService.broadcastSystemMessage(userId, {
message: 'Test system message',
severity: 'info',
});
}, 100);
});
ws.on('error', reject);
});
});
});
describe('Connection Stats', () => {
it('should track connection statistics', async () => {
const token1 = jwt.sign(
{ user_id: 'stats-user-1', email: 'stats1@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const token2 = jwt.sign(
{ user_id: 'stats-user-2', email: 'stats2@example.com', role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' },
);
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token1}`);
const ws2a = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token2}`);
const ws2b = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token2}`);
await new Promise<void>((resolve) => {
let openCount = 0;
const checkOpen = () => {
openCount++;
if (openCount === 3) {
setTimeout(() => {
const stats = wsService.getConnectionStats();
// Should have 2 users (stats-user-1 and stats-user-2)
// and 3 total connections
expect(stats.totalUsers).toBeGreaterThanOrEqual(2);
expect(stats.totalConnections).toBeGreaterThanOrEqual(3);
ws1.close();
ws2a.close();
ws2b.close();
resolve();
}, 100);
}
};
ws1.on('open', checkOpen);
ws2a.on('open', checkOpen);
ws2b.on('open', checkOpen);
});
});
});
});

View File

@@ -907,7 +907,7 @@ export const createMockReceipt = (
const defaultReceipt: Receipt = {
receipt_id: receiptId,
user_id: `user-${getNextId()}`,
store_id: null,
store_location_id: null,
receipt_image_url: `/receipts/mock-receipt-${receiptId}.jpg`,
transaction_date: new Date().toISOString(),
total_amount_cents: null,
@@ -1167,7 +1167,7 @@ export const createMockUserSubmittedPrice = (
user_submitted_price_id: getNextId(),
user_id: `user-${getNextId()}`,
master_item_id: getNextId(),
store_id: getNextId(),
store_location_id: getNextId(),
price_in_cents: 299,
photo_url: null,
upvotes: 0,

View File

@@ -16,14 +16,25 @@ export interface Flyer {
image_url: string;
icon_url: string; // URL for the 64x64 icon version of the flyer
readonly checksum?: string;
readonly store_id?: number;
readonly store_id?: number; // Legacy field - kept for backward compatibility
valid_from?: string | null;
valid_to?: string | null;
store_address?: string | null;
store_address?: string | null; // Legacy field - will be deprecated
status: FlyerStatus;
item_count: number;
readonly uploaded_by?: string | null; // UUID of the user who uploaded it, can be null for anonymous uploads
// Store relationship (legacy - single store)
store?: Store;
// Store locations relationship (many-to-many via flyer_locations table)
// This is the correct relationship - a flyer can be valid at multiple store locations
locations?: Array<{
store_location_id: number;
store: Store;
address: Address;
}>;
readonly created_at: string;
readonly updated_at: string;
}
@@ -260,7 +271,7 @@ export interface UserSubmittedPrice {
readonly user_submitted_price_id: number;
readonly user_id: string; // UUID
readonly master_item_id: number;
readonly store_id: number;
readonly store_location_id: number; // Specific store location (provides geographic specificity)
price_in_cents: number;
photo_url?: string | null;
readonly upvotes: number;
@@ -649,7 +660,7 @@ export interface ShoppingTrip {
export interface Receipt {
readonly receipt_id: number;
readonly user_id: string; // UUID
store_id?: number | null;
store_location_id?: number | null; // Specific store location (nullable if not yet matched)
receipt_image_url: string;
transaction_date?: string | null;
total_amount_cents?: number | null;

110
src/types/websocket.test.ts Normal file
View File

@@ -0,0 +1,110 @@
// src/types/websocket.test.ts
import { describe, it, expect } from 'vitest';
import { createWebSocketMessage } from './websocket';
describe('WebSocket Message Creators', () => {
describe('createWebSocketMessage.dealNotification', () => {
it('should create a valid deal notification message', () => {
const message = createWebSocketMessage.dealNotification({
user_id: 'user-123',
deals: [
{
item_name: 'Milk',
best_price_in_cents: 299,
store_name: 'Test Store',
store_id: 1,
},
],
message: 'You have 1 new deal!',
});
expect(message.type).toBe('deal-notification');
expect(message.data.user_id).toBe('user-123');
expect(message.data.deals).toHaveLength(1);
expect(message.data.deals[0].item_name).toBe('Milk');
expect(message.timestamp).toBeDefined();
});
});
describe('createWebSocketMessage.systemMessage', () => {
it('should create a valid system message', () => {
const message = createWebSocketMessage.systemMessage({
message: 'System maintenance scheduled',
severity: 'warning',
});
expect(message.type).toBe('system-message');
expect(message.data.message).toBe('System maintenance scheduled');
expect(message.data.severity).toBe('warning');
expect(message.timestamp).toBeDefined();
});
});
describe('createWebSocketMessage.error', () => {
it('should create a valid error message', () => {
const message = createWebSocketMessage.error({
message: 'Something went wrong',
code: 'ERR_500',
});
expect(message.type).toBe('error');
expect(message.data.message).toBe('Something went wrong');
expect(message.data.code).toBe('ERR_500');
expect(message.timestamp).toBeDefined();
});
});
describe('createWebSocketMessage.connectionEstablished', () => {
it('should create a valid connection established message', () => {
const message = createWebSocketMessage.connectionEstablished({
user_id: 'user-123',
message: 'Connected successfully',
});
expect(message.type).toBe('connection-established');
expect(message.data.user_id).toBe('user-123');
expect(message.data.message).toBe('Connected successfully');
expect(message.timestamp).toBeDefined();
});
});
describe('createWebSocketMessage.ping', () => {
it('should create a valid ping message', () => {
const message = createWebSocketMessage.ping();
expect(message.type).toBe('ping');
expect(message.data).toEqual({});
expect(message.timestamp).toBeDefined();
});
});
describe('createWebSocketMessage.pong', () => {
it('should create a valid pong message', () => {
const message = createWebSocketMessage.pong();
expect(message.type).toBe('pong');
expect(message.data).toEqual({});
expect(message.timestamp).toBeDefined();
});
});
describe('timestamp validation', () => {
it('should generate valid ISO timestamps', () => {
const message = createWebSocketMessage.ping();
const timestamp = new Date(message.timestamp);
expect(timestamp).toBeInstanceOf(Date);
expect(timestamp.toISOString()).toBe(message.timestamp);
});
it('should generate different timestamps for sequential calls', () => {
const message1 = createWebSocketMessage.ping();
const message2 = createWebSocketMessage.ping();
// Timestamps should be close but potentially different
expect(message1.timestamp).toBeDefined();
expect(message2.timestamp).toBeDefined();
});
});
});

112
src/types/websocket.ts Normal file
View File

@@ -0,0 +1,112 @@
// src/types/websocket.ts
/**
* WebSocket message types for real-time notifications
*/
/**
* Deal information for real-time notifications
*/
export interface DealInfo {
item_name: string;
best_price_in_cents: number;
store_name: string;
store_id: number;
}
/**
* Base WebSocket message structure
*/
export interface WebSocketMessage<T = unknown> {
type: WebSocketMessageType;
data: T;
timestamp: string;
}
/**
* Available WebSocket message types
*/
export type WebSocketMessageType =
| 'deal-notification'
| 'system-message'
| 'ping'
| 'pong'
| 'error'
| 'connection-established';
/**
* Deal notification message payload
*/
export interface DealNotificationData {
notification_id?: string;
deals: DealInfo[];
user_id: string;
message: string;
}
/**
* System message payload
*/
export interface SystemMessageData {
message: string;
severity: 'info' | 'warning' | 'error';
}
/**
* Error message payload
*/
export interface ErrorMessageData {
message: string;
code?: string;
}
/**
* Connection established payload
*/
export interface ConnectionEstablishedData {
user_id: string;
message: string;
}
/**
* Type-safe message creators
*/
export const createWebSocketMessage = {
dealNotification: (data: DealNotificationData): WebSocketMessage<DealNotificationData> => ({
type: 'deal-notification',
data,
timestamp: new Date().toISOString(),
}),
systemMessage: (data: SystemMessageData): WebSocketMessage<SystemMessageData> => ({
type: 'system-message',
data,
timestamp: new Date().toISOString(),
}),
error: (data: ErrorMessageData): WebSocketMessage<ErrorMessageData> => ({
type: 'error',
data,
timestamp: new Date().toISOString(),
}),
connectionEstablished: (
data: ConnectionEstablishedData,
): WebSocketMessage<ConnectionEstablishedData> => ({
type: 'connection-established',
data,
timestamp: new Date().toISOString(),
}),
ping: (): WebSocketMessage<Record<string, never>> => ({
type: 'ping',
data: {},
timestamp: new Date().toISOString(),
}),
pong: (): WebSocketMessage<Record<string, never>> => ({
type: 'pong',
data: {},
timestamp: new Date().toISOString(),
}),
};