test(desktop): Phase 3 store unit tests — 112 new tests for 5 stores
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
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).
This commit is contained in:
453
desktop/tests/store/handStore.test.ts
Normal file
453
desktop/tests/store/handStore.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Hand Store Tests
|
||||
*
|
||||
* Tests for Hands, Triggers, and Approval management.
|
||||
* Uses a mock HandClient injected via setHandStoreClient.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
useHandStore,
|
||||
type HandClient,
|
||||
type TriggerCreateOptions,
|
||||
type RawHandRun,
|
||||
type RawApproval,
|
||||
} from '../../src/store/handStore';
|
||||
|
||||
// ── Mock autonomy-manager (vi.hoisted to survive clearAllMocks) ──
|
||||
const { mockCanAutoExecute, mockGetAutonomyManager } = vi.hoisted(() => ({
|
||||
mockCanAutoExecute: vi.fn(() => ({ canProceed: true, decision: null })),
|
||||
mockGetAutonomyManager: vi.fn(() => ({
|
||||
getConfig: () => ({ level: 'supervised' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/autonomy-manager', () => ({
|
||||
canAutoExecute: mockCanAutoExecute,
|
||||
getAutonomyManager: mockGetAutonomyManager,
|
||||
}));
|
||||
|
||||
// ── Mock saas-client ──
|
||||
const { mockSaasClient } = vi.hoisted(() => ({
|
||||
mockSaasClient: {
|
||||
isAuthenticated: vi.fn(() => false),
|
||||
reportUsageFireAndForget: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/saas-client', () => ({
|
||||
saasClient: mockSaasClient,
|
||||
}));
|
||||
|
||||
// ── Mock GatewayClient ──
|
||||
vi.mock('../../src/lib/gateway-client', () => ({
|
||||
getGatewayClient: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mock KernelClient ──
|
||||
vi.mock('../../src/lib/kernel-client', () => ({}));
|
||||
|
||||
// ── Mock client ──
|
||||
let mockClient: HandClient;
|
||||
|
||||
function createMockClient(): HandClient {
|
||||
return {
|
||||
listHands: vi.fn(async () => ({ hands: [] })),
|
||||
getHand: vi.fn(async () => null),
|
||||
listHandRuns: vi.fn(async () => ({ runs: [] })),
|
||||
triggerHand: vi.fn(async () => null),
|
||||
approveHand: vi.fn(async () => ({ status: 'approved' })),
|
||||
cancelHand: vi.fn(async () => ({ status: 'cancelled' })),
|
||||
listTriggers: vi.fn(async () => ({ triggers: [] })),
|
||||
getTrigger: vi.fn(async () => null),
|
||||
createTrigger: vi.fn(async () => null),
|
||||
updateTrigger: vi.fn(async () => ({ id: 'trig-1' })),
|
||||
deleteTrigger: vi.fn(async () => ({ status: 'deleted' })),
|
||||
listApprovals: vi.fn(async () => ({ approvals: [] })),
|
||||
respondToApproval: vi.fn(async () => ({ status: 'approved' })),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
useHandStore.setState({
|
||||
hands: [],
|
||||
handRuns: {},
|
||||
triggers: [],
|
||||
approvals: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
autonomyDecision: null,
|
||||
client: mockClient,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Initial State
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — initial state', () => {
|
||||
it('should start with empty hands', () => {
|
||||
expect(useHandStore.getState().hands).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not be loading', () => {
|
||||
expect(useHandStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should have no error', () => {
|
||||
expect(useHandStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('should have no autonomy decision', () => {
|
||||
expect(useHandStore.getState().autonomyDecision).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// loadHands
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — loadHands', () => {
|
||||
it('should load and normalize hands', async () => {
|
||||
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
hands: [
|
||||
{ id: 'h1', name: 'browser', description: 'Web automation', status: 'idle', requirements_met: true, category: 'automation', icon: '🌐', tool_count: 5, metric_count: 2 },
|
||||
{ id: 'h2', name: 'researcher', description: 'Deep research', status: 'running', requirements_met: true },
|
||||
{ id: 'h3', name: 'speech', description: 'TTS', requirements_met: false },
|
||||
],
|
||||
});
|
||||
|
||||
await useHandStore.getState().loadHands();
|
||||
|
||||
const state = useHandStore.getState();
|
||||
expect(state.hands).toHaveLength(3);
|
||||
expect(state.hands[0].name).toBe('browser');
|
||||
expect(state.hands[0].status).toBe('idle');
|
||||
expect(state.hands[0].toolCount).toBe(5);
|
||||
// Invalid status should fallback to setup_needed
|
||||
expect(state.hands[2].status).toBe('setup_needed');
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle client error', async () => {
|
||||
(mockClient.listHands as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network fail'));
|
||||
|
||||
await useHandStore.getState().loadHands();
|
||||
|
||||
expect(useHandStore.getState().hands).toEqual([]);
|
||||
expect(useHandStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should return early if no client', async () => {
|
||||
useHandStore.setState({ client: null });
|
||||
|
||||
await useHandStore.getState().loadHands();
|
||||
|
||||
expect(useHandStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// getHandDetails
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — getHandDetails', () => {
|
||||
it('should fetch and return hand details', async () => {
|
||||
useHandStore.setState({
|
||||
hands: [{ id: 'h1', name: 'browser', description: '', status: 'idle' as const, requirements_met: true }],
|
||||
});
|
||||
|
||||
(mockClient.getHand as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
name: 'browser',
|
||||
description: 'Web automation',
|
||||
status: 'idle',
|
||||
requirements_met: true,
|
||||
requirements: [
|
||||
{ description: 'Chrome installed', met: true, hint: 'Install Chrome' },
|
||||
{ name: 'Network', satisfied: false },
|
||||
],
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['navigate', 'click', 'type'],
|
||||
metrics: ['pages_visited', 'actions_taken'],
|
||||
tool_count: 3,
|
||||
metric_count: 2,
|
||||
});
|
||||
|
||||
const hand = await useHandStore.getState().getHandDetails('browser');
|
||||
|
||||
expect(hand).toBeDefined();
|
||||
expect(hand!.name).toBe('browser');
|
||||
expect(hand!.provider).toBe('openai');
|
||||
expect(hand!.requirements).toHaveLength(2);
|
||||
expect(hand!.requirements![0].met).toBe(true);
|
||||
expect(hand!.requirements![1].met).toBe(false);
|
||||
expect(hand!.tools).toEqual(['navigate', 'click', 'type']);
|
||||
expect(hand!.toolCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return undefined for null result', async () => {
|
||||
(mockClient.getHand as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const result = await useHandStore.getState().getHandDetails('nonexistent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if no client', async () => {
|
||||
useHandStore.setState({ client: null });
|
||||
|
||||
const result = await useHandStore.getState().getHandDetails('browser');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// triggerHand
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — triggerHand', () => {
|
||||
it('should trigger hand and add run', async () => {
|
||||
(mockClient.triggerHand as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
runId: 'run-123',
|
||||
status: 'running',
|
||||
});
|
||||
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({ hands: [] });
|
||||
|
||||
const run = await useHandStore.getState().triggerHand('browser', { url: 'https://example.com' });
|
||||
|
||||
expect(run).toBeDefined();
|
||||
expect(run!.runId).toBe('run-123');
|
||||
expect(run!.status).toBe('running');
|
||||
|
||||
// Run should be added to handRuns
|
||||
expect(useHandStore.getState().handRuns['browser']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should block when autonomy denies', async () => {
|
||||
const { canAutoExecute } = await import('../../src/lib/autonomy-manager');
|
||||
(canAutoExecute as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
canProceed: false,
|
||||
decision: { reason: 'Rate limited', action: 'hand_trigger' },
|
||||
});
|
||||
|
||||
const run = await useHandStore.getState().triggerHand('browser');
|
||||
|
||||
expect(run).toBeUndefined();
|
||||
expect(useHandStore.getState().autonomyDecision).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should handle trigger error gracefully', async () => {
|
||||
(mockClient.triggerHand as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Trigger failed'));
|
||||
|
||||
const run = await useHandStore.getState().triggerHand('browser');
|
||||
|
||||
expect(run).toBeUndefined();
|
||||
// Either error is set (client threw) or autonomy decision is set (blocked)
|
||||
const state = useHandStore.getState();
|
||||
expect(state.error || state.autonomyDecision).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return undefined if no client', async () => {
|
||||
useHandStore.setState({ client: null });
|
||||
|
||||
const run = await useHandStore.getState().triggerHand('browser');
|
||||
expect(run).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// loadHandRuns
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — loadHandRuns', () => {
|
||||
it('should load and normalize runs', async () => {
|
||||
(mockClient.listHandRuns as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
runs: [
|
||||
{ runId: 'r1', status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01', result: { url: 'test' } },
|
||||
{ run_id: 'r2', status: 'failed', created_at: '2026-01-02', error: 'Timeout' },
|
||||
] as RawHandRun[],
|
||||
});
|
||||
|
||||
const runs = await useHandStore.getState().loadHandRuns('browser');
|
||||
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs[0].runId).toBe('r1');
|
||||
expect(runs[0].result).toEqual({ url: 'test' });
|
||||
expect(runs[1].runId).toBe('r2');
|
||||
expect(runs[1].error).toBe('Timeout');
|
||||
|
||||
// Stored in handRuns map
|
||||
expect(useHandStore.getState().handRuns['browser']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
(mockClient.listHandRuns as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('fail'));
|
||||
|
||||
const runs = await useHandStore.getState().loadHandRuns('browser');
|
||||
expect(runs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// approveHand / cancelHand
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — approveHand / cancelHand', () => {
|
||||
it('should approve hand and reload', async () => {
|
||||
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({ hands: [] });
|
||||
|
||||
await useHandStore.getState().approveHand('browser', 'run-1', true, 'Looks safe');
|
||||
|
||||
expect(mockClient.approveHand).toHaveBeenCalledWith('browser', 'run-1', true, 'Looks safe');
|
||||
});
|
||||
|
||||
it('should cancel hand and reload', async () => {
|
||||
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({ hands: [] });
|
||||
|
||||
await useHandStore.getState().cancelHand('browser', 'run-1');
|
||||
|
||||
expect(mockClient.cancelHand).toHaveBeenCalledWith('browser', 'run-1');
|
||||
});
|
||||
|
||||
it('should handle approve error', async () => {
|
||||
(mockClient.approveHand as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Approve fail'));
|
||||
|
||||
await expect(
|
||||
useHandStore.getState().approveHand('browser', 'run-1', true)
|
||||
).rejects.toThrow('Approve fail');
|
||||
});
|
||||
|
||||
it('should do nothing if no client', async () => {
|
||||
useHandStore.setState({ client: null });
|
||||
|
||||
// Should not throw
|
||||
await useHandStore.getState().approveHand('browser', 'run-1', true);
|
||||
await useHandStore.getState().cancelHand('browser', 'run-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Triggers
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — triggers', () => {
|
||||
it('should load triggers', async () => {
|
||||
(mockClient.listTriggers as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
triggers: [
|
||||
{ id: 't1', type: 'schedule', enabled: true },
|
||||
{ id: 't2', type: 'event', enabled: false },
|
||||
],
|
||||
});
|
||||
|
||||
await useHandStore.getState().loadTriggers();
|
||||
|
||||
expect(useHandStore.getState().triggers).toHaveLength(2);
|
||||
expect(useHandStore.getState().triggers[0].type).toBe('schedule');
|
||||
});
|
||||
|
||||
it('should create trigger', async () => {
|
||||
(mockClient.createTrigger as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 't-new' });
|
||||
(mockClient.listTriggers as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
triggers: [{ id: 't-new', type: 'schedule', enabled: true }],
|
||||
});
|
||||
|
||||
const opts: TriggerCreateOptions = { type: 'schedule', enabled: true };
|
||||
const trigger = await useHandStore.getState().createTrigger(opts);
|
||||
|
||||
expect(trigger).toBeDefined();
|
||||
expect(trigger!.id).toBe('t-new');
|
||||
});
|
||||
|
||||
it('should update trigger locally', async () => {
|
||||
useHandStore.setState({
|
||||
triggers: [{ id: 't1', type: 'schedule', enabled: true }],
|
||||
});
|
||||
|
||||
await useHandStore.getState().updateTrigger('t1', { enabled: false });
|
||||
|
||||
const updated = useHandStore.getState().triggers[0];
|
||||
expect(updated.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should delete trigger', async () => {
|
||||
useHandStore.setState({
|
||||
triggers: [
|
||||
{ id: 't1', type: 'schedule', enabled: true },
|
||||
{ id: 't2', type: 'event', enabled: false },
|
||||
],
|
||||
});
|
||||
|
||||
await useHandStore.getState().deleteTrigger('t1');
|
||||
|
||||
expect(useHandStore.getState().triggers).toHaveLength(1);
|
||||
expect(useHandStore.getState().triggers[0].id).toBe('t2');
|
||||
});
|
||||
|
||||
it('should handle delete trigger error', async () => {
|
||||
(mockClient.deleteTrigger as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Delete fail'));
|
||||
|
||||
await expect(
|
||||
useHandStore.getState().deleteTrigger('t1')
|
||||
).rejects.toThrow('Delete fail');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Approvals
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — approvals', () => {
|
||||
it('should load and normalize approvals', async () => {
|
||||
(mockClient.listApprovals as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
approvals: [
|
||||
{ id: 'a1', hand_name: 'browser', status: 'pending', requested_at: '2026-01-01' },
|
||||
{ approval_id: 'a2', handName: 'researcher', status: 'approved', requestedAt: '2026-01-02' },
|
||||
] as RawApproval[],
|
||||
});
|
||||
|
||||
await useHandStore.getState().loadApprovals();
|
||||
|
||||
const approvals = useHandStore.getState().approvals;
|
||||
expect(approvals).toHaveLength(2);
|
||||
expect(approvals[0].id).toBe('a1');
|
||||
expect(approvals[0].handName).toBe('browser');
|
||||
expect(approvals[1].id).toBe('a2');
|
||||
expect(approvals[1].handName).toBe('researcher');
|
||||
});
|
||||
|
||||
it('should respond to approval and reload', async () => {
|
||||
(mockClient.listApprovals as ReturnType<typeof vi.fn>).mockResolvedValue({ approvals: [] });
|
||||
|
||||
await useHandStore.getState().respondToApproval('a1', true, 'Approved by admin');
|
||||
|
||||
expect(mockClient.respondToApproval).toHaveBeenCalledWith('a1', true, 'Approved by admin');
|
||||
});
|
||||
|
||||
it('should handle respond error', async () => {
|
||||
(mockClient.respondToApproval as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Respond fail'));
|
||||
|
||||
await expect(
|
||||
useHandStore.getState().respondToApproval('a1', true)
|
||||
).rejects.toThrow('Respond fail');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Utility
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('handStore — utility actions', () => {
|
||||
it('clearError should clear error', () => {
|
||||
useHandStore.setState({ error: 'Test error' });
|
||||
useHandStore.getState().clearError();
|
||||
expect(useHandStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('clearAutonomyDecision should clear decision', () => {
|
||||
useHandStore.setState({ autonomyDecision: { reason: 'test', action: 'hand_trigger' } as any });
|
||||
useHandStore.getState().clearAutonomyDecision();
|
||||
expect(useHandStore.getState().autonomyDecision).toBeNull();
|
||||
});
|
||||
});
|
||||
314
desktop/tests/store/offlineStore.test.ts
Normal file
314
desktop/tests/store/offlineStore.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
586
desktop/tests/store/saasStore.test.ts
Normal file
586
desktop/tests/store/saasStore.test.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* SaaS Store Tests
|
||||
*
|
||||
* Tests for SaaS login/logout, connection mode, billing,
|
||||
* TOTP, and session management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { localStorageMock } from '../setup';
|
||||
|
||||
// ── Mock saas-client (use vi.hoisted to ensure availability before hoisted vi.mock) ──
|
||||
const {
|
||||
mockSetBaseUrl, mockSetToken, mockLogin, mockRegister, mockMe,
|
||||
mockRestoreFromCookie, mockListModels, mockRegisterDevice, mockDeviceHeartbeat,
|
||||
mockSetupTotp, mockVerifyTotp, mockDisableTotp,
|
||||
mockListPlans, mockGetSubscription, mockCreatePayment, mockGetPaymentStatus,
|
||||
mockFetchAvailableTemplates, mockGetAssignedTemplate, mockAssignTemplate, mockUnassignTemplate,
|
||||
mockPullConfig, mockSyncConfig, mockComputeConfigDiff, mockIsAuthenticated,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetBaseUrl: vi.fn(),
|
||||
mockSetToken: vi.fn(),
|
||||
mockLogin: vi.fn(),
|
||||
mockRegister: vi.fn(),
|
||||
mockMe: vi.fn(),
|
||||
mockRestoreFromCookie: vi.fn(),
|
||||
mockListModels: vi.fn(),
|
||||
mockRegisterDevice: vi.fn(),
|
||||
mockDeviceHeartbeat: vi.fn(),
|
||||
mockSetupTotp: vi.fn(),
|
||||
mockVerifyTotp: vi.fn(),
|
||||
mockDisableTotp: vi.fn(),
|
||||
mockListPlans: vi.fn(),
|
||||
mockGetSubscription: vi.fn(),
|
||||
mockCreatePayment: vi.fn(),
|
||||
mockGetPaymentStatus: vi.fn(),
|
||||
mockFetchAvailableTemplates: vi.fn(),
|
||||
mockGetAssignedTemplate: vi.fn(),
|
||||
mockAssignTemplate: vi.fn(),
|
||||
mockUnassignTemplate: vi.fn(),
|
||||
mockPullConfig: vi.fn(),
|
||||
mockSyncConfig: vi.fn(),
|
||||
mockComputeConfigDiff: vi.fn(),
|
||||
mockIsAuthenticated: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/saas-client', () => ({
|
||||
saasClient: {
|
||||
setBaseUrl: mockSetBaseUrl,
|
||||
setToken: mockSetToken,
|
||||
login: mockLogin,
|
||||
register: mockRegister,
|
||||
me: mockMe,
|
||||
restoreFromCookie: mockRestoreFromCookie,
|
||||
listModels: mockListModels,
|
||||
registerDevice: mockRegisterDevice,
|
||||
deviceHeartbeat: mockDeviceHeartbeat,
|
||||
setupTotp: mockSetupTotp,
|
||||
verifyTotp: mockVerifyTotp,
|
||||
disableTotp: mockDisableTotp,
|
||||
listPlans: mockListPlans,
|
||||
getSubscription: mockGetSubscription,
|
||||
createPayment: mockCreatePayment,
|
||||
getPaymentStatus: mockGetPaymentStatus,
|
||||
fetchAvailableTemplates: mockFetchAvailableTemplates,
|
||||
getAssignedTemplate: mockGetAssignedTemplate,
|
||||
assignTemplate: mockAssignTemplate,
|
||||
unassignTemplate: mockUnassignTemplate,
|
||||
pullConfig: mockPullConfig,
|
||||
syncConfig: mockSyncConfig,
|
||||
computeConfigDiff: mockComputeConfigDiff,
|
||||
isAuthenticated: mockIsAuthenticated,
|
||||
},
|
||||
SaaSApiError: class SaaSApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(msg: string, code: string, status: number) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
loadSaaSSession: vi.fn(async () => null),
|
||||
loadSaaSSessionSync: vi.fn(() => null),
|
||||
saveSaaSSession: vi.fn(async () => {}),
|
||||
clearSaaSSession: vi.fn(async () => {}),
|
||||
saveConnectionMode: vi.fn(),
|
||||
loadConnectionMode: vi.fn(() => 'tauri'),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/telemetry-collector', () => ({
|
||||
initTelemetryCollector: vi.fn(),
|
||||
stopTelemetryCollector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/llm-service', () => ({
|
||||
startPromptOTASync: vi.fn(),
|
||||
stopPromptOTASync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { useSaaSStore, type ConnectionMode } from '../../src/store/saasStore';
|
||||
|
||||
const ACCOUNT = {
|
||||
id: 'acc-1',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
totp_enabled: false,
|
||||
created_at: '2026-01-01',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
|
||||
useSaaSStore.setState({
|
||||
isLoggedIn: false,
|
||||
account: null,
|
||||
saasUrl: 'http://127.0.0.1:8080',
|
||||
authToken: null,
|
||||
connectionMode: 'tauri',
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
saasReachable: true,
|
||||
availableTemplates: [],
|
||||
assignedTemplate: null,
|
||||
_consecutiveFailures: 0,
|
||||
_heartbeatTimer: undefined,
|
||||
_healthCheckTimer: undefined,
|
||||
plans: [],
|
||||
subscription: null,
|
||||
billingLoading: false,
|
||||
billingError: null,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Initial State
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — initial state', () => {
|
||||
it('should start not logged in', () => {
|
||||
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should have no account', () => {
|
||||
expect(useSaaSStore.getState().account).toBeNull();
|
||||
});
|
||||
|
||||
it('should default to tauri connection mode', () => {
|
||||
expect(useSaaSStore.getState().connectionMode).toBe('tauri');
|
||||
});
|
||||
|
||||
it('should have empty models', () => {
|
||||
expect(useSaaSStore.getState().availableModels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not be loading', () => {
|
||||
expect(useSaaSStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// login
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — login', () => {
|
||||
it('should login successfully', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
token: 'test-token',
|
||||
account: ACCOUNT,
|
||||
});
|
||||
mockRegisterDevice.mockResolvedValue({});
|
||||
mockListModels.mockResolvedValue([]);
|
||||
mockFetchAvailableTemplates.mockResolvedValue([]);
|
||||
mockGetAssignedTemplate.mockResolvedValue(null);
|
||||
mockListPlans.mockResolvedValue([]);
|
||||
mockGetSubscription.mockResolvedValue(null);
|
||||
mockPullConfig.mockResolvedValue({ configs: [], pulled_at: '2026-01-01' });
|
||||
|
||||
await useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password');
|
||||
|
||||
const state = useSaaSStore.getState();
|
||||
expect(state.isLoggedIn).toBe(true);
|
||||
expect(state.account).toEqual(ACCOUNT);
|
||||
expect(state.connectionMode).toBe('saas');
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(mockSetBaseUrl).toHaveBeenCalledWith('http://localhost:8080');
|
||||
});
|
||||
|
||||
it('should set totpRequired when TOTP error', async () => {
|
||||
const { SaaSApiError } = await import('../../src/lib/saas-client');
|
||||
mockLogin.mockRejectedValue(new SaaSApiError('TOTP required', 'TOTP_ERROR', 400));
|
||||
|
||||
await useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password');
|
||||
|
||||
expect(useSaaSStore.getState().totpRequired).toBe(true);
|
||||
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate empty username', async () => {
|
||||
await expect(
|
||||
useSaaSStore.getState().login('http://localhost:8080', ' ', 'password')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate empty password', async () => {
|
||||
await expect(
|
||||
useSaaSStore.getState().login('http://localhost:8080', 'testuser', '')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle login error', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
await expect(
|
||||
useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'wrong')
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
|
||||
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
|
||||
expect(useSaaSStore.getState().error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should detect connection refused errors', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Failed to fetch'));
|
||||
|
||||
await expect(
|
||||
useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useSaaSStore.getState().error).toContain('无法连接');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// loginWithTotp
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — loginWithTotp', () => {
|
||||
it('should login with TOTP code', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
token: 'test-token',
|
||||
account: { ...ACCOUNT, totp_enabled: true },
|
||||
});
|
||||
mockRegisterDevice.mockResolvedValue({});
|
||||
mockListModels.mockResolvedValue([]);
|
||||
|
||||
await useSaaSStore.getState().loginWithTotp('http://localhost:8080', 'testuser', 'password', '123456');
|
||||
|
||||
expect(useSaaSStore.getState().isLoggedIn).toBe(true);
|
||||
expect(useSaaSStore.getState().totpRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle TOTP login error', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Invalid TOTP code'));
|
||||
|
||||
await expect(
|
||||
useSaaSStore.getState().loginWithTotp('http://localhost:8080', 'testuser', 'password', '000000')
|
||||
).rejects.toThrow('Invalid TOTP code');
|
||||
|
||||
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// register
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — register', () => {
|
||||
it('should register and auto-login', async () => {
|
||||
mockRegister.mockResolvedValue({
|
||||
token: 'reg-token',
|
||||
account: ACCOUNT,
|
||||
});
|
||||
mockRegisterDevice.mockResolvedValue({});
|
||||
mockListModels.mockResolvedValue([]);
|
||||
|
||||
await useSaaSStore.getState().register('http://localhost:8080', 'newuser', 'new@example.com', 'password');
|
||||
|
||||
expect(useSaaSStore.getState().isLoggedIn).toBe(true);
|
||||
expect(useSaaSStore.getState().connectionMode).toBe('saas');
|
||||
});
|
||||
|
||||
it('should validate empty email', async () => {
|
||||
await expect(
|
||||
useSaaSStore.getState().register('http://localhost:8080', 'user', '', 'password')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// logout
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — logout', () => {
|
||||
it('should clear all state on logout', async () => {
|
||||
useSaaSStore.setState({
|
||||
isLoggedIn: true,
|
||||
account: ACCOUNT,
|
||||
connectionMode: 'saas',
|
||||
availableModels: [{ id: 'm1', name: 'test', provider: 'p' }] as any,
|
||||
plans: [{ id: 'p1', name: 'Pro' }] as any,
|
||||
subscription: { id: 's1', plan_id: 'p1' } as any,
|
||||
});
|
||||
|
||||
await useSaaSStore.getState().logout();
|
||||
|
||||
const state = useSaaSStore.getState();
|
||||
expect(state.isLoggedIn).toBe(false);
|
||||
expect(state.account).toBeNull();
|
||||
expect(state.authToken).toBeNull();
|
||||
expect(state.connectionMode).toBe('tauri');
|
||||
expect(state.availableModels).toEqual([]);
|
||||
expect(state.plans).toEqual([]);
|
||||
expect(state.subscription).toBeNull();
|
||||
expect(mockSetToken).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// setConnectionMode
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — setConnectionMode', () => {
|
||||
it('should switch to gateway mode', () => {
|
||||
useSaaSStore.getState().setConnectionMode('gateway');
|
||||
expect(useSaaSStore.getState().connectionMode).toBe('gateway');
|
||||
});
|
||||
|
||||
it('should not switch to saas when not logged in', () => {
|
||||
useSaaSStore.setState({ isLoggedIn: false });
|
||||
useSaaSStore.getState().setConnectionMode('saas');
|
||||
expect(useSaaSStore.getState().connectionMode).toBe('tauri');
|
||||
});
|
||||
|
||||
it('should allow saas mode when logged in', () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, account: ACCOUNT });
|
||||
useSaaSStore.getState().setConnectionMode('saas');
|
||||
expect(useSaaSStore.getState().connectionMode).toBe('saas');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// fetchAvailableModels
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — fetchAvailableModels', () => {
|
||||
it('should fetch models when logged in', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockListModels.mockResolvedValue([
|
||||
{ id: 'glm-4', name: 'GLM-4', provider: 'zhipu' },
|
||||
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' },
|
||||
]);
|
||||
|
||||
await useSaaSStore.getState().fetchAvailableModels();
|
||||
|
||||
expect(useSaaSStore.getState().availableModels).toHaveLength(2);
|
||||
expect(useSaaSStore.getState().availableModels[0].id).toBe('glm-4');
|
||||
});
|
||||
|
||||
it('should clear models when not logged in', async () => {
|
||||
useSaaSStore.setState({
|
||||
isLoggedIn: false,
|
||||
availableModels: [{ id: 'm1', name: 'test', provider: 'p' }] as any,
|
||||
});
|
||||
|
||||
await useSaaSStore.getState().fetchAvailableModels();
|
||||
|
||||
expect(useSaaSStore.getState().availableModels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fetch error gracefully', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockListModels.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useSaaSStore.getState().fetchAvailableModels();
|
||||
|
||||
expect(useSaaSStore.getState().availableModels).toEqual([]);
|
||||
// Should not set global error
|
||||
expect(useSaaSStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TOTP
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — TOTP', () => {
|
||||
it('should setup TOTP', async () => {
|
||||
const setupData = { secret: 'JBSWY3DPEHPK3PXP', qr_code_url: 'otpauth://...' };
|
||||
mockSetupTotp.mockResolvedValue(setupData);
|
||||
|
||||
const result = await useSaaSStore.getState().setupTotp();
|
||||
|
||||
expect(result).toEqual(setupData);
|
||||
expect(useSaaSStore.getState().totpSetupData).toEqual(setupData);
|
||||
expect(useSaaSStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should verify TOTP', async () => {
|
||||
useSaaSStore.setState({
|
||||
isLoggedIn: true,
|
||||
saasUrl: 'http://localhost:8080',
|
||||
});
|
||||
mockVerifyTotp.mockResolvedValue({});
|
||||
mockMe.mockResolvedValue({ ...ACCOUNT, totp_enabled: true });
|
||||
|
||||
await useSaaSStore.getState().verifyTotp('123456');
|
||||
|
||||
expect(useSaaSStore.getState().totpSetupData).toBeNull();
|
||||
expect(useSaaSStore.getState().account?.totp_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable TOTP', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockDisableTotp.mockResolvedValue({});
|
||||
mockMe.mockResolvedValue({ ...ACCOUNT, totp_enabled: false });
|
||||
|
||||
await useSaaSStore.getState().disableTotp('password123');
|
||||
|
||||
expect(useSaaSStore.getState().account?.totp_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle TOTP setup error', async () => {
|
||||
mockSetupTotp.mockRejectedValue(new Error('Setup failed'));
|
||||
|
||||
await expect(
|
||||
useSaaSStore.getState().setupTotp()
|
||||
).rejects.toThrow('Setup failed');
|
||||
|
||||
expect(useSaaSStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('cancelTotpSetup should clear setup data', () => {
|
||||
useSaaSStore.setState({ totpSetupData: { secret: 'abc', qr_code_url: 'url' } as any });
|
||||
useSaaSStore.getState().cancelTotpSetup();
|
||||
expect(useSaaSStore.getState().totpSetupData).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Billing
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — billing', () => {
|
||||
it('should fetch billing overview', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockListPlans.mockResolvedValue([{ id: 'p1', name: 'Pro', price: 99 }]);
|
||||
mockGetSubscription.mockResolvedValue({ id: 's1', plan_id: 'p1', status: 'active' });
|
||||
|
||||
await useSaaSStore.getState().fetchBillingOverview();
|
||||
|
||||
const state = useSaaSStore.getState();
|
||||
expect(state.plans).toHaveLength(1);
|
||||
expect(state.subscription).not.toBeNull();
|
||||
expect(state.billingLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle billing fetch error', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockListPlans.mockRejectedValue(new Error('Billing unavailable'));
|
||||
|
||||
await useSaaSStore.getState().fetchBillingOverview();
|
||||
|
||||
expect(useSaaSStore.getState().billingLoading).toBe(false);
|
||||
expect(useSaaSStore.getState().billingError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create payment', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
const paymentResult = { payment_id: 'pay-1', status: 'pending', payment_url: 'https://pay.example.com' };
|
||||
mockCreatePayment.mockResolvedValue(paymentResult);
|
||||
|
||||
const result = await useSaaSStore.getState().createPayment('plan-1', 'alipay');
|
||||
|
||||
expect(result).toEqual(paymentResult);
|
||||
expect(mockCreatePayment).toHaveBeenCalledWith({
|
||||
plan_id: 'plan-1',
|
||||
payment_method: 'alipay',
|
||||
});
|
||||
});
|
||||
|
||||
it('should poll payment status and refresh on success', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockGetPaymentStatus.mockResolvedValue({ status: 'succeeded', payment_id: 'pay-1' });
|
||||
mockGetSubscription.mockResolvedValue({ id: 's1', plan_id: 'p1', status: 'active' });
|
||||
|
||||
const result = await useSaaSStore.getState().pollPaymentStatus('pay-1');
|
||||
|
||||
expect(result!.status).toBe('succeeded');
|
||||
expect(mockGetSubscription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle payment creation error', async () => {
|
||||
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
|
||||
mockCreatePayment.mockRejectedValue(new Error('Payment failed'));
|
||||
|
||||
const result = await useSaaSStore.getState().createPayment('plan-1', 'wechat');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(useSaaSStore.getState().billingError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clearBillingError should clear error', () => {
|
||||
useSaaSStore.setState({ billingError: 'Test error' });
|
||||
useSaaSStore.getState().clearBillingError();
|
||||
expect(useSaaSStore.getState().billingError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Templates
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — templates', () => {
|
||||
it('should fetch available templates', async () => {
|
||||
const templates = [
|
||||
{ id: 't1', name: 'E-commerce', description: 'For shops' },
|
||||
];
|
||||
mockFetchAvailableTemplates.mockResolvedValue(templates);
|
||||
|
||||
await useSaaSStore.getState().fetchAvailableTemplates();
|
||||
|
||||
expect(useSaaSStore.getState().availableTemplates).toEqual(templates);
|
||||
});
|
||||
|
||||
it('should handle template fetch error gracefully', async () => {
|
||||
mockFetchAvailableTemplates.mockRejectedValue(new Error('fail'));
|
||||
|
||||
await useSaaSStore.getState().fetchAvailableTemplates();
|
||||
|
||||
expect(useSaaSStore.getState().availableTemplates).toEqual([]);
|
||||
});
|
||||
|
||||
it('should assign template', async () => {
|
||||
const template = { id: 't1', name: 'Test', steps: [] };
|
||||
mockAssignTemplate.mockResolvedValue(template);
|
||||
|
||||
await useSaaSStore.getState().assignTemplate('t1');
|
||||
|
||||
expect(useSaaSStore.getState().assignedTemplate).toEqual(template);
|
||||
});
|
||||
|
||||
it('should fetch assigned template', async () => {
|
||||
const template = { id: 't1', name: 'Assigned' };
|
||||
mockGetAssignedTemplate.mockResolvedValue(template);
|
||||
|
||||
await useSaaSStore.getState().fetchAssignedTemplate();
|
||||
|
||||
expect(useSaaSStore.getState().assignedTemplate).toEqual(template);
|
||||
});
|
||||
|
||||
it('should unassign template', async () => {
|
||||
useSaaSStore.setState({ assignedTemplate: { id: 't1', name: 'Test' } as any });
|
||||
mockUnassignTemplate.mockResolvedValue({});
|
||||
|
||||
await useSaaSStore.getState().unassignTemplate();
|
||||
|
||||
expect(useSaaSStore.getState().assignedTemplate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// clearError
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('saasStore — utility', () => {
|
||||
it('clearError should clear error', () => {
|
||||
useSaaSStore.setState({ error: 'Test error' });
|
||||
useSaaSStore.getState().clearError();
|
||||
expect(useSaaSStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
319
desktop/tests/store/streamStore.test.ts
Normal file
319
desktop/tests/store/streamStore.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Stream Store Tests
|
||||
*
|
||||
* Tests for chat mode management, follow-up suggestions,
|
||||
* cancel stream, and skill search.
|
||||
*
|
||||
* Note: sendMessage and initStreamListener have deep integration
|
||||
* dependencies (connectionStore, conversationStore, etc.) and are
|
||||
* tested indirectly through chatStore.test.ts. This file tests
|
||||
* the standalone actions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { localStorageMock } from '../setup';
|
||||
|
||||
// ── Mock all external dependencies ──
|
||||
|
||||
vi.mock('../../src/store/connectionStore', () => ({
|
||||
getClient: vi.fn(() => ({
|
||||
chatStream: vi.fn(),
|
||||
chat: vi.fn(),
|
||||
onAgentStream: vi.fn(() => () => {}),
|
||||
getState: vi.fn(() => 'disconnected'),
|
||||
cancelStream: vi.fn(),
|
||||
})),
|
||||
useConnectionStore: {
|
||||
getState: () => ({
|
||||
connectionState: 'connected',
|
||||
connect: vi.fn(),
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/store/chat/conversationStore', () => ({
|
||||
useConversationStore: {
|
||||
getState: () => ({
|
||||
currentAgent: { id: '1', name: 'ZCLAW' },
|
||||
sessionKey: null,
|
||||
currentConversationId: null,
|
||||
agents: [{ id: '1', name: 'ZCLAW' }],
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
resolveGatewayAgentId: vi.fn(() => 'agent-1'),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/store/chat/messageStore', () => ({
|
||||
useMessageStore: {
|
||||
getState: () => ({
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
addTokenUsage: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/store/chat/artifactStore', () => ({
|
||||
useArtifactStore: {
|
||||
getState: () => ({
|
||||
addArtifact: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/store/offlineStore', () => ({
|
||||
useOfflineStore: {
|
||||
getState: () => ({
|
||||
queueMessage: vi.fn(() => 'queued_123'),
|
||||
}),
|
||||
},
|
||||
isOffline: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/gateway-client', () => ({
|
||||
getGatewayClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/intelligence-client', () => ({
|
||||
intelligenceClient: {
|
||||
compactor: {
|
||||
checkThreshold: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
},
|
||||
memory: {
|
||||
search: vi.fn(),
|
||||
},
|
||||
identity: {
|
||||
buildPrompt: vi.fn(),
|
||||
},
|
||||
reflection: {
|
||||
recordConversation: vi.fn(() => Promise.resolve()),
|
||||
shouldReflect: vi.fn(() => Promise.resolve(false)),
|
||||
reflect: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/memory-extractor', () => ({
|
||||
getMemoryExtractor: vi.fn(() => ({
|
||||
extractFromConversation: vi.fn(() => Promise.resolve([])),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/skill-discovery', () => ({
|
||||
getSkillDiscovery: vi.fn(() => ({
|
||||
searchSkills: vi.fn(() => ({ results: [], totalAvailable: 0 })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/speech-synth', () => ({
|
||||
speechSynth: {
|
||||
speak: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/crypto-utils', () => ({
|
||||
generateRandomString: (len: number) => 'x'.repeat(len),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// CHAT_MODES mock - must match the actual component structure
|
||||
vi.mock('../../src/components/ai', () => ({
|
||||
CHAT_MODES: {
|
||||
thinking: {
|
||||
config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: false, subagent_enabled: false },
|
||||
},
|
||||
normal: {
|
||||
config: { thinking_enabled: false, reasoning_effort: 'medium', plan_mode: false, subagent_enabled: false },
|
||||
},
|
||||
agent: {
|
||||
config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: true, subagent_enabled: true },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { useStreamStore } from '../../src/store/chat/streamStore';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
|
||||
useStreamStore.setState({
|
||||
isStreaming: false,
|
||||
isLoading: false,
|
||||
chatMode: 'thinking',
|
||||
suggestions: [],
|
||||
activeRunId: null,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Initial State
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — initial state', () => {
|
||||
it('should not be streaming', () => {
|
||||
expect(useStreamStore.getState().isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should have thinking chat mode', () => {
|
||||
expect(useStreamStore.getState().chatMode).toBe('thinking');
|
||||
});
|
||||
|
||||
it('should have empty suggestions', () => {
|
||||
expect(useStreamStore.getState().suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have no active run id', () => {
|
||||
expect(useStreamStore.getState().activeRunId).toBeNull();
|
||||
});
|
||||
|
||||
it('should not be loading', () => {
|
||||
expect(useStreamStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Chat Mode
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — setChatMode', () => {
|
||||
it('should switch to normal mode', () => {
|
||||
useStreamStore.getState().setChatMode('normal');
|
||||
expect(useStreamStore.getState().chatMode).toBe('normal');
|
||||
});
|
||||
|
||||
it('should switch to agent mode', () => {
|
||||
useStreamStore.getState().setChatMode('agent');
|
||||
expect(useStreamStore.getState().chatMode).toBe('agent');
|
||||
});
|
||||
|
||||
it('should switch back to thinking mode', () => {
|
||||
useStreamStore.getState().setChatMode('agent');
|
||||
useStreamStore.getState().setChatMode('thinking');
|
||||
expect(useStreamStore.getState().chatMode).toBe('thinking');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// getChatModeConfig
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — getChatModeConfig', () => {
|
||||
it('should return thinking config by default', () => {
|
||||
const config = useStreamStore.getState().getChatModeConfig();
|
||||
expect(config.thinking_enabled).toBe(true);
|
||||
expect(config.reasoning_effort).toBe('high');
|
||||
});
|
||||
|
||||
it('should return normal config', () => {
|
||||
useStreamStore.getState().setChatMode('normal');
|
||||
const config = useStreamStore.getState().getChatModeConfig();
|
||||
expect(config.thinking_enabled).toBe(false);
|
||||
expect(config.reasoning_effort).toBe('medium');
|
||||
});
|
||||
|
||||
it('should return agent config with subagent enabled', () => {
|
||||
useStreamStore.getState().setChatMode('agent');
|
||||
const config = useStreamStore.getState().getChatModeConfig();
|
||||
expect(config.subagent_enabled).toBe(true);
|
||||
expect(config.plan_mode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Suggestions
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — suggestions', () => {
|
||||
it('should set suggestions', () => {
|
||||
useStreamStore.getState().setSuggestions(['Suggestion 1', 'Suggestion 2']);
|
||||
expect(useStreamStore.getState().suggestions).toEqual(['Suggestion 1', 'Suggestion 2']);
|
||||
});
|
||||
|
||||
it('should replace previous suggestions', () => {
|
||||
useStreamStore.getState().setSuggestions(['Old']);
|
||||
useStreamStore.getState().setSuggestions(['New 1', 'New 2']);
|
||||
expect(useStreamStore.getState().suggestions).toEqual(['New 1', 'New 2']);
|
||||
});
|
||||
|
||||
it('should clear suggestions with empty array', () => {
|
||||
useStreamStore.getState().setSuggestions(['Something']);
|
||||
useStreamStore.getState().setSuggestions([]);
|
||||
expect(useStreamStore.getState().suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// setIsLoading
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — setIsLoading', () => {
|
||||
it('should set loading state', () => {
|
||||
useStreamStore.getState().setIsLoading(true);
|
||||
expect(useStreamStore.getState().isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('should unset loading state', () => {
|
||||
useStreamStore.getState().setIsLoading(true);
|
||||
useStreamStore.getState().setIsLoading(false);
|
||||
expect(useStreamStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// cancelStream (without chatStore injection)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — cancelStream', () => {
|
||||
it('should do nothing when not streaming', () => {
|
||||
useStreamStore.getState().cancelStream();
|
||||
// No crash, state unchanged
|
||||
expect(useStreamStore.getState().isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should not crash when no chatStore injected', () => {
|
||||
useStreamStore.setState({ isStreaming: true, activeRunId: 'run-123' });
|
||||
|
||||
// Without _chat injected, cancelStream returns early
|
||||
expect(() => useStreamStore.getState().cancelStream()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// searchSkills
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — searchSkills', () => {
|
||||
it('should return search results', () => {
|
||||
const result = useStreamStore.getState().searchSkills('test query');
|
||||
|
||||
expect(result).toHaveProperty('results');
|
||||
expect(result).toHaveProperty('totalAvailable');
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// initStreamListener
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('streamStore — initStreamListener', () => {
|
||||
it('should return unsubscribe function', () => {
|
||||
const unsubscribe = useStreamStore.getState().initStreamListener();
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
379
desktop/tests/store/workflowStore.test.ts
Normal file
379
desktop/tests/store/workflowStore.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Workflow Store Tests
|
||||
*
|
||||
* Tests for workflow CRUD, trigger, cancel, and run management.
|
||||
* Uses a mock WorkflowClient injected via setWorkflowStoreClient.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
useWorkflowStore,
|
||||
type WorkflowClient,
|
||||
type WorkflowCreateOptions,
|
||||
type UpdateWorkflowInput,
|
||||
} from '../../src/store/workflowStore';
|
||||
|
||||
// ── Mock Tauri ──
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mock client ──
|
||||
let mockClient: WorkflowClient;
|
||||
|
||||
function createMockClient(): WorkflowClient {
|
||||
return {
|
||||
listWorkflows: vi.fn(async () => ({ workflows: [] })),
|
||||
getWorkflow: vi.fn(async () => null),
|
||||
createWorkflow: vi.fn(async () => null),
|
||||
updateWorkflow: vi.fn(async () => null),
|
||||
deleteWorkflow: vi.fn(async () => ({ status: 'deleted' })),
|
||||
executeWorkflow: vi.fn(async () => null),
|
||||
cancelWorkflow: vi.fn(async () => ({ status: 'cancelled' })),
|
||||
listWorkflowRuns: vi.fn(async () => ({ runs: [] })),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
useWorkflowStore.setState({
|
||||
workflows: [],
|
||||
workflowRuns: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
client: mockClient,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Initial State
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — initial state', () => {
|
||||
it('should start with empty workflows', () => {
|
||||
const state = useWorkflowStore.getState();
|
||||
expect(state.workflows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not be loading initially', () => {
|
||||
expect(useWorkflowStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should have no error', () => {
|
||||
expect(useWorkflowStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('should have empty workflowRuns', () => {
|
||||
expect(useWorkflowStore.getState().workflowRuns).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// loadWorkflows
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — loadWorkflows', () => {
|
||||
it('should load workflows from client', async () => {
|
||||
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
workflows: [
|
||||
{ id: 'wf-1', name: 'Test Workflow', steps: 3, description: 'desc', createdAt: '2026-01-01' },
|
||||
{ id: 'wf-2', name: 'Another', steps: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
await useWorkflowStore.getState().loadWorkflows();
|
||||
|
||||
const state = useWorkflowStore.getState();
|
||||
expect(state.workflows).toHaveLength(2);
|
||||
expect(state.workflows[0].name).toBe('Test Workflow');
|
||||
expect(state.workflows[0].steps).toBe(3);
|
||||
expect(state.workflows[1].id).toBe('wf-2');
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null result', async () => {
|
||||
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
await useWorkflowStore.getState().loadWorkflows();
|
||||
|
||||
expect(useWorkflowStore.getState().workflows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle client error', async () => {
|
||||
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useWorkflowStore.getState().loadWorkflows();
|
||||
|
||||
const state = useWorkflowStore.getState();
|
||||
expect(state.error).toBe('Network error');
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// createWorkflow
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — createWorkflow', () => {
|
||||
it('should create workflow and add to list', async () => {
|
||||
const opts: WorkflowCreateOptions = {
|
||||
name: 'New Pipeline',
|
||||
description: 'A test pipeline',
|
||||
steps: [{ handName: 'browser' }],
|
||||
};
|
||||
(mockClient.createWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'wf-new',
|
||||
name: 'New Pipeline',
|
||||
});
|
||||
|
||||
const result = await useWorkflowStore.getState().createWorkflow(opts);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe('wf-new');
|
||||
expect(result!.steps).toBe(1);
|
||||
expect(useWorkflowStore.getState().workflows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return undefined on null result', async () => {
|
||||
(mockClient.createWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const result = await useWorkflowStore.getState().createWorkflow({
|
||||
name: 'X',
|
||||
steps: [],
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle creation error', async () => {
|
||||
(mockClient.createWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
const result = await useWorkflowStore.getState().createWorkflow({
|
||||
name: 'X',
|
||||
steps: [],
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(useWorkflowStore.getState().error).toBe('Create failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// updateWorkflow
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — updateWorkflow', () => {
|
||||
it('should update existing workflow', async () => {
|
||||
useWorkflowStore.setState({
|
||||
workflows: [{ id: 'wf-1', name: 'Old', steps: 2 }],
|
||||
});
|
||||
|
||||
(mockClient.updateWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'wf-1',
|
||||
name: 'Updated',
|
||||
});
|
||||
|
||||
const updates: UpdateWorkflowInput = { name: 'Updated', steps: [{ handName: 'researcher' }] };
|
||||
const result = await useWorkflowStore.getState().updateWorkflow('wf-1', updates);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toBe('Updated');
|
||||
expect(result!.steps).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve unchanged fields', async () => {
|
||||
useWorkflowStore.setState({
|
||||
workflows: [{ id: 'wf-1', name: 'Old', steps: 5, description: 'desc' }],
|
||||
});
|
||||
|
||||
(mockClient.updateWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'wf-1',
|
||||
name: 'Old',
|
||||
});
|
||||
|
||||
await useWorkflowStore.getState().updateWorkflow('wf-1', { description: 'new desc' });
|
||||
|
||||
const wf = useWorkflowStore.getState().workflows[0];
|
||||
expect(wf.name).toBe('Old');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// deleteWorkflow
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — deleteWorkflow', () => {
|
||||
it('should remove workflow from list', async () => {
|
||||
useWorkflowStore.setState({
|
||||
workflows: [
|
||||
{ id: 'wf-1', name: 'First', steps: 1 },
|
||||
{ id: 'wf-2', name: 'Second', steps: 2 },
|
||||
],
|
||||
workflowRuns: { 'wf-1': [{ runId: 'r1', status: 'completed', startedAt: '' }] },
|
||||
});
|
||||
|
||||
await useWorkflowStore.getState().deleteWorkflow('wf-1');
|
||||
|
||||
const state = useWorkflowStore.getState();
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
expect(state.workflows[0].id).toBe('wf-2');
|
||||
expect(state.workflowRuns).not.toHaveProperty('wf-1');
|
||||
});
|
||||
|
||||
it('should handle delete error', async () => {
|
||||
(mockClient.deleteWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Delete failed'));
|
||||
|
||||
await expect(
|
||||
useWorkflowStore.getState().deleteWorkflow('wf-1')
|
||||
).rejects.toThrow('Delete failed');
|
||||
|
||||
expect(useWorkflowStore.getState().error).toBe('Delete failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// triggerWorkflow
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — triggerWorkflow', () => {
|
||||
it('should return run result on success', async () => {
|
||||
(mockClient.executeWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
runId: 'run-123',
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
const result = await useWorkflowStore.getState().triggerWorkflow('wf-1', { topic: 'test' });
|
||||
|
||||
expect(result).toEqual({ runId: 'run-123', status: 'running' });
|
||||
});
|
||||
|
||||
it('should return undefined on null result', async () => {
|
||||
(mockClient.executeWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const result = await useWorkflowStore.getState().triggerWorkflow('wf-1');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle trigger error', async () => {
|
||||
(mockClient.executeWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Exec failed'));
|
||||
|
||||
const result = await useWorkflowStore.getState().triggerWorkflow('wf-1');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(useWorkflowStore.getState().error).toBe('Exec failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// cancelWorkflow
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — cancelWorkflow', () => {
|
||||
it('should cancel and reload workflows', async () => {
|
||||
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockResolvedValue({ workflows: [] });
|
||||
|
||||
await useWorkflowStore.getState().cancelWorkflow('wf-1', 'run-1');
|
||||
|
||||
expect(mockClient.cancelWorkflow).toHaveBeenCalledWith('wf-1', 'run-1');
|
||||
});
|
||||
|
||||
it('should handle cancel error', async () => {
|
||||
(mockClient.cancelWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Cancel failed'));
|
||||
|
||||
await expect(
|
||||
useWorkflowStore.getState().cancelWorkflow('wf-1', 'run-1')
|
||||
).rejects.toThrow('Cancel failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// loadWorkflowRuns
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — loadWorkflowRuns', () => {
|
||||
it('should load and normalize runs', async () => {
|
||||
(mockClient.listWorkflowRuns as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
runs: [
|
||||
{ runId: 'r1', status: 'completed', started_at: '2026-01-01', completed_at: '2026-01-01' },
|
||||
{ run_id: 'r2', status: 'running', current_step: 3 },
|
||||
{ id: 'r3', status: 'failed', error: 'boom' },
|
||||
],
|
||||
});
|
||||
|
||||
const runs = await useWorkflowStore.getState().loadWorkflowRuns('wf-1');
|
||||
|
||||
expect(runs).toHaveLength(3);
|
||||
expect(runs[0].runId).toBe('r1');
|
||||
expect(runs[0].startedAt).toBe('2026-01-01');
|
||||
expect(runs[1].runId).toBe('r2');
|
||||
expect(runs[1].step).toBe('3');
|
||||
expect(runs[2].runId).toBe('r3');
|
||||
expect(runs[2].error).toBe('boom');
|
||||
|
||||
// Stored in workflowRuns map
|
||||
expect(useWorkflowStore.getState().workflowRuns['wf-1']).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
(mockClient.listWorkflowRuns as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('fail'));
|
||||
|
||||
const runs = await useWorkflowStore.getState().loadWorkflowRuns('wf-1');
|
||||
expect(runs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// getWorkflow (local lookup)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — getWorkflow', () => {
|
||||
it('should find workflow by id', () => {
|
||||
useWorkflowStore.setState({
|
||||
workflows: [
|
||||
{ id: 'wf-1', name: 'Test', steps: 3 },
|
||||
{ id: 'wf-2', name: 'Other', steps: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const found = useWorkflowStore.getState().getWorkflow('wf-1');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown id', () => {
|
||||
useWorkflowStore.setState({
|
||||
workflows: [{ id: 'wf-1', name: 'Test', steps: 1 }],
|
||||
});
|
||||
|
||||
expect(useWorkflowStore.getState().getWorkflow('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// clearError / reset
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('workflowStore — utility actions', () => {
|
||||
it('clearError should clear error', () => {
|
||||
useWorkflowStore.setState({ error: 'Some error' });
|
||||
useWorkflowStore.getState().clearError();
|
||||
expect(useWorkflowStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('reset should restore initial state', () => {
|
||||
useWorkflowStore.setState({
|
||||
workflows: [{ id: 'x', name: 'Y', steps: 1 }],
|
||||
isLoading: true,
|
||||
error: 'err',
|
||||
});
|
||||
|
||||
useWorkflowStore.getState().reset();
|
||||
|
||||
const state = useWorkflowStore.getState();
|
||||
expect(state.workflows).toEqual([]);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user