/** * 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(); }); });