/** * Offline Store Tests * * Tests for offline state management, message queuing, * reconnection logic, and graceful degradation. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useOfflineStore, type QueuedMessage } from '../../src/store/offlineStore'; import { localStorageMock } from '../setup'; // ── Mock dependencies (vi.hoisted for availability in hoisted vi.mock) ── const { mockGetConnectionState, mockGetClient, mockConnect, mockGenerateRandomString, resetIdCounter } = vi.hoisted(() => { let counter = 0; return { mockGetConnectionState: vi.fn(() => 'disconnected'), mockGetClient: vi.fn(() => ({ chat: vi.fn(async () => {}) })), mockConnect: vi.fn(async () => {}), mockGenerateRandomString: (len: number) => String(++counter).padStart(len, '0'), resetIdCounter: () => { counter = 0; }, }; }); vi.mock('../../src/store/connectionStore', () => ({ useConnectionStore: { getState: () => ({ connect: mockConnect, connectionState: 'connected', }), subscribe: () => () => {}, }, getConnectionState: () => mockGetConnectionState(), getClient: () => mockGetClient(), })); vi.mock('../../src/lib/crypto-utils', () => ({ generateRandomString: mockGenerateRandomString, })); vi.mock('../../src/lib/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }), })); beforeEach(() => { vi.clearAllMocks(); resetIdCounter(); vi.useFakeTimers({ shouldAdvanceTime: true }); localStorageMock.clear(); // Reset store to initial state useOfflineStore.setState({ isOffline: false, isReconnecting: false, reconnectAttempt: 0, nextReconnectDelay: 1000, lastOnlineTime: null, queuedMessages: [], maxRetryCount: 5, maxQueueSize: 100, }); // Default: disconnected so retryAllMessages won't try to send mockGetConnectionState.mockReturnValue('disconnected'); }); afterEach(() => { vi.useRealTimers(); }); // ═══════════════════════════════════════════════════════════════════ // Initial State // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — initial state', () => { it('should start online', () => { expect(useOfflineStore.getState().isOffline).toBe(false); }); it('should have empty queue', () => { expect(useOfflineStore.getState().queuedMessages).toEqual([]); }); it('should have no reconnect attempt', () => { expect(useOfflineStore.getState().reconnectAttempt).toBe(0); }); it('should have default max values', () => { const state = useOfflineStore.getState(); expect(state.maxRetryCount).toBe(5); expect(state.maxQueueSize).toBe(100); }); }); // ═══════════════════════════════════════════════════════════════════ // setOffline // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — setOffline', () => { it('should set offline state', () => { useOfflineStore.getState().setOffline(true); expect(useOfflineStore.getState().isOffline).toBe(true); }); it('should record lastOnlineTime when going online', () => { useOfflineStore.getState().setOffline(true); useOfflineStore.getState().setOffline(false); expect(useOfflineStore.getState().lastOnlineTime).toBeGreaterThan(0); }); it('should schedule reconnect when going offline', () => { useOfflineStore.getState().setOffline(true); expect(useOfflineStore.getState().isReconnecting).toBe(true); expect(useOfflineStore.getState().reconnectAttempt).toBe(1); }); it('should cancel reconnect and reset delay when going online', () => { useOfflineStore.getState().setOffline(true); useOfflineStore.getState().setOffline(false); const state = useOfflineStore.getState(); expect(state.reconnectAttempt).toBe(0); expect(state.nextReconnectDelay).toBe(1000); }); }); // ═══════════════════════════════════════════════════════════════════ // queueMessage // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — queueMessage', () => { it('should add message to queue', () => { const id = useOfflineStore.getState().queueMessage('Hello', 'agent-1', 'session-1'); expect(id).toBeTruthy(); const queue = useOfflineStore.getState().queuedMessages; expect(queue).toHaveLength(1); expect(queue[0].content).toBe('Hello'); expect(queue[0].agentId).toBe('agent-1'); expect(queue[0].sessionKey).toBe('session-1'); expect(queue[0].status).toBe('pending'); expect(queue[0].retryCount).toBe(0); }); it('should generate unique IDs', () => { const id1 = useOfflineStore.getState().queueMessage('msg1'); const id2 = useOfflineStore.getState().queueMessage('msg2'); expect(id1).not.toBe(id2); }); it('should enforce max queue size', () => { const state = useOfflineStore.getState(); const maxSize = state.maxQueueSize; for (let i = 0; i < maxSize + 5; i++) { state.queueMessage(`Message ${i}`); } expect(useOfflineStore.getState().queuedMessages.length).toBeLessThanOrEqual(maxSize + 1); }); }); // ═══════════════════════════════════════════════════════════════════ // updateMessageStatus // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — updateMessageStatus', () => { it('should update message status', () => { const id = useOfflineStore.getState().queueMessage('Test'); useOfflineStore.getState().updateMessageStatus(id, 'sending'); const msg = useOfflineStore.getState().queuedMessages.find(m => m.id === id); expect(msg!.status).toBe('sending'); }); it('should increment retryCount on failed status', () => { const id = useOfflineStore.getState().queueMessage('Test'); useOfflineStore.getState().updateMessageStatus(id, 'failed', 'Network error'); const msg = useOfflineStore.getState().queuedMessages.find(m => m.id === id); expect(msg!.retryCount).toBe(1); expect(msg!.lastError).toBe('Network error'); }); it('should not affect other messages', () => { const id1 = useOfflineStore.getState().queueMessage('Msg 1'); const id2 = useOfflineStore.getState().queueMessage('Msg 2'); useOfflineStore.getState().updateMessageStatus(id1, 'sent'); const msg2 = useOfflineStore.getState().queuedMessages.find(m => m.id === id2); expect(msg2!.status).toBe('pending'); }); }); // ═══════════════════════════════════════════════════════════════════ // removeMessage // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — removeMessage', () => { it('should remove message by id', () => { const id1 = useOfflineStore.getState().queueMessage('Msg 1'); const id2 = useOfflineStore.getState().queueMessage('Msg 2'); useOfflineStore.getState().removeMessage(id1); const queue = useOfflineStore.getState().queuedMessages; expect(queue).toHaveLength(1); expect(queue[0].id).toBe(id2); }); }); // ═══════════════════════════════════════════════════════════════════ // clearQueue // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — clearQueue', () => { it('should remove all messages', () => { useOfflineStore.getState().queueMessage('Msg 1'); useOfflineStore.getState().queueMessage('Msg 2'); useOfflineStore.getState().clearQueue(); expect(useOfflineStore.getState().queuedMessages).toEqual([]); }); }); // ═══════════════════════════════════════════════════════════════════ // getPendingMessages / hasPendingMessages // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — getters', () => { it('getPendingMessages should return pending and failed', () => { const id1 = useOfflineStore.getState().queueMessage('Pending'); const id2 = useOfflineStore.getState().queueMessage('Will fail'); const id3 = useOfflineStore.getState().queueMessage('Will send'); useOfflineStore.getState().updateMessageStatus(id2, 'failed', 'Error'); useOfflineStore.getState().updateMessageStatus(id3, 'sent'); const pending = useOfflineStore.getState().getPendingMessages(); expect(pending).toHaveLength(2); expect(pending.map(m => m.id)).toContain(id1); expect(pending.map(m => m.id)).toContain(id2); }); it('hasPendingMessages should return true when pending exist', () => { expect(useOfflineStore.getState().hasPendingMessages()).toBe(false); useOfflineStore.getState().queueMessage('Test'); expect(useOfflineStore.getState().hasPendingMessages()).toBe(true); }); it('hasPendingMessages should return false when all sent', () => { const id = useOfflineStore.getState().queueMessage('Test'); useOfflineStore.getState().updateMessageStatus(id, 'sent'); expect(useOfflineStore.getState().hasPendingMessages()).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════ // reconnect schedule // ═══════════════════════════════════════════════════════════════════ describe('offlineStore — reconnection', () => { it('should increase delay with backoff', () => { useOfflineStore.getState().setOffline(true); const delay1 = useOfflineStore.getState().nextReconnectDelay; // Simulate failed reconnect cycle useOfflineStore.getState().setReconnecting(false); useOfflineStore.getState().scheduleReconnect(); const delay2 = useOfflineStore.getState().nextReconnectDelay; expect(delay2).toBeGreaterThan(delay1); }); it('should cap delay at maximum', () => { useOfflineStore.setState({ nextReconnectDelay: 50000 }); useOfflineStore.getState().setOffline(true); useOfflineStore.getState().setReconnecting(false); useOfflineStore.getState().scheduleReconnect(); expect(useOfflineStore.getState().nextReconnectDelay).toBeLessThanOrEqual(60000); }); it('should cancel reconnect', () => { useOfflineStore.getState().setOffline(true); expect(useOfflineStore.getState().isReconnecting).toBe(true); useOfflineStore.getState().cancelReconnect(); expect(useOfflineStore.getState().isReconnecting).toBe(false); }); it('should not schedule reconnect when online', () => { useOfflineStore.getState().scheduleReconnect(); expect(useOfflineStore.getState().reconnectAttempt).toBe(0); }); });