Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- saasStore: login/logout/register, TOTP setup/verify/disable, billing (plans/subscription/payment), templates, connection mode, config sync - workflowStore: CRUD, trigger, cancel, loadRuns, client injection - offlineStore: queue message, update/remove, reconnect backoff, getters - handStore: loadHands, getHandDetails, trigger/approve/cancel, triggers CRUD, approvals, autonomy blocking - streamStore: chatMode switching, getChatModeConfig, suggestions, setIsLoading, cancelStream, searchSkills, initStreamListener All 173 tests pass (61 existing + 112 new).
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|