test: raise the timeout of the OAuth client suite
The suite re-imports GoogleAPIService after vi.resetModules() in every test; under the parallel full run the first import exceeded 5 s and failed (passes alone in about 1 s).
This commit is contained in:
1 parent
8e20c32117
commit
0a6a8ff4bb
1 file changed
+439
-437
@@ -1,437 +1,439 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
|
||||
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
|
||||
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
|
||||
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
FALLBACK_VERSION: '1.22.2',
|
||||
}));
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService OAuth clients', () => {
|
||||
const originalOauthClientsEnv = process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
const originalActiveOauthClientEnv = process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (isString(originalOauthClientsEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
}
|
||||
|
||||
if (isString(originalActiveOauthClientEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads builtin and custom oauth clients with active marker', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS =
|
||||
'custom_a|id-a|secret-a|Custom A;custom_b|id-b|secret-b|Custom B';
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = 'custom_b';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const clients = GoogleAPIService.listOAuthClients();
|
||||
|
||||
expect(clients.some((client) => client.is_builtin)).toBe(true);
|
||||
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
|
||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('switches active oauth client key', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom_a|id-a|secret-a|Custom A';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
GoogleAPIService.setActiveOAuthClientKey('custom_a');
|
||||
|
||||
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('custom_a');
|
||||
});
|
||||
|
||||
it('throws when switching to unknown oauth client key', async () => {
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
expect(() => GoogleAPIService.setActiveOAuthClientKey('missing_client')).toThrow(
|
||||
'Unknown OAuth client key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService user info parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts Google user info responses without family_name', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
verified_email: true,
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.getUserInfo('access-token')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudAccountList auth code auto-submit guard', () => {
|
||||
it('returns true for a fresh auth code while the dialog is open', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'fresh-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after the same auth code was already auto-submitted', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'same-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: 'same-code',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.spyOn(GoogleAPIService, 'fetchProjectContext').mockResolvedValue({
|
||||
projectId: 'project-1',
|
||||
subscriptionTier: 'free',
|
||||
});
|
||||
|
||||
await expect(GoogleAPIService.fetchQuota('access-token')).rejects.toThrow(
|
||||
'HTTP 400 - INVALID_ARGUMENT',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an explicit dispatcher from env proxy settings when no account proxy is provided', async () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
});
|
||||
|
||||
it('uses electron proxy env as an explicit dispatcher fallback', async () => {
|
||||
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.ELECTRON_PROXY_SERVER;
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuotaService fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const postMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
cloudaicompanionProject: 'project-1',
|
||||
currentTier: { id: 'free' },
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: 'INVALID_ARGUMENT' },
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
vi.doMock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: postMock,
|
||||
})),
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
},
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
}));
|
||||
|
||||
const { QuotaService } = await import('../../lib/geminiNexus/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
|
||||
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
|
||||
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
|
||||
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
FALLBACK_VERSION: '1.22.2',
|
||||
}));
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
|
||||
// Each test resets the module registry and re-imports GoogleAPIService, which can exceed the default
|
||||
// 5s timeout while the whole suite runs in parallel.
|
||||
describe('GoogleAPIService OAuth clients', { timeout: 30_000 }, () => {
|
||||
const originalOauthClientsEnv = process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
const originalActiveOauthClientEnv = process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (isString(originalOauthClientsEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
}
|
||||
|
||||
if (isString(originalActiveOauthClientEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads builtin and custom oauth clients with active marker', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS =
|
||||
'custom_a|id-a|secret-a|Custom A;custom_b|id-b|secret-b|Custom B';
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = 'custom_b';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const clients = GoogleAPIService.listOAuthClients();
|
||||
|
||||
expect(clients.some((client) => client.is_builtin)).toBe(true);
|
||||
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
|
||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('switches active oauth client key', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom_a|id-a|secret-a|Custom A';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
GoogleAPIService.setActiveOAuthClientKey('custom_a');
|
||||
|
||||
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('custom_a');
|
||||
});
|
||||
|
||||
it('throws when switching to unknown oauth client key', async () => {
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
expect(() => GoogleAPIService.setActiveOAuthClientKey('missing_client')).toThrow(
|
||||
'Unknown OAuth client key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService user info parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts Google user info responses without family_name', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
id: 'google-user-1',
|
||||
email: 'user@example.com',
|
||||
verified_email: true,
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.getUserInfo('access-token')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'google-user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudAccountList auth code auto-submit guard', () => {
|
||||
it('returns true for a fresh auth code while the dialog is open', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'fresh-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after the same auth code was already auto-submitted', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'same-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: 'same-code',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.spyOn(GoogleAPIService, 'fetchProjectContext').mockResolvedValue({
|
||||
projectId: 'project-1',
|
||||
subscriptionTier: 'free',
|
||||
});
|
||||
|
||||
await expect(GoogleAPIService.fetchQuota('access-token')).rejects.toThrow(
|
||||
'HTTP 400 - INVALID_ARGUMENT',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an explicit dispatcher from env proxy settings when no account proxy is provided', async () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
});
|
||||
|
||||
it('uses electron proxy env as an explicit dispatcher fallback', async () => {
|
||||
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.ELECTRON_PROXY_SERVER;
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuotaService fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const postMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
cloudaicompanionProject: 'project-1',
|
||||
currentTier: { id: 'free' },
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: 'INVALID_ARGUMENT' },
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
vi.doMock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: postMock,
|
||||
})),
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
},
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
}));
|
||||
|
||||
const { QuotaService } = await import('../../lib/geminiNexus/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user