diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 3abb78d9..f28c9b62 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -25,18 +25,15 @@ const GEMINI_CLI_GROUPS: Record< { label: string; models: string[]; - preferred: string; } > = { 'gemini-flash-series': { label: 'Gemini Flash Series', models: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'], - preferred: 'gemini-3-flash-preview', }, 'gemini-pro-series': { label: 'Gemini Pro Series', models: ['gemini-3-pro-preview', 'gemini-2.5-pro'], - preferred: 'gemini-3-pro-preview', }, }; diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts new file mode 100644 index 00000000..a6003b80 --- /dev/null +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -0,0 +1,88 @@ +/** + * Auth Utilities Unit Tests + * + * Tests for shared authentication utility functions + */ + +import { describe, it, expect } from 'bun:test'; +import { sanitizeEmail, isTokenExpired } from '../../../src/cliproxy/auth-utils'; + +describe('Auth Utilities', () => { + describe('sanitizeEmail', () => { + it('should replace @ with underscore', () => { + const result = sanitizeEmail('user@example.com'); + expect(result).not.toContain('@'); + expect(result).toContain('user_example'); + }); + + it('should replace . with underscore', () => { + const result = sanitizeEmail('user@example.com'); + expect(result).not.toContain('.'); + expect(result).toBe('user_example_com'); + }); + + it('should handle multiple dots', () => { + const result = sanitizeEmail('user.name@sub.example.com'); + expect(result).toBe('user_name_sub_example_com'); + }); + + it('should handle email without dots in domain', () => { + const result = sanitizeEmail('user@localhost'); + expect(result).toBe('user_localhost'); + }); + + it('should handle empty string', () => { + const result = sanitizeEmail(''); + expect(result).toBe(''); + }); + }); + + describe('isTokenExpired', () => { + it('should return false for undefined input', () => { + const result = isTokenExpired(undefined); + expect(result).toBe(false); + }); + + it('should return false for empty string', () => { + const result = isTokenExpired(''); + expect(result).toBe(false); + }); + + it('should return true for past date', () => { + const pastDate = new Date(Date.now() - 3600000).toISOString(); // 1 hour ago + const result = isTokenExpired(pastDate); + expect(result).toBe(true); + }); + + it('should return false for future date', () => { + const futureDate = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now + const result = isTokenExpired(futureDate); + expect(result).toBe(false); + }); + + it('should return false for invalid date string', () => { + // new Date('invalid') returns Invalid Date, getTime() returns NaN + // NaN < Date.now() is false + const result = isTokenExpired('not-a-date'); + expect(result).toBe(false); + }); + + it('should handle ISO date strings', () => { + const pastISO = '2020-01-01T00:00:00.000Z'; + expect(isTokenExpired(pastISO)).toBe(true); + + const futureISO = '2030-01-01T00:00:00.000Z'; + expect(isTokenExpired(futureISO)).toBe(false); + }); + + it('should handle Unix timestamp strings', () => { + // JavaScript Date can parse numeric strings as timestamps + const pastTimestamp = String(Date.now() - 86400000); // Yesterday + // Note: Date parsing of pure numbers as strings is inconsistent + // This test documents the actual behavior + const result = isTokenExpired(pastTimestamp); + // The behavior depends on how Date parses the string + expect(typeof result).toBe('boolean'); + }); + }); +}); diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts new file mode 100644 index 00000000..5c774f4b --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -0,0 +1,190 @@ +/** + * Codex Quota Fetcher Unit Tests + * + * Tests for Codex quota window parsing and transformation logic + */ + +import { describe, it, expect } from 'bun:test'; +import { buildCodexQuotaWindows } from '../../../src/cliproxy/quota-fetcher-codex'; + +describe('Codex Quota Fetcher', () => { + describe('buildCodexQuotaWindows', () => { + it('should parse snake_case API response', () => { + const response = { + rate_limit: { + primary_window: { + used_percent: 25, + reset_after_seconds: 3600, + }, + secondary_window: { + used_percent: 50, + reset_after_seconds: 86400, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(2); + expect(windows[0].label).toBe('Primary'); + expect(windows[0].usedPercent).toBe(25); + expect(windows[0].remainingPercent).toBe(75); + expect(windows[0].resetAfterSeconds).toBe(3600); + expect(windows[1].label).toBe('Secondary'); + expect(windows[1].usedPercent).toBe(50); + }); + + it('should parse camelCase API response', () => { + const response = { + rateLimit: { + primaryWindow: { + usedPercent: 30, + resetAfterSeconds: 7200, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].usedPercent).toBe(30); + expect(windows[0].resetAfterSeconds).toBe(7200); + }); + + it('should handle code review rate limits', () => { + const response = { + code_review_rate_limit: { + primary_window: { + used_percent: 80, + reset_after_seconds: 1800, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].label).toBe('Code Review (Primary)'); + expect(windows[0].usedPercent).toBe(80); + }); + + it('should clamp usedPercent to 0-100 range', () => { + const response = { + rate_limit: { + primary_window: { + used_percent: 150, // Over 100 + reset_after_seconds: null, + }, + secondary_window: { + used_percent: -20, // Negative + reset_after_seconds: null, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows[0].usedPercent).toBe(100); + expect(windows[0].remainingPercent).toBe(0); + expect(windows[1].usedPercent).toBe(0); + expect(windows[1].remainingPercent).toBe(100); + }); + + it('should handle null reset_after_seconds', () => { + const response = { + rate_limit: { + primary_window: { + used_percent: 10, + reset_after_seconds: null, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows[0].resetAfterSeconds).toBeNull(); + expect(windows[0].resetAt).toBeNull(); + }); + + it('should calculate resetAt from positive seconds', () => { + const response = { + rate_limit: { + primary_window: { + used_percent: 10, + reset_after_seconds: 3600, // 1 hour + }, + }, + }; + + const before = Date.now(); + const windows = buildCodexQuotaWindows(response); + const after = Date.now(); + + expect(windows[0].resetAt).not.toBeNull(); + const resetTime = new Date(windows[0].resetAt!).getTime(); + expect(resetTime).toBeGreaterThanOrEqual(before + 3600000); + expect(resetTime).toBeLessThanOrEqual(after + 3600000); + }); + + it('should not calculate resetAt for zero or negative seconds', () => { + const response = { + rate_limit: { + primary_window: { + used_percent: 10, + reset_after_seconds: 0, + }, + secondary_window: { + used_percent: 20, + reset_after_seconds: -100, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows[0].resetAt).toBeNull(); + expect(windows[1].resetAt).toBeNull(); + }); + + it('should return empty array for empty response', () => { + const windows = buildCodexQuotaWindows({}); + expect(windows).toHaveLength(0); + }); + + it('should return empty array for missing rate limit', () => { + const response = { + plan_type: 'plus', + }; + + const windows = buildCodexQuotaWindows(response); + expect(windows).toHaveLength(0); + }); + + it('should handle missing window data gracefully', () => { + const response = { + rate_limit: { + primary_window: undefined, + secondary_window: null, + }, + }; + + const windows = buildCodexQuotaWindows(response as never); + expect(windows).toHaveLength(0); + }); + + it('should default usedPercent to 0 when missing', () => { + const response = { + rate_limit: { + primary_window: { + reset_after_seconds: 3600, + }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows[0].usedPercent).toBe(0); + expect(windows[0].remainingPercent).toBe(100); + }); + }); +}); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts new file mode 100644 index 00000000..2a9e6b6f --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -0,0 +1,196 @@ +/** + * Gemini CLI Quota Fetcher Unit Tests + * + * Tests for Gemini CLI bucket parsing and transformation logic + */ + +import { describe, it, expect } from 'bun:test'; +import { + buildGeminiCliBuckets, + resolveGeminiCliProjectId, +} from '../../../src/cliproxy/quota-fetcher-gemini-cli'; + +describe('Gemini CLI Quota Fetcher', () => { + describe('resolveGeminiCliProjectId', () => { + it('should extract project ID from account field', () => { + const account = 'user@example.com (cloudaicompanion-abc-123)'; + const projectId = resolveGeminiCliProjectId(account); + expect(projectId).toBe('cloudaicompanion-abc-123'); + }); + + it('should return last parenthetical when multiple exist', () => { + const account = 'user (org) (cloudaicompanion-xyz-789)'; + const projectId = resolveGeminiCliProjectId(account); + expect(projectId).toBe('cloudaicompanion-xyz-789'); + }); + + it('should return null for account without parentheses', () => { + const account = 'user@example.com'; + const projectId = resolveGeminiCliProjectId(account); + expect(projectId).toBeNull(); + }); + + it('should return null for empty string', () => { + const projectId = resolveGeminiCliProjectId(''); + expect(projectId).toBeNull(); + }); + + it('should handle nested parentheses', () => { + const account = 'user@example.com (project-id)'; + const projectId = resolveGeminiCliProjectId(account); + expect(projectId).toBe('project-id'); + }); + }); + + describe('buildGeminiCliBuckets', () => { + it('should group models by series', () => { + const rawBuckets = [ + { model_id: 'gemini-3-flash-preview', remaining_fraction: 0.8 }, + { model_id: 'gemini-2.5-flash', remaining_fraction: 0.6 }, + { model_id: 'gemini-3-pro-preview', remaining_fraction: 0.9 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + // Should have 2 groups: Flash Series and Pro Series + expect(buckets.length).toBeGreaterThanOrEqual(2); + + const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series'); + expect(flashBucket).toBeDefined(); + // Takes minimum remaining fraction (0.6) + expect(flashBucket!.remainingFraction).toBe(0.6); + expect(flashBucket!.remainingPercent).toBe(60); + + const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series'); + expect(proBucket).toBeDefined(); + expect(proBucket!.remainingFraction).toBe(0.9); + }); + + it('should handle camelCase API response', () => { + const rawBuckets = [ + { modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + expect(buckets).toHaveLength(1); + expect(buckets[0].remainingFraction).toBe(0.75); + }); + + it('should clamp remainingFraction to 0-1 range', () => { + const rawBuckets = [ + { model_id: 'gemini-3-flash-preview', remaining_fraction: 1.5 }, + { model_id: 'gemini-3-pro-preview', remaining_fraction: -0.2 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series'); + expect(flashBucket!.remainingFraction).toBe(1); + expect(flashBucket!.remainingPercent).toBe(100); + + const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series'); + expect(proBucket!.remainingFraction).toBe(0); + expect(proBucket!.remainingPercent).toBe(0); + }); + + it('should group by token type', () => { + const rawBuckets = [ + { model_id: 'gemini-3-flash-preview', token_type: 'input', remaining_fraction: 0.8 }, + { model_id: 'gemini-3-flash-preview', token_type: 'output', remaining_fraction: 0.5 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + // Should have separate buckets for input and output + expect(buckets.length).toBe(2); + const inputBucket = buckets.find((b) => b.tokenType === 'input'); + const outputBucket = buckets.find((b) => b.tokenType === 'output'); + expect(inputBucket).toBeDefined(); + expect(outputBucket).toBeDefined(); + expect(inputBucket!.remainingFraction).toBe(0.8); + expect(outputBucket!.remainingFraction).toBe(0.5); + }); + + it('should ignore deprecated models', () => { + const rawBuckets = [ + { model_id: 'gemini-2.0-flash-deprecated', remaining_fraction: 0.1 }, + { model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + // Only gemini-3-flash-preview should be included + expect(buckets).toHaveLength(1); + expect(buckets[0].remainingFraction).toBe(0.9); + }); + + it('should categorize unknown models as "other"', () => { + const rawBuckets = [{ model_id: 'unknown-model-xyz', remaining_fraction: 0.7 }]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + expect(buckets).toHaveLength(1); + expect(buckets[0].label).toBe('Other Models'); + }); + + it('should handle empty buckets array', () => { + const buckets = buildGeminiCliBuckets([]); + expect(buckets).toHaveLength(0); + }); + + it('should skip buckets with empty model_id', () => { + const rawBuckets = [ + { model_id: '', remaining_fraction: 0.5 }, + { model_id: 'gemini-3-flash-preview', remaining_fraction: 0.8 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + expect(buckets).toHaveLength(1); + expect(buckets[0].remainingFraction).toBe(0.8); + }); + + it('should keep earliest reset time when merging', () => { + const rawBuckets = [ + { + model_id: 'gemini-3-flash-preview', + remaining_fraction: 0.8, + reset_time: '2026-01-30T12:00:00Z', + }, + { + model_id: 'gemini-2.5-flash', + remaining_fraction: 0.6, + reset_time: '2026-01-30T10:00:00Z', // Earlier + }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series'); + expect(flashBucket!.resetTime).toBe('2026-01-30T10:00:00Z'); + }); + + it('should default remainingFraction to 1 when missing', () => { + const rawBuckets = [{ model_id: 'gemini-3-flash-preview' }]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + expect(buckets[0].remainingFraction).toBe(1); + expect(buckets[0].remainingPercent).toBe(100); + }); + + it('should collect modelIds in bucket', () => { + const rawBuckets = [ + { model_id: 'gemini-3-flash-preview', remaining_fraction: 0.8 }, + { model_id: 'gemini-2.5-flash', remaining_fraction: 0.6 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series'); + expect(flashBucket!.modelIds).toContain('gemini-3-flash-preview'); + expect(flashBucket!.modelIds).toContain('gemini-2.5-flash'); + }); + }); +});