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:
lzt404 committed 2026-09-27 20:03:33 +08:00
1 parent 02d5a2d469
commit c52039dfed
21 files changed
+1271 -193

No files matched your search

+5 -5
View File
@@ -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')) {
+4 -4
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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: [] });
});
});
+1 -1
View File
@@ -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' };