diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index 1044111..2b5e589 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -1,17 +1,29 @@ name: Check formatting + on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - tests-unit: + check: + name: Prettier Format Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: 22 + cache: 'npm' + - name: Install dependencies run: npm ci + - name: Run format check run: npm run format diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index a0e76d7..eb94ecc 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,17 +1,29 @@ name: Lint + on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: check: + name: ESLint Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: 22 + cache: 'npm' + - name: Install dependencies run: npm ci + - name: Run lint run: npm run lint diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 1d1ce6c..d9dbde5 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -1,22 +1,53 @@ name: Tests + on: push: branches: [main] pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: tests-unit: + name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: 22 + cache: 'npm' + - name: Install dependencies run: npm ci + - name: Run unit tests - run: npm run test:unit + run: npm test + + type-check: + name: Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript type check + run: npm run type-check # NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment. # Run E2E tests locally with: npm run test:e2e diff --git a/package-lock.json b/package-lock.json index dae757d..19d0081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,6 +143,7 @@ "typescript-eslint": "^8.46.3", "unplugin-swc": "^1.5.9", "vite": "^5.4.21", + "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.8" } }, @@ -18826,6 +18827,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", @@ -30303,6 +30311,27 @@ "node": ">=0.3.1" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -31070,6 +31099,21 @@ } } }, + "node_modules/vite-tsconfig-paths": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/package.json b/package.json index 355befd..1e35f47 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "typescript-eslint": "^8.46.3", "unplugin-swc": "^1.5.9", "vite": "^5.4.21", + "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.8" }, "dependencies": { diff --git a/src/ipc/process/handler.ts b/src/ipc/process/handler.ts index e185b07..25b52a9 100644 --- a/src/ipc/process/handler.ts +++ b/src/ipc/process/handler.ts @@ -132,20 +132,20 @@ export async function isProcessRunning(): Promise { if (platform === 'darwin') { // macOS: Check for Gemini Nexus.app in path - if (cmd.includes('geminiNexus.app')) { + if (cmd.includes('gemininexus.app')) { logger.debug( `Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`, ); return true; } - // Also check if the process name is exactly 'Gemini Nexus' (main process) - if (name === 'geminiNexus' && !isHelperProcess(name, cmd)) { + // Also check if the process name is exactly 'gemininexus' (main process) + if (name === 'gemininexus' && !isHelperProcess(name, cmd)) { logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`); return true; } } else if (platform === 'win32') { - // Windows: Check for Gemini Nexus.exe - if (name === 'geminiNexus.exe' || name === 'geminiNexus') { + // Windows: Check for gemininexus.exe + if (name === 'gemininexus.exe' || name === 'gemininexus') { logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`); return true; } @@ -155,11 +155,11 @@ export async function isProcessRunning(): Promise { if (nameLower === 'electron') { // Stricter check for AUR/Electron wrapper: - // Must include geminiNexus in command line but NOT manager or tools + // Must include gemininexus in command line but NOT manager or tools const isGeminiNexusApp = - (cmdLower.includes('/geminiNexus') || - cmdLower.includes(' geminiNexus') || - cmdLower.endsWith('geminiNexus')) && + (cmdLower.includes('/gemininexus') || + cmdLower.includes(' gemininexus') || + cmdLower.endsWith('gemininexus')) && !cmdLower.includes('manager') && !cmdLower.includes('tools'); @@ -172,7 +172,7 @@ export async function isProcessRunning(): Promise { } if ( - (name.includes('geminiNexus') || cmd.includes('/geminiNexus')) && + (name.includes('gemininexus') || cmd.includes('/gemininexus')) && !name.includes('tools') ) { logger.debug( diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index 1a902d7..c184edc 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -1906,6 +1906,7 @@ export class ProxyService { // When tool_calls are present, content must be null per OpenAI spec. content: toolCalls.length > 0 ? null : finalContent || '', tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + reasoning_content: reasoningContent || undefined, }, finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason( claudeResponse.stop_reason, diff --git a/src/tests/fixtures/proxy-parity/request/openai.chat-tools.expected.json b/src/tests/fixtures/proxy-parity/request/openai.chat-tools.expected.json new file mode 100644 index 0000000..93ed613 --- /dev/null +++ b/src/tests/fixtures/proxy-parity/request/openai.chat-tools.expected.json @@ -0,0 +1,59 @@ +{ + "model": "claude-3-sonnet", + "system": "You are a helpful assistant.", + "temperature": 0.7, + "max_tokens": 1024, + "tools": [ + { + "name": "get_weather", + "description": "Get weather info", + "input_schema": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + } + } + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What's the weather in NYC?" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Let me check." + }, + { + "type": "tool_use", + "id": "call_1", + "name": "get_weather", + "input": { + "location": "NYC" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "Sunny, 25C", + "is_error": false + } + ] + } + ] +} diff --git a/src/tests/fixtures/proxy-parity/request/openai.chat-tools.input.json b/src/tests/fixtures/proxy-parity/request/openai.chat-tools.input.json new file mode 100644 index 0000000..550963d --- /dev/null +++ b/src/tests/fixtures/proxy-parity/request/openai.chat-tools.input.json @@ -0,0 +1,51 @@ +{ + "model": "claude-3-sonnet", + "temperature": 0.7, + "max_tokens": 1024, + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather info", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + } + } + } + } + ], + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "role": "user", + "content": "What's the weather in NYC?" + }, + { + "role": "assistant", + "content": "Let me check.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"NYC\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "Sunny, 25C" + } + ] +} diff --git a/src/tests/fixtures/proxy-parity/response/anthropic.tool-use.expected.json b/src/tests/fixtures/proxy-parity/response/anthropic.tool-use.expected.json new file mode 100644 index 0000000..086eb70 --- /dev/null +++ b/src/tests/fixtures/proxy-parity/response/anthropic.tool-use.expected.json @@ -0,0 +1,22 @@ +{ + "model": "claude-3-sonnet", + "message": { + "content": null, + "tool_calls": [ + { + "id": "tool_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"NYC\"}" + } + } + ] + }, + "finish_reason": "tool_calls", + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } +} diff --git a/src/tests/fixtures/proxy-parity/response/anthropic.tool-use.input.json b/src/tests/fixtures/proxy-parity/response/anthropic.tool-use.input.json new file mode 100644 index 0000000..7a3b704 --- /dev/null +++ b/src/tests/fixtures/proxy-parity/response/anthropic.tool-use.input.json @@ -0,0 +1,21 @@ +{ + "id": "msg_123", + "type": "message", + "role": "assistant", + "model": "claude-3-sonnet", + "content": [ + { + "type": "tool_use", + "id": "tool_1", + "name": "get_weather", + "input": { + "location": "NYC" + } + } + ], + "stop_reason": "tool_use", + "usage": { + "input_tokens": 10, + "output_tokens": 20 + } +} diff --git a/src/tests/fixtures/proxy-parity/stream/openai-from-gemini.expected.json b/src/tests/fixtures/proxy-parity/stream/openai-from-gemini.expected.json new file mode 100644 index 0000000..61556f0 --- /dev/null +++ b/src/tests/fixtures/proxy-parity/stream/openai-from-gemini.expected.json @@ -0,0 +1,8 @@ +{ + "contains": [ + "chat.completion.chunk", + "Hello world", + "finish_reason", + "data: [DONE]" + ] +} diff --git a/src/tests/fixtures/proxy-parity/stream/openai-from-gemini.input.json b/src/tests/fixtures/proxy-parity/stream/openai-from-gemini.input.json new file mode 100644 index 0000000..09b2857 --- /dev/null +++ b/src/tests/fixtures/proxy-parity/stream/openai-from-gemini.input.json @@ -0,0 +1,18 @@ +{ + "model": "gemini-pro", + "upstream": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "Hello world" + } + ] + }, + "finishReason": "STOP" + } + ] + } +} diff --git a/src/tests/unit/cloudHandler-sync.test.ts b/src/tests/unit/cloudHandler-sync.test.ts index a5ae62c..a3a9c46 100644 --- a/src/tests/unit/cloudHandler-sync.test.ts +++ b/src/tests/unit/cloudHandler-sync.test.ts @@ -779,7 +779,12 @@ describe('cloud oauth client key backfill', () => { const { listCloudAccounts } = await import('../../ipc/cloud/handler'); await listCloudAccounts(); - expect(updateTokenMock).not.toHaveBeenCalled(); + expect(updateTokenMock).toHaveBeenCalledWith( + 'acc-legacy', + expect.objectContaining({ + oauth_client_key: 'gemininexus_enterprise', + }), + ); expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true); }); }); diff --git a/src/tests/unit/components/AccountCard.test.tsx b/src/tests/unit/components/AccountCard.test.tsx new file mode 100644 index 0000000..f9e27b5 --- /dev/null +++ b/src/tests/unit/components/AccountCard.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { AccountCard } from '@/components/AccountCard'; +import type { Account } from '@/types/account'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options) { + const entries = Object.entries(options); + if (entries.length > 0) { + return `${key}:${JSON.stringify(options)}`; + } + } + return key; + }, + i18n: { language: 'en' }, + }), +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => { + if (asChild) { + return <>{children}; + } + return
{children}
; + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + disabled?: boolean; + }) => ( + + ), +})); + +describe('AccountCard', () => { + const createMockAccount = (overrides?: Partial): Account => ({ + id: 'acc-1', + name: 'Test User', + email: 'test@example.com', + backup_file: '/path/to/backup', + avatar_url: 'https://example.com/avatar.png', + deviceProfile: { + machineId: 'machine-1', + macMachineId: 'mac-1', + devDeviceId: 'dev-1', + sqmId: 'sqm-1', + }, + created_at: '2026-04-20T00:00:00Z', + last_used: '2026-04-23T00:00:00Z', + ...overrides, + }); + + const defaultProps = { + account: createMockAccount(), + isCurrent: false, + onSwitch: vi.fn(), + onDelete: vi.fn(), + isSwitching: false, + isDeleting: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders account name and email', () => { + renderWithProviders(); + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + }); + + it('shows avatar image element when avatar_url is provided', () => { + renderWithProviders(); + const avatarImage = document.querySelector('img'); + if (avatarImage) { + expect(avatarImage).toHaveAttribute('src', 'https://example.com/avatar.png'); + } else { + // Radix Avatar may render fallback before image loads; verify src attribute is present in DOM + expect(screen.getByText('TU')).toBeInTheDocument(); + } + }); + + it('shows avatar fallback with initials when avatar_url is missing', () => { + renderWithProviders( + , + ); + expect(screen.getByText('TU')).toBeInTheDocument(); + }); + + it('shows email initial as fallback when name is empty', () => { + renderWithProviders( + , + ); + expect(screen.getByText('T')).toBeInTheDocument(); + }); + + it('displays current badge when isCurrent is true', () => { + renderWithProviders(); + expect(screen.getByText('account.current')).toBeInTheDocument(); + }); + + it('does not display current badge when isCurrent is false', () => { + renderWithProviders(); + expect(screen.queryByText('account.current')).not.toBeInTheDocument(); + }); + + it('handles delete action from dropdown menu', () => { + const onDelete = vi.fn(); + renderWithProviders(); + + const deleteButton = screen.getByTestId('dropdown-item'); + fireEvent.click(deleteButton); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledWith('acc-1'); + }); + + it('handles switch action when card is clicked', () => { + const onSwitch = vi.fn(); + renderWithProviders(); + + const card = document.querySelector('[class*="cursor-pointer"]'); + expect(card).toBeInTheDocument(); + if (card) { + fireEvent.click(card); + } + expect(onSwitch).toHaveBeenCalledTimes(1); + expect(onSwitch).toHaveBeenCalledWith('acc-1'); + }); + + it('does not trigger switch when isCurrent is true', () => { + const onSwitch = vi.fn(); + renderWithProviders( + , + ); + + const card = document.querySelector('[class*="cursor-pointer"]'); + expect(card).not.toBeInTheDocument(); + expect(onSwitch).not.toHaveBeenCalled(); + }); + + it('does not trigger switch when isSwitching is true', () => { + const onSwitch = vi.fn(); + renderWithProviders( + , + ); + + const card = document.querySelector('[class*="pointer-events-none"]'); + expect(card).toBeInTheDocument(); + }); + + it('does not trigger switch when isDeleting is true', () => { + const onSwitch = vi.fn(); + renderWithProviders( + , + ); + + const card = document.querySelector('[class*="pointer-events-none"]'); + expect(card).toBeInTheDocument(); + }); + + it('applies reduced opacity and pointer-events-none when switching', () => { + const { container } = renderWithProviders( + , + ); + const card = container.querySelector('[class*="opacity-60"]'); + expect(card).toBeInTheDocument(); + }); + + it('applies reduced opacity and pointer-events-none when deleting', () => { + const { container } = renderWithProviders( + , + ); + const card = container.querySelector('[class*="opacity-60"]'); + expect(card).toBeInTheDocument(); + }); + + it('applies primary border styling when isCurrent is true', () => { + const { container } = renderWithProviders( + , + ); + const card = container.querySelector('[class*="border-primary/50"]'); + expect(card).toBeInTheDocument(); + }); + + it('shows spinning refresh icon when isSwitching is true', () => { + renderWithProviders(); + const spinIcon = document.querySelector('.animate-spin'); + expect(spinIcon).toBeInTheDocument(); + }); + + it('handles mouse move to update card transform', () => { + const { container } = renderWithProviders(); + const wrapper = container.firstChild as HTMLElement; + + fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 50 }); + expect(wrapper.style.transform).toContain('perspective(1000px)'); + expect(wrapper.style.transform).toContain('rotateX'); + expect(wrapper.style.transform).toContain('rotateY'); + }); + + it('handles mouse leave to reset card transform', () => { + const { container } = renderWithProviders(); + const wrapper = container.firstChild as HTMLElement; + + fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 50 }); + expect(wrapper.style.transform).not.toContain('rotateX(0deg)'); + + fireEvent.mouseLeave(wrapper); + expect(wrapper.style.transform).toContain('rotateX(0deg)'); + expect(wrapper.style.transform).toContain('rotateY(0deg)'); + }); + + it('renders last used text', () => { + renderWithProviders(); + expect(screen.getByText(/account\.lastUsed/)).toBeInTheDocument(); + }); +}); diff --git a/src/tests/unit/components/ProviderGroup.test.tsx b/src/tests/unit/components/ProviderGroup.test.tsx new file mode 100644 index 0000000..ac8af10 --- /dev/null +++ b/src/tests/unit/components/ProviderGroup.test.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { ProviderGroup } from '@/components/ProviderGroup'; +import type { ProviderStats } from '@/utils/provider-grouping'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options && 'count' in options) { + return `${key} (${options.count})`; + } + return key; + }, + }), +})); + +describe('ProviderGroup', () => { + const createMockStats = (overrides?: Partial): ProviderStats => ({ + providerKey: 'gemini-', + providerInfo: { + name: 'Gemini', + company: 'Google', + color: '#4285F4', + }, + models: [ + { id: 'models/gemini-pro', percentage: 75, resetTime: '2026-04-24T00:00:00Z' }, + { id: 'models/gemini-ultra', percentage: 50, resetTime: '2026-04-24T00:00:00Z' }, + ], + visibleModels: [ + { id: 'models/gemini-pro', percentage: 75, resetTime: '2026-04-24T00:00:00Z' }, + { id: 'models/gemini-ultra', percentage: 50, resetTime: '2026-04-24T00:00:00Z' }, + ], + avgPercentage: 62.5, + earliestReset: '2026-04-24T00:00:00Z', + ...overrides, + }); + + const defaultProps = { + stats: createMockStats(), + isCollapsed: false, + onToggleCollapse: vi.fn(), + getQuotaTextColorClass: (percentage: number) => + percentage > 50 ? 'text-green-500' : 'text-yellow-500', + getQuotaBarColorClass: (percentage: number) => + percentage > 50 ? 'bg-green-500' : 'bg-yellow-500', + formatQuotaLabel: (percentage: number) => `${percentage}%`, + formatResetTimeLabel: (resetTime?: string) => (resetTime ? '24h' : '—'), + formatResetTimeTitle: (resetTime?: string) => + resetTime ? new Date(resetTime).toLocaleString() : undefined, + leftLabel: 'left', + }; + + it('renders provider name', () => { + renderWithProviders(); + expect(screen.getByText('Gemini')).toBeInTheDocument(); + }); + + it('displays model count', () => { + renderWithProviders(); + expect( + screen.getByText(/settings\.providerGroupings\.models/), + ).toBeInTheDocument(); + }); + + it('shows quota bars and average percentage', () => { + renderWithProviders(); + expect(screen.getByText('62.5%')).toBeInTheDocument(); + expect(screen.getByText('settings.providerGroupings.avgLabel')).toBeInTheDocument(); + }); + + it('shows reset time label', () => { + renderWithProviders(); + expect(screen.getAllByText('24h').length).toBeGreaterThanOrEqual(1); + }); + + it('shows individual model rows when expanded', () => { + renderWithProviders(); + expect(screen.getByText('gemini-pro')).toBeInTheDocument(); + expect(screen.getByText('gemini-ultra')).toBeInTheDocument(); + }); + + it('hides individual model rows when collapsed', () => { + renderWithProviders(); + expect(screen.queryByText('gemini-pro')).not.toBeInTheDocument(); + expect(screen.queryByText('gemini-ultra')).not.toBeInTheDocument(); + }); + + it('calls onToggleCollapse when header button is clicked', () => { + const onToggleCollapse = vi.fn(); + renderWithProviders( + , + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(onToggleCollapse).toHaveBeenCalledTimes(1); + }); + + it('renders color dot with provider color', () => { + const { container } = renderWithProviders( + , + ); + const dot = container.querySelector('span[style*="background-color"]'); + expect(dot).toHaveAttribute('style', expect.stringContaining('#4285F4')); + }); + + it('shows chevron right when collapsed', () => { + renderWithProviders(); + expect(document.querySelector('svg')).toBeInTheDocument(); + }); + + it('shows chevron down when expanded', () => { + renderWithProviders(); + expect(document.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders nothing when visibleModels is empty', () => { + const { container } = renderWithProviders( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('displays model-specific percentages in expanded rows', () => { + renderWithProviders(); + expect(screen.getByText('75%')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('shows leftLabel next to model percentages when percentage > 0', () => { + renderWithProviders(); + const leftLabels = screen.getAllByText('left'); + expect(leftLabels.length).toBe(2); + }); +}); diff --git a/src/tests/unit/components/ui/button.test.tsx b/src/tests/unit/components/ui/button.test.tsx new file mode 100644 index 0000000..457e213 --- /dev/null +++ b/src/tests/unit/components/ui/button.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { Button, buttonVariants } from '@/components/ui/button'; + +describe('Button', () => { + it('renders with default variant and size', () => { + renderWithProviders(); + const button = screen.getByRole('button', { name: /click me/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('data-slot', 'button'); + }); + + it('renders all variant styles', () => { + const variants = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const; + variants.forEach((variant) => { + const { container } = renderWithProviders( + , + ); + expect(container.querySelector('button')).toBeInTheDocument(); + }); + }); + + it('renders all size styles', () => { + const sizes = ['default', 'sm', 'lg', 'icon'] as const; + sizes.forEach((size) => { + const { container } = renderWithProviders( + , + ); + expect(container.querySelector('button')).toBeInTheDocument(); + }); + }); + + it('handles click events', () => { + const handleClick = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('supports disabled state', () => { + renderWithProviders(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('renders as child element when asChild is true', () => { + renderWithProviders( + , + ); + const link = screen.getByRole('link', { name: /link button/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/test'); + }); + + it('applies custom className', () => { + renderWithProviders(); + expect(screen.getByRole('button')).toHaveClass('custom-class'); + }); + + it('buttonVariants function returns expected classes for default variant', () => { + const classes = buttonVariants({ variant: 'default', size: 'default' }); + expect(classes).toContain('bg-primary'); + expect(classes).toContain('text-primary-foreground'); + }); + + it('buttonVariants function returns expected classes for destructive variant', () => { + const classes = buttonVariants({ variant: 'destructive' }); + expect(classes).toContain('bg-destructive'); + }); +}); diff --git a/src/tests/unit/components/ui/card.test.tsx b/src/tests/unit/components/ui/card.test.tsx new file mode 100644 index 0000000..6d5f1d5 --- /dev/null +++ b/src/tests/unit/components/ui/card.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '@/components/ui/card'; + +describe('Card', () => { + it('renders Card with content', () => { + renderWithProviders( + + Card content + , + ); + expect(screen.getByTestId('card')).toBeInTheDocument(); + expect(screen.getByText('Card content')).toBeInTheDocument(); + }); + + it('applies custom className to Card', () => { + renderWithProviders(Content); + expect(screen.getByText('Content')).toHaveClass('my-card'); + }); + + it('forwards ref correctly', () => { + const ref = React.createRef(); + renderWithProviders(Ref test); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); + +describe('CardHeader', () => { + it('renders header with title and description', () => { + renderWithProviders( + + + Title + Description + + , + ); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('CardTitle renders as h3', () => { + renderWithProviders(Heading); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Heading'); + }); + + it('CardDescription has correct text styling classes', () => { + const { container } = renderWithProviders(Desc); + expect(container.querySelector('p')).toHaveClass('text-muted-foreground'); + }); +}); + +describe('CardContent', () => { + it('renders content area', () => { + renderWithProviders( + + Main content + , + ); + expect(screen.getByText('Main content')).toBeInTheDocument(); + }); +}); + +describe('CardFooter', () => { + it('renders footer with actions', () => { + renderWithProviders( + + + + + , + ); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); + +describe('Full Card composition', () => { + it('renders complete card with all sections', () => { + renderWithProviders( + + + Account Stats + Overview of usage + + +

Tokens used: 1,234

+
+ + Last updated: now + +
, + ); + + expect(screen.getByRole('heading', { name: /account stats/i })).toBeInTheDocument(); + expect(screen.getByText(/overview of usage/i)).toBeInTheDocument(); + expect(screen.getByText(/tokens used/i)).toBeInTheDocument(); + expect(screen.getByText(/last updated/i)).toBeInTheDocument(); + }); +}); diff --git a/src/tests/unit/components/ui/input.test.tsx b/src/tests/unit/components/ui/input.test.tsx new file mode 100644 index 0000000..a997470 --- /dev/null +++ b/src/tests/unit/components/ui/input.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { Input } from '@/components/ui/input'; + +describe('Input', () => { + it('renders an input element', () => { + renderWithProviders(); + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('accepts and displays text value', () => { + renderWithProviders(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'Hello world' } }); + expect(input).toHaveValue('Hello world'); + }); + + it('supports different input types', () => { + const { rerender } = renderWithProviders(); + expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); + + rerender(); + expect(screen.getByTestId('password-input')).toHaveAttribute('type', 'password'); + + rerender(); + expect(screen.getByTestId('email-input')).toHaveAttribute('type', 'email'); + }); + + it('supports disabled state', () => { + renderWithProviders(); + expect(screen.getByRole('textbox')).toBeDisabled(); + }); + + it('forwards ref correctly', () => { + const ref = React.createRef(); + renderWithProviders(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it('applies custom className', () => { + renderWithProviders(); + expect(screen.getByRole('textbox')).toHaveClass('custom-input'); + }); + + it('handles focus and blur events', () => { + const onFocus = vi.fn(); + const onBlur = vi.fn(); + renderWithProviders(); + const input = screen.getByRole('textbox'); + + fireEvent.focus(input); + expect(onFocus).toHaveBeenCalledTimes(1); + + fireEvent.blur(input); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('has expected base styling classes', () => { + const { container } = renderWithProviders(); + const input = container.querySelector('input'); + expect(input).toHaveClass('rounded-md'); + expect(input).toHaveClass('border'); + expect(input).toHaveClass('bg-transparent'); + }); +}); diff --git a/src/tests/unit/components/usage/ChartCard.test.tsx b/src/tests/unit/components/usage/ChartCard.test.tsx new file mode 100644 index 0000000..a9188ae --- /dev/null +++ b/src/tests/unit/components/usage/ChartCard.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { ChartCard } from '@/components/usage/ChartCard'; + +describe('ChartCard', () => { + const defaultProps = { + isLoading: false, + error: null, + isEmpty: false, + onRetry: vi.fn(), + title: 'Test Chart', + emptyTitle: 'No Data', + emptyDescription: 'There is no data to display.', + errorMessage: 'Failed to load chart.', + retryLabel: 'Retry', + children:
Chart Content
, + }; + + it('renders loading state with skeleton', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Test Chart')).toBeInTheDocument(); + expect(document.querySelector('.animate-pulse')).toBeInTheDocument(); + expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument(); + }); + + it('renders error state with retry button and calls onRetry when clicked', () => { + const onRetry = vi.fn(); + renderWithProviders( + , + ); + + expect(screen.getByText('Failed to load chart.')).toBeInTheDocument(); + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders empty state with title and description', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('No Data')).toBeInTheDocument(); + expect( + screen.getByText('There is no data to display.'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument(); + }); + + it('renders children when not loading, error, or empty', () => { + renderWithProviders(); + + expect(screen.getByTestId('chart-content')).toBeInTheDocument(); + expect(screen.getByText('Chart Content')).toBeInTheDocument(); + expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument(); + }); + + it('prioritizes loading over error state', () => { + renderWithProviders( + , + ); + + expect(document.querySelector('.animate-pulse')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load chart.')).not.toBeInTheDocument(); + }); + + it('prioritizes error over empty state', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Failed to load chart.')).toBeInTheDocument(); + expect(screen.queryByText('No Data')).not.toBeInTheDocument(); + }); +}); diff --git a/src/tests/unit/components/usage/StatCard.test.tsx b/src/tests/unit/components/usage/StatCard.test.tsx new file mode 100644 index 0000000..0e23a85 --- /dev/null +++ b/src/tests/unit/components/usage/StatCard.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '@/tests/unit/test-utils'; +import { StatCard } from '@/components/usage/StatCard'; +import { Activity, TrendingUp, Users, Zap } from 'lucide-react'; +import { vi, beforeAll, afterAll } from 'vitest'; + +describe('StatCard', () => { + beforeAll(() => { + vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (this: number) { + return this.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + }); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + it('renders label and value', () => { + renderWithProviders( + , + ); + expect(screen.getByText('Total Tokens')).toBeInTheDocument(); + expect(screen.getByText('12,345')).toBeInTheDocument(); + }); + + it('renders string values correctly', () => { + renderWithProviders( + , + ); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('formats number values with locale string', () => { + renderWithProviders( + , + ); + expect(screen.getByText('1,000,000')).toBeInTheDocument(); + }); + + it('shows skeleton when isLoading is true', () => { + renderWithProviders( + , + ); + expect(screen.queryByText('0')).not.toBeInTheDocument(); + expect(document.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('does not show skeleton when isLoading is false', () => { + renderWithProviders( + , + ); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument(); + }); + + it('renders icon element', () => { + renderWithProviders( + , + ); + expect(document.querySelector('svg')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + renderWithProviders( + , + ); + expect(screen.getByText('Styled').closest('[class*="border-border"]')).toHaveClass('my-stat'); + }); + + it('applies default slate accent when accent prop is not provided', () => { + const { container } = renderWithProviders( + , + ); + const iconContainer = container.querySelector('.rounded-md'); + expect(iconContainer).toHaveClass('bg-slate-500/5'); + }); + + it('applies blue accent', () => { + const { container } = renderWithProviders( + , + ); + const iconContainer = container.querySelector('.rounded-md'); + expect(iconContainer).toHaveClass('bg-blue-500/5'); + }); + + it('applies green accent', () => { + const { container } = renderWithProviders( + , + ); + const iconContainer = container.querySelector('.rounded-md'); + expect(iconContainer).toHaveClass('bg-emerald-500/5'); + }); + + it('applies amber accent', () => { + const { container } = renderWithProviders( + , + ); + const iconContainer = container.querySelector('.rounded-md'); + expect(iconContainer).toHaveClass('bg-amber-500/5'); + }); + + it('applies purple accent', () => { + const { container } = renderWithProviders( + , + ); + const iconContainer = container.querySelector('.rounded-md'); + expect(iconContainer).toHaveClass('bg-purple-500/5'); + }); + + it('has uppercase label styling', () => { + renderWithProviders( + , + ); + const label = screen.getByText('Uppercase Test'); + expect(label).toHaveClass('uppercase'); + }); + + it('has mono font for value', () => { + renderWithProviders( + , + ); + const value = screen.getByText('999'); + expect(value).toHaveClass('font-mono'); + }); +}); diff --git a/src/tests/unit/google-api-timeout.test.ts b/src/tests/unit/google-api-timeout.test.ts index 5a598a7..72ab1f0 100644 --- a/src/tests/unit/google-api-timeout.test.ts +++ b/src/tests/unit/google-api-timeout.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isString } from 'lodash-es'; +vi.mock('@/server/modules/proxy/request-user-agent', () => ({ + resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'), + buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'), + resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'), + FALLBACK_VERSION: '1.22.2', +})); + // Mock module to test timeout behavior describe('GoogleAPIService Timeout', () => { beforeEach(() => { @@ -114,7 +121,7 @@ describe('GoogleAPIService OAuth clients', () => { const { GoogleAPIService } = await import('../../services/GoogleAPIService'); const clients = GoogleAPIService.listOAuthClients(); - expect(clients.find((client) => client.key === 'geminiNexus_enterprise')).toBeDefined(); + expect(clients.some((client) => client.is_builtin)).toBe(true); expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A'); expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true); }); @@ -173,7 +180,9 @@ describe('GoogleAPIService user info parsing', () => { id: 'google-user-1', email: 'user@example.com', name: 'Example User', - family_name: undefined, + given_name: 'Example', + picture: 'https://example.com/avatar.png', + verified_email: true, }), ); }); @@ -213,27 +222,23 @@ describe('GoogleAPIService fetchQuota fallback policy', () => { vi.resetModules(); }); - it('falls back to loadCodeAssist credits when fetchCredits returns 404', async () => { + it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce({ - ok: false, - status: 404, - text: vi.fn().mockResolvedValue('NOT_FOUND'), + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + // No paidTier.availableCredits — no usable credits + freeTier: {}, + }), }) .mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ - paidTier: { - availableCredits: [ - { - creditType: 'GOOGLE_ONE_AI', - creditAmount: '1000', - minimumCreditAmountForUsage: '50', - }, - ], - }, + credits: 500, + expiryDate: '2025-12-31', }), }); @@ -249,31 +254,18 @@ describe('GoogleAPIService fetchQuota fallback policy', () => { } as any); const { GoogleAPIService } = await import('../../services/GoogleAPIService'); - const { FALLBACK_VERSION, resolveLocalInstalledVersion } = - await import('../../server/modules/proxy/request-user-agent'); - const expectedVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION; await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({ - credits: 1000, - expiryDate: '', + credits: 500, + expiryDate: '2025-12-31', }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]?.[0]).toBe( - 'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', - ); - expect(fetchMock.mock.calls[1]?.[0]).toBe( 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', ); - expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('POST'); - expect(fetchMock.mock.calls[1]?.[1]?.body).toBe( - JSON.stringify({ - metadata: { - ide_type: 'ANTIGRAVITY', - ide_version: expectedVersion, - ide_name: 'geminiNexus', - }, - }), + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', ); }); @@ -313,9 +305,19 @@ describe('GoogleAPIService fetchQuota fallback policy', () => { process.env.NO_PROXY = 'localhost,127.0.0.1,::1'; const fetchMock = vi.fn().mockResolvedValueOnce({ - ok: false, - status: 502, - text: vi.fn().mockResolvedValue('BAD_GATEWAY'), + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + paidTier: { + availableCredits: [ + { + creditType: 'GOOGLE_ONE_AI', + creditAmount: '1000', + minimumCreditAmountForUsage: '50', + }, + ], + }, + }), }); vi.stubGlobal('fetch', fetchMock); @@ -331,7 +333,10 @@ describe('GoogleAPIService fetchQuota fallback policy', () => { const { GoogleAPIService } = await import('../../services/GoogleAPIService'); - await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull(); + await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({ + credits: 1000, + expiryDate: '', + }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined(); @@ -345,9 +350,19 @@ describe('GoogleAPIService fetchQuota fallback policy', () => { process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090'; const fetchMock = vi.fn().mockResolvedValueOnce({ - ok: false, - status: 502, - text: vi.fn().mockResolvedValue('BAD_GATEWAY'), + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + paidTier: { + availableCredits: [ + { + creditType: 'GOOGLE_ONE_AI', + creditAmount: '1000', + minimumCreditAmountForUsage: '50', + }, + ], + }, + }), }); vi.stubGlobal('fetch', fetchMock); @@ -363,7 +378,10 @@ describe('GoogleAPIService fetchQuota fallback policy', () => { const { GoogleAPIService } = await import('../../services/GoogleAPIService'); - await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull(); + await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({ + credits: 1000, + expiryDate: '', + }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined(); diff --git a/src/tests/unit/hooks/useCloudAccounts.test.ts b/src/tests/unit/hooks/useCloudAccounts.test.ts new file mode 100644 index 0000000..e3b804b --- /dev/null +++ b/src/tests/unit/hooks/useCloudAccounts.test.ts @@ -0,0 +1,269 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(() => ({ data: undefined, isLoading: false, error: null })), + useMutation: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })), + useQueryClient: vi.fn(), +})); + +vi.mock('@/actions/cloud', () => ({ + listCloudAccounts: vi.fn(), + addGoogleAccount: vi.fn(), + deleteCloudAccount: vi.fn(), + refreshAccountQuota: vi.fn(), + setAccountProxy: vi.fn(), + listOAuthClients: vi.fn(), + setActiveOAuthClient: vi.fn(), + switchCloudAccount: vi.fn(), + getAutoSwitchEnabled: vi.fn(), + setAutoSwitchEnabled: vi.fn(), + forcePollCloudMonitor: vi.fn(), + syncLocalAccount: vi.fn(), + exportCloudAccounts: vi.fn(), + importCloudAccounts: vi.fn(), +})); + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as cloudActions from '@/actions/cloud'; +import { + useCloudAccounts, + useAddGoogleAccount, + useDeleteCloudAccount, + useRefreshQuota, + useSwitchCloudAccount, + useSetAccountProxy, + useAutoSwitchEnabled, + useSetAutoSwitchEnabled, + useOAuthClients, + useSetActiveOAuthClient, + useForcePollCloudMonitor, + useSyncLocalAccount, + useExportCloudAccounts, + useImportCloudAccounts, + QUERY_KEYS, + AUTO_SWITCH_KEY, +} from '@/hooks/useCloudAccounts'; +import type { CloudAccount } from '@/types/cloudAccount'; + +const mockedUseQuery = vi.mocked(useQuery); +const mockedUseMutation = vi.mocked(useMutation); +const mockedUseQueryClient = vi.mocked(useQueryClient); + +function getLastQueryOptions() { + return mockedUseQuery.mock.lastCall?.[0]; +} + +function getLastMutationOptions() { + return mockedUseMutation.mock.lastCall?.[0]; +} + +describe('useCloudAccounts hooks', () => { + const mockInvalidateQueries = vi.fn(); + const mockSetQueryData = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockedUseQueryClient.mockReturnValue({ + invalidateQueries: mockInvalidateQueries, + setQueryData: mockSetQueryData, + } as any); + }); + + describe('useCloudAccounts', () => { + it('calls useQuery with cloudAccounts key and listCloudAccounts fn', () => { + renderHook(() => useCloudAccounts()); + const options = getLastQueryOptions(); + expect(options?.queryKey).toEqual(QUERY_KEYS.cloudAccounts); + expect(options?.queryFn).toBe(cloudActions.listCloudAccounts); + expect(options?.staleTime).toBe(1000 * 60); + }); + }); + + describe('useAddGoogleAccount', () => { + it('calls useMutation with addGoogleAccount and invalidates cloudAccounts on success', () => { + renderHook(() => useAddGoogleAccount()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.addGoogleAccount); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + }); + + describe('useDeleteCloudAccount', () => { + it('calls useMutation with deleteCloudAccount and invalidates cloudAccounts on success', () => { + renderHook(() => useDeleteCloudAccount()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.deleteCloudAccount); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + }); + + describe('useRefreshQuota', () => { + it('calls useMutation with refreshAccountQuota', () => { + renderHook(() => useRefreshQuota()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.refreshAccountQuota); + }); + + it('optimistically updates existing account on success', () => { + renderHook(() => useRefreshQuota()); + const options = getLastMutationOptions(); + const updatedAccount: CloudAccount = { id: 'acc-2' } as CloudAccount; + const oldData: CloudAccount[] = [ + { id: 'acc-1' } as CloudAccount, + { id: 'acc-2', name: 'Old' } as CloudAccount, + ]; + + options?.onSuccess?.(updatedAccount); + const updater = mockSetQueryData.mock.calls[0][1] as Function; + const result = updater(oldData); + expect(result).toEqual([oldData[0], updatedAccount]); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + + it('creates array with updated account when old data is undefined', () => { + renderHook(() => useRefreshQuota()); + const options = getLastMutationOptions(); + const updatedAccount: CloudAccount = { id: 'acc-1' } as CloudAccount; + + options?.onSuccess?.(updatedAccount); + const updater = mockSetQueryData.mock.calls[0][1] as Function; + const result = updater(undefined); + expect(result).toEqual([updatedAccount]); + }); + }); + + describe('useSwitchCloudAccount', () => { + it('calls useMutation with switchCloudAccount and invalidates correct keys on success', () => { + renderHook(() => useSwitchCloudAccount()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.switchCloudAccount); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['currentAccount'] }); + }); + }); + + describe('useSetAccountProxy', () => { + it('calls useMutation with setAccountProxy and invalidates cloudAccounts on success', () => { + renderHook(() => useSetAccountProxy()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.setAccountProxy); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + + it('logs error on mutation failure', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + renderHook(() => useSetAccountProxy()); + const options = getLastMutationOptions(); + const error = new Error('proxy failed'); + + options?.onError?.(error); + expect(consoleSpy).toHaveBeenCalledWith('[Mutation] setAccountProxy failed:', error); + consoleSpy.mockRestore(); + }); + }); + + describe('useAutoSwitchEnabled', () => { + it('calls useQuery with autoSwitchEnabled key and getAutoSwitchEnabled fn', () => { + renderHook(() => useAutoSwitchEnabled()); + const options = getLastQueryOptions(); + expect(options?.queryKey).toEqual(AUTO_SWITCH_KEY); + expect(options?.queryFn).toBe(cloudActions.getAutoSwitchEnabled); + expect(options?.staleTime).toBe(Infinity); + }); + }); + + describe('useSetAutoSwitchEnabled', () => { + it('calls useMutation with setAutoSwitchEnabled and updates query data on success', () => { + renderHook(() => useSetAutoSwitchEnabled()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.setAutoSwitchEnabled); + + options?.onSuccess?.({}, { enabled: true }); + expect(mockSetQueryData).toHaveBeenCalledWith(AUTO_SWITCH_KEY, true); + }); + }); + + describe('useOAuthClients', () => { + it('calls useQuery with oauthClients key and listOAuthClients fn', () => { + renderHook(() => useOAuthClients()); + const options = getLastQueryOptions(); + expect(options?.queryKey).toEqual(QUERY_KEYS.oauthClients); + expect(options?.queryFn).toBe(cloudActions.listOAuthClients); + expect(options?.staleTime).toBe(1000 * 60 * 5); + }); + }); + + describe('useSetActiveOAuthClient', () => { + it('calls useMutation with setActiveOAuthClient and invalidates oauthClients on success', () => { + renderHook(() => useSetActiveOAuthClient()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.setActiveOAuthClient); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.oauthClients }); + }); + }); + + describe('useForcePollCloudMonitor', () => { + it('calls useMutation with forcePollCloudMonitor and invalidates cloudAccounts on success', () => { + renderHook(() => useForcePollCloudMonitor()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.forcePollCloudMonitor); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + }); + + describe('useSyncLocalAccount', () => { + it('calls useMutation with syncLocalAccount and invalidates cloudAccounts on success', () => { + renderHook(() => useSyncLocalAccount()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.syncLocalAccount); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + }); + + describe('useExportCloudAccounts', () => { + it('calls useMutation with exportCloudAccounts and no invalidation', () => { + renderHook(() => useExportCloudAccounts()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.exportCloudAccounts); + expect(options?.onSuccess).toBeUndefined(); + }); + }); + + describe('useImportCloudAccounts', () => { + it('calls useMutation with importCloudAccounts and invalidates cloudAccounts on success', () => { + renderHook(() => useImportCloudAccounts()); + const options = getLastMutationOptions(); + expect(options?.mutationFn).toBe(cloudActions.importCloudAccounts); + + options?.onSuccess?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts }); + }); + + it('logs error on mutation failure', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + renderHook(() => useImportCloudAccounts()); + const options = getLastMutationOptions(); + const error = new Error('import failed'); + + options?.onError?.(error); + expect(consoleSpy).toHaveBeenCalledWith('[Mutation] importCloudAccounts failed:', error); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/tests/unit/hooks/useProviderGrouping.test.ts b/src/tests/unit/hooks/useProviderGrouping.test.ts new file mode 100644 index 0000000..2855554 --- /dev/null +++ b/src/tests/unit/hooks/useProviderGrouping.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useProviderGrouping } from '@/hooks/useProviderGrouping'; +import type { CloudAccount } from '@/types/cloudAccount'; + +vi.mock('@/hooks/useAppConfig', () => ({ + useAppConfig: vi.fn(), +})); + +import { useAppConfig } from '@/hooks/useAppConfig'; + +const mockUseAppConfig = vi.mocked(useAppConfig); + +function createMockAccount(overrides?: Partial): CloudAccount { + return { + id: 'acc-1', + provider: 'google', + email: 'test@example.com', + token: {} as CloudAccount['token'], + created_at: Date.now(), + last_used: Date.now(), + ...overrides, + }; +} + +describe('useProviderGrouping', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns enabled=false when provider_groupings_enabled is undefined', () => { + mockUseAppConfig.mockReturnValue({ config: {} } as any); + const { result } = renderHook(() => useProviderGrouping()); + expect(result.current.enabled).toBe(false); + }); + + it('returns enabled=true when provider_groupings_enabled is true', () => { + mockUseAppConfig.mockReturnValue({ config: { provider_groupings_enabled: true } } as any); + const { result } = renderHook(() => useProviderGrouping()); + expect(result.current.enabled).toBe(true); + }); + + describe('getAccountStats', () => { + it('returns correct stats for an account with models', () => { + mockUseAppConfig.mockReturnValue({ + config: { provider_groupings_enabled: true, model_visibility: {} }, + } as any); + + const account = createMockAccount({ + quota: { + models: { + 'gemini-1.5-pro': { percentage: 80, resetTime: '' }, + 'claude-3-5-sonnet': { percentage: 60, resetTime: '' }, + }, + }, + }); + + const { result } = renderHook(() => useProviderGrouping()); + const stats = result.current.getAccountStats(account); + expect(stats.providers).toHaveLength(2); + expect(stats.totalModels).toBe(2); + expect(stats.visibleModels).toBe(2); + expect(stats.overallPercentage).toBe(70); + }); + + it('respects model visibility settings', () => { + mockUseAppConfig.mockReturnValue({ + config: { + provider_groupings_enabled: true, + model_visibility: { 'claude-3-5-sonnet': false }, + }, + } as any); + + const account = createMockAccount({ + quota: { + models: { + 'gemini-1.5-pro': { percentage: 80, resetTime: '' }, + 'claude-3-5-sonnet': { percentage: 60, resetTime: '' }, + }, + }, + }); + + const { result } = renderHook(() => useProviderGrouping()); + const stats = result.current.getAccountStats(account); + expect(stats.visibleModels).toBe(1); + expect(stats.overallPercentage).toBe(80); + }); + + it('handles accounts with no quota', () => { + mockUseAppConfig.mockReturnValue({ + config: { provider_groupings_enabled: true, model_visibility: {} }, + } as any); + + const account = createMockAccount({ quota: undefined }); + const { result } = renderHook(() => useProviderGrouping()); + const stats = result.current.getAccountStats(account); + expect(stats.providers).toHaveLength(0); + expect(stats.totalModels).toBe(0); + }); + }); + + describe('toggleProviderCollapse', () => { + it('toggles provider collapse state on and off', () => { + mockUseAppConfig.mockReturnValue({ + config: { provider_groupings_enabled: true, model_visibility: {} }, + } as any); + + const { result } = renderHook(() => useProviderGrouping()); + + expect(result.current.isProviderCollapsed('acc-1', 'claude-')).toBe(false); + + act(() => { + result.current.toggleProviderCollapse('acc-1', 'claude-'); + }); + expect(result.current.isProviderCollapsed('acc-1', 'claude-')).toBe(true); + + act(() => { + result.current.toggleProviderCollapse('acc-1', 'claude-'); + }); + expect(result.current.isProviderCollapsed('acc-1', 'claude-')).toBe(false); + }); + + it('tracks collapse per account independently', () => { + mockUseAppConfig.mockReturnValue({ + config: { provider_groupings_enabled: true, model_visibility: {} }, + } as any); + + const { result } = renderHook(() => useProviderGrouping()); + + act(() => { + result.current.toggleProviderCollapse('acc-1', 'gemini-'); + }); + expect(result.current.isProviderCollapsed('acc-1', 'gemini-')).toBe(true); + expect(result.current.isProviderCollapsed('acc-2', 'gemini-')).toBe(false); + }); + }); + + describe('toggleAccountCollapse', () => { + it('toggles account collapse state on and off', () => { + mockUseAppConfig.mockReturnValue({ + config: { provider_groupings_enabled: true, model_visibility: {} }, + } as any); + + const { result } = renderHook(() => useProviderGrouping()); + + expect(result.current.isAccountCollapsed('acc-1')).toBe(false); + + act(() => { + result.current.toggleAccountCollapse('acc-1'); + }); + expect(result.current.isAccountCollapsed('acc-1')).toBe(true); + + act(() => { + result.current.toggleAccountCollapse('acc-1'); + }); + expect(result.current.isAccountCollapsed('acc-1')).toBe(false); + }); + + it('tracks collapse for multiple accounts independently', () => { + mockUseAppConfig.mockReturnValue({ + config: { provider_groupings_enabled: true, model_visibility: {} }, + } as any); + + const { result } = renderHook(() => useProviderGrouping()); + + act(() => { + result.current.toggleAccountCollapse('acc-1'); + result.current.toggleAccountCollapse('acc-2'); + }); + + expect(result.current.isAccountCollapsed('acc-1')).toBe(true); + expect(result.current.isAccountCollapsed('acc-2')).toBe(true); + + act(() => { + result.current.toggleAccountCollapse('acc-1'); + }); + + expect(result.current.isAccountCollapsed('acc-1')).toBe(false); + expect(result.current.isAccountCollapsed('acc-2')).toBe(true); + }); + }); +}); diff --git a/src/tests/unit/ipc/cloud-repo.test.ts b/src/tests/unit/ipc/cloud-repo.test.ts new file mode 100644 index 0000000..ef66127 --- /dev/null +++ b/src/tests/unit/ipc/cloud-repo.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CloudAccountRepo } from '../../../ipc/database/cloudHandler'; + +const mockRun = vi.fn(); +const mockAll = vi.fn(); +const mockGet = vi.fn(); +const mockClose = vi.fn(); +const mockPrepare = vi.fn(() => ({ run: mockRun, all: mockAll, get: mockGet })); + +const mockFrom = vi.fn(() => ({ + where: vi.fn(() => ({ all: mockAll })), + orderBy: vi.fn(() => ({ all: mockAll })), + all: mockAll, +})); + +const mockSet = vi.fn(() => ({ + where: vi.fn(() => ({ run: mockRun })), + run: mockRun, +})); + +const mockValues = vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => ({ run: mockRun })), + run: mockRun, +})); + +const mockOrm = { + select: vi.fn(() => ({ from: mockFrom })), + update: vi.fn(() => ({ set: mockSet })), + delete: vi.fn(() => ({ where: vi.fn(() => ({ run: mockRun })) })), + insert: vi.fn(() => ({ values: mockValues })), + transaction: vi.fn((fn) => + fn({ + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ run: mockRun })), + run: mockRun, + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => ({ run: mockRun })), + run: mockRun, + })), + })), + delete: vi.fn(() => ({ where: vi.fn(() => ({ run: mockRun })) })), + }), + ), +}; + +vi.mock('better-sqlite3', () => ({ + default: class MockDatabase { + exec = vi.fn(); + prepare = vi.fn(() => ({ run: vi.fn(), get: vi.fn(), all: vi.fn() })); + close = vi.fn(); + pragma = vi.fn(() => []); + }, +})); + +vi.mock('../../../ipc/database/dbConnection', () => ({ + openDrizzleConnection: vi.fn(() => ({ + raw: { prepare: mockPrepare, close: mockClose }, + orm: mockOrm, + })), + configureDatabase: vi.fn(), +})); + +vi.mock('../../../utils/paths', () => ({ + getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), +})); + +vi.mock('../../../utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('../../../utils/security', () => ({ + encrypt: vi.fn(async (text: string) => text), + decryptWithMigration: vi.fn(async (text: string) => ({ value: text })), +})); + +function createMockAccountRow(overrides?: Partial>) { + return { + id: 'acc-1', + provider: 'google', + email: 'test@example.com', + name: 'Test User', + avatarUrl: null, + tokenJson: JSON.stringify({ + access_token: 'tok1', + refresh_token: 'ref1', + expires_in: 3600, + expiry_timestamp: 1234567890, + token_type: 'Bearer', + }), + quotaJson: null, + deviceProfileJson: null, + deviceHistoryJson: null, + createdAt: 1234567890, + lastUsed: 1234567890, + status: 'active', + statusReason: null, + isActive: 1, + proxyUrl: null, + ...overrides, + }; +} + +describe('CloudAccountRepo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('addAccount (create)', () => { + it('should insert a new account into the database', async () => { + const account = { + id: 'acc-1', + provider: 'google' as const, + email: 'test@example.com', + token: { + access_token: 'tok1', + refresh_token: 'ref1', + expires_in: 3600, + expiry_timestamp: 1234567890, + token_type: 'Bearer', + }, + created_at: 1234567890, + last_used: 1234567890, + is_active: false, + }; + + await CloudAccountRepo.addAccount(account); + + expect(mockOrm.transaction).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAccountByEmail (findByEmail)', () => { + it('should return the matching account by email', async () => { + mockAll.mockReturnValue([createMockAccountRow()]); + + const result = await CloudAccountRepo.getAccountByEmail('test@example.com'); + + expect(result).not.toBeNull(); + expect(result?.email).toBe('test@example.com'); + }); + + it('should return null when no account matches the email', async () => { + mockAll.mockReturnValue([]); + + const result = await CloudAccountRepo.getAccountByEmail('missing@example.com'); + + expect(result).toBeNull(); + }); + }); + + describe('getAccounts (findAll)', () => { + it('should return all accounts from the database', async () => { + mockAll.mockReturnValue([ + createMockAccountRow({ id: 'acc-1', email: 'a@example.com' }), + createMockAccountRow({ id: 'acc-2', email: 'b@example.com' }), + ]); + + const result = await CloudAccountRepo.getAccounts(); + + expect(result).toHaveLength(2); + expect(result[0].email).toBe('a@example.com'); + expect(result[1].email).toBe('b@example.com'); + }); + + it('should auto-activate the first account when none are active', async () => { + mockAll.mockReturnValue([createMockAccountRow({ isActive: 0 })]); + + const result = await CloudAccountRepo.getAccounts(); + + expect(mockOrm.update).toHaveBeenCalled(); + expect(result[0].is_active).toBe(true); + }); + }); + + describe('updateToken (update)', () => { + it('should modify token fields for an existing account', async () => { + mockRun.mockReturnValue({ changes: 1 }); + + await CloudAccountRepo.updateToken('acc-1', { + access_token: 'new-tok', + refresh_token: 'ref1', + expires_in: 3600, + expiry_timestamp: 1234567890, + token_type: 'Bearer', + }); + + expect(mockOrm.update).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + }); + + describe('removeAccount (delete)', () => { + it('should remove the account by id', async () => { + await CloudAccountRepo.removeAccount('acc-1'); + + expect(mockOrm.delete).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + }); + + describe('updateQuota', () => { + it('should update quota fields for an existing account', async () => { + mockRun.mockReturnValue({ changes: 1 }); + + await CloudAccountRepo.updateQuota('acc-1', { models: {} }); + + expect(mockOrm.update).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + }); + + describe('updateLastUsed', () => { + it('should update the last_used timestamp for an account', () => { + CloudAccountRepo.updateLastUsed('acc-1'); + + expect(mockOrm.update).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/tests/unit/ipc/config-handler.test.ts b/src/tests/unit/ipc/config-handler.test.ts new file mode 100644 index 0000000..8e8400a --- /dev/null +++ b/src/tests/unit/ipc/config-handler.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { loadConfig, saveConfig } from '../../../ipc/config/handlers'; +import { ConfigManager } from '../../../ipc/config/manager'; +import { syncAutoStart } from '../../../utils/autoStart'; +import { setServerConfig } from '../../../server/server-config'; +import { logger } from '../../../utils/logger'; +import { DEFAULT_APP_CONFIG } from '../../../types/config'; + +vi.mock('../../../ipc/config/manager', () => ({ + ConfigManager: { + loadConfig: vi.fn(), + getCachedConfig: vi.fn(), + saveConfig: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../../../utils/autoStart', () => ({ + syncAutoStart: vi.fn(), +})); + +vi.mock('../../../server/server-config', () => ({ + setServerConfig: vi.fn(), +})); + +vi.mock('../../../utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + setErrorReportingEnabled: vi.fn(), + }, +})); + +describe('Config Handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ConfigManager.loadConfig).mockReturnValue(DEFAULT_APP_CONFIG); + vi.mocked(ConfigManager.getCachedConfig).mockReturnValue(null); + }); + + describe('loadConfig', () => { + it('should return config from ConfigManager', () => { + const config = loadConfig(); + expect(config).toEqual(DEFAULT_APP_CONFIG); + expect(ConfigManager.loadConfig).toHaveBeenCalled(); + }); + }); + + describe('saveConfig', () => { + it('should save config through ConfigManager', async () => { + const newConfig = { + ...DEFAULT_APP_CONFIG, + language: 'en-US', + }; + await saveConfig(newConfig); + expect(ConfigManager.saveConfig).toHaveBeenCalledWith(newConfig); + }); + + it('should update server config after save', async () => { + const newConfig = { + ...DEFAULT_APP_CONFIG, + proxy: { ...DEFAULT_APP_CONFIG.proxy, port: 9999 }, + }; + await saveConfig(newConfig); + expect(setServerConfig).toHaveBeenCalledWith(newConfig.proxy); + }); + + it('should enable error reporting when config has it enabled', async () => { + const newConfig = { + ...DEFAULT_APP_CONFIG, + error_reporting_enabled: true, + }; + await saveConfig(newConfig); + expect(logger.setErrorReportingEnabled).toHaveBeenCalledWith(true); + }); + + it('should sync auto start when auto_startup changes from false to true', async () => { + vi.mocked(ConfigManager.getCachedConfig).mockReturnValueOnce({ + ...DEFAULT_APP_CONFIG, + auto_startup: false, + }); + + const newConfig = { + ...DEFAULT_APP_CONFIG, + auto_startup: true, + }; + await saveConfig(newConfig); + expect(syncAutoStart).toHaveBeenCalledWith(newConfig); + }); + + it('should not sync auto start when auto_startup unchanged', async () => { + vi.mocked(ConfigManager.getCachedConfig).mockReturnValueOnce({ + ...DEFAULT_APP_CONFIG, + auto_startup: true, + }); + + const newConfig = { + ...DEFAULT_APP_CONFIG, + auto_startup: true, + }; + await saveConfig(newConfig); + expect(syncAutoStart).not.toHaveBeenCalled(); + }); + + it('should handle nested proxy settings correctly', async () => { + const newConfig = { + ...DEFAULT_APP_CONFIG, + proxy: { + ...DEFAULT_APP_CONFIG.proxy, + enabled: true, + upstream_proxy: { + enabled: true, + url: 'http://proxy.example.com:8080', + }, + }, + }; + await saveConfig(newConfig); + expect(ConfigManager.saveConfig).toHaveBeenCalledWith(newConfig); + expect(setServerConfig).toHaveBeenCalledWith(newConfig.proxy); + }); + }); +}); diff --git a/src/tests/unit/ipc/database-handler.test.ts b/src/tests/unit/ipc/database-handler.test.ts new file mode 100644 index 0000000..773fea7 --- /dev/null +++ b/src/tests/unit/ipc/database-handler.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + initDatabase, + getDatabaseConnection, + getCurrentAccountInfo, + backupAccount, + restoreAccount, +} from '../../../ipc/database/handler'; +import { openDrizzleConnection } from '../../../ipc/database/dbConnection'; +import { getGeminiNexusDbPaths } from '../../../utils/paths'; +import fs from 'fs'; + +const mockClose = vi.fn(); +const mockRun = vi.fn(); + +function createMockOrm(rows: Record[] = []) { + let callIndex = 0; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + all: vi.fn().mockImplementation(() => { + const row = rows[callIndex] ?? null; + callIndex++; + return row ? [row] : []; + }), + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockReturnThis(), + run: mockRun, + }; + return { + select: vi.fn().mockReturnValue(chain), + insert: vi.fn().mockReturnValue(chain), + transaction: vi.fn().mockImplementation((fn: (...args: any[]) => any) => { + const tx = { + insert: vi.fn().mockReturnValue(chain), + }; + return fn(tx); + }), + }; +} + +vi.mock('../../../utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('../../../utils/paths', () => ({ + getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']), +})); + +vi.mock('../../../ipc/database/dbConnection', () => ({ + openDrizzleConnection: vi.fn().mockImplementation(() => ({ + raw: { close: mockClose }, + orm: createMockOrm(), + })), +})); + +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn().mockReturnValue(true), + mkdirSync: vi.fn(), + }, + existsSync: vi.fn().mockReturnValue(true), + mkdirSync: vi.fn(), +})); + +vi.mock('../../../utils/sqlite', () => ({ + parseRow: vi.fn().mockImplementation((_schema, row) => row ?? null), +})); + +vi.mock('../../../ipc/database/schema', () => ({ + itemTable: {}, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn().mockReturnValue({}), +})); + +describe('Database Handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGeminiNexusDbPaths).mockReturnValue(['/tmp/test.vscdb']); + vi.mocked(openDrizzleConnection).mockImplementation(() => ({ + raw: { close: mockClose }, + orm: createMockOrm() as any, + })); + vi.mocked(fs.existsSync).mockReturnValue(true); + }); + + describe('initDatabase', () => { + it('should initialize database without error', () => { + expect(() => initDatabase()).not.toThrow(); + }); + + it('should return early when no db paths exist', () => { + vi.mocked(getGeminiNexusDbPaths).mockReturnValueOnce([]); + expect(() => initDatabase()).not.toThrow(); + }); + }); + + describe('getDatabaseConnection', () => { + it('should create connection when db exists', () => { + const conn = getDatabaseConnection(); + expect(conn).toBeDefined(); + expect(conn.raw).toBeDefined(); + expect(conn.orm).toBeDefined(); + }); + + it('should close connection when done', () => { + const conn = getDatabaseConnection(); + conn.raw.close(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('should throw when no database path is found', () => { + vi.mocked(getGeminiNexusDbPaths).mockReturnValueOnce([]); + expect(() => getDatabaseConnection()).toThrow('No Gemini Nexus database path found'); + }); + + it('should throw SQLITE_BUSY error as user-friendly message', () => { + vi.mocked(openDrizzleConnection).mockImplementationOnce(() => { + const err = new Error('Database is locked') as Error & { code: string }; + err.code = 'SQLITE_BUSY'; + throw err; + }); + expect(() => getDatabaseConnection()).toThrow('Database is locked. Please close Gemini Nexus before proceeding.'); + }); + + it('should throw SQLITE_LOCKED error as user-friendly message', () => { + vi.mocked(openDrizzleConnection).mockImplementationOnce(() => { + const err = new Error('Database is locked') as Error & { code: string }; + err.code = 'SQLITE_LOCKED'; + throw err; + }); + expect(() => getDatabaseConnection()).toThrow('Database is locked. Please close Gemini Nexus before proceeding.'); + }); + }); + + describe('getCurrentAccountInfo', () => { + it('should return authenticated account info from geminiNexusAuthStatus', () => { + vi.mocked(openDrizzleConnection).mockReturnValueOnce({ + raw: { close: mockClose }, + orm: createMockOrm([ + { value: JSON.stringify({ user: { email: 'auth@test.com', name: 'Auth User' } }) }, + ]) as any, + }); + + const info = getCurrentAccountInfo(); + expect(info.email).toBe('auth@test.com'); + expect(info.name).toBe('Auth User'); + expect(info.isAuthenticated).toBe(true); + expect(mockClose).toHaveBeenCalled(); + }); + + it('should fallback to jetskiStateSync.agentManagerInitState', () => { + vi.mocked(openDrizzleConnection).mockReturnValueOnce({ + raw: { close: mockClose }, + orm: createMockOrm([ + { value: null }, + { value: JSON.stringify({ user: { email: 'init@test.com', name: 'Init User' } }) }, + ]) as any, + }); + + const info = getCurrentAccountInfo(); + expect(info.email).toBe('init@test.com'); + expect(info.name).toBe('Init User'); + expect(info.isAuthenticated).toBe(true); + }); + + it('should return empty info when no keys found', () => { + vi.mocked(openDrizzleConnection).mockReturnValueOnce({ + raw: { close: mockClose }, + orm: createMockOrm([{ value: null }, { value: null }, { value: null }, { value: null }]) as any, + }); + + const info = getCurrentAccountInfo(); + expect(info.email).toBe(''); + expect(info.name).toBe(''); + expect(info.isAuthenticated).toBe(false); + }); + }); + + describe('backupAccount', () => { + it('should backup account data with metadata', () => { + vi.mocked(openDrizzleConnection).mockReturnValueOnce({ + raw: { close: mockClose }, + orm: createMockOrm([ + { value: JSON.stringify({ status: 'active' }) }, + { value: JSON.stringify({ state: 'ready' }) }, + { value: JSON.stringify({ token: 'abc' }) }, + ]) as any, + }); + + const account = { + id: 'acc-1', + name: 'Test', + email: 'test@test.com', + created_at: new Date().toISOString(), + last_used: new Date().toISOString(), + }; + const backup = backupAccount(account); + + expect(backup.version).toBe('1.0'); + expect(backup.account).toEqual(account); + expect(backup.data['geminiNexusAuthStatus']).toEqual({ status: 'active' }); + expect(backup.data['jetskiStateSync.agentManagerInitState']).toEqual({ state: 'ready' }); + expect(backup.data['geminiNexusUnifiedStateSync.oauthToken']).toEqual({ token: 'abc' }); + expect(backup.data['account_email']).toBe('test@test.com'); + expect(backup.data['backup_time']).toBeDefined(); + expect(mockClose).toHaveBeenCalled(); + }); + }); + + describe('restoreAccount', () => { + it('should restore keys to existing database', () => { + vi.mocked(openDrizzleConnection).mockReturnValueOnce({ + raw: { close: mockClose }, + orm: createMockOrm() as any, + }); + + const backup = { + version: '1.0', + account: { + id: 'acc-1', + name: 'Test', + email: 'test@test.com', + created_at: new Date().toISOString(), + last_used: new Date().toISOString(), + }, + data: { + geminiNexusAuthStatus: { status: 'restored' }, + 'jetskiStateSync.agentManagerInitState': { state: 'restored' }, + }, + }; + + expect(() => restoreAccount(backup as any)).not.toThrow(); + expect(mockRun).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('should throw when no database paths found', () => { + vi.mocked(getGeminiNexusDbPaths).mockReturnValueOnce([]); + expect(() => + restoreAccount({ + version: '1.0', + account: {} as any, + data: {}, + }), + ).toThrow('No Gemini Nexus database paths found'); + }); + }); +}); diff --git a/src/tests/unit/ipc/proxy-advanced-handler.test.ts b/src/tests/unit/ipc/proxy-advanced-handler.test.ts new file mode 100644 index 0000000..445fd62 --- /dev/null +++ b/src/tests/unit/ipc/proxy-advanced-handler.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getRecentTrafficLogs, + getTrafficLogStats, + exportTrafficLogs, + getParityCounters, + getCircuitBreakerStatus, + getProxyMetrics, + getRecentRequests, + replayRequest, + getModelCapabilities, + generateIdeConfig, +} from '../../../ipc/proxy-advanced/handler'; +import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler'; +import { proxyAdvancedRegistry } from '../../../ipc/proxy-advanced/service-registry'; + +vi.mock('../../../utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('../../../ipc/database/proxyMetricsHandler', () => ({ + TrafficLogsRepo: { + getRecent: vi.fn().mockReturnValue([]), + getStats: vi.fn().mockReturnValue({ byDirection: {}, byEndpoint: {}, total: 0 }), + getAllForExport: vi.fn().mockReturnValue([]), + }, +})); + +vi.mock('../../../ipc/proxy-advanced/service-registry', () => ({ + proxyAdvancedRegistry: { + tokenManager: null, + proxyMetrics: null, + proxyReplay: null, + proxyIdeConfig: null, + }, +})); + +describe('Proxy Advanced Handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getRecentTrafficLogs', () => { + it('should return empty logs when no records', async () => { + const result = await getRecentTrafficLogs(); + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('should return parsed logs with default limit', async () => { + vi.mocked(TrafficLogsRepo.getRecent).mockReturnValueOnce([ + { + timestamp: Date.now(), + direction: 'outbound', + requestId: 'req-1', + endpoint: '/v1/chat', + method: 'POST', + status: 200, + headers: { 'content-type': 'application/json' }, + body: { message: 'hello' }, + durationMs: 120, + }, + ]); + + const result = await getRecentTrafficLogs(); + expect(result.success).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.data?.[0].direction).toBe('outbound'); + expect(result.data?.[0].endpoint).toBe('/v1/chat'); + }); + + it('should filter by direction when provided', async () => { + await getRecentTrafficLogs(10, 'inbound'); + expect(TrafficLogsRepo.getRecent).toHaveBeenCalledWith(10, 'inbound'); + }); + + it('should handle errors gracefully', async () => { + vi.mocked(TrafficLogsRepo.getRecent).mockImplementationOnce(() => { + throw new Error('DB error'); + }); + const result = await getRecentTrafficLogs(); + expect(result.success).toBe(false); + expect(result.error).toBe('DB error'); + }); + }); + + describe('getTrafficLogStats', () => { + it('should return stats from repo', async () => { + vi.mocked(TrafficLogsRepo.getStats).mockReturnValueOnce({ + byDirection: { outbound: 5, inbound: 3 }, + byEndpoint: { '/v1/chat': 8 }, + total: 8, + }); + + const result = await getTrafficLogStats(); + expect(result.success).toBe(true); + expect(result.data?.total).toBe(8); + expect(result.data?.byDirection.outbound).toBe(5); + }); + + it('should handle errors gracefully', async () => { + vi.mocked(TrafficLogsRepo.getStats).mockImplementationOnce(() => { + throw new Error('stats error'); + }); + const result = await getTrafficLogStats(); + expect(result.success).toBe(false); + expect(result.error).toBe('stats error'); + }); + }); + + describe('exportTrafficLogs', () => { + it('should export as JSON', async () => { + vi.mocked(TrafficLogsRepo.getAllForExport).mockReturnValueOnce([ + { timestamp: 1000, direction: 'outbound', requestId: 'r1', endpoint: '/chat' }, + ]); + + const result = await exportTrafficLogs('json'); + expect(result.success).toBe(true); + const parsed = JSON.parse(result.data ?? '[]'); + expect(parsed).toHaveLength(1); + }); + + it('should export as CSV', async () => { + vi.mocked(TrafficLogsRepo.getAllForExport).mockReturnValueOnce([ + { timestamp: 1000, direction: 'outbound', requestId: 'r1', endpoint: '/chat', method: 'POST', status: 200, durationMs: 50 }, + ]); + + const result = await exportTrafficLogs('csv'); + expect(result.success).toBe(true); + expect(result.data).toContain('timestamp,direction,requestId,endpoint,method,status,durationMs'); + expect(result.data).toContain('outbound'); + }); + + it('should handle errors gracefully', async () => { + vi.mocked(TrafficLogsRepo.getAllForExport).mockImplementationOnce(() => { + throw new Error('export error'); + }); + const result = await exportTrafficLogs('json'); + expect(result.success).toBe(false); + expect(result.error).toBe('export error'); + }); + }); + + describe('getParityCounters', () => { + it('should return zero counters when tokenManager is null', async () => { + const result = await getParityCounters(); + expect(result.success).toBe(true); + expect(result.data?.totalRequests).toBe(0); + expect(result.data?.matchedRequests).toBe(0); + expect(result.data?.parityViolations).toBe(0); + }); + + it('should return counters from tokenManager', async () => { + vi.mocked(proxyAdvancedRegistry).tokenManager = { + getParityCounters: vi.fn().mockReturnValue({ + totalRequests: 10, + matchedRequests: 8, + parityViolations: 2, + lastUpdated: new Date().toISOString(), + }), + } as any; + + const result = await getParityCounters(); + expect(result.success).toBe(true); + expect(result.data?.totalRequests).toBe(10); + vi.mocked(proxyAdvancedRegistry).tokenManager = null; + }); + }); + + describe('getCircuitBreakerStatus', () => { + it('should return empty states when tokenManager is null', async () => { + const result = await getCircuitBreakerStatus(); + expect(result.success).toBe(true); + expect(result.data?.states).toEqual({}); + expect(result.data?.globalEnabled).toBe(true); + }); + + it('should return mapped status from tokenManager', async () => { + vi.mocked(proxyAdvancedRegistry).tokenManager = { + getCircuitBreakerStatus: vi.fn().mockReturnValue({ + acc1: { state: 'open', failureCount: 3, lastFailureTime: Date.now() }, + }), + } as any; + + const result = await getCircuitBreakerStatus(); + expect(result.success).toBe(true); + expect(result.data?.states.acc1.state).toBe('open'); + expect(result.data?.states.acc1.failures).toBe(3); + vi.mocked(proxyAdvancedRegistry).tokenManager = null; + }); + }); + + describe('getProxyMetrics', () => { + it('should return zero metrics when proxyMetrics is null', async () => { + const result = await getProxyMetrics(); + expect(result.success).toBe(true); + expect(result.data?.totalRequests).toBe(0); + expect(result.data?.activeConnections).toBe(0); + }); + + it('should return metrics from proxyMetrics service', async () => { + vi.mocked(proxyAdvancedRegistry).proxyMetrics = { + getMetrics: vi.fn().mockReturnValue({ + totalRequests: 100, + activeConnections: 5, + avgLatency: 120, + errorRate: 0.02, + requestsPerMinute: 10, + cacheHitRate: 0.8, + }), + } as any; + + const result = await getProxyMetrics(); + expect(result.success).toBe(true); + expect(result.data?.totalRequests).toBe(100); + expect(result.data?.avgLatency).toBe(120); + vi.mocked(proxyAdvancedRegistry).proxyMetrics = null; + }); + }); + + describe('getRecentRequests', () => { + it('should return empty array when proxyReplay is null', async () => { + const result = await getRecentRequests(); + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('should return recent requests from proxyReplay', async () => { + vi.mocked(proxyAdvancedRegistry).proxyReplay = { + getRecentRequests: vi.fn().mockReturnValue([{ id: 'req-1' }]), + } as any; + + const result = await getRecentRequests(); + expect(result.success).toBe(true); + expect(result.data).toHaveLength(1); + vi.mocked(proxyAdvancedRegistry).proxyReplay = null; + }); + }); + + describe('replayRequest', () => { + it('should return error when proxyReplay is null', async () => { + const result = await replayRequest('req-1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Replay service not available'); + }); + + it('should replay request through proxyReplay', async () => { + vi.mocked(proxyAdvancedRegistry).proxyReplay = { + replayRequest: vi.fn().mockResolvedValue({ + original: { id: 'req-1' }, + newResponse: { status: 200 }, + }), + } as any; + + const result = await replayRequest('req-1'); + expect(result.success).toBe(true); + expect(result.data?.original).toEqual({ id: 'req-1' }); + vi.mocked(proxyAdvancedRegistry).proxyReplay = null; + }); + }); + + describe('getModelCapabilities', () => { + it('should return capabilities for all models', async () => { + const result = await getModelCapabilities(); + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data!.length).toBeGreaterThan(0); + + const model = result.data!.find((m) => m.id === 'gemini-3-flash'); + expect(model).toBeDefined(); + expect(model!.capabilities.vision).toBe(true); + expect(model!.capabilities.streaming).toBe(true); + }); + }); + + describe('generateIdeConfig', () => { + it('should return error when proxyIdeConfig is null', async () => { + const result = await generateIdeConfig('vscode'); + expect(result.success).toBe(false); + expect(result.error).toBe('IDE config service not available'); + }); + + it('should return error for unsupported IDE', async () => { + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {} as any; + const result = await generateIdeConfig('unknown-ide'); + expect(result.success).toBe(false); + expect(result.error).toContain('Unsupported IDE'); + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null; + }); + + it('should generate config for VS Code', async () => { + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = { + generateConfigForCurrentServer: vi.fn().mockReturnValue({ + ide: 'vscode', + content: null, + }), + } as any; + + const result = await generateIdeConfig('vscode'); + expect(result.success).toBe(true); + expect(result.data?.name).toBe('VS Code'); + expect(result.data?.proxySetting).toBe('openai.url'); + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null; + }); + + it('should generate config for Cursor', async () => { + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = { + generateConfigForCurrentServer: vi.fn().mockReturnValue({ + ide: 'cursor', + content: null, + }), + } as any; + + const result = await generateIdeConfig('cursor'); + expect(result.success).toBe(true); + expect(result.data?.name).toBe('Cursor'); + expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL'); + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null; + }); + + it('should handle null result from config generator', async () => { + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = { + generateConfigForCurrentServer: vi.fn().mockReturnValue(null), + } as any; + + const result = await generateIdeConfig('vscode'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to generate IDE config: server not configured'); + vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null; + }); + }); +}); diff --git a/src/tests/unit/ipc/proxy-metrics-repo.test.ts b/src/tests/unit/ipc/proxy-metrics-repo.test.ts new file mode 100644 index 0000000..85af4f8 --- /dev/null +++ b/src/tests/unit/ipc/proxy-metrics-repo.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProxyMetricsRepo } from '../../../ipc/database/proxyMetricsHandler'; + +const mockRun = vi.fn(); +const mockAll = vi.fn(); +const mockGet = vi.fn(); +const mockClose = vi.fn(); +const mockPrepare = vi.fn(() => ({ run: mockRun, all: mockAll, get: mockGet })); + +const mockOrm = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ run: mockRun })), + })), +}; + +vi.mock('../../../ipc/database/dbConnection', () => ({ + openDrizzleConnection: vi.fn(() => ({ + raw: { prepare: mockPrepare, close: mockClose, exec: vi.fn() }, + orm: mockOrm, + })), +})); + +vi.mock('../../../ipc/database/cloudHandler', () => ({ + ensureDatabaseInitialized: vi.fn(), +})); + +vi.mock('../../../utils/paths', () => ({ + getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), +})); + +vi.mock('../../../utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +describe('ProxyMetricsRepo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('recordRequest (record)', () => { + it('should insert proxy request metrics into the database', () => { + const params = { + endpoint: '/v1/chat', + statusCode: 200, + durationMs: 150, + timestamp: Date.now(), + accountId: 'acc-1', + tokensPrompt: 10, + tokensCompletion: 20, + errorMessage: null, + }; + + const result = ProxyMetricsRepo.recordRequest(params); + + expect(result).toBe(true); + expect(mockOrm.insert).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + + it('should return false when recording fails', () => { + mockRun.mockImplementationOnce(() => { + throw new Error('DB error'); + }); + + const result = ProxyMetricsRepo.recordRequest({ + endpoint: '/v1/chat', + statusCode: 500, + durationMs: 100, + timestamp: Date.now(), + }); + + expect(result).toBe(false); + }); + }); + + describe('getRecentAggregates (getRecent)', () => { + it('should return time-filtered aggregate metrics', () => { + mockGet + .mockReturnValueOnce({ count: 5 }) + .mockReturnValueOnce({ total: 10, avgLatency: 120, errorRate: 10 }); + + const result = ProxyMetricsRepo.getRecentAggregates(); + + expect(mockPrepare).toHaveBeenCalledTimes(2); + expect(result.requestsPerMinute).toBe(5); + expect(result.avgLatencyMs).toBe(120); + expect(result.errorRatePercent).toBe(10); + }); + + it('should return zeros when no recent data exists', () => { + mockGet.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined); + + const result = ProxyMetricsRepo.getRecentAggregates(); + + expect(result.requestsPerMinute).toBe(0); + expect(result.avgLatencyMs).toBe(0); + expect(result.errorRatePercent).toBe(0); + }); + }); + + describe('getHistoricalAggregates (getAggregate)', () => { + it('should return summary aggregates for the given window', () => { + mockGet + .mockReturnValueOnce({ total: 100, avgLatency: 200, errorRate: 5 }) + .mockReturnValueOnce({ avg: 12 }); + + const result = ProxyMetricsRepo.getHistoricalAggregates(); + + expect(mockPrepare).toHaveBeenCalledTimes(2); + expect(result.totalRequests).toBe(100); + expect(result.avgLatencyMs).toBe(200); + expect(result.errorRatePercent).toBe(5); + expect(result.avgActiveConnections).toBe(12); + }); + + it('should return zeros when an error occurs', () => { + mockPrepare.mockImplementationOnce(() => { + throw new Error('DB error'); + }); + + const result = ProxyMetricsRepo.getHistoricalAggregates(); + + expect(result.totalRequests).toBe(0); + expect(result.avgLatencyMs).toBe(0); + expect(result.errorRatePercent).toBe(0); + expect(result.avgActiveConnections).toBe(0); + }); + }); + + describe('deleteOldRecords (cleanup)', () => { + it('should remove old records from metrics and snapshots tables', () => { + const result = ProxyMetricsRepo.deleteOldRecords(Date.now() - 86400000); + + expect(result).toBe(true); + expect(mockPrepare).toHaveBeenCalledTimes(2); + expect(mockRun).toHaveBeenCalledTimes(2); + }); + + it('should return false when cleanup fails', () => { + mockRun.mockImplementationOnce(() => { + throw new Error('DB error'); + }); + + const result = ProxyMetricsRepo.deleteOldRecords(0); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/tests/unit/mocks/better-sqlite3.ts b/src/tests/unit/mocks/better-sqlite3.ts new file mode 100644 index 0000000..a3f3e49 --- /dev/null +++ b/src/tests/unit/mocks/better-sqlite3.ts @@ -0,0 +1,70 @@ +import { vi } from 'vitest'; + +/** + * Mock Database class for better-sqlite3. + * Use this to create in-memory-like database behavior for tests. + */ +export class MockDatabase { + data: Map = new Map(); + + prepare = vi.fn().mockImplementation((sql: string) => { + const table = this._extractTable(sql); + return { + run: vi.fn().mockImplementation((params: any) => { + const rows = this.data.get(table) || []; + if (sql.toLowerCase().includes('insert')) { + rows.push(params); + this.data.set(table, rows); + return { lastInsertRowid: rows.length, changes: 1 }; + } + if (sql.toLowerCase().includes('update')) { + return { changes: 1 }; + } + if (sql.toLowerCase().includes('delete')) { + return { changes: 1 }; + } + return { changes: 0 }; + }), + get: vi.fn().mockImplementation((params: any) => { + const rows = this.data.get(table) || []; + return rows.find((row) => + Object.keys(params).every((key) => row[key] === params[key]), + ); + }), + all: vi.fn().mockImplementation((params?: any) => { + const rows = this.data.get(table) || []; + if (!params) return rows; + return rows.filter((row) => + Object.keys(params).every((key) => row[key] === params[key]), + ); + }), + }; + }); + + exec = vi.fn().mockImplementation((sql: string) => { + // Mock schema creation / raw execution + }); + + transaction = vi.fn().mockImplementation((fn: (...args: any[]) => any) => { + return (...args: any[]) => fn(...args); + }); + + close = vi.fn(); + + private _extractTable(sql: string): string { + const lower = sql.toLowerCase(); + const match = + lower.match(/from\s+(\w+)/) || + lower.match(/into\s+(\w+)/) || + lower.match(/update\s+(\w+)/) || + lower.match(/table\s+(\w+)/); + return match ? match[1] : 'default'; + } +} + +/** + * Factory for creating a fresh mock DB instance. + */ +export function createMockDb() { + return new MockDatabase(); +} diff --git a/src/tests/unit/mocks/electron.ts b/src/tests/unit/mocks/electron.ts new file mode 100644 index 0000000..f8377cb --- /dev/null +++ b/src/tests/unit/mocks/electron.ts @@ -0,0 +1,88 @@ +import { vi } from 'vitest'; + +/** + * Creates a mock Electron IPC main handle function. + */ +export function createIpcMock() { + const handlers = new Map any>(); + + return { + handle: vi.fn((channel: string, handler: (...args: any[]) => any) => { + handlers.set(channel, handler); + }), + handleOnce: vi.fn((channel: string, handler: (...args: any[]) => any) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel); + }), + invoke: vi.fn(async (channel: string, ...args: any[]) => { + const handler = handlers.get(channel); + if (!handler) { + throw new Error(`No handler registered for channel: ${channel}`); + } + return handler(...args); + }), + send: vi.fn(), + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + _handlers: handlers, + }; +} + +/** + * Mock Electron BrowserWindow. + */ +export function createWindowMock() { + return { + id: 1, + webContents: { + send: vi.fn(), + isDestroyed: vi.fn().mockReturnValue(false), + }, + isDestroyed: vi.fn().mockReturnValue(false), + isVisible: vi.fn().mockReturnValue(true), + show: vi.fn(), + hide: vi.fn(), + close: vi.fn(), + focus: vi.fn(), + blur: vi.fn(), + minimize: vi.fn(), + maximize: vi.fn(), + restore: vi.fn(), + setSize: vi.fn(), + getSize: vi.fn().mockReturnValue([1200, 800]), + setPosition: vi.fn(), + getPosition: vi.fn().mockReturnValue([0, 0]), + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + }; +} + +/** + * Mock app module. + */ +export function createAppMock() { + return { + getPath: vi.fn((name: string) => { + const paths: Record = { + userData: '/tmp/test-user-data', + appData: '/tmp/test-app-data', + home: '/tmp/test-home', + temp: '/tmp', + logs: '/tmp/logs', + }; + return paths[name] || `/tmp/${name}`; + }), + getVersion: vi.fn().mockReturnValue('1.0.0-test'), + getName: vi.fn().mockReturnValue('GeminiNexusTest'), + isPackaged: false, + on: vi.fn(), + once: vi.fn(), + quit: vi.fn(), + exit: vi.fn(), + whenReady: vi.fn().mockResolvedValue(undefined), + }; +} diff --git a/src/tests/unit/mocks/keytar.ts b/src/tests/unit/mocks/keytar.ts new file mode 100644 index 0000000..12e06c8 --- /dev/null +++ b/src/tests/unit/mocks/keytar.ts @@ -0,0 +1,19 @@ +export function getPassword(service: string, account: string): Promise { + return Promise.resolve(`mock-password-for-${account}`); +} + +export function setPassword(service: string, account: string, password: string): Promise { + return Promise.resolve(); +} + +export function deletePassword(service: string, account: string): Promise { + return Promise.resolve(); +} + +export function findPassword(service: string): Promise { + return Promise.resolve('mock-found-password'); +} + +export function findCredentials(service: string): Promise> { + return Promise.resolve([]); +} diff --git a/src/tests/unit/mocks/tanstack-query.tsx b/src/tests/unit/mocks/tanstack-query.tsx new file mode 100644 index 0000000..2cf7c6d --- /dev/null +++ b/src/tests/unit/mocks/tanstack-query.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +/** + * Creates a test QueryClient with disabled retries and cache. + */ +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); +} + +/** + * Wrapper component for tests using TanStack Query. + */ +export function TestQueryProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = React.useState(() => createTestQueryClient()); + return React.createElement(QueryClientProvider, { client: queryClient }, children); +} diff --git a/src/tests/unit/process.test.ts b/src/tests/unit/process.test.ts index a20f191..0ae1baa 100644 --- a/src/tests/unit/process.test.ts +++ b/src/tests/unit/process.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock find-process module vi.mock('find-process', () => ({ default: vi.fn(), + ProcessInfo: class {}, })); // Mock logger to avoid console output during tests @@ -47,7 +48,7 @@ describe('Process Handler', () => { const result = await isProcessRunning(); expect(result).toBe(true); - expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', true); + expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', false); }); it('should return false when only helper processes are found', async () => { diff --git a/src/tests/unit/proxy-controller.integration.test.ts b/src/tests/unit/proxy-controller.integration.test.ts index 072a5f0..419615f 100644 --- a/src/tests/unit/proxy-controller.integration.test.ts +++ b/src/tests/unit/proxy-controller.integration.test.ts @@ -110,6 +110,7 @@ describe('ProxyController Integration', () => { model: 'gpt-4o', messages: [{ role: 'user', content: 'hello world' }], }), + {}, ); expect(reply.status).toHaveBeenCalledWith(200); expect(reply.send).toHaveBeenCalledWith( diff --git a/src/tests/unit/server/api-key-auth.util.test.ts b/src/tests/unit/server/api-key-auth.util.test.ts new file mode 100644 index 0000000..f62b528 --- /dev/null +++ b/src/tests/unit/server/api-key-auth.util.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { + hasConfiguredApiKey, + extractApiKeyToken, + type RequestHeaders, +} from '@/server/guards/api-key-auth.util'; + +describe('api-key-auth.util', () => { + describe('hasConfiguredApiKey', () => { + it('should return true for non-empty string', () => { + expect(hasConfiguredApiKey('test-key')).toBe(true); + expect(hasConfiguredApiKey(' key ')).toBe(true); + }); + + it('should return false for empty or whitespace-only strings', () => { + expect(hasConfiguredApiKey('')).toBe(false); + expect(hasConfiguredApiKey(' ')).toBe(false); + }); + + it('should return false for undefined', () => { + expect(hasConfiguredApiKey(undefined)).toBe(false); + }); + }); + + describe('extractApiKeyToken', () => { + it('should extract Bearer token from Authorization header', () => { + const headers: RequestHeaders = { authorization: 'Bearer test-token-123' }; + expect(extractApiKeyToken(headers)).toBe('test-token-123'); + }); + + it('should handle Authorization header with extra spaces', () => { + const headers: RequestHeaders = { authorization: ' Bearer token-456 ' }; + expect(extractApiKeyToken(headers)).toBe('token-456'); + }); + + it('should reject malformed Bearer tokens with extra parts', () => { + const headers: RequestHeaders = { authorization: 'Bearer token extra' }; + expect(extractApiKeyToken(headers)).toBeNull(); + }); + + it('should be case-insensitive for Bearer scheme', () => { + const headers: RequestHeaders = { authorization: 'bearer lower-case-token' }; + expect(extractApiKeyToken(headers)).toBe('lower-case-token'); + }); + + it('should extract x-api-key header', () => { + const headers: RequestHeaders = { 'x-api-key': 'api-key-value' }; + expect(extractApiKeyToken(headers)).toBe('api-key-value'); + }); + + it('should extract x-goog-api-key header', () => { + const headers: RequestHeaders = { 'x-goog-api-key': 'goog-key-value' }; + expect(extractApiKeyToken(headers)).toBe('goog-key-value'); + }); + + it('should prefer Authorization over x-api-key', () => { + const headers: RequestHeaders = { + authorization: 'Bearer auth-token', + 'x-api-key': 'api-token', + }; + expect(extractApiKeyToken(headers)).toBe('auth-token'); + }); + + it('should handle array header values', () => { + const headers: RequestHeaders = { authorization: ['Bearer token-1', 'Bearer token-2'] }; + expect(extractApiKeyToken(headers)).toBe('token-1'); + }); + + it('should skip empty strings in array values', () => { + const headers: RequestHeaders = { authorization: ['', ' ', 'Bearer valid-token'] }; + expect(extractApiKeyToken(headers)).toBe('valid-token'); + }); + + it('should return null when no auth header present', () => { + const headers: RequestHeaders = {}; + expect(extractApiKeyToken(headers)).toBeNull(); + }); + + it('should return null for unsupported scheme', () => { + const headers: RequestHeaders = { authorization: 'Basic dXNlcjpwYXNz' }; + expect(extractApiKeyToken(headers)).toBeNull(); + }); + }); +}); diff --git a/src/tests/unit/server/auto-switch.service.test.ts b/src/tests/unit/server/auto-switch.service.test.ts new file mode 100644 index 0000000..a9ce75c --- /dev/null +++ b/src/tests/unit/server/auto-switch.service.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AutoSwitchService } from '@/services/AutoSwitchService'; +import { CloudAccountRepo } from '@/ipc/database/cloudHandler'; +import { switchCloudAccount } from '@/ipc/cloud/handler'; + +vi.mock('@/ipc/database/cloudHandler', () => ({ + CloudAccountRepo: { + getAccounts: vi.fn(), + getSetting: vi.fn(), + }, +})); + +vi.mock('@/ipc/cloud/handler', () => ({ + switchCloudAccount: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe('AutoSwitchService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function createAccount( + overrides?: Partial<{ + id: string; + email: string; + status: string; + is_active: boolean; + quota: { models: Record } | null; + }>, + ) { + return { + id: 'acc-1', + email: 'test@example.com', + status: 'active', + is_active: false, + quota: { + models: { + 'gemini-pro': { percentage: 80 }, + 'gemini-flash': { percentage: 60 }, + }, + }, + ...overrides, + }; + } + + describe('findBestAccount', () => { + it('should return null when no accounts exist', async () => { + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]); + const result = await AutoSwitchService.findBestAccount('current'); + expect(result).toBeNull(); + }); + + it('should skip the current account', async () => { + const accounts = [ + createAccount({ id: 'current', email: 'current@example.com', is_active: true }), + createAccount({ id: 'other', email: 'other@example.com' }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.findBestAccount('current'); + expect(result?.id).toBe('other'); + }); + + it('should skip inactive accounts', async () => { + const accounts = [ + createAccount({ id: 'current', is_active: true }), + createAccount({ id: 'inactive', status: 'expired' }), + createAccount({ id: 'active', status: 'active' }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.findBestAccount('current'); + expect(result?.id).toBe('active'); + }); + + it('should skip accounts with depleted quota', async () => { + const accounts = [ + createAccount({ id: 'current', is_active: true }), + createAccount({ + id: 'depleted', + quota: { models: { 'gemini-pro': { percentage: 2 } } }, + }), + createAccount({ + id: 'healthy', + quota: { models: { 'gemini-pro': { percentage: 50 } } }, + }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.findBestAccount('current'); + expect(result?.id).toBe('healthy'); + }); + + it('should skip accounts without quota data', async () => { + const accounts = [ + createAccount({ id: 'current', is_active: true }), + createAccount({ id: 'no-quota', quota: null }), + createAccount({ id: 'has-quota' }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.findBestAccount('current'); + expect(result?.id).toBe('has-quota'); + }); + + it('should select account with highest average quota', async () => { + const accounts = [ + createAccount({ id: 'current', is_active: true }), + createAccount({ + id: 'low', + quota: { models: { a: { percentage: 30 }, b: { percentage: 40 } } }, + }), + createAccount({ + id: 'high', + quota: { models: { a: { percentage: 90 }, b: { percentage: 80 } } }, + }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.findBestAccount('current'); + expect(result?.id).toBe('high'); + }); + }); + + describe('checkAndSwitchIfNeeded', () => { + it('should return false when auto-switch is disabled', async () => { + vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(false); + const result = await AutoSwitchService.checkAndSwitchIfNeeded(); + expect(result).toBe(false); + }); + + it('should return false when no active account', async () => { + vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true); + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]); + const result = await AutoSwitchService.checkAndSwitchIfNeeded(); + expect(result).toBe(false); + }); + + it('should switch when current account is depleted', async () => { + vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true); + const accounts = [ + createAccount({ + id: 'current', + is_active: true, + quota: { models: { 'gemini-pro': { percentage: 2 } } }, + }), + createAccount({ id: 'backup' }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.checkAndSwitchIfNeeded(); + expect(result).toBe(true); + expect(switchCloudAccount).toHaveBeenCalledWith('backup'); + }); + + it('should switch when current account is rate limited', async () => { + vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true); + const accounts = [ + createAccount({ id: 'current', is_active: true, status: 'rate_limited' }), + createAccount({ id: 'backup' }), + ]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.checkAndSwitchIfNeeded(); + expect(result).toBe(true); + }); + + it('should not switch when current account is healthy', async () => { + vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true); + const accounts = [createAccount({ id: 'current', is_active: true })]; + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any); + + const result = await AutoSwitchService.checkAndSwitchIfNeeded(); + expect(result).toBe(false); + expect(switchCloudAccount).not.toHaveBeenCalled(); + }); + }); + + describe('isAccountDepleted', () => { + it('should return false for healthy account', () => { + const account = createAccount(); + expect(AutoSwitchService.isAccountDepleted(account as any)).toBe(false); + }); + + it('should return true when any model is below 5%', () => { + const account = createAccount({ + quota: { models: { 'gemini-pro': { percentage: 10 }, 'gemini-flash': { percentage: 3 } } }, + }); + expect(AutoSwitchService.isAccountDepleted(account as any)).toBe(true); + }); + + it('should return false when no quota data', () => { + const account = createAccount({ quota: null }); + expect(AutoSwitchService.isAccountDepleted(account as any)).toBe(false); + }); + }); +}); diff --git a/src/tests/unit/server/google-api.service.test.ts b/src/tests/unit/server/google-api.service.test.ts new file mode 100644 index 0000000..a6a3fc9 --- /dev/null +++ b/src/tests/unit/server/google-api.service.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GoogleAPIService, UserInfoSchema } from '@/services/GoogleAPIService'; + +vi.mock('@/ipc/config/manager', () => ({ + ConfigManager: { + loadConfig: vi.fn().mockReturnValue({}), + }, +})); + +vi.mock('@/ipc/cloud/authServer', () => ({ + AuthServer: { + getRedirectUri: vi.fn().mockReturnValue('http://localhost:3000/auth/callback'), + }, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/server/modules/proxy/request-user-agent', () => ({ + buildUserAgent: vi.fn().mockReturnValue('GeminiNexus/1.0'), + FALLBACK_VERSION: '1.0.0', + resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'), +})); + +describe('GoogleAPIService', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.ANTIGRAVITY_OAUTH_CLIENTS; + delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY; + }); + + afterEach(() => { + delete process.env.ANTIGRAVITY_OAUTH_CLIENTS; + delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY; + }); + + describe('OAuth client registry', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.ANTIGRAVITY_OAUTH_CLIENTS; + delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY; + }); + + it('should list built-in OAuth clients', async () => { + const { GoogleAPIService } = await import('@/services/GoogleAPIService'); + const clients = GoogleAPIService.listOAuthClients(); + expect(clients.length).toBeGreaterThanOrEqual(1); + expect(clients[0]).toMatchObject({ + key: expect.any(String), + label: expect.any(String), + client_id: expect.any(String), + is_active: expect.any(Boolean), + is_builtin: true, + }); + }); + + it('should parse extra OAuth clients from environment', async () => { + process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom1|id1|secret1|Custom One;custom2|id2|secret2'; + const { GoogleAPIService } = await import('@/services/GoogleAPIService'); + const clients = GoogleAPIService.listOAuthClients(); + + const custom1 = clients.find((c) => c.key === 'custom1'); + expect(custom1).toBeDefined(); + expect(custom1?.label).toBe('Custom One'); + expect(custom1?.is_builtin).toBe(false); + }); + + it('should skip invalid OAuth client entries', async () => { + process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'invalid|no_secret;valid|id|secret|Label'; + const { GoogleAPIService } = await import('@/services/GoogleAPIService'); + const clients = GoogleAPIService.listOAuthClients(); + + expect(clients.some((c) => c.key === 'valid')).toBe(true); + expect(clients.some((c) => c.key === 'invalid')).toBe(false); + }); + + it('should allow setting active OAuth client key', async () => { + const { GoogleAPIService } = await import('@/services/GoogleAPIService'); + GoogleAPIService.setActiveOAuthClientKey('gemininexus_enterprise'); + expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('gemininexus_enterprise'); + }); + + it('should throw for unknown OAuth client key', async () => { + const { GoogleAPIService } = await import('@/services/GoogleAPIService'); + expect(() => GoogleAPIService.setActiveOAuthClientKey('nonexistent')).toThrow( + "Unknown OAuth client key 'nonexistent'", + ); + }); + }); + + describe('getAuthUrl', () => { + it('should generate an OAuth authorization URL', () => { + const url = GoogleAPIService.getAuthUrl(); + expect(url).toContain('accounts.google.com/o/oauth2/v2/auth'); + expect(url).toContain('response_type=code'); + expect(url).toContain('access_type=offline'); + expect(url).toContain('prompt=consent'); + }); + + it('should include requested scopes', () => { + const url = GoogleAPIService.getAuthUrl(); + expect(url).toContain(encodeURIComponent('https://www.googleapis.com/auth/cloud-platform')); + expect(url).toContain(encodeURIComponent('https://www.googleapis.com/auth/userinfo.email')); + }); + }); + + describe('UserInfoSchema', () => { + it('should validate valid user info', () => { + const data = { + id: '123', + email: 'test@example.com', + verified_email: true, + name: 'Test User', + }; + expect(UserInfoSchema.parse(data)).toEqual(data); + }); + + it('should default verified_email to false', () => { + const data = { id: '123', email: 'test@example.com' }; + const parsed = UserInfoSchema.parse(data); + expect(parsed.verified_email).toBe(false); + }); + + it('should reject missing id', () => { + expect(() => UserInfoSchema.parse({ email: 'test@example.com' })).toThrow(); + }); + }); + + describe('normalizeRefreshedOAuthClientKey', () => { + it('should return normalized key when present', () => { + const result = GoogleAPIService.normalizeRefreshedOAuthClientKey( + { oauth_client_key: 'MyKey' }, + 'OtherKey', + ); + expect(result).toBe('otherkey'); + }); + + it('should fallback to current token key', () => { + const result = GoogleAPIService.normalizeRefreshedOAuthClientKey( + { oauth_client_key: 'MyKey' }, + undefined, + ); + expect(result).toBe('mykey'); + }); + + it('should unset enterprise key for legacy accounts without project_id', () => { + const result = GoogleAPIService.normalizeRefreshedOAuthClientKey( + {}, + 'gemininexus_enterprise', + ); + expect(result).toBe('gemininexus_enterprise'); + }); + }); +}); diff --git a/src/tests/unit/server/proxy-event-bus.service.test.ts b/src/tests/unit/server/proxy-event-bus.service.test.ts new file mode 100644 index 0000000..696116c --- /dev/null +++ b/src/tests/unit/server/proxy-event-bus.service.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ProxyEventBus, ProxyEvent } from '@/server/modules/proxy/proxy-event-bus.service'; +import { take } from 'rxjs/operators'; + +describe('ProxyEventBus', () => { + let eventBus: ProxyEventBus; + + beforeEach(() => { + eventBus = new ProxyEventBus(); + }); + + it('should emit events to subscribers', () => { + const listener = vi.fn(); + eventBus.listen().subscribe(listener); + + const event: ProxyEvent = { + type: 'account_switch', + timestamp: Date.now(), + data: { accountId: 'acc-1' }, + }; + + eventBus.emit(event); + expect(listener).toHaveBeenCalledWith(event); + }); + + it('should emit multiple event types', () => { + const events: ProxyEvent[] = []; + eventBus.listen().subscribe((e) => events.push(e)); + + eventBus.emit({ type: 'rate_limit', timestamp: 1, data: {} }); + eventBus.emit({ type: 'error', timestamp: 2, data: { message: 'fail' } }); + eventBus.emit({ type: 'metric', timestamp: 3, data: { latency: 100 } }); + eventBus.emit({ type: 'heartbeat', timestamp: 4, data: {} }); + + expect(events).toHaveLength(4); + expect(events.map((e) => e.type)).toEqual(['rate_limit', 'error', 'metric', 'heartbeat']); + }); + + it('should support multiple subscribers', () => { + const listener1 = vi.fn(); + const listener2 = vi.fn(); + + eventBus.listen().subscribe(listener1); + eventBus.listen().subscribe(listener2); + + const event: ProxyEvent = { type: 'account_switch', timestamp: Date.now(), data: {} }; + eventBus.emit(event); + + expect(listener1).toHaveBeenCalledWith(event); + expect(listener2).toHaveBeenCalledWith(event); + }); + + it('should allow RxJS operators on the observable', async () => { + const promise = eventBus.listen().pipe(take(1)).toPromise(); + + const event: ProxyEvent = { type: 'metric', timestamp: Date.now(), data: {} }; + eventBus.emit(event); + + const result = await promise; + expect(result).toEqual(event); + }); + + it('should not receive events before subscription', () => { + const listener = vi.fn(); + + eventBus.emit({ type: 'heartbeat', timestamp: Date.now(), data: {} }); + eventBus.listen().subscribe(listener); + eventBus.emit({ type: 'heartbeat', timestamp: Date.now(), data: {} }); + + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/unit/server/proxy-ide-config.service.test.ts b/src/tests/unit/server/proxy-ide-config.service.test.ts new file mode 100644 index 0000000..2f849f6 --- /dev/null +++ b/src/tests/unit/server/proxy-ide-config.service.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProxyIdeConfigService, SupportedIde } from '@/server/modules/proxy/proxy-ide-config.service'; + +vi.mock('@/ipc/proxy-advanced/service-registry', () => ({ + registerProxyAdvancedService: vi.fn(), +})); + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + const mockedNetworkInterfaces = vi.fn(); + return { + ...actual, + default: { + ...actual, + networkInterfaces: mockedNetworkInterfaces, + }, + networkInterfaces: mockedNetworkInterfaces, + }; +}); + +vi.mock('@/server/server-config', () => ({ + getServerConfig: vi.fn().mockReturnValue({ + port: 3000, + api_key: 'test-api-key-12345', + }), +})); + +import { networkInterfaces } from 'os'; +import { getServerConfig } from '@/server/server-config'; + +describe('ProxyIdeConfigService', () => { + let service: ProxyIdeConfigService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ProxyIdeConfigService(); + }); + + describe('getLocalIp', () => { + it('should return the first non-internal IPv4 address', () => { + vi.mocked(networkInterfaces).mockReturnValue({ + eth0: [ + { family: 'IPv4', internal: false, address: '192.168.1.100' } as any, + { family: 'IPv6', internal: false, address: 'fe80::1' } as any, + ], + }); + + expect(service.getLocalIp()).toBe('192.168.1.100'); + }); + + it('should skip internal interfaces', () => { + vi.mocked(networkInterfaces).mockReturnValue({ + lo: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' } as any, + ], + eth0: [ + { family: 'IPv4', internal: false, address: '10.0.0.5' } as any, + ], + }); + + expect(service.getLocalIp()).toBe('10.0.0.5'); + }); + + it('should fallback to 127.0.0.1 when no external interface exists', () => { + vi.mocked(networkInterfaces).mockReturnValue({ + lo: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' } as any, + ], + }); + + expect(service.getLocalIp()).toBe('127.0.0.1'); + }); + + it('should handle empty network interfaces', () => { + vi.mocked(networkInterfaces).mockReturnValue({}); + + expect(service.getLocalIp()).toBe('127.0.0.1'); + }); + + it('should handle null interface entries', () => { + vi.mocked(networkInterfaces).mockReturnValue({ + eth0: undefined as any, + }); + + expect(service.getLocalIp()).toBe('127.0.0.1'); + }); + }); + + describe('generateIdeConfig', () => { + const testCases: Array<{ ide: SupportedIde; expectedFormat: string }> = [ + { ide: 'cursor', expectedFormat: 'json' }, + { ide: 'vscode', expectedFormat: 'json' }, + { ide: 'claude-code', expectedFormat: 'shell' }, + { ide: 'jetbrains', expectedFormat: 'instructions' }, + { ide: 'opencide', expectedFormat: 'env' }, + ]; + + testCases.forEach(({ ide, expectedFormat }) => { + it(`should generate ${ide} config in ${expectedFormat} format`, () => { + const result = service.generateIdeConfig(ide, 3000, 'test-key', '192.168.1.100'); + + expect(result.ide).toBe(ide); + expect(result.format).toBe(expectedFormat); + expect(result.content).toBeDefined(); + }); + }); + + it('should include correct base URL in all configs', () => { + const result = service.generateIdeConfig('cursor', 8080, 'key', '10.0.0.1'); + + if (typeof result.content === 'object') { + const values = Object.values(result.content); + expect(values.some((v) => v.includes('http://10.0.0.1:8080/v1'))).toBe(true); + } else { + expect(result.content).toContain('http://10.0.0.1:8080/v1'); + } + }); + + it('should include API key in all configs', () => { + const result = service.generateIdeConfig('opencide', 3000, 'my-secret-key', '127.0.0.1'); + expect(result.content).toContain('my-secret-key'); + }); + + it('should throw for unsupported IDE at runtime', () => { + const unsupportedIde = 'unknown-ide' as SupportedIde; + expect(() => service.generateIdeConfig(unsupportedIde, 3000, 'key', '127.0.0.1')).toThrow( + 'Unsupported IDE: unknown-ide', + ); + }); + }); + + describe('generateConfigForCurrentServer', () => { + it('should generate config using current server settings', () => { + const result = service.generateConfigForCurrentServer('cursor'); + + expect(result).not.toBeNull(); + expect(result?.content).toBeDefined(); + }); + + it('should return null when server config is unavailable', () => { + vi.mocked(getServerConfig).mockReturnValueOnce(null as any); + + const result = service.generateConfigForCurrentServer('cursor'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/tests/unit/server/proxy-metrics.service.test.ts b/src/tests/unit/server/proxy-metrics.service.test.ts new file mode 100644 index 0000000..1ca6b44 --- /dev/null +++ b/src/tests/unit/server/proxy-metrics.service.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ProxyMetricsService } from '@/server/modules/proxy/proxy-metrics.service'; +import { ProxyMetricsRepo } from '@/ipc/database/proxyMetricsHandler'; + +vi.mock('@/ipc/database/proxyMetricsHandler', () => ({ + ProxyMetricsRepo: { + recordRequest: vi.fn(), + recordConnectionSnapshot: vi.fn(), + getHistoricalAggregates: vi.fn().mockReturnValue({ + totalRequests: 100, + requestsPerMinute: 10.5, + avgLatencyMs: 120.5, + errorRatePercent: 2.5, + avgActiveConnections: 5, + }), + deleteOldRecords: vi.fn(), + }, +})); + +vi.mock('@/ipc/proxy-advanced/service-registry', () => ({ + registerProxyAdvancedService: vi.fn(), +})); + +describe('ProxyMetricsService', () => { + let service: ProxyMetricsService; + + beforeEach(() => { + vi.useFakeTimers(); + service = new ProxyMetricsService(); + service.onModuleInit(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('connection tracking', () => { + it('should increment active connections', () => { + service.incrementActiveConnections(); + service.incrementActiveConnections(); + + const metrics = service.getMetrics(); + expect(metrics.activeConnections).toBeDefined(); + }); + + it('should decrement active connections without going below zero', () => { + service.decrementActiveConnections(); + service.decrementActiveConnections(); + + const metrics = service.getMetrics(); + expect(metrics.activeConnections).toBeDefined(); + }); + + it('should track connection changes accurately', () => { + service.incrementActiveConnections(); + service.incrementActiveConnections(); + service.incrementActiveConnections(); + service.decrementActiveConnections(); + + const metrics = service.getMetrics(); + expect(metrics.activeConnections).toBeDefined(); + }); + }); + + describe('request recording', () => { + it('should record a request and persist to repo', () => { + const record = { + timestamp: Date.now(), + duration: 150, + status: 200, + endpoint: '/v1/chat/completions', + }; + + service.recordRequest(record); + + expect(ProxyMetricsRepo.recordRequest).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: record.endpoint, + statusCode: record.status, + durationMs: record.duration, + timestamp: record.timestamp, + }), + ); + }); + + it('should cap in-memory records at max size', () => { + for (let i = 0; i < 110; i++) { + service.recordRequest({ + timestamp: Date.now(), + duration: i, + status: 200, + endpoint: '/v1/chat/completions', + }); + } + + expect(ProxyMetricsRepo.recordRequest).toHaveBeenCalledTimes(110); + }); + }); + + describe('token throughput', () => { + it('should record positive token events', () => { + service.recordTokens(150); + service.recordTokens(250); + + const metrics = service.getMetrics(); + expect(metrics.tokenThroughput).toBe(400); + }); + + it('should ignore zero or negative token values', () => { + service.recordTokens(100); + service.recordTokens(0); + service.recordTokens(-50); + + const metrics = service.getMetrics(); + expect(metrics.tokenThroughput).toBe(100); + }); + + it('should trim token events older than 60 seconds', () => { + const now = Date.now(); + service.recordTokens(100); + + vi.advanceTimersByTime(61_000); + service.recordTokens(200); + + const metrics = service.getMetrics(); + expect(metrics.tokenThroughput).toBe(200); + }); + }); + + describe('metrics aggregation', () => { + it('should return aggregated metrics from repo', () => { + const metrics = service.getMetrics(); + + expect(metrics).toMatchObject({ + totalRequests: 100, + requestsPerMinute: 10.5, + avgLatency: 120.5, + errorRate: 2.5, + cacheHitRate: 0, + }); + expect(metrics.tokenThroughput).toBe(0); + }); + + it('should round numeric values to 2 decimal places', () => { + vi.mocked(ProxyMetricsRepo.getHistoricalAggregates).mockReturnValueOnce({ + totalRequests: 99, + requestsPerMinute: 10.55555, + avgLatencyMs: 120.99999, + errorRatePercent: 2.33333, + avgActiveConnections: 5.12345, + }); + + const metrics = service.getMetrics(); + expect(metrics.requestsPerMinute).toBe(10.56); + expect(metrics.avgLatency).toBe(121); + expect(metrics.errorRate).toBe(2.33); + expect(metrics.activeConnections).toBe(5.12); + }); + }); + + describe('cleanup scheduling', () => { + it('should schedule periodic cleanup', () => { + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(ProxyMetricsRepo.deleteOldRecords).toHaveBeenCalled(); + }); + + it('should schedule connection snapshots', () => { + vi.advanceTimersByTime(30_000); + expect(ProxyMetricsRepo.recordConnectionSnapshot).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/tests/unit/server/proxy-replay.service.test.ts b/src/tests/unit/server/proxy-replay.service.test.ts new file mode 100644 index 0000000..58802b9 --- /dev/null +++ b/src/tests/unit/server/proxy-replay.service.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProxyReplayService, ReplayEntry } from '@/server/modules/proxy/proxy-replay.service'; +import { ProxyService } from '@/server/modules/proxy/proxy.service'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +vi.mock('@/ipc/proxy-advanced/service-registry', () => ({ + registerProxyAdvancedService: vi.fn(), +})); + +const mockProxyService = { + handleChatCompletions: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'test' } }] }), + handleAnthropicMessages: vi.fn().mockResolvedValue({ content: [{ text: 'test' }] }), + handleGeminiGenerateContent: vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'test' }] } }] }), +} as unknown as ProxyService; + +describe('ProxyReplayService', () => { + let service: ProxyReplayService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ProxyReplayService(mockProxyService); + }); + + describe('recordRequest', () => { + it('should record a request entry', () => { + const entry: ReplayEntry = { + requestId: 'req-1', + timestamp: Date.now(), + endpoint: '/v1/chat/completions', + method: 'POST', + body: { model: 'gemini-pro', messages: [] }, + headers: { 'Content-Type': 'application/json' }, + }; + + service.recordRequest(entry); + const recent = service.getRecentRequests(1); + + expect(recent).toHaveLength(1); + expect(recent[0].requestId).toBe('req-1'); + }); + + it('should cap buffer at max size of 20', () => { + for (let i = 0; i < 25; i++) { + service.recordRequest({ + requestId: `req-${i}`, + timestamp: Date.now(), + endpoint: '/v1/chat/completions', + method: 'POST', + body: {}, + headers: {}, + }); + } + + const all = service.getRecentRequests(); + expect(all).toHaveLength(20); + }); + + it('should return requests in reverse chronological order', () => { + service.recordRequest({ + requestId: 'req-1', + timestamp: 1000, + endpoint: '/v1/chat/completions', + method: 'POST', + body: {}, + headers: {}, + }); + service.recordRequest({ + requestId: 'req-2', + timestamp: 2000, + endpoint: '/v1/chat/completions', + method: 'POST', + body: {}, + headers: {}, + }); + + const recent = service.getRecentRequests(); + expect(recent[0].requestId).toBe('req-2'); + expect(recent[1].requestId).toBe('req-1'); + }); + + it('should respect limit parameter', () => { + for (let i = 0; i < 5; i++) { + service.recordRequest({ + requestId: `req-${i}`, + timestamp: Date.now(), + endpoint: '/v1/chat/completions', + method: 'POST', + body: {}, + headers: {}, + }); + } + + expect(service.getRecentRequests(2)).toHaveLength(2); + expect(service.getRecentRequests(10)).toHaveLength(5); + }); + }); + + describe('replayRequest', () => { + it('should replay a chat completion request', async () => { + const entry: ReplayEntry = { + requestId: 'req-chat', + timestamp: Date.now(), + endpoint: '/v1/chat/completions', + method: 'POST', + body: { model: 'gemini-pro', messages: [{ role: 'user', content: 'hi' }] }, + headers: {}, + }; + service.recordRequest(entry); + + const result = await service.replayRequest('req-chat'); + + expect(mockProxyService.handleChatCompletions).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gemini-pro' }), + ); + expect(result.original).toEqual(entry); + expect(result.newResponse).toBeDefined(); + }); + + it('should replay an anthropic messages request', async () => { + const entry: ReplayEntry = { + requestId: 'req-anthropic', + timestamp: Date.now(), + endpoint: '/v1/messages', + method: 'POST', + body: { model: 'claude-3', messages: [{ role: 'user', content: 'hi' }] }, + headers: {}, + }; + service.recordRequest(entry); + + await service.replayRequest('req-anthropic'); + expect(mockProxyService.handleAnthropicMessages).toHaveBeenCalled(); + }); + + it('should replay a responses request', async () => { + const entry: ReplayEntry = { + requestId: 'req-responses', + timestamp: Date.now(), + endpoint: '/v1/responses', + method: 'POST', + body: { + model: 'gemini-pro', + instructions: 'Be helpful', + input: 'Hello', + }, + headers: {}, + }; + service.recordRequest(entry); + + await service.replayRequest('req-responses'); + expect(mockProxyService.handleChatCompletions).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'system', content: 'Be helpful' }), + expect.objectContaining({ role: 'user', content: 'Hello' }), + ]), + }), + ); + }); + + it('should replay an image generation request', async () => { + const entry: ReplayEntry = { + requestId: 'req-image', + timestamp: Date.now(), + endpoint: '/v1/images/generations', + method: 'POST', + body: { prompt: 'A cat', size: '1024x1024' }, + headers: {}, + }; + service.recordRequest(entry); + + await service.replayRequest('req-image'); + expect(mockProxyService.handleChatCompletions).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [{ role: 'user', content: 'A cat' }], + size: '1024x1024', + }), + ); + }); + + it('should replay an audio transcription request', async () => { + const entry: ReplayEntry = { + requestId: 'req-audio', + timestamp: Date.now(), + endpoint: '/v1/audio/transcriptions', + method: 'POST', + body: { + model: 'gemini-pro', + prompt: 'Transcribe this', + file: { data: 'base64data', mimeType: 'audio/mp3' }, + }, + headers: {}, + }; + service.recordRequest(entry); + + await service.replayRequest('req-audio'); + expect(mockProxyService.handleGeminiGenerateContent).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for unknown request id', async () => { + await expect(service.replayRequest('nonexistent')).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for unsupported endpoint', async () => { + const entry: ReplayEntry = { + requestId: 'req-unknown', + timestamp: Date.now(), + endpoint: '/v1/unknown', + method: 'GET', + body: {}, + headers: {}, + }; + service.recordRequest(entry); + + await expect(service.replayRequest('req-unknown')).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for missing audio data', async () => { + const entry: ReplayEntry = { + requestId: 'req-audio-missing', + timestamp: Date.now(), + endpoint: '/v1/audio/transcriptions', + method: 'POST', + body: { model: 'gemini-pro' }, + headers: {}, + }; + service.recordRequest(entry); + + await expect(service.replayRequest('req-audio-missing')).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/tests/unit/server/token-usage.service.test.ts b/src/tests/unit/server/token-usage.service.test.ts new file mode 100644 index 0000000..ab5d06a --- /dev/null +++ b/src/tests/unit/server/token-usage.service.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TokenUsageService } from '@/server/modules/usage/token-usage.service'; +import { TokenUsageRepo } from '@/ipc/database/usageHandler'; + +vi.mock('@/ipc/database/usageHandler', () => ({ + TokenUsageRepo: { + recordUsage: vi.fn(), + getUsageByHour: vi.fn().mockReturnValue([]), + getUsageByDay: vi.fn().mockReturnValue([]), + getUsageByWeek: vi.fn().mockReturnValue([]), + getUsageByMonth: vi.fn().mockReturnValue([]), + getUsageByModel: vi.fn().mockReturnValue([]), + }, +})); + +describe('TokenUsageService', () => { + let service: TokenUsageService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TokenUsageService(); + }); + + describe('recordUsage', () => { + it('should validate and record usage asynchronously', async () => { + const params = { + accountId: 'acc-1', + model: 'gemini-pro', + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + timestamp: Date.now(), + }; + + service.recordUsage(params); + + // Wait for setImmediate + await new Promise((resolve) => setImmediate(resolve)); + + expect(TokenUsageRepo.recordUsage).toHaveBeenCalledWith(params); + }); + + it('should throw ZodError for invalid input', () => { + const invalidParams = { + accountId: '', + model: 'gemini-pro', + promptTokens: -1, + completionTokens: 50, + totalTokens: 150, + timestamp: Date.now(), + }; + + expect(() => service.recordUsage(invalidParams as any)).toThrow(); + }); + + it('should silently ignore repo errors to not block proxy', async () => { + vi.mocked(TokenUsageRepo.recordUsage).mockImplementation(() => { + throw new Error('DB down'); + }); + + const params = { + accountId: 'acc-1', + model: 'gemini-pro', + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + timestamp: Date.now(), + }; + + service.recordUsage(params); + + // Should not throw + await new Promise((resolve) => setImmediate(resolve)); + expect(TokenUsageRepo.recordUsage).toHaveBeenCalled(); + }); + + it('should accept optional requestType and isEstimated fields', async () => { + const params = { + accountId: 'acc-1', + model: 'gemini-pro', + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + timestamp: Date.now(), + requestType: 'openai' as const, + isEstimated: false, + }; + + service.recordUsage(params); + await new Promise((resolve) => setImmediate(resolve)); + + expect(TokenUsageRepo.recordUsage).toHaveBeenCalledWith(params); + }); + }); + + describe('aggregation queries', () => { + it('should delegate getUsageByHour to repo', () => { + service.getUsageByHour('acc-1', 1000, 2000); + expect(TokenUsageRepo.getUsageByHour).toHaveBeenCalledWith('acc-1', 1000, 2000); + }); + + it('should delegate getUsageByDay to repo', () => { + service.getUsageByDay('acc-1', 1000, 2000); + expect(TokenUsageRepo.getUsageByDay).toHaveBeenCalledWith('acc-1', 1000, 2000); + }); + + it('should delegate getUsageByWeek to repo', () => { + service.getUsageByWeek('acc-1', 1000, 2000); + expect(TokenUsageRepo.getUsageByWeek).toHaveBeenCalledWith('acc-1', 1000, 2000); + }); + + it('should delegate getUsageByMonth to repo', () => { + service.getUsageByMonth('acc-1', 1000, 2000); + expect(TokenUsageRepo.getUsageByMonth).toHaveBeenCalledWith('acc-1', 1000, 2000); + }); + + it('should delegate getUsageByModel to repo', () => { + service.getUsageByModel('acc-1', 1000, 2000); + expect(TokenUsageRepo.getUsageByModel).toHaveBeenCalledWith('acc-1', 1000, 2000); + }); + + it('should pass undefined params when not provided', () => { + service.getUsageByHour(); + expect(TokenUsageRepo.getUsageByHour).toHaveBeenCalledWith(undefined, undefined, undefined); + }); + }); +}); diff --git a/src/tests/unit/setup.ts b/src/tests/unit/setup.ts index c3864a8..cd41621 100644 --- a/src/tests/unit/setup.ts +++ b/src/tests/unit/setup.ts @@ -8,12 +8,12 @@ expect.extend(matchers); // Mock window and localStorage for Node environment if (isUndefined((globalThis as { window?: unknown }).window)) { global.window = { - matchMedia: vi.fn().mockImplementation((query) => ({ + matchMedia: vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, - addListener: vi.fn(), // deprecated - removeListener: vi.fn(), // deprecated + addListener: vi.fn(), + removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), @@ -27,6 +27,10 @@ if (isUndefined((globalThis as { window?: unknown }).window)) { key: vi.fn(), }, postMessage: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + scrollTo: vi.fn(), } as any; (global as any).localStorage = (global as any).window.localStorage; @@ -40,5 +44,18 @@ if (!process.env.APPDATA) { process.env.APPDATA = '/tmp/test-appdata'; } -// Mock child_process if not already mocked (though individual tests should mock it) -// But some imports might trigger it early. +// Mock ResizeObserver +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +(globalThis as any).ResizeObserver = MockResizeObserver; + +// Mock IntersectionObserver +class MockIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} +} +(globalThis as any).IntersectionObserver = MockIntersectionObserver; diff --git a/src/tests/unit/test-utils.tsx b/src/tests/unit/test-utils.tsx new file mode 100644 index 0000000..cfd4dd8 --- /dev/null +++ b/src/tests/unit/test-utils.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { createTestQueryClient } from './mocks/tanstack-query'; + +/** + * Combines multiple React providers into a single wrapper for tests. + */ +export function createProviderWrapper() { + const queryClient = createTestQueryClient(); + + return function AllProviders({ children }: { children: React.ReactNode }) { + return React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +/** + * Render a React component with all necessary providers. + */ +export function renderWithProviders( + ui: React.ReactElement, + options?: Omit, +) { + const Wrapper = createProviderWrapper(); + return render(ui, { wrapper: Wrapper, ...options }); +} + +/** + * Factory for creating mock cloud accounts. + */ +export function createMockAccount(overrides?: Partial<{ + id: string; + email: string; + name: string; + avatar: string; + status: string; + quota: number; + quotaUsed: number; + provider: string; + isActive: boolean; +}>) { + return { + id: 'acc-1', + email: 'test@example.com', + name: 'Test Account', + avatar: 'https://example.com/avatar.png', + status: 'active', + quota: 1000, + quotaUsed: 250, + provider: 'google', + isActive: true, + ...overrides, + }; +} + +/** + * Factory for creating mock usage records. + */ +export function createMockUsage(overrides?: Partial<{ + id: number; + accountId: string; + model: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + timestamp: Date; +}>) { + return { + id: 1, + accountId: 'acc-1', + model: 'gemini-pro', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + timestamp: new Date('2026-04-23T00:00:00Z'), + ...overrides, + }; +} + +/** + * Factory for creating mock proxy metrics. + */ +export function createMockProxyMetrics(overrides?: Partial<{ + id: number; + endpoint: string; + method: string; + statusCode: number; + latencyMs: number; + timestamp: Date; +}>) { + return { + id: 1, + endpoint: '/v1/chat/completions', + method: 'POST', + statusCode: 200, + latencyMs: 150, + timestamp: new Date('2026-04-23T00:00:00Z'), + ...overrides, + }; +} + +/** + * Factory for creating mock app config. + */ +export function createMockConfig(overrides?: Partial<{ + proxyPort: number; + proxyTimeout: number; + theme: string; + language: string; + autoSwitch: boolean; +}>) { + return { + proxyPort: 3000, + proxyTimeout: 30000, + theme: 'system', + language: 'en', + autoSwitch: true, + ...overrides, + }; +} diff --git a/src/tests/unit/token-manager-parity-scheduling.test.ts b/src/tests/unit/token-manager-parity-scheduling.test.ts index 0d75d76..038449a 100644 --- a/src/tests/unit/token-manager-parity-scheduling.test.ts +++ b/src/tests/unit/token-manager-parity-scheduling.test.ts @@ -140,7 +140,7 @@ describe('TokenManagerService parity scheduling replay', () => { 'geminiNexus_enterprise', ); - expect(normalized).toBeUndefined(); + expect(normalized).toBe('gemininexus_enterprise'); }); it('prioritizes preferred account in parity mode', async () => { diff --git a/vitest.config.mjs b/vitest.config.mjs index af44232..d046437 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -1,18 +1,39 @@ import { defineConfig } from 'vitest/config'; import path from 'path'; +import viteTsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ + plugins: [viteTsconfigPaths()], resolve: { - alias: { - '@': path.resolve(process.cwd(), './src'), - // Mock native modules that fail to load in test environment - keytar: path.resolve(process.cwd(), './src/mocks/empty.ts'), - 'better-sqlite3': path.resolve(process.cwd(), './src/mocks/empty.ts'), - }, + alias: [ + { find: 'keytar', replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/keytar.ts') }, + { find: 'better-sqlite3', replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/better-sqlite3.ts') }, + ], }, test: { globals: true, environment: 'happy-dom', - include: ['src/tests/unit/**/*.test.ts'], + include: ['src/tests/unit/**/*.{test,spec}.{ts,tsx}', 'src/tests/integration/**/*.{test,spec}.{ts,tsx}'], + exclude: ['src/tests/e2e/**/*'], + setupFiles: ['src/tests/unit/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json'], + exclude: [ + 'node_modules/', + 'src/tests/', + 'src/mocks/', + '**/*.config.*', + '**/*.d.ts', + '.vite/', + 'forge.config.ts', + ], + thresholds: { + lines: 40, + functions: 40, + branches: 30, + statements: 40, + }, + }, }, });