Claude Code CLI sends cache_control on text, thinking, tool_use, and tool_result blocks for prompt caching. Cloud Code API rejects these with "Extra inputs are not permitted". - Add cleanCacheControl() to proactively strip cache_control at pipeline entry - Add sanitizeTextBlock() and sanitizeToolUseBlock() for defense-in-depth - Update reorderAssistantContent() to use block sanitizers - Add test-cache-control.cjs with multi-model test coverage - Update frontend dashboard tests to match current UI design - Update strategy tests to match v2.4.0 fallback behavior - Update CLAUDE.md and README.md with recent features Inspired by Antigravity-Manager's clean_cache_control_from_messages() pattern. Co-Authored-By: Claude <noreply@anthropic.com>
1056 lines
45 KiB
JavaScript
1056 lines
45 KiB
JavaScript
/**
|
|
* Test Account Selection Strategies - Unit Tests
|
|
*
|
|
* Tests the strategy pattern implementation for account selection:
|
|
* - HealthTracker: health score tracking with passive recovery
|
|
* - TokenBucketTracker: token bucket rate limiting
|
|
* - StickyStrategy: cache-optimized sticky selection
|
|
* - RoundRobinStrategy: load-balanced rotation
|
|
* - HybridStrategy: smart multi-signal distribution
|
|
* - Strategy Factory: createStrategy, isValidStrategy, getStrategyLabel
|
|
*/
|
|
|
|
// Since we're in CommonJS and the module is ESM, we need to use dynamic import
|
|
async function runTests() {
|
|
console.log('╔══════════════════════════════════════════════════════════════╗');
|
|
console.log('║ ACCOUNT SELECTION STRATEGY TEST SUITE ║');
|
|
console.log('╚══════════════════════════════════════════════════════════════╝\n');
|
|
|
|
// Dynamic imports for ESM modules
|
|
const { HealthTracker } = await import('../src/account-manager/strategies/trackers/health-tracker.js');
|
|
const { TokenBucketTracker } = await import('../src/account-manager/strategies/trackers/token-bucket-tracker.js');
|
|
const { QuotaTracker } = await import('../src/account-manager/strategies/trackers/quota-tracker.js');
|
|
const { StickyStrategy } = await import('../src/account-manager/strategies/sticky-strategy.js');
|
|
const { RoundRobinStrategy } = await import('../src/account-manager/strategies/round-robin-strategy.js');
|
|
const { HybridStrategy } = await import('../src/account-manager/strategies/hybrid-strategy.js');
|
|
const { BaseStrategy } = await import('../src/account-manager/strategies/base-strategy.js');
|
|
const {
|
|
createStrategy,
|
|
isValidStrategy,
|
|
getStrategyLabel,
|
|
STRATEGY_NAMES,
|
|
DEFAULT_STRATEGY
|
|
} = await import('../src/account-manager/strategies/index.js');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(`✓ ${name}`);
|
|
passed++;
|
|
} catch (e) {
|
|
console.log(`✗ ${name}`);
|
|
console.log(` Error: ${e.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function assertEqual(actual, expected, message = '') {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
|
}
|
|
}
|
|
|
|
function assertDeepEqual(actual, expected, message = '') {
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(`${message}\nExpected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`);
|
|
}
|
|
}
|
|
|
|
function assertTrue(value, message = '') {
|
|
if (!value) {
|
|
throw new Error(message || 'Expected true but got false');
|
|
}
|
|
}
|
|
|
|
function assertFalse(value, message = '') {
|
|
if (value) {
|
|
throw new Error(message || 'Expected false but got true');
|
|
}
|
|
}
|
|
|
|
function assertNull(value, message = '') {
|
|
if (value !== null) {
|
|
throw new Error(`${message}\nExpected null but got: ${value}`);
|
|
}
|
|
}
|
|
|
|
function assertNotNull(value, message = '') {
|
|
if (value === null || value === undefined) {
|
|
throw new Error(`${message}\nExpected non-null value but got: ${value}`);
|
|
}
|
|
}
|
|
|
|
function assertWithin(actual, min, max, message = '') {
|
|
if (actual < min || actual > max) {
|
|
throw new Error(`${message}\nExpected value between ${min} and ${max}, got: ${actual}`);
|
|
}
|
|
}
|
|
|
|
// Helper to create mock accounts
|
|
function createMockAccounts(count = 3, options = {}) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
email: `account${i + 1}@example.com`,
|
|
enabled: true,
|
|
isInvalid: false,
|
|
lastUsed: Date.now() - (i * 60000), // Stagger by 1 minute
|
|
modelRateLimits: {},
|
|
...options
|
|
}));
|
|
}
|
|
|
|
// ==========================================================================
|
|
// HEALTH TRACKER TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── HealthTracker Tests ───');
|
|
|
|
test('HealthTracker: initial score is 70 by default', () => {
|
|
const tracker = new HealthTracker();
|
|
const score = tracker.getScore('new@example.com');
|
|
assertEqual(score, 70, 'Default initial score should be 70');
|
|
});
|
|
|
|
test('HealthTracker: custom initial score', () => {
|
|
const tracker = new HealthTracker({ initial: 80 });
|
|
const score = tracker.getScore('new@example.com');
|
|
assertEqual(score, 80, 'Custom initial score should be 80');
|
|
});
|
|
|
|
test('HealthTracker: recordSuccess increases score', () => {
|
|
const tracker = new HealthTracker({ initial: 70, successReward: 1 });
|
|
tracker.recordSuccess('test@example.com');
|
|
const score = tracker.getScore('test@example.com');
|
|
assertEqual(score, 71, 'Score should increase by 1 on success');
|
|
});
|
|
|
|
test('HealthTracker: recordRateLimit decreases score', () => {
|
|
const tracker = new HealthTracker({ initial: 70, rateLimitPenalty: -10 });
|
|
tracker.recordRateLimit('test@example.com');
|
|
const score = tracker.getScore('test@example.com');
|
|
assertEqual(score, 60, 'Score should decrease by 10 on rate limit');
|
|
});
|
|
|
|
test('HealthTracker: recordFailure decreases score', () => {
|
|
const tracker = new HealthTracker({ initial: 70, failurePenalty: -20 });
|
|
tracker.recordFailure('test@example.com');
|
|
const score = tracker.getScore('test@example.com');
|
|
assertEqual(score, 50, 'Score should decrease by 20 on failure');
|
|
});
|
|
|
|
test('HealthTracker: score cannot exceed maxScore', () => {
|
|
const tracker = new HealthTracker({ initial: 99, maxScore: 100, successReward: 5 });
|
|
tracker.recordSuccess('test@example.com');
|
|
const score = tracker.getScore('test@example.com');
|
|
assertEqual(score, 100, 'Score should be capped at maxScore');
|
|
});
|
|
|
|
test('HealthTracker: score cannot go below 0', () => {
|
|
const tracker = new HealthTracker({ initial: 10, failurePenalty: -50 });
|
|
tracker.recordFailure('test@example.com');
|
|
const score = tracker.getScore('test@example.com');
|
|
assertEqual(score, 0, 'Score should not go below 0');
|
|
});
|
|
|
|
test('HealthTracker: isUsable returns true when score >= minUsable', () => {
|
|
const tracker = new HealthTracker({ initial: 50, minUsable: 50 });
|
|
assertTrue(tracker.isUsable('test@example.com'), 'Should be usable at minUsable');
|
|
});
|
|
|
|
test('HealthTracker: isUsable returns false when score < minUsable', () => {
|
|
const tracker = new HealthTracker({ initial: 49, minUsable: 50 });
|
|
assertFalse(tracker.isUsable('test@example.com'), 'Should not be usable below minUsable');
|
|
});
|
|
|
|
test('HealthTracker: reset restores initial score', () => {
|
|
const tracker = new HealthTracker({ initial: 70 });
|
|
tracker.recordFailure('test@example.com'); // Score drops
|
|
tracker.reset('test@example.com');
|
|
const score = tracker.getScore('test@example.com');
|
|
assertEqual(score, 70, 'Reset should restore initial score');
|
|
});
|
|
|
|
test('HealthTracker: clear removes all scores', () => {
|
|
const tracker = new HealthTracker({ initial: 70 });
|
|
tracker.recordSuccess('a@example.com');
|
|
tracker.recordSuccess('b@example.com');
|
|
tracker.clear();
|
|
// After clear, new accounts should get initial score
|
|
assertEqual(tracker.getScore('a@example.com'), 70);
|
|
assertEqual(tracker.getScore('b@example.com'), 70);
|
|
});
|
|
|
|
test('HealthTracker: getConsecutiveFailures returns 0 for new account', () => {
|
|
const tracker = new HealthTracker();
|
|
assertEqual(tracker.getConsecutiveFailures('new@example.com'), 0);
|
|
});
|
|
|
|
test('HealthTracker: recordRateLimit increments consecutiveFailures', () => {
|
|
const tracker = new HealthTracker();
|
|
tracker.recordRateLimit('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 1);
|
|
tracker.recordRateLimit('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 2);
|
|
});
|
|
|
|
test('HealthTracker: recordFailure increments consecutiveFailures', () => {
|
|
const tracker = new HealthTracker();
|
|
tracker.recordFailure('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 1);
|
|
});
|
|
|
|
test('HealthTracker: recordSuccess resets consecutiveFailures', () => {
|
|
const tracker = new HealthTracker();
|
|
tracker.recordRateLimit('test@example.com');
|
|
tracker.recordRateLimit('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 2);
|
|
tracker.recordSuccess('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 0);
|
|
});
|
|
|
|
test('HealthTracker: reset clears consecutiveFailures', () => {
|
|
const tracker = new HealthTracker();
|
|
tracker.recordFailure('test@example.com');
|
|
tracker.recordFailure('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 2);
|
|
tracker.reset('test@example.com');
|
|
assertEqual(tracker.getConsecutiveFailures('test@example.com'), 0);
|
|
});
|
|
|
|
// ==========================================================================
|
|
// TOKEN BUCKET TRACKER TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── TokenBucketTracker Tests ───');
|
|
|
|
test('TokenBucketTracker: initial tokens is 50 by default', () => {
|
|
const tracker = new TokenBucketTracker();
|
|
const tokens = tracker.getTokens('new@example.com');
|
|
assertEqual(tokens, 50, 'Default initial tokens should be 50');
|
|
});
|
|
|
|
test('TokenBucketTracker: custom initial tokens', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 30 });
|
|
const tokens = tracker.getTokens('new@example.com');
|
|
assertEqual(tokens, 30, 'Custom initial tokens should be 30');
|
|
});
|
|
|
|
test('TokenBucketTracker: consume decreases tokens', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 10, maxTokens: 10 });
|
|
const consumed = tracker.consume('test@example.com');
|
|
assertTrue(consumed, 'Consume should return true');
|
|
assertEqual(tracker.getTokens('test@example.com'), 9, 'Tokens should decrease by 1');
|
|
});
|
|
|
|
test('TokenBucketTracker: consume fails when no tokens', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 0, maxTokens: 10 });
|
|
const consumed = tracker.consume('test@example.com');
|
|
assertFalse(consumed, 'Consume should return false when no tokens');
|
|
});
|
|
|
|
test('TokenBucketTracker: hasTokens returns true when tokens > 0', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 1 });
|
|
assertTrue(tracker.hasTokens('test@example.com'), 'Should have tokens');
|
|
});
|
|
|
|
test('TokenBucketTracker: hasTokens returns false when tokens < 1', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 0 });
|
|
assertFalse(tracker.hasTokens('test@example.com'), 'Should not have tokens');
|
|
});
|
|
|
|
test('TokenBucketTracker: refund increases tokens', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 5, maxTokens: 10 });
|
|
tracker.consume('test@example.com'); // 5 -> 4
|
|
tracker.refund('test@example.com'); // 4 -> 5
|
|
assertEqual(tracker.getTokens('test@example.com'), 5, 'Refund should restore token');
|
|
});
|
|
|
|
test('TokenBucketTracker: refund cannot exceed maxTokens', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 10, maxTokens: 10 });
|
|
tracker.refund('test@example.com');
|
|
assertEqual(tracker.getTokens('test@example.com'), 10, 'Refund should not exceed max');
|
|
});
|
|
|
|
test('TokenBucketTracker: getMaxTokens returns configured max', () => {
|
|
const tracker = new TokenBucketTracker({ maxTokens: 100 });
|
|
assertEqual(tracker.getMaxTokens(), 100, 'getMaxTokens should return 100');
|
|
});
|
|
|
|
test('TokenBucketTracker: reset restores initial tokens', () => {
|
|
const tracker = new TokenBucketTracker({ initialTokens: 50, maxTokens: 50 });
|
|
tracker.consume('test@example.com');
|
|
tracker.consume('test@example.com');
|
|
tracker.reset('test@example.com');
|
|
assertEqual(tracker.getTokens('test@example.com'), 50, 'Reset should restore initial');
|
|
});
|
|
|
|
// ==========================================================================
|
|
// QUOTA TRACKER TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── QuotaTracker Tests ───');
|
|
|
|
test('QuotaTracker: getQuotaFraction returns null for missing data', () => {
|
|
const tracker = new QuotaTracker();
|
|
const account = { email: 'test@example.com' };
|
|
assertNull(tracker.getQuotaFraction(account, 'model'), 'Missing quota should return null');
|
|
});
|
|
|
|
test('QuotaTracker: getQuotaFraction returns correct value', () => {
|
|
const tracker = new QuotaTracker();
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.75 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
};
|
|
assertEqual(tracker.getQuotaFraction(account, 'model'), 0.75);
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaFresh returns false when no lastChecked', () => {
|
|
const tracker = new QuotaTracker();
|
|
const account = { email: 'test@example.com' };
|
|
assertFalse(tracker.isQuotaFresh(account), 'Missing lastChecked should not be fresh');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaFresh returns true for recent data', () => {
|
|
const tracker = new QuotaTracker({ staleMs: 300000 }); // 5 min
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: { lastChecked: Date.now() - 60000 } // 1 min ago
|
|
};
|
|
assertTrue(tracker.isQuotaFresh(account), 'Recent data should be fresh');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaFresh returns false for stale data', () => {
|
|
const tracker = new QuotaTracker({ staleMs: 300000 }); // 5 min
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: { lastChecked: Date.now() - 600000 } // 10 min ago
|
|
};
|
|
assertFalse(tracker.isQuotaFresh(account), 'Old data should be stale');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaCritical returns false for unknown quota', () => {
|
|
const tracker = new QuotaTracker({ criticalThreshold: 0.05 });
|
|
const account = { email: 'test@example.com' };
|
|
assertFalse(tracker.isQuotaCritical(account, 'model'), 'Unknown quota should not be critical');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaCritical returns true when quota <= threshold', () => {
|
|
const tracker = new QuotaTracker({ criticalThreshold: 0.05 });
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.04 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
};
|
|
assertTrue(tracker.isQuotaCritical(account, 'model'), 'Low quota should be critical');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaCritical returns false when quota > threshold', () => {
|
|
const tracker = new QuotaTracker({ criticalThreshold: 0.05 });
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.10 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
};
|
|
assertFalse(tracker.isQuotaCritical(account, 'model'), 'Higher quota should not be critical');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaCritical returns false for stale data', () => {
|
|
const tracker = new QuotaTracker({ criticalThreshold: 0.05, staleMs: 300000 });
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.01 } },
|
|
lastChecked: Date.now() - 600000 // 10 min ago (stale)
|
|
}
|
|
};
|
|
assertFalse(tracker.isQuotaCritical(account, 'model'), 'Stale critical data should be ignored');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaLow returns true for low but not critical quota', () => {
|
|
const tracker = new QuotaTracker({ lowThreshold: 0.10, criticalThreshold: 0.05 });
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.08 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
};
|
|
assertTrue(tracker.isQuotaLow(account, 'model'), 'Quota at 8% should be low');
|
|
});
|
|
|
|
test('QuotaTracker: isQuotaLow returns false for critical quota', () => {
|
|
const tracker = new QuotaTracker({ lowThreshold: 0.10, criticalThreshold: 0.05 });
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.03 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
};
|
|
assertFalse(tracker.isQuotaLow(account, 'model'), 'Critical quota should not be just low');
|
|
});
|
|
|
|
test('QuotaTracker: getScore returns unknownScore for missing quota', () => {
|
|
const tracker = new QuotaTracker({ unknownScore: 50 });
|
|
const account = { email: 'test@example.com' };
|
|
assertEqual(tracker.getScore(account, 'model'), 50, 'Unknown quota should return default score');
|
|
});
|
|
|
|
test('QuotaTracker: getScore returns 0-100 based on fraction', () => {
|
|
const tracker = new QuotaTracker();
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.75 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
};
|
|
assertEqual(tracker.getScore(account, 'model'), 75, 'Score should be fraction * 100');
|
|
});
|
|
|
|
test('QuotaTracker: getScore applies penalty for stale data', () => {
|
|
const tracker = new QuotaTracker({ staleMs: 300000 });
|
|
const account = {
|
|
email: 'test@example.com',
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 1.0 } },
|
|
lastChecked: Date.now() - 600000 // 10 min ago
|
|
}
|
|
};
|
|
assertEqual(tracker.getScore(account, 'model'), 90, 'Stale data should have 10% penalty');
|
|
});
|
|
|
|
// ==========================================================================
|
|
// BASE STRATEGY TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── BaseStrategy Tests ───');
|
|
|
|
test('BaseStrategy: cannot be instantiated directly', () => {
|
|
try {
|
|
new BaseStrategy();
|
|
throw new Error('Should have thrown');
|
|
} catch (e) {
|
|
assertTrue(e.message.includes('abstract'), 'Should throw abstract error');
|
|
}
|
|
});
|
|
|
|
test('BaseStrategy: isAccountUsable returns false for null account', () => {
|
|
// Create a minimal subclass to test
|
|
class TestStrategy extends BaseStrategy {
|
|
selectAccount() { return { account: null, index: 0 }; }
|
|
}
|
|
const strategy = new TestStrategy();
|
|
assertFalse(strategy.isAccountUsable(null, 'model'), 'Null account should not be usable');
|
|
});
|
|
|
|
test('BaseStrategy: isAccountUsable returns false for invalid account', () => {
|
|
class TestStrategy extends BaseStrategy {
|
|
selectAccount() { return { account: null, index: 0 }; }
|
|
}
|
|
const strategy = new TestStrategy();
|
|
const account = { email: 'test@example.com', isInvalid: true };
|
|
assertFalse(strategy.isAccountUsable(account, 'model'), 'Invalid account should not be usable');
|
|
});
|
|
|
|
test('BaseStrategy: isAccountUsable returns false for disabled account', () => {
|
|
class TestStrategy extends BaseStrategy {
|
|
selectAccount() { return { account: null, index: 0 }; }
|
|
}
|
|
const strategy = new TestStrategy();
|
|
const account = { email: 'test@example.com', enabled: false };
|
|
assertFalse(strategy.isAccountUsable(account, 'model'), 'Disabled account should not be usable');
|
|
});
|
|
|
|
test('BaseStrategy: isAccountUsable returns false for rate-limited model', () => {
|
|
class TestStrategy extends BaseStrategy {
|
|
selectAccount() { return { account: null, index: 0 }; }
|
|
}
|
|
const strategy = new TestStrategy();
|
|
const account = {
|
|
email: 'test@example.com',
|
|
modelRateLimits: {
|
|
'claude-sonnet': {
|
|
isRateLimited: true,
|
|
resetTime: Date.now() + 60000 // 1 minute in future
|
|
}
|
|
}
|
|
};
|
|
assertFalse(strategy.isAccountUsable(account, 'claude-sonnet'), 'Rate-limited model should not be usable');
|
|
});
|
|
|
|
test('BaseStrategy: isAccountUsable returns true for expired rate limit', () => {
|
|
class TestStrategy extends BaseStrategy {
|
|
selectAccount() { return { account: null, index: 0 }; }
|
|
}
|
|
const strategy = new TestStrategy();
|
|
const account = {
|
|
email: 'test@example.com',
|
|
modelRateLimits: {
|
|
'claude-sonnet': {
|
|
isRateLimited: true,
|
|
resetTime: Date.now() - 1000 // 1 second in past
|
|
}
|
|
}
|
|
};
|
|
assertTrue(strategy.isAccountUsable(account, 'claude-sonnet'), 'Expired rate limit should be usable');
|
|
});
|
|
|
|
test('BaseStrategy: getUsableAccounts filters correctly', () => {
|
|
class TestStrategy extends BaseStrategy {
|
|
selectAccount() { return { account: null, index: 0 }; }
|
|
}
|
|
const strategy = new TestStrategy();
|
|
const accounts = [
|
|
{ email: 'a@example.com', enabled: true },
|
|
{ email: 'b@example.com', enabled: false },
|
|
{ email: 'c@example.com', enabled: true, isInvalid: true },
|
|
{ email: 'd@example.com', enabled: true }
|
|
];
|
|
const usable = strategy.getUsableAccounts(accounts, 'model');
|
|
assertEqual(usable.length, 2, 'Should have 2 usable accounts');
|
|
assertEqual(usable[0].account.email, 'a@example.com');
|
|
assertEqual(usable[1].account.email, 'd@example.com');
|
|
});
|
|
|
|
// ==========================================================================
|
|
// STICKY STRATEGY TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── StickyStrategy Tests ───');
|
|
|
|
test('StickyStrategy: returns null for empty accounts', () => {
|
|
const strategy = new StickyStrategy();
|
|
const result = strategy.selectAccount([], 'model', { currentIndex: 0 });
|
|
assertNull(result.account, 'Should return null for empty accounts');
|
|
});
|
|
|
|
test('StickyStrategy: keeps using current account when available', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
|
|
const result1 = strategy.selectAccount(accounts, 'model', { currentIndex: 0 });
|
|
assertEqual(result1.account.email, 'account1@example.com');
|
|
assertEqual(result1.index, 0);
|
|
|
|
const result2 = strategy.selectAccount(accounts, 'model', { currentIndex: 0 });
|
|
assertEqual(result2.account.email, 'account1@example.com', 'Should stick to same account');
|
|
assertEqual(result2.index, 0);
|
|
});
|
|
|
|
test('StickyStrategy: switches when current account is rate-limited', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
// Rate-limit account1 for 5 minutes (longer than MAX_WAIT)
|
|
accounts[0].modelRateLimits = {
|
|
'model': { isRateLimited: true, resetTime: Date.now() + 300000 }
|
|
};
|
|
|
|
const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 });
|
|
assertEqual(result.account.email, 'account2@example.com', 'Should switch to next available');
|
|
assertEqual(result.index, 1);
|
|
});
|
|
|
|
test('StickyStrategy: returns waitMs when current account has short rate limit', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(1); // Only one account
|
|
// Rate-limit for 30 seconds (less than MAX_WAIT of 2 minutes)
|
|
accounts[0].modelRateLimits = {
|
|
'model': { isRateLimited: true, resetTime: Date.now() + 30000 }
|
|
};
|
|
|
|
const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 });
|
|
assertNull(result.account, 'Should return null when waiting');
|
|
assertWithin(result.waitMs, 29000, 31000, 'Should return ~30s wait time');
|
|
});
|
|
|
|
test('StickyStrategy: switches when current account is disabled', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
accounts[0].enabled = false;
|
|
|
|
const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 });
|
|
assertEqual(result.account.email, 'account2@example.com', 'Should switch to next');
|
|
});
|
|
|
|
test('StickyStrategy: switches when current account is invalid', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
accounts[0].isInvalid = true;
|
|
|
|
const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 });
|
|
assertEqual(result.account.email, 'account2@example.com', 'Should switch to next');
|
|
});
|
|
|
|
test('StickyStrategy: wraps around when at end of list', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
accounts[2].isInvalid = true; // Last account invalid
|
|
|
|
const result = strategy.selectAccount(accounts, 'model', { currentIndex: 2 });
|
|
assertEqual(result.account.email, 'account1@example.com', 'Should wrap to first');
|
|
assertEqual(result.index, 0);
|
|
});
|
|
|
|
test('StickyStrategy: clamps invalid currentIndex', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
|
|
const result = strategy.selectAccount(accounts, 'model', { currentIndex: 10 });
|
|
assertEqual(result.account.email, 'account1@example.com', 'Should clamp to valid index');
|
|
assertEqual(result.index, 0);
|
|
});
|
|
|
|
// ==========================================================================
|
|
// ROUND-ROBIN STRATEGY TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── RoundRobinStrategy Tests ───');
|
|
|
|
test('RoundRobinStrategy: returns null for empty accounts', () => {
|
|
const strategy = new RoundRobinStrategy();
|
|
const result = strategy.selectAccount([], 'model');
|
|
assertNull(result.account, 'Should return null for empty accounts');
|
|
});
|
|
|
|
test('RoundRobinStrategy: rotates through accounts', () => {
|
|
const strategy = new RoundRobinStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
|
|
const r1 = strategy.selectAccount(accounts, 'model');
|
|
const r2 = strategy.selectAccount(accounts, 'model');
|
|
const r3 = strategy.selectAccount(accounts, 'model');
|
|
const r4 = strategy.selectAccount(accounts, 'model');
|
|
|
|
// First call starts at cursor 0, looks at (0+1)%3 = 1
|
|
// Then cursor becomes 1, next looks at (1+1)%3 = 2
|
|
// Then cursor becomes 2, next looks at (2+1)%3 = 0
|
|
// Then cursor becomes 0, next looks at (0+1)%3 = 1
|
|
assertEqual(r1.account.email, 'account2@example.com', 'First should be account2');
|
|
assertEqual(r2.account.email, 'account3@example.com', 'Second should be account3');
|
|
assertEqual(r3.account.email, 'account1@example.com', 'Third should wrap to account1');
|
|
assertEqual(r4.account.email, 'account2@example.com', 'Fourth should continue rotation');
|
|
});
|
|
|
|
test('RoundRobinStrategy: skips unavailable accounts', () => {
|
|
const strategy = new RoundRobinStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
accounts[1].enabled = false; // Disable account2
|
|
|
|
const r1 = strategy.selectAccount(accounts, 'model');
|
|
const r2 = strategy.selectAccount(accounts, 'model');
|
|
const r3 = strategy.selectAccount(accounts, 'model');
|
|
|
|
// account2 is skipped
|
|
assertEqual(r1.account.email, 'account3@example.com');
|
|
assertEqual(r2.account.email, 'account1@example.com');
|
|
assertEqual(r3.account.email, 'account3@example.com');
|
|
});
|
|
|
|
test('RoundRobinStrategy: returns null when all accounts unavailable', () => {
|
|
const strategy = new RoundRobinStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
accounts.forEach(a => a.enabled = false);
|
|
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertNull(result.account, 'Should return null when all unavailable');
|
|
});
|
|
|
|
test('RoundRobinStrategy: resetCursor resets position', () => {
|
|
const strategy = new RoundRobinStrategy();
|
|
const accounts = createMockAccounts(3);
|
|
|
|
strategy.selectAccount(accounts, 'model'); // Moves cursor
|
|
strategy.selectAccount(accounts, 'model'); // Moves cursor
|
|
strategy.resetCursor();
|
|
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertEqual(result.account.email, 'account2@example.com', 'Should start from beginning after reset');
|
|
});
|
|
|
|
// ==========================================================================
|
|
// HYBRID STRATEGY TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── HybridStrategy Tests ───');
|
|
|
|
test('HybridStrategy: returns null for empty accounts', () => {
|
|
const strategy = new HybridStrategy();
|
|
const result = strategy.selectAccount([], 'model');
|
|
assertNull(result.account, 'Should return null for empty accounts');
|
|
});
|
|
|
|
test('HybridStrategy: selects best scored account', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70 },
|
|
tokenBucket: { initialTokens: 50, maxTokens: 50 }
|
|
});
|
|
const accounts = createMockAccounts(3);
|
|
// Make account3 older (higher LRU score)
|
|
accounts[2].lastUsed = Date.now() - 3600000; // 1 hour ago
|
|
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
// account3 should win due to higher LRU score
|
|
assertEqual(result.account.email, 'account3@example.com', 'Oldest account should be selected');
|
|
});
|
|
|
|
test('HybridStrategy: uses emergency fallback for unhealthy accounts', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 40, minUsable: 50 },
|
|
tokenBucket: { initialTokens: 50, maxTokens: 50 }
|
|
});
|
|
const accounts = createMockAccounts(3);
|
|
|
|
// All accounts start with health 40, which is below minUsable 50
|
|
// But emergency fallback should still return an account
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertNotNull(result.account, 'Emergency fallback should return an account');
|
|
// waitMs indicates fallback was used (250ms for emergency)
|
|
assertTrue(result.waitMs >= 250, 'Emergency fallback should add throttle delay');
|
|
});
|
|
|
|
test('HybridStrategy: uses last resort fallback for accounts without tokens', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70 },
|
|
tokenBucket: { initialTokens: 0, maxTokens: 50 }
|
|
});
|
|
const accounts = createMockAccounts(3);
|
|
|
|
// No tokens, but last resort fallback should still return an account
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertNotNull(result.account, 'Last resort fallback should return an account');
|
|
// waitMs indicates fallback was used (500ms for lastResort)
|
|
assertTrue(result.waitMs >= 500, 'Last resort fallback should add throttle delay');
|
|
});
|
|
|
|
test('HybridStrategy: consumes token on selection', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70 },
|
|
tokenBucket: { initialTokens: 10, maxTokens: 50 }
|
|
});
|
|
const accounts = createMockAccounts(1);
|
|
|
|
strategy.selectAccount(accounts, 'model');
|
|
const tracker = strategy.getTokenBucketTracker();
|
|
assertEqual(tracker.getTokens(accounts[0].email), 9, 'Token should be consumed');
|
|
});
|
|
|
|
test('HybridStrategy: onSuccess increases health', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70, successReward: 5 }
|
|
});
|
|
const account = { email: 'test@example.com' };
|
|
|
|
strategy.onSuccess(account, 'model');
|
|
const tracker = strategy.getHealthTracker();
|
|
assertEqual(tracker.getScore('test@example.com'), 75, 'Health should increase');
|
|
});
|
|
|
|
test('HybridStrategy: onRateLimit decreases health', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70, rateLimitPenalty: -10 }
|
|
});
|
|
const account = { email: 'test@example.com' };
|
|
|
|
strategy.onRateLimit(account, 'model');
|
|
const tracker = strategy.getHealthTracker();
|
|
assertEqual(tracker.getScore('test@example.com'), 60, 'Health should decrease');
|
|
});
|
|
|
|
test('HybridStrategy: onFailure decreases health and refunds token', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70, failurePenalty: -20 },
|
|
tokenBucket: { initialTokens: 10, maxTokens: 50 }
|
|
});
|
|
const accounts = createMockAccounts(1);
|
|
|
|
// First consume a token
|
|
strategy.selectAccount(accounts, 'model');
|
|
const tokensBefore = strategy.getTokenBucketTracker().getTokens(accounts[0].email);
|
|
|
|
// Then fail
|
|
strategy.onFailure(accounts[0], 'model');
|
|
|
|
const healthTracker = strategy.getHealthTracker();
|
|
const tokenTracker = strategy.getTokenBucketTracker();
|
|
|
|
assertEqual(healthTracker.getScore(accounts[0].email), 50, 'Health should decrease by 20');
|
|
assertEqual(tokenTracker.getTokens(accounts[0].email), tokensBefore + 1, 'Token should be refunded');
|
|
});
|
|
|
|
test('HybridStrategy: scoring formula weights work correctly', () => {
|
|
// Test that health, tokens, and LRU all contribute to score
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 100 },
|
|
tokenBucket: { initialTokens: 50, maxTokens: 50 },
|
|
weights: { health: 2, tokens: 5, lru: 0.1 }
|
|
});
|
|
|
|
const accounts = [
|
|
{ email: 'high-health@example.com', enabled: true, lastUsed: Date.now() },
|
|
{ email: 'old-account@example.com', enabled: true, lastUsed: Date.now() - 3600000 }
|
|
];
|
|
|
|
// Both have same health and tokens, but old-account has higher LRU
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertEqual(result.account.email, 'old-account@example.com', 'Older account should win with LRU weight');
|
|
});
|
|
|
|
test('HybridStrategy: filters out accounts with critical quota', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70 },
|
|
tokenBucket: { initialTokens: 50, maxTokens: 50 },
|
|
quota: { criticalThreshold: 0.05, staleMs: 300000 }
|
|
});
|
|
|
|
const accounts = [
|
|
{
|
|
email: 'critical@example.com',
|
|
enabled: true,
|
|
lastUsed: Date.now() - 3600000, // Older (would normally win LRU)
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.02 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
},
|
|
{
|
|
email: 'healthy@example.com',
|
|
enabled: true,
|
|
lastUsed: Date.now()
|
|
}
|
|
];
|
|
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertEqual(result.account.email, 'healthy@example.com', 'Critical quota account should be excluded');
|
|
});
|
|
|
|
test('HybridStrategy: prefers higher quota accounts', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70 },
|
|
tokenBucket: { initialTokens: 50, maxTokens: 50 },
|
|
quota: { weight: 3 },
|
|
weights: { health: 2, tokens: 5, quota: 3, lru: 0.1 }
|
|
});
|
|
|
|
// Create accounts with same lastUsed (equal LRU)
|
|
const now = Date.now();
|
|
const accounts = [
|
|
{
|
|
email: 'low-quota@example.com',
|
|
enabled: true,
|
|
lastUsed: now,
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.20 } },
|
|
lastChecked: now
|
|
}
|
|
},
|
|
{
|
|
email: 'high-quota@example.com',
|
|
enabled: true,
|
|
lastUsed: now,
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.80 } },
|
|
lastChecked: now
|
|
}
|
|
}
|
|
];
|
|
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertEqual(result.account.email, 'high-quota@example.com', 'Higher quota account should be preferred');
|
|
});
|
|
|
|
test('HybridStrategy: falls back when all accounts have critical quota', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70 },
|
|
tokenBucket: { initialTokens: 50, maxTokens: 50 },
|
|
quota: { criticalThreshold: 0.05, staleMs: 300000 }
|
|
});
|
|
|
|
const accounts = [
|
|
{
|
|
email: 'critical1@example.com',
|
|
enabled: true,
|
|
lastUsed: Date.now() - 60000,
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.02 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
},
|
|
{
|
|
email: 'critical2@example.com',
|
|
enabled: true,
|
|
lastUsed: Date.now(),
|
|
quota: {
|
|
models: { 'model': { remainingFraction: 0.01 } },
|
|
lastChecked: Date.now()
|
|
}
|
|
}
|
|
];
|
|
|
|
// Should fall back and select an account even though all are critical
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertTrue(result.account !== null, 'Should fall back to critical quota accounts when no alternatives');
|
|
});
|
|
|
|
test('HybridStrategy: getQuotaTracker returns tracker', () => {
|
|
const strategy = new HybridStrategy();
|
|
const tracker = strategy.getQuotaTracker();
|
|
assertTrue(tracker instanceof QuotaTracker, 'Should return QuotaTracker instance');
|
|
});
|
|
|
|
// ==========================================================================
|
|
// STRATEGY FACTORY TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── Strategy Factory Tests ───');
|
|
|
|
test('createStrategy: creates StickyStrategy for "sticky"', () => {
|
|
const strategy = createStrategy('sticky');
|
|
assertTrue(strategy instanceof StickyStrategy, 'Should create StickyStrategy');
|
|
});
|
|
|
|
test('createStrategy: creates RoundRobinStrategy for "round-robin"', () => {
|
|
const strategy = createStrategy('round-robin');
|
|
assertTrue(strategy instanceof RoundRobinStrategy, 'Should create RoundRobinStrategy');
|
|
});
|
|
|
|
test('createStrategy: creates RoundRobinStrategy for "roundrobin"', () => {
|
|
const strategy = createStrategy('roundrobin');
|
|
assertTrue(strategy instanceof RoundRobinStrategy, 'Should accept roundrobin alias');
|
|
});
|
|
|
|
test('createStrategy: creates HybridStrategy for "hybrid"', () => {
|
|
const strategy = createStrategy('hybrid');
|
|
assertTrue(strategy instanceof HybridStrategy, 'Should create HybridStrategy');
|
|
});
|
|
|
|
test('createStrategy: falls back to HybridStrategy for unknown strategy', () => {
|
|
const strategy = createStrategy('unknown');
|
|
assertTrue(strategy instanceof HybridStrategy, 'Should fall back to HybridStrategy');
|
|
});
|
|
|
|
test('createStrategy: uses default when null', () => {
|
|
const strategy = createStrategy(null);
|
|
assertTrue(strategy instanceof HybridStrategy, 'Null should use default HybridStrategy');
|
|
});
|
|
|
|
test('createStrategy: is case-insensitive', () => {
|
|
const s1 = createStrategy('STICKY');
|
|
const s2 = createStrategy('Hybrid');
|
|
const s3 = createStrategy('ROUND-ROBIN');
|
|
assertTrue(s1 instanceof StickyStrategy);
|
|
assertTrue(s2 instanceof HybridStrategy);
|
|
assertTrue(s3 instanceof RoundRobinStrategy);
|
|
});
|
|
|
|
test('isValidStrategy: returns true for valid strategies', () => {
|
|
assertTrue(isValidStrategy('sticky'));
|
|
assertTrue(isValidStrategy('round-robin'));
|
|
assertTrue(isValidStrategy('hybrid'));
|
|
assertTrue(isValidStrategy('roundrobin'));
|
|
});
|
|
|
|
test('isValidStrategy: returns false for invalid strategies', () => {
|
|
assertFalse(isValidStrategy('invalid'));
|
|
assertFalse(isValidStrategy(''));
|
|
assertFalse(isValidStrategy(null));
|
|
assertFalse(isValidStrategy(undefined));
|
|
});
|
|
|
|
test('getStrategyLabel: returns correct labels', () => {
|
|
assertEqual(getStrategyLabel('sticky'), 'Sticky (Cache Optimized)');
|
|
assertEqual(getStrategyLabel('round-robin'), 'Round Robin (Load Balanced)');
|
|
assertEqual(getStrategyLabel('roundrobin'), 'Round Robin (Load Balanced)');
|
|
assertEqual(getStrategyLabel('hybrid'), 'Hybrid (Smart Distribution)');
|
|
});
|
|
|
|
test('getStrategyLabel: returns default label for unknown', () => {
|
|
assertEqual(getStrategyLabel('unknown'), 'Hybrid (Smart Distribution)');
|
|
assertEqual(getStrategyLabel(null), 'Hybrid (Smart Distribution)');
|
|
});
|
|
|
|
test('STRATEGY_NAMES contains all valid strategies', () => {
|
|
assertDeepEqual(STRATEGY_NAMES, ['sticky', 'round-robin', 'hybrid']);
|
|
});
|
|
|
|
test('DEFAULT_STRATEGY is hybrid', () => {
|
|
assertEqual(DEFAULT_STRATEGY, 'hybrid');
|
|
});
|
|
|
|
// ==========================================================================
|
|
// INTEGRATION TESTS
|
|
// ==========================================================================
|
|
console.log('\n─── Integration Tests ───');
|
|
|
|
test('Integration: Hybrid strategy recovers from rate limits', () => {
|
|
const strategy = new HybridStrategy({
|
|
healthScore: { initial: 70, rateLimitPenalty: -10, minUsable: 50 }
|
|
});
|
|
const accounts = createMockAccounts(2);
|
|
|
|
// Rate limit first account multiple times
|
|
for (let i = 0; i < 3; i++) {
|
|
strategy.onRateLimit(accounts[0], 'model');
|
|
}
|
|
|
|
// Health of first account should be 40 (below minUsable 50)
|
|
const healthTracker = strategy.getHealthTracker();
|
|
assertEqual(healthTracker.getScore(accounts[0].email), 40);
|
|
assertFalse(healthTracker.isUsable(accounts[0].email));
|
|
|
|
// Selection should prefer second account
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertEqual(result.account.email, 'account2@example.com');
|
|
});
|
|
|
|
test('Integration: Token exhaustion triggers last resort fallback', () => {
|
|
const strategy = new HybridStrategy({
|
|
tokenBucket: { initialTokens: 2, maxTokens: 10 }
|
|
});
|
|
const accounts = createMockAccounts(1);
|
|
|
|
// Consume all tokens
|
|
strategy.selectAccount(accounts, 'model'); // 2 -> 1
|
|
strategy.selectAccount(accounts, 'model'); // 1 -> 0
|
|
|
|
// Third request should use last resort fallback (not null)
|
|
const result = strategy.selectAccount(accounts, 'model');
|
|
assertNotNull(result.account, 'Last resort fallback should return an account');
|
|
// waitMs indicates fallback was used (500ms for lastResort)
|
|
assertTrue(result.waitMs >= 500, 'Last resort fallback should add throttle delay');
|
|
});
|
|
|
|
test('Integration: Multi-model rate limiting is independent', () => {
|
|
const strategy = new StickyStrategy();
|
|
const accounts = createMockAccounts(2);
|
|
|
|
// Rate limit account1 for model-a only
|
|
accounts[0].modelRateLimits = {
|
|
'model-a': { isRateLimited: true, resetTime: Date.now() + 300000 }
|
|
};
|
|
|
|
// model-a should switch to account2
|
|
const resultA = strategy.selectAccount(accounts, 'model-a', { currentIndex: 0 });
|
|
assertEqual(resultA.account.email, 'account2@example.com');
|
|
|
|
// model-b should still use account1
|
|
const resultB = strategy.selectAccount(accounts, 'model-b', { currentIndex: 0 });
|
|
assertEqual(resultB.account.email, 'account1@example.com');
|
|
});
|
|
|
|
// Summary
|
|
console.log('\n' + '═'.repeat(60));
|
|
console.log(`Tests completed: ${passed} passed, ${failed} failed`);
|
|
|
|
if (failed > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
runTests().catch(err => {
|
|
console.error('Test suite failed:', err);
|
|
process.exit(1);
|
|
});
|