Files
2026-07-13 13:20:22 +08:00

605 lines
25 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for common/config/configMigration.ts (T4 in N3 test checklist).
* Tests config and provider migration logic with mocked dependencies.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Phase 8 §8.5 gate: N3 helper must be imported by at least one N3 domain test.
// Consumed by the helper smoke-test block at the bottom; plan explicitly allows
// an extra demo test that is NOT counted against the T1-T6 clause.
import { createMockHttpBridge } from '../_helpers/mockHttpBridge';
// Mock dependencies BEFORE importing the module under test
vi.mock('@/common/adapter/httpBridge', () => ({
httpRequest: vi.fn(),
}));
vi.mock('@/common', () => ({
ipcBridge: {
mode: {
listProviders: { invoke: vi.fn(), provider: vi.fn() },
createProvider: { invoke: vi.fn(), provider: vi.fn() },
},
assistants: {
list: { invoke: vi.fn() },
},
channel: {
getPlatformSettings: { invoke: vi.fn() },
setAssistantSetting: { invoke: vi.fn() },
setDefaultModelSetting: { invoke: vi.fn() },
syncChannelSettings: { invoke: vi.fn() },
},
},
}));
// Import after mocks are registered
import { migrateConfigStorage, migrateProviders, type ConfigFile } from '@/common/config/configMigration';
import { httpRequest } from '@/common/adapter/httpBridge';
import { ipcBridge } from '@/common';
describe('configMigration', () => {
beforeEach(() => {
vi.clearAllMocks();
(ipcBridge.assistants.list.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(ipcBridge.channel.getPlatformSettings.invoke as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(ipcBridge.channel.setAssistantSetting.invoke as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(ipcBridge.channel.setDefaultModelSetting.invoke as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(ipcBridge.channel.syncChannelSettings.invoke as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
});
describe('migrateConfigStorage', () => {
it('skips migration when no keys are found', async () => {
const configFile: ConfigFile = {
get: vi.fn().mockRejectedValue(new Error('not found')),
set: vi.fn(),
};
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(httpRequest).not.toHaveBeenCalledWith('PUT', expect.anything(), expect.anything());
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('skipped'));
expect(configFile.set).not.toHaveBeenCalled();
});
it('collects multiple legacy keys and sends one PUT with merge strategy', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'language') return Promise.resolve('zh-CN');
if (key === 'theme') return Promise.resolve('dark');
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(httpRequest).toHaveBeenCalledWith('PUT', '/api/settings/client', {
language: 'zh-CN',
theme: 'dark',
});
expect(configFile.set).not.toHaveBeenCalled();
});
it('skips keys that already exist in backend (merge strategy)', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'language') return Promise.resolve('en');
if (key === 'theme') return Promise.resolve('dark');
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({ theme: 'light' });
return Promise.resolve(undefined);
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(httpRequest).toHaveBeenCalledWith('PUT', '/api/settings/client', {
language: 'en',
});
});
it('ignores null values', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'language') return Promise.resolve('en');
if (key === 'theme') return Promise.resolve(null);
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
const putCall = (httpRequest as ReturnType<typeof vi.fn>).mock.calls.find((c: unknown[]) => c[0] === 'PUT');
expect(putCall?.[2]).toEqual({ language: 'en' });
expect(putCall?.[2]).not.toHaveProperty('theme');
});
it('handles configFile.get exceptions by skipping those keys', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'language') return Promise.resolve('en');
throw new Error('access error');
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(httpRequest).toHaveBeenCalledWith('PUT', '/api/settings/client', {
language: 'en',
});
});
it('does not probe removed ACP cache keys during migration', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'language') return Promise.resolve('en');
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(configFile.get).not.toHaveBeenCalledWith('acp.cachedInitializeResult');
expect(configFile.get).not.toHaveBeenCalledWith('acp.cached_config_options');
expect(configFile.get).not.toHaveBeenCalledWith('acp.cachedModes');
});
it('does not probe removed ACP/Codex legacy config blobs during migration', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'language') return Promise.resolve('en');
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(configFile.get).not.toHaveBeenCalledWith('acp.config');
expect(configFile.get).not.toHaveBeenCalledWith('codex.config');
});
it('migrates legacy channel settings through dedicated channel APIs', async () => {
const legacyChannelAgent = {
assistant_id: 'missing_assistant',
backend: 'codex',
name: 'Telegram Assistant',
};
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'assistant.telegram.agent') return Promise.resolve(legacyChannelAgent);
if (key === 'assistant.telegram.defaultModel') {
return Promise.resolve({ id: 'provider_1', use_model: 'gpt-5' });
}
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
(ipcBridge.assistants.list.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 'bare_codex',
source: 'generated',
agent_id: 'agent-codex',
agent: { type: 'acp', source: 'builtin', acp_backend: 'codex' },
},
]);
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(httpRequest).not.toHaveBeenCalledWith('PUT', '/api/settings/client', {
'assistant.telegram.agent': expect.anything(),
'assistant.telegram.defaultModel': expect.anything(),
});
expect(ipcBridge.channel.setAssistantSetting.invoke).toHaveBeenCalledWith({
platform: 'telegram',
assistant: { assistant_id: 'bare_codex' },
});
expect(ipcBridge.channel.setDefaultModelSetting.invoke).toHaveBeenCalledWith({
platform: 'telegram',
default_model: { id: 'provider_1', use_model: 'gpt-5' },
});
expect(ipcBridge.channel.syncChannelSettings.invoke).toHaveBeenCalledWith({
platform: 'telegram',
});
});
it('preserves backend channel settings and skips rewriting existing values', async () => {
const configFile: ConfigFile = {
get: vi.fn((key: string) => {
if (key === 'assistant.telegram.agent') return Promise.resolve({ backend: 'codex' });
if (key === 'assistant.telegram.defaultModel') {
return Promise.resolve({ id: 'provider_1', use_model: 'gpt-5' });
}
return Promise.reject(new Error('not found'));
}),
set: vi.fn(),
};
(httpRequest as ReturnType<typeof vi.fn>).mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve({});
return Promise.resolve(undefined);
});
(ipcBridge.assistants.list.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 'bare_codex',
source: 'generated',
agent_id: 'agent-codex',
agent: { type: 'acp', source: 'builtin', acp_backend: 'codex' },
},
]);
(ipcBridge.channel.getPlatformSettings.invoke as ReturnType<typeof vi.fn>).mockResolvedValue({
platform: 'telegram',
assistant: { assistant_id: 'existing_assistant' },
default_model: { id: 'provider_existing', use_model: 'o3' },
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateConfigStorage(configFile);
expect(ipcBridge.channel.setAssistantSetting.invoke).not.toHaveBeenCalled();
expect(ipcBridge.channel.setDefaultModelSetting.invoke).not.toHaveBeenCalled();
expect(ipcBridge.channel.syncChannelSettings.invoke).not.toHaveBeenCalled();
});
});
describe('migrateProviders', () => {
/**
* In-memory fake of the local config file. Backed by a Map so we can both
* seed legacy data and assert that the migration sets the completion flag
* (ELECTRON-1KT). `get` returns `undefined` for missing keys (matching the
* real JsonFileBuilder behaviour); tests that simulate read failures
* override `get` directly.
*/
function makeConfig(seed: Record<string, unknown> = {}): ConfigFile & { store: Map<string, unknown> } {
const store = new Map<string, unknown>(Object.entries(seed));
return {
get: vi.fn(async (key: string) => store.get(key) as never),
set: vi.fn(async (key: string, value: unknown) => {
store.set(key, value);
}),
store,
} as unknown as ConfigFile & { store: Map<string, unknown> };
}
it('skips when all legacy providers already exist in backend', async () => {
const legacyProviders = [{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] }];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 'p1' }]);
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(ipcBridge.mode.createProvider.invoke).not.toHaveBeenCalled();
// Legacy field must remain untouched (downgrade safety); only the new
// completion flag is written.
expect(configFile.store.get('model.config')).toBe(legacyProviders);
expect(configFile.store.get('migration.providersMigrated_v1')).toBe(true);
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('already exist in backend'), 1);
});
it('migrates 4 legacy providers with field mapping', async () => {
const legacyProviders = [
{
id: 'p1',
platform: 'openai',
name: 'Provider 1',
baseUrl: 'https://api.openai.com',
apiKey: 'key1',
model: ['gpt-4'],
enabled: true,
contextLimit: 8000,
},
{
id: 'p2',
platform: 'anthropic',
name: 'Provider 2',
baseUrl: 'https://api.anthropic.com',
apiKey: 'key2',
model: ['claude-3'],
},
{
id: 'p3',
platform: 'bedrock',
name: 'Provider 3',
baseUrl: '',
apiKey: '',
model: ['claude-3-sonnet'],
bedrockConfig: {
authMethod: 'accessKey',
region: 'us-east-1',
accessKeyId: 'AKIA',
secretAccessKey: 'secret',
},
},
{
id: 'p4',
platform: 'openai',
name: 'Provider 4',
baseUrl: 'https://api.openai.com',
apiKey: 'key4',
model: ['gpt-3.5-turbo'],
modelHealth: {
'gpt-3.5-turbo': {
status: 'healthy',
lastCheck: 100,
latency: 50,
},
},
},
];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'created' });
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(ipcBridge.mode.createProvider.invoke).toHaveBeenCalledTimes(4);
const firstCall = (ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(firstCall).toEqual({
id: 'p1',
platform: 'openai',
name: 'Provider 1',
base_url: 'https://api.openai.com',
api_key: 'key1',
models: ['gpt-4'],
enabled: true,
capabilities: undefined,
context_limit: 8000,
model_protocols: undefined,
model_enabled: undefined,
model_health: undefined,
bedrock_config: undefined,
});
const thirdCall = (ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mock.calls[2][0];
expect(thirdCall.bedrock_config).toEqual({
auth_method: 'accessKey',
region: 'us-east-1',
access_key_id: 'AKIA',
secret_access_key: 'secret',
profile: undefined,
});
const fourthCall = (ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mock.calls[3][0];
expect(fourthCall.model_health).toEqual({
'gpt-3.5-turbo': {
status: 'healthy',
last_check: 100,
latency: 50,
error: undefined,
},
});
// Legacy field is preserved; the new completion flag is written.
expect(configFile.store.get('model.config')).toBe(legacyProviders);
expect(configFile.store.get('migration.providersMigrated_v1')).toBe(true);
});
it('only migrates providers not already in backend (by id)', async () => {
const legacyProviders = [
{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] },
{ id: 'p2', platform: 'anthropic', name: 'P2', baseUrl: '', apiKey: '', model: ['claude-3'] },
];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 'p1' }]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'p2' });
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(ipcBridge.mode.createProvider.invoke).toHaveBeenCalledTimes(1);
expect((ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mock.calls[0][0].id).toBe('p2');
});
it('continues migration even when some providers fail', async () => {
const legacyProviders = [
{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] },
{ id: 'p2', platform: 'anthropic', name: 'P2', baseUrl: '', apiKey: '', model: ['claude-3'] },
];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ id: 'p1' })
.mockRejectedValueOnce(new Error('fail'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(warnSpy).toHaveBeenCalledWith('[Migration] failed to create provider %s:', 'p2', expect.any(Error));
// Partial failure: legacy field untouched AND flag must remain unset so
// the next launch retries the still-missing row.
expect(configFile.store.get('model.config')).toBe(legacyProviders);
expect(configFile.store.has('migration.providersMigrated_v1')).toBe(false);
});
it('handles missing model.config gracefully', async () => {
// get() rejects for everything (legacy file truly absent). The flag
// read failure is caught as "not migrated yet"; the model.config read
// failure routes to the early return that now also sets the flag so
// we don't redundantly hit this path on every subsequent boot.
const setMock = vi.fn();
const configFile: ConfigFile = {
get: vi.fn().mockRejectedValue(new Error('not found')),
set: setMock,
};
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(ipcBridge.mode.listProviders.invoke).not.toHaveBeenCalled();
expect(ipcBridge.mode.createProvider.invoke).not.toHaveBeenCalled();
// The only write must be the completion flag — never the legacy field.
expect(setMock).toHaveBeenCalledWith('migration.providersMigrated_v1', true);
const legacyWrite = setMock.mock.calls.find((c) => c[0] === 'model.config');
expect(legacyWrite).toBeUndefined();
});
it('handles empty model.config array gracefully', async () => {
const configFile = makeConfig();
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(ipcBridge.mode.listProviders.invoke).not.toHaveBeenCalled();
expect(ipcBridge.mode.createProvider.invoke).not.toHaveBeenCalled();
});
// ---------------------------------------------------------------------
// ELECTRON-1KT regression coverage: the providers migration must persist
// a one-shot flag on success and short-circuit on subsequent launches so
// user-deleted providers don't reappear on every restart.
// ---------------------------------------------------------------------
it('sets completion flag after successful migration of legacy providers', async () => {
const legacyProviders = [{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] }];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'p1' });
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(ipcBridge.mode.createProvider.invoke).toHaveBeenCalledTimes(1);
expect(configFile.store.get('migration.providersMigrated_v1')).toBe(true);
// Legacy field is intentionally preserved on disk for downgrade safety.
expect(configFile.store.get('model.config')).toEqual(legacyProviders);
});
it('skips migration entirely on subsequent runs once flag is set', async () => {
// Simulate a second launch: legacy file still on disk, flag already true.
const legacyProviders = [{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] }];
const configFile = makeConfig({
'model.config': legacyProviders,
'migration.providersMigrated_v1': true,
});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
// Critical: the migration must not even read the legacy provider list,
// and createProvider must never be called.
expect(ipcBridge.mode.listProviders.invoke).not.toHaveBeenCalled();
expect(ipcBridge.mode.createProvider.invoke).not.toHaveBeenCalled();
});
it('does not re-import a provider deleted by the user after migration (ELECTRON-1KT)', async () => {
// Run 1: full migration succeeds, flag gets set.
const legacyProviders = [
{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] },
{ id: 'p2', platform: 'anthropic', name: 'P2', baseUrl: '', apiKey: '', model: ['claude-3'] },
];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'ok' });
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(configFile.store.get('migration.providersMigrated_v1')).toBe(true);
// Run 2: user has deleted `p1` from the backend. The legacy list on
// disk is unchanged. listProviders would now return only [p2]. With
// the flag in place, the migration must NOT call createProvider for
// p1 — that's the bug being fixed.
vi.clearAllMocks();
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 'p2' }]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'p1' });
await migrateProviders(configFile);
expect(ipcBridge.mode.listProviders.invoke).not.toHaveBeenCalled();
expect(ipcBridge.mode.createProvider.invoke).not.toHaveBeenCalled();
});
it('does not set flag on partial failure so retry can fill the gap', async () => {
const legacyProviders = [
{ id: 'p1', platform: 'openai', name: 'P1', baseUrl: '', apiKey: '', model: ['gpt-4'] },
{ id: 'p2', platform: 'anthropic', name: 'P2', baseUrl: '', apiKey: '', model: ['claude-3'] },
];
const configFile = makeConfig({ 'model.config': legacyProviders });
(ipcBridge.mode.listProviders.invoke as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(ipcBridge.mode.createProvider.invoke as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ id: 'p1' })
.mockRejectedValueOnce(new Error('boom'));
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
// Flag must remain unset so the next launch retries the still-missing row.
expect(configFile.store.has('migration.providersMigrated_v1')).toBe(false);
});
it('sets flag when there is no legacy model.config to migrate', async () => {
// Fresh install or already-cleaned config: nothing to do, but we still
// want to set the flag so we never re-read this path again.
const configFile = makeConfig();
vi.spyOn(console, 'info').mockImplementation(() => {});
await migrateProviders(configFile);
expect(configFile.store.get('migration.providersMigrated_v1')).toBe(true);
});
});
// Helper smoke test: validates that the N3 frozen helper signature is reachable
// from a domain test file (satisfies Phase 8 §8.5 grep gate — not counted in T4).
describe('mockHttpBridge helper reachability (Phase 8 §8.5 smoke)', () => {
it('createMockHttpBridge exposes the frozen public API surface', () => {
const mock = createMockHttpBridge({ unmatched: 'warn' });
expect(typeof mock.onGet).toBe('function');
expect(typeof mock.onPost).toBe('function');
expect(typeof mock.emit).toBe('function');
expect(typeof mock.reset).toBe('function');
expect(typeof mock.asModule).toBe('function');
expect(mock.routeCount).toBe(0);
expect(mock.wsListenerCount).toBe(0);
});
});
});