/** * @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).mockResolvedValue([]); (ipcBridge.channel.getPlatformSettings.invoke as ReturnType).mockResolvedValue(null); (ipcBridge.channel.setAssistantSetting.invoke as ReturnType).mockResolvedValue(undefined); (ipcBridge.channel.setDefaultModelSetting.invoke as ReturnType).mockResolvedValue(undefined); (ipcBridge.channel.syncChannelSettings.invoke as ReturnType).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).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).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).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).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).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).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).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).mockImplementation((method: string) => { if (method === 'GET') return Promise.resolve({}); return Promise.resolve(undefined); }); (ipcBridge.assistants.list.invoke as ReturnType).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).mockImplementation((method: string) => { if (method === 'GET') return Promise.resolve({}); return Promise.resolve(undefined); }); (ipcBridge.assistants.list.invoke as ReturnType).mockResolvedValue([ { id: 'bare_codex', source: 'generated', agent_id: 'agent-codex', agent: { type: 'acp', source: 'builtin', acp_backend: 'codex' }, }, ]); (ipcBridge.channel.getPlatformSettings.invoke as ReturnType).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 = {}): ConfigFile & { store: Map } { const store = new Map(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 }; } 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).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).mockResolvedValue([]); (ipcBridge.mode.createProvider.invoke as ReturnType).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).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).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).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).mockResolvedValue([{ id: 'p1' }]); (ipcBridge.mode.createProvider.invoke as ReturnType).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).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).mockResolvedValue([]); (ipcBridge.mode.createProvider.invoke as ReturnType) .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).mockResolvedValue([]); (ipcBridge.mode.createProvider.invoke as ReturnType).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).mockResolvedValue([]); (ipcBridge.mode.createProvider.invoke as ReturnType).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).mockResolvedValue([{ id: 'p2' }]); (ipcBridge.mode.createProvider.invoke as ReturnType).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).mockResolvedValue([]); (ipcBridge.mode.createProvider.invoke as ReturnType) .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); }); }); });