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
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s
This commit is contained in:
131
src/components/NotificationBell.tsx
Normal file
131
src/components/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/components/NotificationToastHandler.tsx
Normal file
177
src/components/NotificationToastHandler.tsx
Normal 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
41
src/hooks/useEventBus.ts
Normal 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
284
src/hooks/useWebSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.'),
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
209
src/services/db/flyerLocation.db.ts
Normal file
209
src/services/db/flyerLocation.db.ts
Normal 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.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>'),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
123
src/services/websocketService.server.test.ts
Normal file
123
src/services/websocketService.server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
358
src/services/websocketService.server.ts
Normal file
358
src/services/websocketService.server.ts
Normal 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);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
452
src/tests/integration/websocket.integration.test.ts
Normal file
452
src/tests/integration/websocket.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
19
src/types.ts
19
src/types.ts
@@ -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
110
src/types/websocket.test.ts
Normal 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
112
src/types/websocket.ts
Normal 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(),
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user