Implement a comprehensive online status monitoring system: - OnlineStatusService: Centralized service for checking device/sensor online status with caching, configurable thresholds, and batch operations - useOnlineStatus hook: React hook for easy component integration with auto-polling, loading states, and app state handling - Support for status levels: online (<5 min), warning (5-60 min), offline (>60 min) - Utility functions for status colors, labels, and formatting Includes 49 passing tests covering service and hook functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
/**
|
|
* Tests for OnlineStatusService
|
|
*/
|
|
|
|
import {
|
|
OnlineStatusService,
|
|
calculateOnlineStatus,
|
|
formatLastSeen,
|
|
getStatusColor,
|
|
getStatusLabel,
|
|
calculateOverallStatus,
|
|
DEFAULT_THRESHOLDS,
|
|
DeviceStatusInfo,
|
|
} from '../onlineStatusService';
|
|
import { api } from '../api';
|
|
|
|
// Mock the api module
|
|
jest.mock('../api', () => ({
|
|
api: {
|
|
getDevicesForBeneficiary: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockApi = api as jest.Mocked<typeof api>;
|
|
|
|
describe('OnlineStatusService', () => {
|
|
describe('calculateOnlineStatus', () => {
|
|
it('should return online for recent timestamps (< 5 minutes)', () => {
|
|
const now = new Date();
|
|
const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000);
|
|
|
|
expect(calculateOnlineStatus(twoMinutesAgo)).toBe('online');
|
|
});
|
|
|
|
it('should return online for just now', () => {
|
|
const now = new Date();
|
|
expect(calculateOnlineStatus(now)).toBe('online');
|
|
});
|
|
|
|
it('should return warning for timestamps between 5 and 60 minutes', () => {
|
|
const now = new Date();
|
|
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000);
|
|
|
|
expect(calculateOnlineStatus(thirtyMinutesAgo)).toBe('warning');
|
|
});
|
|
|
|
it('should return offline for timestamps older than 60 minutes', () => {
|
|
const now = new Date();
|
|
const twoHoursAgo = new Date(now.getTime() - 120 * 60 * 1000);
|
|
|
|
expect(calculateOnlineStatus(twoHoursAgo)).toBe('offline');
|
|
});
|
|
|
|
it('should respect custom thresholds', () => {
|
|
const now = new Date();
|
|
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000);
|
|
|
|
// With default thresholds, 10 minutes should be warning
|
|
expect(calculateOnlineStatus(tenMinutesAgo, DEFAULT_THRESHOLDS)).toBe('warning');
|
|
|
|
// With custom thresholds, 10 minutes could be online
|
|
expect(calculateOnlineStatus(tenMinutesAgo, { onlineMaxMinutes: 15, warningMaxMinutes: 60 })).toBe('online');
|
|
});
|
|
});
|
|
|
|
describe('formatLastSeen', () => {
|
|
it('should return "Just now" for timestamps less than 1 minute ago', () => {
|
|
const now = new Date();
|
|
expect(formatLastSeen(now)).toBe('Just now');
|
|
|
|
const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000);
|
|
expect(formatLastSeen(thirtySecondsAgo)).toBe('Just now');
|
|
});
|
|
|
|
it('should return minutes for timestamps less than 1 hour ago', () => {
|
|
const now = new Date();
|
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
expect(formatLastSeen(fiveMinutesAgo)).toBe('5 min ago');
|
|
|
|
const fortyFiveMinutesAgo = new Date(now.getTime() - 45 * 60 * 1000);
|
|
expect(formatLastSeen(fortyFiveMinutesAgo)).toBe('45 min ago');
|
|
});
|
|
|
|
it('should return hours for timestamps less than 24 hours ago', () => {
|
|
const now = new Date();
|
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
expect(formatLastSeen(oneHourAgo)).toBe('1 hour ago');
|
|
|
|
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
|
|
expect(formatLastSeen(threeHoursAgo)).toBe('3 hours ago');
|
|
});
|
|
|
|
it('should return days for timestamps older than 24 hours', () => {
|
|
const now = new Date();
|
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
expect(formatLastSeen(oneDayAgo)).toBe('1 day ago');
|
|
|
|
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
|
expect(formatLastSeen(threeDaysAgo)).toBe('3 days ago');
|
|
});
|
|
});
|
|
|
|
describe('getStatusColor', () => {
|
|
it('should return green for online', () => {
|
|
expect(getStatusColor('online')).toBe('#22c55e');
|
|
});
|
|
|
|
it('should return amber for warning', () => {
|
|
expect(getStatusColor('warning')).toBe('#f59e0b');
|
|
});
|
|
|
|
it('should return red for offline', () => {
|
|
expect(getStatusColor('offline')).toBe('#ef4444');
|
|
});
|
|
});
|
|
|
|
describe('getStatusLabel', () => {
|
|
it('should return proper labels', () => {
|
|
expect(getStatusLabel('online')).toBe('Online');
|
|
expect(getStatusLabel('warning')).toBe('Warning');
|
|
expect(getStatusLabel('offline')).toBe('Offline');
|
|
});
|
|
});
|
|
|
|
describe('calculateOverallStatus', () => {
|
|
const createDevice = (status: 'online' | 'warning' | 'offline'): DeviceStatusInfo => ({
|
|
deviceId: `device-${Math.random()}`,
|
|
deviceName: 'Test Device',
|
|
status,
|
|
lastSeen: new Date(),
|
|
lastSeenMinutesAgo: 0,
|
|
lastSeenFormatted: 'Just now',
|
|
});
|
|
|
|
it('should return offline for empty array', () => {
|
|
expect(calculateOverallStatus([])).toBe('offline');
|
|
});
|
|
|
|
it('should return online when all devices are online', () => {
|
|
const devices = [
|
|
createDevice('online'),
|
|
createDevice('online'),
|
|
createDevice('online'),
|
|
];
|
|
expect(calculateOverallStatus(devices)).toBe('online');
|
|
});
|
|
|
|
it('should return warning when at least one device has warning (but none offline)', () => {
|
|
const devices = [
|
|
createDevice('online'),
|
|
createDevice('warning'),
|
|
createDevice('online'),
|
|
];
|
|
expect(calculateOverallStatus(devices)).toBe('warning');
|
|
});
|
|
|
|
it('should return offline when at least one device is offline', () => {
|
|
const devices = [
|
|
createDevice('online'),
|
|
createDevice('warning'),
|
|
createDevice('offline'),
|
|
];
|
|
expect(calculateOverallStatus(devices)).toBe('offline');
|
|
});
|
|
|
|
it('should prioritize offline over warning', () => {
|
|
const devices = [
|
|
createDevice('offline'),
|
|
createDevice('warning'),
|
|
];
|
|
expect(calculateOverallStatus(devices)).toBe('offline');
|
|
});
|
|
});
|
|
|
|
describe('OnlineStatusService class', () => {
|
|
let service: OnlineStatusService;
|
|
|
|
beforeEach(() => {
|
|
service = new OnlineStatusService();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('setThresholds / getThresholds', () => {
|
|
it('should update thresholds', () => {
|
|
service.setThresholds({ onlineMaxMinutes: 10 });
|
|
const thresholds = service.getThresholds();
|
|
expect(thresholds.onlineMaxMinutes).toBe(10);
|
|
expect(thresholds.warningMaxMinutes).toBe(60); // Default
|
|
});
|
|
});
|
|
|
|
describe('checkBeneficiaryStatus', () => {
|
|
it('should return error when API fails', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: { message: 'Network error' },
|
|
} as any);
|
|
|
|
const result = await service.checkBeneficiaryStatus('ben-123');
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBe('Network error');
|
|
});
|
|
|
|
it('should return empty status when no devices', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: [],
|
|
} as any);
|
|
|
|
const result = await service.checkBeneficiaryStatus('ben-123');
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.data?.totalDevices).toBe(0);
|
|
expect(result.data?.devices).toHaveLength(0);
|
|
expect(result.data?.overallStatus).toBe('offline'); // No devices = offline
|
|
});
|
|
|
|
it('should correctly transform devices', async () => {
|
|
const now = new Date();
|
|
const mockDevices = [
|
|
{
|
|
deviceId: 'dev-1',
|
|
wellId: 123,
|
|
mac: 'AABBCCDDEEFF',
|
|
name: 'WP_123_ddeeff',
|
|
status: 'online',
|
|
lastSeen: now,
|
|
beneficiaryId: 'ben-123',
|
|
deploymentId: 456,
|
|
source: 'api',
|
|
},
|
|
{
|
|
deviceId: 'dev-2',
|
|
wellId: 456,
|
|
mac: '112233445566',
|
|
name: 'WP_456_445566',
|
|
status: 'offline',
|
|
lastSeen: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
|
|
beneficiaryId: 'ben-123',
|
|
deploymentId: 456,
|
|
source: 'api',
|
|
},
|
|
];
|
|
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: mockDevices,
|
|
} as any);
|
|
|
|
const result = await service.checkBeneficiaryStatus('ben-123');
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.data?.totalDevices).toBe(2);
|
|
expect(result.data?.onlineCount).toBe(1);
|
|
expect(result.data?.offlineCount).toBe(1);
|
|
expect(result.data?.overallStatus).toBe('offline'); // One offline = overall offline
|
|
});
|
|
|
|
it('should use cached data when available and valid', async () => {
|
|
const mockDevices = [
|
|
{
|
|
deviceId: 'dev-1',
|
|
name: 'WP_123_ddeeff',
|
|
status: 'online',
|
|
lastSeen: new Date(),
|
|
wellId: 123,
|
|
mac: 'AABBCCDDEEFF',
|
|
},
|
|
];
|
|
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValue({
|
|
ok: true,
|
|
data: mockDevices,
|
|
} as any);
|
|
|
|
// First call - should hit API
|
|
await service.checkBeneficiaryStatus('ben-123');
|
|
expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(1);
|
|
|
|
// Second call - should use cache
|
|
await service.checkBeneficiaryStatus('ben-123');
|
|
expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(1);
|
|
|
|
// Force refresh - should hit API again
|
|
await service.checkBeneficiaryStatus('ben-123', true);
|
|
expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('checkMultipleBeneficiaries', () => {
|
|
it('should check multiple beneficiaries', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValue({
|
|
ok: true,
|
|
data: [],
|
|
} as any);
|
|
|
|
const results = await service.checkMultipleBeneficiaries(['ben-1', 'ben-2', 'ben-3']);
|
|
|
|
expect(results.size).toBe(3);
|
|
expect(results.get('ben-1')?.ok).toBe(true);
|
|
expect(results.get('ben-2')?.ok).toBe(true);
|
|
expect(results.get('ben-3')?.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isDeviceOnline', () => {
|
|
it('should return true for online device', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: [
|
|
{
|
|
deviceId: 'dev-1',
|
|
name: 'Test',
|
|
status: 'online',
|
|
lastSeen: new Date(),
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
const isOnline = await service.isDeviceOnline('ben-123', 'dev-1');
|
|
expect(isOnline).toBe(true);
|
|
});
|
|
|
|
it('should return false for offline device', async () => {
|
|
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: [
|
|
{
|
|
deviceId: 'dev-1',
|
|
name: 'Test',
|
|
status: 'offline',
|
|
lastSeen: twoHoursAgo,
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
const isOnline = await service.isDeviceOnline('ben-123', 'dev-1');
|
|
expect(isOnline).toBe(false);
|
|
});
|
|
|
|
it('should return false for non-existent device', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: [],
|
|
} as any);
|
|
|
|
const isOnline = await service.isDeviceOnline('ben-123', 'non-existent');
|
|
expect(isOnline).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getStatusSummary', () => {
|
|
it('should return correct summary', async () => {
|
|
const now = new Date();
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: [
|
|
{ deviceId: 'd1', name: 'D1', status: 'online', lastSeen: now },
|
|
{ deviceId: 'd2', name: 'D2', status: 'online', lastSeen: now },
|
|
{ deviceId: 'd3', name: 'D3', status: 'warning', lastSeen: new Date(now.getTime() - 30 * 60 * 1000) },
|
|
{ deviceId: 'd4', name: 'D4', status: 'offline', lastSeen: new Date(now.getTime() - 2 * 60 * 60 * 1000) },
|
|
],
|
|
} as any);
|
|
|
|
const summary = await service.getStatusSummary('ben-123');
|
|
|
|
expect(summary).not.toBeNull();
|
|
expect(summary?.total).toBe(4);
|
|
expect(summary?.online).toBe(2);
|
|
expect(summary?.warning).toBe(1);
|
|
expect(summary?.offline).toBe(1);
|
|
});
|
|
|
|
it('should return null when API fails', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: 'Failed',
|
|
} as any);
|
|
|
|
const summary = await service.getStatusSummary('ben-123');
|
|
expect(summary).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('clearCache', () => {
|
|
it('should clear all cached data', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValue({
|
|
ok: true,
|
|
data: [],
|
|
} as any);
|
|
|
|
// Populate cache
|
|
await service.checkBeneficiaryStatus('ben-1');
|
|
await service.checkBeneficiaryStatus('ben-2');
|
|
|
|
expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(2);
|
|
|
|
// Verify cache is being used
|
|
await service.checkBeneficiaryStatus('ben-1');
|
|
expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(2);
|
|
|
|
// Clear cache
|
|
service.clearCache();
|
|
|
|
// Now should hit API again
|
|
await service.checkBeneficiaryStatus('ben-1');
|
|
expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe('getCachedStatus', () => {
|
|
it('should return cached status if valid', async () => {
|
|
mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({
|
|
ok: true,
|
|
data: [{ deviceId: 'd1', name: 'D1', status: 'online', lastSeen: new Date() }],
|
|
} as any);
|
|
|
|
// Populate cache
|
|
await service.checkBeneficiaryStatus('ben-123');
|
|
|
|
const cached = service.getCachedStatus('ben-123');
|
|
expect(cached).not.toBeUndefined();
|
|
expect(cached?.beneficiaryId).toBe('ben-123');
|
|
});
|
|
|
|
it('should return undefined for non-cached beneficiary', () => {
|
|
const cached = service.getCachedStatus('non-existent');
|
|
expect(cached).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
});
|