|
|
@@ -1,629 +1,556 @@
|
|
|
/**
|
|
|
- * Integration test for hot configuration reload
|
|
|
+ * Integration Test for Hot-Reload Configuration
|
|
|
*
|
|
|
- * This test verifies the complete hot reload workflow:
|
|
|
- * 1. Load initial configuration
|
|
|
- * 2. Start watching for changes
|
|
|
- * 3. Modify configuration file
|
|
|
- * 4. Verify automatic reload within 100ms
|
|
|
- * 5. Verify account updates are reflected
|
|
|
- *
|
|
|
- * Tests MUST FAIL initially until implementation is provided.
|
|
|
+ * Tests the file watching and hot-reload functionality of the credential manager.
|
|
|
+ * Validates that configuration changes are detected and applied without manual restarts.
|
|
|
*/
|
|
|
|
|
|
-import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
|
-import * as fs from 'fs/promises';
|
|
|
-import * as path from 'path';
|
|
|
-import * as os from 'os';
|
|
|
-
|
|
|
-// Import types (this import will fail until types are implemented)
|
|
|
-import type {
|
|
|
- ICredentialManager,
|
|
|
- Account,
|
|
|
- Platform,
|
|
|
- ConfigFile
|
|
|
-} from '@/specs/001-credential-manager/contracts/credential-manager';
|
|
|
+import { CredentialManager } from '@/core/credential-manager/CredentialManager'
|
|
|
+import { Platform } from '@/types/credential'
|
|
|
+import fs from 'fs/promises'
|
|
|
+import path from 'path'
|
|
|
+import { tmpdir } from 'os'
|
|
|
|
|
|
-describe('Hot Configuration Reload Integration Tests', () => {
|
|
|
- let credentialManager: ICredentialManager;
|
|
|
- let tempDir: string;
|
|
|
- let configPath: string;
|
|
|
+describe('Hot-Reload Integration Test', () => {
|
|
|
+ let credentialManager: CredentialManager
|
|
|
+ let tempConfigPath: string
|
|
|
+ let tempDir: string
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
- // This will fail until CredentialManager is implemented
|
|
|
- const { CredentialManager } = await import('@/core/credential-manager/CredentialManager');
|
|
|
- credentialManager = new CredentialManager();
|
|
|
+ // Create temporary directory for test configs
|
|
|
+ tempDir = await fs.mkdtemp(path.join(tmpdir(), 'credential-manager-test-'))
|
|
|
+ tempConfigPath = path.join(tempDir, 'accounts.json')
|
|
|
+
|
|
|
+ // Initial configuration
|
|
|
+ const initialConfig = {
|
|
|
+ accounts: [
|
|
|
+ {
|
|
|
+ id: 'initial-account',
|
|
|
+ name: 'Initial Account',
|
|
|
+ platform: Platform.PACIFICA,
|
|
|
+ enabled: true,
|
|
|
+ credentials: {
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['initial-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
+ }
|
|
|
+ },
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Create temporary directory for test files
|
|
|
- tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hot-reload-test-'));
|
|
|
- configPath = path.join(tempDir, 'credentials.json');
|
|
|
- });
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(initialConfig, null, 2))
|
|
|
+
|
|
|
+ credentialManager = new CredentialManager()
|
|
|
+ await credentialManager.loadConfigurationFromFile(tempConfigPath, {
|
|
|
+ enableHotReload: true,
|
|
|
+ watchDebounceMs: 100
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
afterEach(async () => {
|
|
|
- // Clean up
|
|
|
- credentialManager.stopWatching();
|
|
|
+ await credentialManager.shutdown()
|
|
|
|
|
|
- // Remove temporary directory
|
|
|
+ // Clean up temp files
|
|
|
try {
|
|
|
- await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ await fs.rm(tempDir, { recursive: true })
|
|
|
} catch (error) {
|
|
|
// Ignore cleanup errors
|
|
|
}
|
|
|
- });
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('Configuration File Watching', () => {
|
|
|
+ test('should detect new account additions', async () => {
|
|
|
+ // Initial state - should have 1 account
|
|
|
+ let accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(1)
|
|
|
+ expect(accounts[0].id).toBe('initial-account')
|
|
|
|
|
|
- describe('Initial Load and Setup', () => {
|
|
|
- test('should load initial configuration successfully', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ // Update configuration with new account
|
|
|
+ const updatedConfig = {
|
|
|
accounts: [
|
|
|
{
|
|
|
- id: "initial-pacifica-account",
|
|
|
+ id: 'initial-account',
|
|
|
+ name: 'Initial Account',
|
|
|
platform: Platform.PACIFICA,
|
|
|
- name: "Initial Pacifica Account",
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
}
|
|
|
- }
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2));
|
|
|
-
|
|
|
- // Act
|
|
|
- const loadResult = await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- // Assert
|
|
|
- expect(loadResult.success).toBe(true);
|
|
|
- expect(loadResult.accounts).toHaveLength(1);
|
|
|
- expect(loadResult.accounts[0].id).toBe("initial-pacifica-account");
|
|
|
-
|
|
|
- // Verify account is accessible via getAccount
|
|
|
- const account = credentialManager.getAccount("initial-pacifica-account");
|
|
|
- expect(account).not.toBeNull();
|
|
|
- expect(account!.platform).toBe(Platform.PACIFICA);
|
|
|
- });
|
|
|
-
|
|
|
- test('should start file watching without errors', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: []
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- const changeCallback = jest.fn();
|
|
|
-
|
|
|
- // Act & Assert - should not throw
|
|
|
- expect(() => {
|
|
|
- credentialManager.watchConfig(configPath, changeCallback);
|
|
|
- }).not.toThrow();
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('Hot Reload Performance', () => {
|
|
|
- test('should complete hot reload within 100ms performance requirement', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: [
|
|
|
+ },
|
|
|
{
|
|
|
- id: "performance-test-account",
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: "Performance Test Account",
|
|
|
+ id: 'new-account',
|
|
|
+ name: 'New Account',
|
|
|
+ platform: Platform.ASTER,
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ type: 'secp256k1' as const,
|
|
|
+ privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- // Setup reload detection
|
|
|
- const reloadPromise = new Promise<{accounts: Account[], reloadTime: number}>((resolve) => {
|
|
|
- const startTime = Date.now();
|
|
|
- credentialManager.watchConfig(configPath, (accounts) => {
|
|
|
- const reloadTime = Date.now() - startTime;
|
|
|
- resolve({ accounts, reloadTime });
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for watcher to initialize
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
-
|
|
|
- // Act - modify configuration
|
|
|
- const modifiedConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: [
|
|
|
- {
|
|
|
- id: "performance-test-account",
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: "Modified Performance Test Account",
|
|
|
- credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['initial-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
+ },
|
|
|
+ [Platform.ASTER]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['new-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
}
|
|
|
},
|
|
|
- {
|
|
|
- id: "new-account",
|
|
|
- platform: Platform.BINANCE,
|
|
|
- name: "New Account",
|
|
|
- credentials: {
|
|
|
- type: "hmac",
|
|
|
- apiKey: "test-api-key",
|
|
|
- secretKey: "test-secret-key"
|
|
|
- }
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: true,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- const modifyStartTime = Date.now();
|
|
|
- await fs.writeFile(configPath, JSON.stringify(modifiedConfig, null, 2));
|
|
|
-
|
|
|
- // Assert - wait for reload with timeout
|
|
|
- const { accounts, reloadTime } = await Promise.race([
|
|
|
- reloadPromise,
|
|
|
- new Promise<never>((_, reject) =>
|
|
|
- setTimeout(() => reject(new Error('Hot reload timeout - exceeded 5 seconds')), 5000)
|
|
|
- )
|
|
|
- ]);
|
|
|
-
|
|
|
- // Performance requirement: < 100ms
|
|
|
- expect(reloadTime).toBeLessThan(100);
|
|
|
- expect(accounts).toHaveLength(2);
|
|
|
-
|
|
|
- // Verify accounts are updated in credential manager
|
|
|
- const updatedAccount = credentialManager.getAccount("performance-test-account");
|
|
|
- expect(updatedAccount!.name).toBe("Modified Performance Test Account");
|
|
|
-
|
|
|
- const newAccount = credentialManager.getAccount("new-account");
|
|
|
- expect(newAccount).not.toBeNull();
|
|
|
- expect(newAccount!.platform).toBe(Platform.BINANCE);
|
|
|
- });
|
|
|
-
|
|
|
- test('should handle rapid successive file changes efficiently', async () => {
|
|
|
- // Arrange
|
|
|
- const baseConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: []
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(baseConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- let reloadCount = 0;
|
|
|
- const reloadTimes: number[] = [];
|
|
|
-
|
|
|
- credentialManager.watchConfig(configPath, (accounts) => {
|
|
|
- reloadCount++;
|
|
|
- reloadTimes.push(Date.now());
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for watcher to initialize
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
-
|
|
|
- // Act - make rapid changes
|
|
|
- const startTime = Date.now();
|
|
|
- for (let i = 0; i < 5; i++) {
|
|
|
- const config = {
|
|
|
- ...baseConfig,
|
|
|
- accounts: [
|
|
|
- {
|
|
|
- id: `rapid-account-${i}`,
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: `Rapid Account ${i}`,
|
|
|
- credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
- }
|
|
|
- }
|
|
|
- ]
|
|
|
- };
|
|
|
- await fs.writeFile(configPath, JSON.stringify(config));
|
|
|
- await new Promise(resolve => setTimeout(resolve, 20)); // Small delay between changes
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // Wait for debouncing and final reload
|
|
|
- await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(updatedConfig, null, 2))
|
|
|
+
|
|
|
+ // Wait for file watcher to detect change
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
+
|
|
|
+ // Should now have 2 accounts
|
|
|
+ accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(2)
|
|
|
+ expect(accounts.map(a => a.id)).toContain('initial-account')
|
|
|
+ expect(accounts.map(a => a.id)).toContain('new-account')
|
|
|
+ })
|
|
|
+
|
|
|
+ test('should detect account removal', async () => {
|
|
|
+ // Initial state
|
|
|
+ let accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(1)
|
|
|
+
|
|
|
+ // Remove account from configuration
|
|
|
+ const updatedConfig = {
|
|
|
+ accounts: [],
|
|
|
+ hedging: {
|
|
|
+ platforms: {},
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Assert - should be debounced (fewer reloads than changes)
|
|
|
- expect(reloadCount).toBeLessThan(5);
|
|
|
- expect(reloadCount).toBeGreaterThan(0);
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(updatedConfig, null, 2))
|
|
|
|
|
|
- // Final state should be correct
|
|
|
- const finalAccount = credentialManager.getAccount("rapid-account-4");
|
|
|
- expect(finalAccount).not.toBeNull();
|
|
|
- });
|
|
|
- });
|
|
|
+ // Wait for file watcher
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- describe('Account Updates and State Management', () => {
|
|
|
- test('should add new accounts on configuration update', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ // Should have no accounts
|
|
|
+ accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('should detect account modifications', async () => {
|
|
|
+ // Initial state
|
|
|
+ let account = credentialManager.getAccount('initial-account')
|
|
|
+ expect(account?.name).toBe('Initial Account')
|
|
|
+ expect(account?.enabled).toBe(true)
|
|
|
+
|
|
|
+ // Update account properties
|
|
|
+ const updatedConfig = {
|
|
|
accounts: [
|
|
|
{
|
|
|
- id: "existing-account",
|
|
|
+ id: 'initial-account',
|
|
|
+ name: 'Modified Account Name',
|
|
|
platform: Platform.PACIFICA,
|
|
|
- name: "Existing Account",
|
|
|
+ enabled: false, // Disable the account
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['initial-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
+ }
|
|
|
+ },
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(updatedConfig, null, 2))
|
|
|
|
|
|
- const updatePromise = new Promise<Account[]>((resolve) => {
|
|
|
- credentialManager.watchConfig(configPath, resolve);
|
|
|
- });
|
|
|
+ // Wait for file watcher
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ // Account should be updated
|
|
|
+ account = credentialManager.getAccount('initial-account')
|
|
|
+ expect(account?.name).toBe('Modified Account Name')
|
|
|
+ expect(account?.enabled).toBe(false)
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- // Act - add new account
|
|
|
- const updatedConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ describe('Performance Requirements', () => {
|
|
|
+ test('should reload configuration within 100ms', async () => {
|
|
|
+ const updatedConfig = {
|
|
|
accounts: [
|
|
|
- ...initialConfig.accounts,
|
|
|
{
|
|
|
- id: "new-aster-account",
|
|
|
- platform: Platform.ASTER,
|
|
|
- name: "New Aster Account",
|
|
|
+ id: 'performance-test',
|
|
|
+ name: 'Performance Test Account',
|
|
|
+ platform: Platform.PACIFICA,
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "eip191",
|
|
|
- privateKey: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['performance-test'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
+ }
|
|
|
+ },
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2));
|
|
|
+ const startTime = Date.now()
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(updatedConfig, null, 2))
|
|
|
|
|
|
- // Assert
|
|
|
- const accounts = await updatePromise;
|
|
|
- expect(accounts).toHaveLength(2);
|
|
|
+ // Wait for reload to complete
|
|
|
+ let reloaded = false
|
|
|
+ const maxWaitTime = 100
|
|
|
+ const checkInterval = 10
|
|
|
|
|
|
- // Verify both accounts are accessible
|
|
|
- expect(credentialManager.getAccount("existing-account")).not.toBeNull();
|
|
|
- expect(credentialManager.getAccount("new-aster-account")).not.toBeNull();
|
|
|
- expect(credentialManager.getAccount("new-aster-account")!.platform).toBe(Platform.ASTER);
|
|
|
- });
|
|
|
+ for (let elapsed = 0; elapsed < maxWaitTime; elapsed += checkInterval) {
|
|
|
+ await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
|
|
|
|
- test('should remove accounts when deleted from configuration', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: [
|
|
|
- {
|
|
|
- id: "account-to-keep",
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: "Account to Keep",
|
|
|
- credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ const account = credentialManager.getAccount('performance-test')
|
|
|
+ if (account) {
|
|
|
+ reloaded = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const reloadTime = Date.now() - startTime
|
|
|
+
|
|
|
+ expect(reloaded).toBe(true)
|
|
|
+ expect(reloadTime).toBeLessThan(100) // Performance requirement
|
|
|
+ })
|
|
|
+
|
|
|
+ test('should handle rapid configuration changes', async () => {
|
|
|
+ const configs = [
|
|
|
+ {
|
|
|
+ accounts: [
|
|
|
+ {
|
|
|
+ id: 'rapid-test-1',
|
|
|
+ name: 'Rapid Test 1',
|
|
|
+ platform: Platform.PACIFICA,
|
|
|
+ enabled: true,
|
|
|
+ credentials: {
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
+ }
|
|
|
}
|
|
|
- },
|
|
|
- {
|
|
|
- id: "account-to-remove",
|
|
|
- platform: Platform.BINANCE,
|
|
|
- name: "Account to Remove",
|
|
|
- credentials: {
|
|
|
- type: "hmac",
|
|
|
- apiKey: "test-api-key",
|
|
|
- secretKey: "test-secret-key"
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ accounts: [
|
|
|
+ {
|
|
|
+ id: 'rapid-test-2',
|
|
|
+ name: 'Rapid Test 2',
|
|
|
+ platform: Platform.ASTER,
|
|
|
+ enabled: true,
|
|
|
+ credentials: {
|
|
|
+ type: 'secp256k1' as const,
|
|
|
+ privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+
|
|
|
+ // Rapid succession of changes
|
|
|
+ for (const config of configs) {
|
|
|
+ const fullConfig = {
|
|
|
+ ...config,
|
|
|
+ hedging: {
|
|
|
+ platforms: {},
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
+ }
|
|
|
+
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(fullConfig, null, 2))
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
+ }
|
|
|
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
+ // Wait for final state
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- // Verify both accounts exist initially
|
|
|
- expect(credentialManager.getAccount("account-to-keep")).not.toBeNull();
|
|
|
- expect(credentialManager.getAccount("account-to-remove")).not.toBeNull();
|
|
|
+ // Should have the last configuration
|
|
|
+ const accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(1)
|
|
|
+ expect(accounts[0].id).toBe('rapid-test-2')
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- const updatePromise = new Promise<Account[]>((resolve) => {
|
|
|
- credentialManager.watchConfig(configPath, resolve);
|
|
|
- });
|
|
|
+ describe('Error Handling', () => {
|
|
|
+ test('should handle invalid JSON gracefully', async () => {
|
|
|
+ const invalidJson = '{ invalid json content'
|
|
|
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ await fs.writeFile(tempConfigPath, invalidJson)
|
|
|
|
|
|
- // Act - remove one account
|
|
|
- const updatedConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: [
|
|
|
- {
|
|
|
- id: "account-to-keep",
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: "Account to Keep",
|
|
|
- credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
- }
|
|
|
- }
|
|
|
- ]
|
|
|
- };
|
|
|
+ // Wait for file watcher
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2));
|
|
|
+ // Should still have original accounts (no change due to error)
|
|
|
+ const accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(1)
|
|
|
+ expect(accounts[0].id).toBe('initial-account')
|
|
|
+ })
|
|
|
|
|
|
- // Assert
|
|
|
- const accounts = await updatePromise;
|
|
|
- expect(accounts).toHaveLength(1);
|
|
|
+ test('should handle missing file gracefully', async () => {
|
|
|
+ // Delete the config file
|
|
|
+ await fs.unlink(tempConfigPath)
|
|
|
|
|
|
- // Verify correct account state
|
|
|
- expect(credentialManager.getAccount("account-to-keep")).not.toBeNull();
|
|
|
- expect(credentialManager.getAccount("account-to-remove")).toBeNull();
|
|
|
- });
|
|
|
+ // Wait for file watcher
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- test('should update existing account credentials', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ // Should handle missing file without crashing
|
|
|
+ // Behavior depends on implementation - might keep existing accounts or clear them
|
|
|
+ const accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(Array.isArray(accounts)).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('should validate configuration before applying changes', async () => {
|
|
|
+ const invalidConfig = {
|
|
|
accounts: [
|
|
|
{
|
|
|
- id: "updatable-account",
|
|
|
+ id: '', // Invalid empty ID
|
|
|
+ name: 'Invalid Account',
|
|
|
platform: Platform.PACIFICA,
|
|
|
- name: "Original Name",
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'invalid-key' // Invalid key
|
|
|
}
|
|
|
}
|
|
|
]
|
|
|
- };
|
|
|
+ }
|
|
|
+
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(invalidConfig, null, 2))
|
|
|
+
|
|
|
+ // Wait for file watcher
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
+ // Should reject invalid config and keep original
|
|
|
+ const accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(1)
|
|
|
+ expect(accounts[0].id).toBe('initial-account')
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- const updatePromise = new Promise<Account[]>((resolve) => {
|
|
|
- credentialManager.watchConfig(configPath, resolve);
|
|
|
- });
|
|
|
+ describe('Configuration Backup and Recovery', () => {
|
|
|
+ test('should maintain service availability during configuration updates', async () => {
|
|
|
+ const message = new TextEncoder().encode('availability test')
|
|
|
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ // Service should work before update
|
|
|
+ let result = await credentialManager.sign('initial-account', message)
|
|
|
+ expect(result.success).toBe(true)
|
|
|
|
|
|
- // Act - update account details
|
|
|
- const updatedConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ // Update configuration
|
|
|
+ const updatedConfig = {
|
|
|
accounts: [
|
|
|
{
|
|
|
- id: "updatable-account",
|
|
|
+ id: 'initial-account',
|
|
|
+ name: 'Updated Account',
|
|
|
platform: Platform.PACIFICA,
|
|
|
- name: "Updated Name",
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2));
|
|
|
-
|
|
|
- // Assert
|
|
|
- const accounts = await updatePromise;
|
|
|
- expect(accounts).toHaveLength(1);
|
|
|
-
|
|
|
- const updatedAccount = credentialManager.getAccount("updatable-account");
|
|
|
- expect(updatedAccount).not.toBeNull();
|
|
|
- expect(updatedAccount!.name).toBe("Updated Name");
|
|
|
- expect(updatedAccount!.credentials.privateKey).toBe("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210");
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('Error Handling During Hot Reload', () => {
|
|
|
- test('should handle malformed configuration during reload', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: [
|
|
|
- {
|
|
|
- id: "stable-account",
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: "Stable Account",
|
|
|
- credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['initial-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
}
|
|
|
+ },
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- // Verify initial state
|
|
|
- expect(credentialManager.getAccount("stable-account")).not.toBeNull();
|
|
|
-
|
|
|
- let errorOccurred = false;
|
|
|
- credentialManager.watchConfig(configPath, (accounts) => {
|
|
|
- // This callback should not be called for malformed config
|
|
|
- errorOccurred = true;
|
|
|
- });
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(updatedConfig, null, 2))
|
|
|
|
|
|
- // Act - write malformed configuration
|
|
|
- const malformedConfig = '{ "version": "1.0", "accounts": [ invalid json }';
|
|
|
- await fs.writeFile(configPath, malformedConfig);
|
|
|
+ // Wait for reload
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- // Wait for file watcher to process
|
|
|
- await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
+ // Service should still work after update
|
|
|
+ result = await credentialManager.sign('initial-account', message)
|
|
|
+ expect(result.success).toBe(true)
|
|
|
|
|
|
- // Assert - original state should be preserved
|
|
|
- expect(errorOccurred).toBe(false);
|
|
|
- expect(credentialManager.getAccount("stable-account")).not.toBeNull();
|
|
|
- });
|
|
|
+ // Account should have updated name
|
|
|
+ const account = credentialManager.getAccount('initial-account')
|
|
|
+ expect(account?.name).toBe('Updated Account')
|
|
|
+ })
|
|
|
|
|
|
- test('should handle file deletion during watching', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ test('should handle partial configuration updates', async () => {
|
|
|
+ // Add second account first
|
|
|
+ const configWithTwoAccounts = {
|
|
|
accounts: [
|
|
|
{
|
|
|
- id: "persistent-account",
|
|
|
+ id: 'initial-account',
|
|
|
+ name: 'Initial Account',
|
|
|
platform: Platform.PACIFICA,
|
|
|
- name: "Persistent Account",
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'second-account',
|
|
|
+ name: 'Second Account',
|
|
|
+ platform: Platform.ASTER,
|
|
|
+ enabled: true,
|
|
|
+ credentials: {
|
|
|
+ type: 'secp256k1' as const,
|
|
|
+ privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- credentialManager.watchConfig(configPath, (accounts) => {
|
|
|
- // Should handle file deletion gracefully
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
-
|
|
|
- // Act - delete configuration file
|
|
|
- await fs.unlink(configPath);
|
|
|
-
|
|
|
- // Wait for file watcher to process
|
|
|
- await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
-
|
|
|
- // Assert - should not crash, previous state may be preserved
|
|
|
- expect(() => {
|
|
|
- credentialManager.getAccount("persistent-account");
|
|
|
- }).not.toThrow();
|
|
|
- });
|
|
|
-
|
|
|
- test('should handle temporary file operations (atomic writes)', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: []
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['initial-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
+ },
|
|
|
+ [Platform.ASTER]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['second-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
+ }
|
|
|
+ },
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: true,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- let updateCount = 0;
|
|
|
- credentialManager.watchConfig(configPath, (accounts) => {
|
|
|
- updateCount++;
|
|
|
- });
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(configWithTwoAccounts, null, 2))
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ // Should have both accounts
|
|
|
+ let accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(2)
|
|
|
|
|
|
- // Act - simulate atomic write operations (common with editors)
|
|
|
- const tempPath = configPath + '.tmp';
|
|
|
- const finalConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
+ // Now remove one account
|
|
|
+ const configWithOneAccount = {
|
|
|
accounts: [
|
|
|
{
|
|
|
- id: "atomic-account",
|
|
|
+ id: 'initial-account',
|
|
|
+ name: 'Initial Account',
|
|
|
platform: Platform.PACIFICA,
|
|
|
- name: "Atomic Account",
|
|
|
+ enabled: true,
|
|
|
credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ type: 'ed25519' as const,
|
|
|
+ privateKey: 'f26670e2ca334117f8859f9f32e50251641953a30b54f6ffcf82db836cfdfea5'
|
|
|
}
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- // Write to temp file then rename (atomic operation)
|
|
|
- await fs.writeFile(tempPath, JSON.stringify(finalConfig, null, 2));
|
|
|
- await fs.rename(tempPath, configPath);
|
|
|
-
|
|
|
- // Wait for file watcher to process
|
|
|
- await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
-
|
|
|
- // Assert - should detect the final state
|
|
|
- expect(updateCount).toBeGreaterThan(0);
|
|
|
- expect(credentialManager.getAccount("atomic-account")).not.toBeNull();
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- describe('Cleanup and Resource Management', () => {
|
|
|
- test('should stop watching when requested', async () => {
|
|
|
- // Arrange
|
|
|
- const initialConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: []
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(initialConfig));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- let callbackCount = 0;
|
|
|
- credentialManager.watchConfig(configPath, () => {
|
|
|
- callbackCount++;
|
|
|
- });
|
|
|
-
|
|
|
- // Wait for watcher
|
|
|
- await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
-
|
|
|
- // Act - stop watching
|
|
|
- credentialManager.stopWatching();
|
|
|
-
|
|
|
- // Modify file after stopping
|
|
|
- const modifiedConfig: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: [
|
|
|
- {
|
|
|
- id: "should-not-trigger",
|
|
|
- platform: Platform.PACIFICA,
|
|
|
- name: "Should Not Trigger",
|
|
|
- credentials: {
|
|
|
- type: "ed25519",
|
|
|
- privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
+ ],
|
|
|
+ hedging: {
|
|
|
+ platforms: {
|
|
|
+ [Platform.PACIFICA]: {
|
|
|
+ enabled: true,
|
|
|
+ primaryAccounts: ['initial-account'],
|
|
|
+ backupAccounts: [],
|
|
|
+ loadBalanceStrategy: 'round-robin',
|
|
|
+ healthCheckInterval: 30000,
|
|
|
+ failoverThreshold: 3
|
|
|
}
|
|
|
+ },
|
|
|
+ hedging: {
|
|
|
+ enableCrossplatformBalancing: false,
|
|
|
+ maxAccountsPerPlatform: 5,
|
|
|
+ reservationTimeoutMs: 60000
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(modifiedConfig));
|
|
|
-
|
|
|
- // Wait to ensure no callback is triggered
|
|
|
- await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
-
|
|
|
- // Assert - callback should not have been called after stopping
|
|
|
- expect(callbackCount).toBe(0);
|
|
|
- });
|
|
|
-
|
|
|
- test('should handle multiple start/stop watch cycles', async () => {
|
|
|
- // Arrange
|
|
|
- const config: ConfigFile = {
|
|
|
- version: "1.0",
|
|
|
- accounts: []
|
|
|
- };
|
|
|
-
|
|
|
- await fs.writeFile(configPath, JSON.stringify(config));
|
|
|
- await credentialManager.loadConfig(configPath);
|
|
|
-
|
|
|
- // Act & Assert - multiple cycles should not cause issues
|
|
|
- for (let i = 0; i < 3; i++) {
|
|
|
- credentialManager.watchConfig(configPath, () => {});
|
|
|
- await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
- credentialManager.stopWatching();
|
|
|
- await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // Should end in clean state
|
|
|
- expect(() => {
|
|
|
- credentialManager.stopWatching();
|
|
|
- }).not.toThrow();
|
|
|
- });
|
|
|
- });
|
|
|
-});
|
|
|
+ await fs.writeFile(tempConfigPath, JSON.stringify(configWithOneAccount, null, 2))
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
+
|
|
|
+ // Should have only one account
|
|
|
+ accounts = credentialManager.getAllAccounts()
|
|
|
+ expect(accounts).toHaveLength(1)
|
|
|
+ expect(accounts[0].id).toBe('initial-account')
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|