Files
zclaw_openfang/desktop/tests/store/saasStore.test.ts
iven d758a4477f
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
test(desktop): Phase 3 store unit tests — 112 new tests for 5 stores
- saasStore: login/logout/register, TOTP setup/verify/disable, billing
  (plans/subscription/payment), templates, connection mode, config sync
- workflowStore: CRUD, trigger, cancel, loadRuns, client injection
- offlineStore: queue message, update/remove, reconnect backoff, getters
- handStore: loadHands, getHandDetails, trigger/approve/cancel,
  triggers CRUD, approvals, autonomy blocking
- streamStore: chatMode switching, getChatModeConfig, suggestions,
  setIsLoading, cancelStream, searchSkills, initStreamListener

All 173 tests pass (61 existing + 112 new).
2026-04-07 17:08:34 +08:00

587 lines
22 KiB
TypeScript

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