Files
EasyCLIProxyAPI/tests/providerEntryEditing.test.ts
T
lzt404 d29a1af69b feat: add plugin management and redesign dashboard and usage analytics
- Add plugin store, version selection, configuration, and OAuth login
- Support Muse (Meta) OAuth device authorization
- Redesign home dashboard and add batch model health checks
- Support per-credential provider editing, removal, and reordering
- Improve usage charts and track actual response models
- Preserve installed plugins and runtime data during portable rebuilds
- Update translations, documentation, and related tests
2026-10-04 17:31:52 +08:00

177 lines
10 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import {
buildProviderKeyRecord, providerDraftFromRecord, type ProviderDraft, type ProviderSection,
} from '../src/pages/ApiAccessPage';
import { providerEntries, updateProviderEntry } from '../src/services/providerEntries';
import { cleanProviderGroup } from '../src/services/providerGroups';
type Value = Record<string, unknown>;
const keys = (group: Value) => group.keys as Value[];
const fixture = (): Value[] => [{
name: 'shared', 'base-url': 'https://gateway.test/v1', priority: 3,
headers: { 'X-Shared': 'group', 'auth-index': 'literal header' }, 'excluded-models': ['old-*'],
models: [
{ name: 'same-upstream', alias: 'regular', vendor: { index: 1 }, thinking: { levels: ['low'], 'zero-allowed': false } },
{ name: 'same-upstream', alias: 'fast', vendor: { index: 2 }, thinking: { levels: ['high'] } },
{ name: 'same-upstream', alias: 'careful', vendor: { index: 3 }, 'max-context-length': 128000 },
],
'request-retry': 2, 'request-scoped-errors': [{ status: 429, action: 'continue', match: ['quota'] }],
keys: [
{ 'api-key': 'key-a', priority: null, models: null, 'request-retry': null, 'disable-cooling': null,
weight: null, 'auth-index': 'runtime-a', custom: { list: [1, 2], 'test-model': 'extension' } },
{ 'api-key': 'key-b', headers: {}, 'excluded-models': [], weight: 0, websockets: false },
],
}];
const edit = (groups: Value[], change: (draft: ProviderDraft) => void, section: ProviderSection = 'codex-api-key', index = 0) => {
const entry = providerEntries(section, groups)[index];
const draft = providerDraftFromRecord(section, entry.record);
change(draft);
const after = buildProviderKeyRecord(section, draft, entry.record);
return { after, draft, result: updateProviderEntry(groups, entry.source, entry.record, after) };
};
describe('single-provider form edits over native groups', () => {
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'interactions-api-key', 'vertex-api-key', 'xai-api-key', 'meta-api-key'] as const) {
test(`${section} untouched form preserves inheritance, aliases, unknown configuration and sibling keys`, () => {
const original = fixture();
const { draft, result } = edit(original, () => {}, section);
expect(draft.models.map((model) => model.alias)).toEqual(['regular', 'fast', 'careful']);
expect(result).toEqual(original.map(cleanProviderGroup));
expect(keys(result[0])[0]).toMatchObject({ priority: null, models: null, 'request-retry': null, 'disable-cooling': null, weight: null });
expect(keys(result[0])[0]).not.toHaveProperty('headers');
expect(keys(result[0])[0]).not.toHaveProperty('websockets');
expect(keys(result[0])[0].custom).toEqual({ list: [1, 2], 'test-model': 'extension' });
});
}
test('untouched absent, null and explicit empty form values retain their stored representations', () => {
for (const fields of [
{},
{ models: null, headers: null, priority: null, 'proxy-url': null, 'excluded-models': null, 'disable-cooling': null, websockets: null },
{ models: [], headers: {}, priority: 0, 'proxy-url': '', 'excluded-models': [], 'disable-cooling': false, websockets: false },
]) {
const original = [{ name: 'untouched', keys: [{ 'api-key': 'key', ...fields }] }];
expect(edit(original, () => {}).result).toEqual(original);
}
});
test('changing only a local remark does not modify provider configuration', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.remark = 'My display note'; });
expect(result).toEqual(original.map(cleanProviderGroup));
expect(keys(result[0])[0]).not.toHaveProperty('remark');
});
test('preserves an existing key-owned name extension on unrelated edits', () => {
const original = fixture();
keys(original[0])[0].name = 'retained key metadata';
expect(edit(original, (draft) => { draft.remark = 'Updated note'; }).result).toEqual(original.map(cleanProviderGroup));
});
test('removes exactly one repeated-source alias and keeps other model metadata', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.models = draft.models.filter((_, index) => index !== 1); });
const existing = original[0].models as Value[];
expect(keys(result[0])[0].models).toEqual([existing[0], existing[2]]);
expect(result[0].models).toEqual(existing);
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
expect(providerEntries('codex-api-key', result)[1].record.models).toEqual(existing);
});
test('renames one alias without duplicating or merging its repeated-source siblings', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.models[1].alias = 'fast-renamed'; });
const existing = original[0].models as Value[];
expect(keys(result[0])[0].models).toEqual([existing[0], { ...existing[1], alias: 'fast-renamed' }, existing[2]]);
});
test('clearing every selected model writes an explicit empty list for that credential', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.models = []; });
expect(keys(result[0])[0].models).toEqual([]);
expect(result[0].models).toEqual(original[0].models);
});
test('clearing shared headers and exclusions writes empty overrides while preserving siblings', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.headersText = ''; draft.excludedModelsText = ''; });
expect(keys(result[0])[0]).toMatchObject({ headers: {}, 'excluded-models': [] });
expect(result[0].headers).toEqual(original[0].headers);
expect(result[0]['excluded-models']).toEqual(original[0]['excluded-models']);
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
});
test('editing header values does not strip fields named like response metadata', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.headersText = 'auth-index: literal header\ntest-model: allowed header\nX-New: added'; });
expect(keys(result[0])[0].headers).toEqual({ 'auth-index': 'literal header', 'test-model': 'allowed header', 'X-New': 'added' });
});
test('changing a shared endpoint splits only the edited credential and retains inherited policies', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.baseUrl = 'https://new.test/v1'; });
expect(result).toHaveLength(2);
expect(result[0]['base-url']).toBe(original[0]['base-url']);
expect(keys(result[0])).toEqual([keys(original[0])[1]]);
expect(result[1]['base-url']).toBe('https://new.test/v1');
expect(result[1].models).toEqual(original[0].models);
expect(result[1].headers).toEqual(original[0].headers);
expect(keys(result[1])).toEqual([cleanProviderGroup(keys(original[0])[0])]);
});
test('weight changes affect only the selected key and clearing it restores the default', () => {
const original = fixture();
const changed = edit(original, (draft) => { draft.weight = '5'; }).result;
expect(keys(changed[0])[0].weight).toBe(5);
expect(keys(changed[0])[1].weight).toBe(0);
const cleared = edit(changed, (draft) => { draft.weight = ''; }).result;
expect(keys(cleared[0])[0]).not.toHaveProperty('weight');
for (const weight of ['1.5', 'nope', '1000001']) {
expect(() => edit(original, (draft) => { draft.weight = weight; })).toThrow();
}
});
test('template edits retain untouched policies and apply explicit false and zero', () => {
const original = fixture();
keys(original[0])[0]['disable-codex-cloaking'] = true;
keys(original[0])[0]['alpha-search'] = null;
const { result } = edit(original, (draft) => {
draft.templateFields = { ...draft.templateFields, 'request-retry': 0, 'disable-codex-cloaking': false, 'alpha-search': true };
});
expect(keys(result[0])[0]).toMatchObject({ 'request-retry': 0, 'disable-codex-cloaking': false, 'alpha-search': true });
expect(keys(result[0])[0]).not.toHaveProperty('request-scoped-errors');
expect(result[0]['request-scoped-errors']).toEqual(original[0]['request-scoped-errors']);
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
});
test('edited template rules retain unedited extension fields and clearing inherited rules is explicit', () => {
const original = fixture();
const rules = [{ status: 503, action: 'continue-and-cooldown', match: ['overloaded'], custom: { retained: true } }];
const changed = edit(original, (draft) => { draft.templateFields = { ...draft.templateFields, 'request-scoped-errors': rules }; }).result;
expect(keys(changed[0])[0]['request-scoped-errors']).toEqual(rules);
const cleared = edit(original, (draft) => { delete draft.templateFields!['request-scoped-errors']; }).result;
expect(keys(cleared[0])[0]['request-scoped-errors']).toEqual([]);
});
test('untouched Claude cloak metadata survives and an edited cache choice preserves unrelated fields', () => {
const original = fixture();
keys(original[0])[0].cloak = { mode: 'always', 'strict-mode': true, 'cache-user-id': true, 'sensitive-words': ['private'], custom: { retained: true } };
expect(edit(original, () => {}, 'claude-api-key').result).toEqual(original.map(cleanProviderGroup));
const changed = edit(original, (draft) => { draft.cloakCacheUserId = false; }, 'claude-api-key').result;
expect(keys(changed[0])[0].cloak).toEqual({ ...(keys(original[0])[0].cloak as Value), 'cache-user-id': false });
});
test('editing one Claude cloak option keeps explicit false and absent sibling options distinct', () => {
for (const cloak of [
{ 'strict-mode': false, 'cache-user-id': false, custom: { retained: true } },
{ 'cache-user-id': false, custom: { retained: true } },
]) {
const original = fixture();
keys(original[0])[0].cloak = cloak;
const changed = edit(original, (draft) => { draft.cloakCacheUserId = true; }, 'claude-api-key').result;
expect(keys(changed[0])[0].cloak).toEqual({ ...cloak, 'cache-user-id': true });
}
});
});