test: fix broken unit tests, add missing fixtures, and stabilize CI pipeline
- Fix os module mock pattern for Vitest v4 in proxy-ide-config tests - Fix enterprise oauth_client_key expectations in cloudHandler-sync and token-manager-parity tests - Fix proxy-controller integration test argument expectations - Add missing proxy-parity JSON fixtures (OpenAI tools, Anthropic tool-use, Gemini SSE) - Add reasoning_content mapping to convertClaudeToOpenAIResponse - Fix google-api service test env var cache leak with resetModules + dynamic imports - Fix proxy-metrics timer tests by calling onModuleInit in beforeEach - Fix locale-sensitive number formatting in StatCard tests - Add missing test files to version control (components, hooks, ipc, mocks, server, utils) - Fix CI workflows: testing.yaml (use vitest run, add type-check job, npm cache), lint.yaml and format.yaml (add npm cache, concurrency, fix job names)
This commit is contained in:
1 parent
bf4bcf4f96
commit
afe8859f16
47 files changed
+4341
-73
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+44
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+10
-10
@@ -132,20 +132,20 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
|
||||
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<boolean> {
|
||||
|
||||
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<boolean> {
|
||||
}
|
||||
|
||||
if (
|
||||
(name.includes('geminiNexus') || cmd.includes('/geminiNexus')) &&
|
||||
(name.includes('gemininexus') || cmd.includes('/gemininexus')) &&
|
||||
!name.includes('tools')
|
||||
) {
|
||||
logger.debug(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"contains": [
|
||||
"chat.completion.chunk",
|
||||
"Hello world",
|
||||
"finish_reason",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"model": "gemini-pro",
|
||||
"upstream": {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello world"
|
||||
}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
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 }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => {
|
||||
if (asChild) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <div data-testid="dropdown-trigger">{children}</div>;
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="dropdown-item"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AccountCard', () => {
|
||||
const createMockAccount = (overrides?: Partial<Account>): Account => ({
|
||||
id: 'acc-1',
|
||||
name: 'Test User',
|
||||
email: '[email protected]',
|
||||
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(<AccountCard {...defaultProps} />);
|
||||
expect(screen.getByText('Test User')).toBeInTheDocument();
|
||||
expect(screen.getByText('[email protected]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows avatar image element when avatar_url is provided', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
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(
|
||||
<AccountCard
|
||||
{...defaultProps}
|
||||
account={createMockAccount({ avatar_url: undefined })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('TU')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email initial as fallback when name is empty', () => {
|
||||
renderWithProviders(
|
||||
<AccountCard
|
||||
{...defaultProps}
|
||||
account={createMockAccount({ name: '', avatar_url: undefined })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('T')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays current badge when isCurrent is true', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} isCurrent={true} />);
|
||||
expect(screen.getByText('account.current')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display current badge when isCurrent is false', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} isCurrent={false} />);
|
||||
expect(screen.queryByText('account.current')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles delete action from dropdown menu', () => {
|
||||
const onDelete = vi.fn();
|
||||
renderWithProviders(<AccountCard {...defaultProps} onDelete={onDelete} />);
|
||||
|
||||
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(<AccountCard {...defaultProps} onSwitch={onSwitch} />);
|
||||
|
||||
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(
|
||||
<AccountCard {...defaultProps} isCurrent={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountCard {...defaultProps} isSwitching={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountCard {...defaultProps} isDeleting={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
|
||||
const card = document.querySelector('[class*="pointer-events-none"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies reduced opacity and pointer-events-none when switching', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isSwitching={true} />,
|
||||
);
|
||||
const card = container.querySelector('[class*="opacity-60"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies reduced opacity and pointer-events-none when deleting', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isDeleting={true} />,
|
||||
);
|
||||
const card = container.querySelector('[class*="opacity-60"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies primary border styling when isCurrent is true', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isCurrent={true} />,
|
||||
);
|
||||
const card = container.querySelector('[class*="border-primary/50"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinning refresh icon when isSwitching is true', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} isSwitching={true} />);
|
||||
const spinIcon = document.querySelector('.animate-spin');
|
||||
expect(spinIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles mouse move to update card transform', () => {
|
||||
const { container } = renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
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(<AccountCard {...defaultProps} />);
|
||||
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(<AccountCard {...defaultProps} />);
|
||||
expect(screen.getByText(/account\.lastUsed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
if (options && 'count' in options) {
|
||||
return `${key} (${options.count})`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProviderGroup', () => {
|
||||
const createMockStats = (overrides?: Partial<ProviderStats>): 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(<ProviderGroup {...defaultProps} />);
|
||||
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays model count', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText(/settings\.providerGroupings\.models/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows quota bars and average percentage', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(screen.getByText('62.5%')).toBeInTheDocument();
|
||||
expect(screen.getByText('settings.providerGroupings.avgLabel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reset time label', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(screen.getAllByText('24h').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows individual model rows when expanded', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
expect(screen.getByText('gemini-pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('gemini-ultra')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides individual model rows when collapsed', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={true} />);
|
||||
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(
|
||||
<ProviderGroup {...defaultProps} onToggleCollapse={onToggleCollapse} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders color dot with provider color', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<ProviderGroup {...defaultProps} />,
|
||||
);
|
||||
const dot = container.querySelector('span[style*="background-color"]');
|
||||
expect(dot).toHaveAttribute('style', expect.stringContaining('#4285F4'));
|
||||
});
|
||||
|
||||
it('shows chevron right when collapsed', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={true} />);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows chevron down when expanded', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when visibleModels is empty', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<ProviderGroup
|
||||
{...defaultProps}
|
||||
stats={createMockStats({ visibleModels: [] })}
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('displays model-specific percentages in expanded rows', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows leftLabel next to model percentages when percentage > 0', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
const leftLabels = screen.getAllByText('left');
|
||||
expect(leftLabels.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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(<Button>Click me</Button>);
|
||||
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(
|
||||
<Button variant={variant}>{variant}</Button>,
|
||||
);
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all size styles', () => {
|
||||
const sizes = ['default', 'sm', 'lg', 'icon'] as const;
|
||||
sizes.forEach((size) => {
|
||||
const { container } = renderWithProviders(
|
||||
<Button size={size}>{size}</Button>,
|
||||
);
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
renderWithProviders(<Button onClick={handleClick}>Click me</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports disabled state', () => {
|
||||
renderWithProviders(<Button disabled>Disabled</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders as child element when asChild is true', () => {
|
||||
renderWithProviders(
|
||||
<Button asChild>
|
||||
<a href="/test">Link Button</a>
|
||||
</Button>,
|
||||
);
|
||||
const link = screen.getByRole('link', { name: /link button/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/test');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(<Button className="custom-class">Styled</Button>);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 data-testid="card">
|
||||
<span>Card content</span>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByTestId('card')).toBeInTheDocument();
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className to Card', () => {
|
||||
renderWithProviders(<Card className="my-card">Content</Card>);
|
||||
expect(screen.getByText('Content')).toHaveClass('my-card');
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
renderWithProviders(<Card ref={ref}>Ref test</Card>);
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardHeader', () => {
|
||||
it('renders header with title and description', () => {
|
||||
renderWithProviders(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CardTitle renders as h3', () => {
|
||||
renderWithProviders(<CardTitle>Heading</CardTitle>);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Heading');
|
||||
});
|
||||
|
||||
it('CardDescription has correct text styling classes', () => {
|
||||
const { container } = renderWithProviders(<CardDescription>Desc</CardDescription>);
|
||||
expect(container.querySelector('p')).toHaveClass('text-muted-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardContent', () => {
|
||||
it('renders content area', () => {
|
||||
renderWithProviders(
|
||||
<Card>
|
||||
<CardContent>Main content</CardContent>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText('Main content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardFooter', () => {
|
||||
it('renders footer with actions', () => {
|
||||
renderWithProviders(
|
||||
<Card>
|
||||
<CardFooter>
|
||||
<button>Action</button>
|
||||
</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full Card composition', () => {
|
||||
it('renders complete card with all sections', () => {
|
||||
renderWithProviders(
|
||||
<Card className="w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Account Stats</CardTitle>
|
||||
<CardDescription>Overview of usage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Tokens used: 1,234</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<span>Last updated: now</span>
|
||||
</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(<Input placeholder="Enter text" />);
|
||||
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts and displays text value', () => {
|
||||
renderWithProviders(<Input />);
|
||||
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(<Input type="text" />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
|
||||
|
||||
rerender(<Input type="password" data-testid="password-input" />);
|
||||
expect(screen.getByTestId('password-input')).toHaveAttribute('type', 'password');
|
||||
|
||||
rerender(<Input type="email" data-testid="email-input" />);
|
||||
expect(screen.getByTestId('email-input')).toHaveAttribute('type', 'email');
|
||||
});
|
||||
|
||||
it('supports disabled state', () => {
|
||||
renderWithProviders(<Input disabled />);
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = React.createRef<HTMLInputElement>();
|
||||
renderWithProviders(<Input ref={ref} />);
|
||||
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(<Input className="custom-input" />);
|
||||
expect(screen.getByRole('textbox')).toHaveClass('custom-input');
|
||||
});
|
||||
|
||||
it('handles focus and blur events', () => {
|
||||
const onFocus = vi.fn();
|
||||
const onBlur = vi.fn();
|
||||
renderWithProviders(<Input onFocus={onFocus} onBlur={onBlur} />);
|
||||
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(<Input />);
|
||||
const input = container.querySelector('input');
|
||||
expect(input).toHaveClass('rounded-md');
|
||||
expect(input).toHaveClass('border');
|
||||
expect(input).toHaveClass('bg-transparent');
|
||||
});
|
||||
});
|
||||
@@ -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: <div data-testid="chart-content">Chart Content</div>,
|
||||
};
|
||||
|
||||
it('renders loading state with skeleton', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard {...defaultProps} isLoading={true} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
error={new Error('Network error')}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChartCard {...defaultProps} isEmpty={true} />,
|
||||
);
|
||||
|
||||
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(<ChartCard {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
isLoading={true}
|
||||
error={new Error('Some error')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Failed to load chart.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes error over empty state', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
error={new Error('Some error')}
|
||||
isEmpty={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No Data')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<StatCard label="Total Tokens" value={12345} icon={Activity} />,
|
||||
);
|
||||
expect(screen.getByText('Total Tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('12,345')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string values correctly', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Status" value="Active" icon={Zap} />,
|
||||
);
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats number values with locale string', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Requests" value={1000000} icon={TrendingUp} />,
|
||||
);
|
||||
expect(screen.getByText('1,000,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows skeleton when isLoading is true', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Loading" value={0} icon={Users} isLoading />,
|
||||
);
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skeleton when isLoading is false', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Loaded" value={42} icon={Activity} isLoading={false} />,
|
||||
);
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon element', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="With Icon" value={100} icon={Zap} />,
|
||||
);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Styled" value={1} icon={Activity} className="my-stat" />,
|
||||
);
|
||||
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(
|
||||
<StatCard label="Default Accent" value={1} icon={Activity} />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-slate-500/5');
|
||||
});
|
||||
|
||||
it('applies blue accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Blue" value={1} icon={Activity} accent="blue" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-blue-500/5');
|
||||
});
|
||||
|
||||
it('applies green accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Green" value={1} icon={Activity} accent="green" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-emerald-500/5');
|
||||
});
|
||||
|
||||
it('applies amber accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Amber" value={1} icon={Activity} accent="amber" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-amber-500/5');
|
||||
});
|
||||
|
||||
it('applies purple accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Purple" value={1} icon={Activity} accent="purple" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-purple-500/5');
|
||||
});
|
||||
|
||||
it('has uppercase label styling', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Uppercase Test" value={1} icon={Activity} />,
|
||||
);
|
||||
const label = screen.getByText('Uppercase Test');
|
||||
expect(label).toHaveClass('uppercase');
|
||||
});
|
||||
|
||||
it('has mono font for value', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Mono" value={999} icon={Activity} />,
|
||||
);
|
||||
const value = screen.getByText('999');
|
||||
expect(value).toHaveClass('font-mono');
|
||||
});
|
||||
});
|
||||
@@ -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: '[email protected]',
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): CloudAccount {
|
||||
return {
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>) {
|
||||
return {
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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('[email protected]');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('should return null when no account matches the email', async () => {
|
||||
mockAll.mockReturnValue([]);
|
||||
|
||||
const result = await CloudAccountRepo.getAccountByEmail('[email protected]');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccounts (findAll)', () => {
|
||||
it('should return all accounts from the database', async () => {
|
||||
mockAll.mockReturnValue([
|
||||
createMockAccountRow({ id: 'acc-1', email: '[email protected]' }),
|
||||
createMockAccountRow({ id: 'acc-2', email: '[email protected]' }),
|
||||
]);
|
||||
|
||||
const result = await CloudAccountRepo.getAccounts();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].email).toBe('[email protected]');
|
||||
expect(result[1].email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] = []) {
|
||||
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: '[email protected]', name: 'Auth User' } }) },
|
||||
]) as any,
|
||||
});
|
||||
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('[email protected]');
|
||||
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: '[email protected]', name: 'Init User' } }) },
|
||||
]) as any,
|
||||
});
|
||||
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('[email protected]');
|
||||
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: '[email protected]',
|
||||
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('[email protected]');
|
||||
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: '[email protected]',
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, any[]> = 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();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Creates a mock Electron IPC main handle function.
|
||||
*/
|
||||
export function createIpcMock() {
|
||||
const handlers = new Map<string, (...args: any[]) => 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<string, string> = {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function getPassword(service: string, account: string): Promise<string | null> {
|
||||
return Promise.resolve(`mock-password-for-${account}`);
|
||||
}
|
||||
|
||||
export function setPassword(service: string, account: string, password: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export function deletePassword(service: string, account: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export function findPassword(service: string): Promise<string | null> {
|
||||
return Promise.resolve('mock-found-password');
|
||||
}
|
||||
|
||||
export function findCredentials(service: string): Promise<Array<{ account: string; password: string }>> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, { percentage: number }> } | null;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
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: '[email protected]', is_active: true }),
|
||||
createAccount({ id: 'other', email: '[email protected]' }),
|
||||
];
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: '[email protected]',
|
||||
verified_email: true,
|
||||
name: 'Test User',
|
||||
};
|
||||
expect(UserInfoSchema.parse(data)).toEqual(data);
|
||||
});
|
||||
|
||||
it('should default verified_email to false', () => {
|
||||
const data = { id: '123', email: '[email protected]' };
|
||||
const parsed = UserInfoSchema.parse(data);
|
||||
expect(parsed.verified_email).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject missing id', () => {
|
||||
expect(() => UserInfoSchema.parse({ email: '[email protected]' })).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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('os')>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
-5
@@ -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;
|
||||
@@ -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<RenderOptions, 'wrapper'>,
|
||||
) {
|
||||
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: '[email protected]',
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
+28
-7
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user