fix: handle missing v8 config nodes and normalize alias transaction views
- Default absent optional config nodes to empty values without hiding other errors - Normalize empty parent mappings to prevent alias save and rollback conflicts - Add regression tests for missing config, first-time alias creation, and rollback
This commit is contained in:
1 parent
02d5a2d469
commit
c52039dfed
21 files changed
+1271
-193
No files matched your search
+5
-5
@@ -39,15 +39,15 @@ mockIPC(async (cmd, args) => {
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd === 'management_request') {
|
||||
const request = args?.request as { path: string; method: string; query?: Record<string, string>; body?: Record<string, unknown> };
|
||||
if (request.path === '/auth-files/download') {
|
||||
if (request.path === '/credentials/download') {
|
||||
if (params.has('metadataError')) throw new Error('Fixture metadata read failed');
|
||||
return { ...settingsFor(request.query?.name ?? '') };
|
||||
}
|
||||
if (request.path === '/auth-files/models') {
|
||||
if (request.path === '/credentials/models') {
|
||||
if (params.has('catalogError')) throw new Error('Fixture catalog read failed');
|
||||
return { models: [{ id: 'gpt-example' }, { id: 'gpt-old-example' }, { id: 'claude-example' }] };
|
||||
}
|
||||
if (request.path === '/auth-files/fields' && request.method === 'PATCH') {
|
||||
if (request.path === '/credentials/fields' && request.method === 'PATCH') {
|
||||
if (params.has('saveError')) throw new Error('Fixture settings save failed');
|
||||
const { name, headers, ...patch } = request.body ?? {};
|
||||
const target = settingsFor(String(name));
|
||||
@@ -64,7 +64,7 @@ mockIPC(async (cmd, args) => {
|
||||
if (output) output.textContent = JSON.stringify(request.body, null, 2);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
if (request.path === '/api-call') {
|
||||
if (request.path === '/requests/api-call') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
if (params.has('quotaError')) throw new Error('Fixture quota unavailable');
|
||||
if (String(request.body?.url).endsWith('/consume')) {
|
||||
@@ -77,7 +77,7 @@ mockIPC(async (cmd, args) => {
|
||||
? { available_count: Math.max(0, 2 - resetCount), applicable_available_count: 0, credits: [{ reset_type: 'codex_rate_limits', status: 'available', expires_at: '2030-01-01T00:00:00Z' }] }
|
||||
: { plan_type: 'Plus', rate_limit: { primary_window: { used_percent: resetCount ? 0 : 16, limit_window_seconds: 18000, reset_after_seconds: 3600 }, secondary_window: { used_percent: resetCount ? 0 : 72, limit_window_seconds: 604800, reset_after_seconds: 86400 } } }) };
|
||||
}
|
||||
if (request.path === '/auth-files' && request.method === 'GET') {
|
||||
if (request.path === '/credentials' && request.method === 'GET') {
|
||||
refreshCount += 1;
|
||||
if (params.has('cards')) return { files: cardFiles.map((file) => ({ source: 'file', size: 2048, updated_at: '2026-09-20T01:20:00Z', success: 1200, failed: 28, recent_requests: buckets, ...file, ...settingsFor(file.name) })) };
|
||||
if (params.has('health')) {
|
||||
|
||||
Vendored
+4
-4
@@ -45,14 +45,14 @@ mockIPC(async (cmd, args: any) => {
|
||||
if (cmd === 'open_oauth_url') return null;
|
||||
if (cmd === 'management_request') {
|
||||
const request = args.request;
|
||||
if (request.path === '/auth-files') return { files: fixture.files };
|
||||
if (request.path === '/credentials') return { files: fixture.files };
|
||||
if (request.path === '/config') return {};
|
||||
if (request.path === '/auth-files/fields') return {};
|
||||
if (request.path === '/oauth-session') {
|
||||
if (request.path === '/credentials/fields') return {};
|
||||
if (request.path === '/oauth/session') {
|
||||
if (fixture.cancelError) throw new Error('Cancellation failed');
|
||||
return { status: 'ok', cancelled: true };
|
||||
}
|
||||
if (request.path === '/api-call') return fixture.quotaError
|
||||
if (request.path === '/requests/api-call') return fixture.quotaError
|
||||
? { status_code: 401, body: { error: 'Session expired' } }
|
||||
: { status_code: 200, body: { userStatus: { planStatus: {
|
||||
dailyQuotaRemainingPercent: 75, weeklyQuotaRemainingPercent: 0,
|
||||
|
||||
Vendored
+1
-1
@@ -31,7 +31,7 @@ fixture.fixtureSaved = null;
|
||||
mockIPC(async (cmd, rawArgs) => {
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
const args = rawArgs as { request?: { path?: string } };
|
||||
if (cmd === 'management_request' && args.request?.path === '/api-call') {
|
||||
if (cmd === 'management_request' && args.request?.path === '/requests/api-call') {
|
||||
const response = fixture.fixtureFailFetch
|
||||
? { status_code: 503, body: 'Fixture discovery failed' }
|
||||
: { status_code: 200, body: { data: fixture.fixtureCatalog.slice() } };
|
||||
|
||||
+100
-2
@@ -1,5 +1,44 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { apiCallErrorMessage } from '../src/services/managementApi';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
|
||||
import {
|
||||
apiCallErrorMessage,
|
||||
flattenV8ProviderGroups,
|
||||
groupLegacyProviderRecords,
|
||||
legacyManagementConfigView,
|
||||
managementApi,
|
||||
} from '../src/services/managementApi';
|
||||
|
||||
describe('optional v8 config reads', () => {
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
it('defaults absent exclusions and provider groups without swallowing other errors', async () => {
|
||||
for (const [path, key, empty] of [
|
||||
['/oauth-excluded-models', 'oauth-excluded-models', {}],
|
||||
['/codex-api-key', 'codex-api-key', []],
|
||||
['/openai-compatibility', 'openai-compatibility', []],
|
||||
] as const) {
|
||||
mockIPC(() => { throw 'Management API error (404): not_found'; });
|
||||
expect(await managementApi.get(path)).toEqual({ [key]: empty });
|
||||
for (const message of [
|
||||
'Management API error (401): unauthorized',
|
||||
'Management API error (404): 404 page not found',
|
||||
'Management API request failed: connection refused',
|
||||
]) {
|
||||
mockIPC(() => { throw new Error(message); });
|
||||
await expect(managementApi.get(path)).rejects.toThrow(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiCallErrorMessage', () => {
|
||||
it('从对象和 JSON 字符串错误体中提取可读消息', () => {
|
||||
@@ -18,3 +57,62 @@ describe('apiCallErrorMessage', () => {
|
||||
expect(apiCallErrorMessage({ status_code: 429 })).toBe('Upstream returned HTTP 429');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v8 Management API compatibility view', () => {
|
||||
it('flattens grouped credentials and preserves per-key overrides', () => {
|
||||
const groups = [{
|
||||
name: 'production',
|
||||
'base-url': 'https://example.test/v1',
|
||||
priority: 10,
|
||||
models: [{ name: 'model-a' }],
|
||||
keys: [
|
||||
{ 'api-key': 'first', priority: null, 'proxy-url': 'direct' },
|
||||
{ 'api-key': 'second', priority: 0 },
|
||||
],
|
||||
}];
|
||||
expect(flattenV8ProviderGroups('codex', groups)).toEqual([
|
||||
{
|
||||
'base-url': 'https://example.test/v1', priority: 10,
|
||||
models: [{ name: 'model-a' }], 'api-key': 'first', 'proxy-url': 'direct',
|
||||
},
|
||||
{
|
||||
'base-url': 'https://example.test/v1', priority: 0,
|
||||
models: [{ name: 'model-a' }], 'api-key': 'second',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups legacy records without moving key-specific fields into shared policy', () => {
|
||||
expect(groupLegacyProviderRecords('claude', [{
|
||||
name: 'delegated-key',
|
||||
'api-key': 'secret',
|
||||
'base-url': 'https://claude.test',
|
||||
models: [{ name: 'claude-test' }],
|
||||
cloak: { mode: 'always' },
|
||||
}])).toEqual([{
|
||||
name: 'claude-1',
|
||||
'base-url': 'https://claude.test',
|
||||
models: [{ name: 'claude-test' }],
|
||||
keys: [{ name: 'delegated-key', 'api-key': 'secret', cloak: { mode: 'always' } }],
|
||||
}]);
|
||||
});
|
||||
|
||||
it('projects the v8 config tree into the legacy semantic view used by the UI', () => {
|
||||
const view = legacyManagementConfigView({
|
||||
access: { 'api-keys': ['client-key'] },
|
||||
'api-keys': {
|
||||
codex: [{ name: 'codex-1', keys: [{ 'api-key': 'upstream-key' }] }],
|
||||
'openai-compatibility': [{ name: 'openrouter', keys: [{ 'api-key': 'openrouter-key' }] }],
|
||||
},
|
||||
oauth: { 'model-alias': { codex: [{ name: 'gpt', alias: 'custom' }] } },
|
||||
requests: { payload: { override: [] } },
|
||||
}) as Record<string, unknown>;
|
||||
expect(view['api-keys']).toEqual(['client-key']);
|
||||
expect(view['codex-api-key']).toEqual([{ 'api-key': 'upstream-key' }]);
|
||||
expect(view['openai-compatibility']).toEqual([{
|
||||
name: 'openrouter', 'api-key-entries': [{ 'api-key': 'openrouter-key' }],
|
||||
}]);
|
||||
expect(view['oauth-model-alias']).toEqual({ codex: [{ name: 'gpt', alias: 'custom' }] });
|
||||
expect(view.payload).toEqual({ override: [] });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ beforeEach(() => {
|
||||
mockIPC((command, payload) => {
|
||||
if (command !== 'management_request') throw new Error('Unexpected IPC command: ' + command);
|
||||
const request = (payload as { request: { path: string; body: { url: string; method: string } } }).request;
|
||||
expect(request.path).toBe('/api-call');
|
||||
expect(request.path).toBe('/requests/api-call');
|
||||
upstreamCalls.push(request.body);
|
||||
if (request.body.url.endsWith('/consume') && consumeError) return { status_code: 409, body: 'reset denied' };
|
||||
if (request.body.url.endsWith('/usage') && refreshError) return { status_code: 503, body: 'usage unavailable' };
|
||||
|
||||
Reference in new issue
Block a user