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
|
name: Check formatting
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests-unit:
|
check:
|
||||||
|
name: Prettier Format Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- uses: actions/setup-node@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run format check
|
- name: Run format check
|
||||||
run: npm run format
|
run: npm run format
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
name: ESLint Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- uses: actions/setup-node@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
@@ -1,22 +1,53 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests-unit:
|
tests-unit:
|
||||||
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- uses: actions/setup-node@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run unit tests
|
- 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.
|
# NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment.
|
||||||
# Run E2E tests locally with: npm run test:e2e
|
# Run E2E tests locally with: npm run test:e2e
|
||||||
Generated
+44
@@ -143,6 +143,7 @@
|
|||||||
"typescript-eslint": "^8.46.3",
|
"typescript-eslint": "^8.46.3",
|
||||||
"unplugin-swc": "^1.5.9",
|
"unplugin-swc": "^1.5.9",
|
||||||
"vite": "^5.4.21",
|
"vite": "^5.4.21",
|
||||||
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18826,6 +18827,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/goober": {
|
||||||
"version": "2.1.18",
|
"version": "2.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
@@ -30303,6 +30311,27 @@
|
|||||||
"node": ">=0.3.1"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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": {
|
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
"typescript-eslint": "^8.46.3",
|
"typescript-eslint": "^8.46.3",
|
||||||
"unplugin-swc": "^1.5.9",
|
"unplugin-swc": "^1.5.9",
|
||||||
"vite": "^5.4.21",
|
"vite": "^5.4.21",
|
||||||
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+10
-10
@@ -132,20 +132,20 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
// macOS: Check for Gemini Nexus.app in path
|
// macOS: Check for Gemini Nexus.app in path
|
||||||
if (cmd.includes('geminiNexus.app')) {
|
if (cmd.includes('gemininexus.app')) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Also check if the process name is exactly 'Gemini Nexus' (main process)
|
// Also check if the process name is exactly 'gemininexus' (main process)
|
||||||
if (name === 'geminiNexus' && !isHelperProcess(name, cmd)) {
|
if (name === 'gemininexus' && !isHelperProcess(name, cmd)) {
|
||||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (platform === 'win32') {
|
} else if (platform === 'win32') {
|
||||||
// Windows: Check for Gemini Nexus.exe
|
// Windows: Check for gemininexus.exe
|
||||||
if (name === 'geminiNexus.exe' || name === 'geminiNexus') {
|
if (name === 'gemininexus.exe' || name === 'gemininexus') {
|
||||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -155,11 +155,11 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
|
|
||||||
if (nameLower === 'electron') {
|
if (nameLower === 'electron') {
|
||||||
// Stricter check for AUR/Electron wrapper:
|
// 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 =
|
const isGeminiNexusApp =
|
||||||
(cmdLower.includes('/geminiNexus') ||
|
(cmdLower.includes('/gemininexus') ||
|
||||||
cmdLower.includes(' geminiNexus') ||
|
cmdLower.includes(' gemininexus') ||
|
||||||
cmdLower.endsWith('geminiNexus')) &&
|
cmdLower.endsWith('gemininexus')) &&
|
||||||
!cmdLower.includes('manager') &&
|
!cmdLower.includes('manager') &&
|
||||||
!cmdLower.includes('tools');
|
!cmdLower.includes('tools');
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(name.includes('geminiNexus') || cmd.includes('/geminiNexus')) &&
|
(name.includes('gemininexus') || cmd.includes('/gemininexus')) &&
|
||||||
!name.includes('tools')
|
!name.includes('tools')
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -1906,6 +1906,7 @@ export class ProxyService {
|
|||||||
// When tool_calls are present, content must be null per OpenAI spec.
|
// When tool_calls are present, content must be null per OpenAI spec.
|
||||||
content: toolCalls.length > 0 ? null : finalContent || '',
|
content: toolCalls.length > 0 ? null : finalContent || '',
|
||||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||||
|
reasoning_content: reasoningContent || undefined,
|
||||||
},
|
},
|
||||||
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
||||||
claudeResponse.stop_reason,
|
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');
|
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
|
||||||
await listCloudAccounts();
|
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);
|
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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { isString } from 'lodash-es';
|
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
|
// Mock module to test timeout behavior
|
||||||
describe('GoogleAPIService Timeout', () => {
|
describe('GoogleAPIService Timeout', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -114,7 +121,7 @@ describe('GoogleAPIService OAuth clients', () => {
|
|||||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||||
const clients = GoogleAPIService.listOAuthClients();
|
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_a')?.label).toBe('Custom A');
|
||||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
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',
|
id: 'google-user-1',
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
name: 'Example User',
|
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();
|
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
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: true,
|
||||||
status: 404,
|
status: 200,
|
||||||
text: vi.fn().mockResolvedValue('NOT_FOUND'),
|
json: vi.fn().mockResolvedValue({
|
||||||
|
// No paidTier.availableCredits — no usable credits
|
||||||
|
freeTier: {},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: vi.fn().mockResolvedValue({
|
json: vi.fn().mockResolvedValue({
|
||||||
paidTier: {
|
credits: 500,
|
||||||
availableCredits: [
|
expiryDate: '2025-12-31',
|
||||||
{
|
|
||||||
creditType: 'GOOGLE_ONE_AI',
|
|
||||||
creditAmount: '1000',
|
|
||||||
minimumCreditAmountForUsage: '50',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,31 +254,18 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
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({
|
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||||
credits: 1000,
|
credits: 500,
|
||||||
expiryDate: '',
|
expiryDate: '2025-12-31',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
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',
|
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||||
);
|
);
|
||||||
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('POST');
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||||
expect(fetchMock.mock.calls[1]?.[1]?.body).toBe(
|
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||||
JSON.stringify({
|
|
||||||
metadata: {
|
|
||||||
ide_type: 'ANTIGRAVITY',
|
|
||||||
ide_version: expectedVersion,
|
|
||||||
ide_name: 'geminiNexus',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,9 +305,19 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
|||||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||||
|
|
||||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: true,
|
||||||
status: 502,
|
status: 200,
|
||||||
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
|
json: vi.fn().mockResolvedValue({
|
||||||
|
paidTier: {
|
||||||
|
availableCredits: [
|
||||||
|
{
|
||||||
|
creditType: 'GOOGLE_ONE_AI',
|
||||||
|
creditAmount: '1000',
|
||||||
|
minimumCreditAmountForUsage: '50',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
@@ -331,7 +333,10 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
|||||||
|
|
||||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
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';
|
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||||
|
|
||||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: true,
|
||||||
status: 502,
|
status: 200,
|
||||||
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
|
json: vi.fn().mockResolvedValue({
|
||||||
|
paidTier: {
|
||||||
|
availableCredits: [
|
||||||
|
{
|
||||||
|
creditType: 'GOOGLE_ONE_AI',
|
||||||
|
creditAmount: '1000',
|
||||||
|
minimumCreditAmountForUsage: '50',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
@@ -363,7 +378,10 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
|||||||
|
|
||||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
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
|
// Mock find-process module
|
||||||
vi.mock('find-process', () => ({
|
vi.mock('find-process', () => ({
|
||||||
default: vi.fn(),
|
default: vi.fn(),
|
||||||
|
ProcessInfo: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger to avoid console output during tests
|
// Mock logger to avoid console output during tests
|
||||||
@@ -47,7 +48,7 @@ describe('Process Handler', () => {
|
|||||||
|
|
||||||
const result = await isProcessRunning();
|
const result = await isProcessRunning();
|
||||||
expect(result).toBe(true);
|
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 () => {
|
it('should return false when only helper processes are found', async () => {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ describe('ProxyController Integration', () => {
|
|||||||
model: 'gpt-4o',
|
model: 'gpt-4o',
|
||||||
messages: [{ role: 'user', content: 'hello world' }],
|
messages: [{ role: 'user', content: 'hello world' }],
|
||||||
}),
|
}),
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(reply.status).toHaveBeenCalledWith(200);
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
expect(reply.send).toHaveBeenCalledWith(
|
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
|
// Mock window and localStorage for Node environment
|
||||||
if (isUndefined((globalThis as { window?: unknown }).window)) {
|
if (isUndefined((globalThis as { window?: unknown }).window)) {
|
||||||
global.window = {
|
global.window = {
|
||||||
matchMedia: vi.fn().mockImplementation((query) => ({
|
matchMedia: vi.fn().mockImplementation((query: string) => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
media: query,
|
media: query,
|
||||||
onchange: null,
|
onchange: null,
|
||||||
addListener: vi.fn(), // deprecated
|
addListener: vi.fn(),
|
||||||
removeListener: vi.fn(), // deprecated
|
removeListener: vi.fn(),
|
||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
@@ -27,6 +27,10 @@ if (isUndefined((globalThis as { window?: unknown }).window)) {
|
|||||||
key: vi.fn(),
|
key: vi.fn(),
|
||||||
},
|
},
|
||||||
postMessage: vi.fn(),
|
postMessage: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
scrollTo: vi.fn(),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
(global as any).localStorage = (global as any).window.localStorage;
|
(global as any).localStorage = (global as any).window.localStorage;
|
||||||
@@ -40,5 +44,18 @@ if (!process.env.APPDATA) {
|
|||||||
process.env.APPDATA = '/tmp/test-appdata';
|
process.env.APPDATA = '/tmp/test-appdata';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock child_process if not already mocked (though individual tests should mock it)
|
// Mock ResizeObserver
|
||||||
// But some imports might trigger it early.
|
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',
|
'geminiNexus_enterprise',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(normalized).toBeUndefined();
|
expect(normalized).toBe('gemininexus_enterprise');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prioritizes preferred account in parity mode', async () => {
|
it('prioritizes preferred account in parity mode', async () => {
|
||||||
|
|||||||
+28
-7
@@ -1,18 +1,39 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [viteTsconfigPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: [
|
||||||
'@': path.resolve(process.cwd(), './src'),
|
{ find: 'keytar', replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/keytar.ts') },
|
||||||
// Mock native modules that fail to load in test environment
|
{ find: 'better-sqlite3', replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/better-sqlite3.ts') },
|
||||||
keytar: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
],
|
||||||
'better-sqlite3': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'happy-dom',
|
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