Files
zclaw_openfang/desktop/tests/store/offlineStore.test.ts
iven d758a4477f
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
test(desktop): Phase 3 store unit tests — 112 new tests for 5 stores
- 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).
2026-04-07 17:08:34 +08:00

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);
});
});