import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ ensureProviderStoreMigrated: vi.fn(), listProviderAccounts: vi.fn(), deleteProviderAccount: vi.fn(), saveProviderAccount: vi.fn(), getActiveOpenClawProviders: vi.fn(), getOpenClawProvidersConfig: vi.fn(), getOpenClawProviderKeyForType: vi.fn(), loggerWarn: vi.fn(), loggerInfo: vi.fn(), })); vi.mock('@electron/services/providers/provider-migration', () => ({ ensureProviderStoreMigrated: mocks.ensureProviderStoreMigrated, })); vi.mock('@electron/services/providers/provider-store', () => ({ listProviderAccounts: mocks.listProviderAccounts, deleteProviderAccount: mocks.deleteProviderAccount, getProviderAccount: vi.fn(), getDefaultProviderAccountId: vi.fn(), providerAccountToConfig: vi.fn(), providerConfigToAccount: vi.fn(), saveProviderAccount: mocks.saveProviderAccount, setDefaultProviderAccount: vi.fn(), })); vi.mock('@electron/utils/openclaw-auth', () => ({ getActiveOpenClawProviders: mocks.getActiveOpenClawProviders, getOpenClawProvidersConfig: mocks.getOpenClawProvidersConfig, })); vi.mock('@electron/utils/provider-keys', () => ({ getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType, })); vi.mock('@electron/utils/secure-storage', () => ({ deleteApiKey: vi.fn(), deleteProvider: vi.fn(), getApiKey: vi.fn(), hasApiKey: vi.fn(), saveProvider: vi.fn(), setDefaultProvider: vi.fn(), storeApiKey: vi.fn(), })); vi.mock('@electron/utils/logger', () => ({ logger: { debug: vi.fn(), info: mocks.loggerInfo, warn: mocks.loggerWarn, error: vi.fn(), }, })); vi.mock('@electron/shared/providers/registry', () => ({ PROVIDER_DEFINITIONS: [], getProviderDefinition: vi.fn(), })); import { ProviderService } from '@electron/services/providers/provider-service'; import type { ProviderAccount } from '@electron/shared/providers/types'; function makeAccount(overrides: Partial = {}): ProviderAccount { return { id: 'test-account', vendorId: 'moonshot' as ProviderAccount['vendorId'], label: 'Test', authMode: 'api_key' as ProviderAccount['authMode'], enabled: true, isDefault: false, createdAt: '2026-03-19T00:00:00.000Z', updatedAt: '2026-03-19T00:00:00.000Z', ...overrides, }; } describe('ProviderService.listAccounts stale-account cleanup', () => { let service: ProviderService; beforeEach(() => { vi.clearAllMocks(); mocks.ensureProviderStoreMigrated.mockResolvedValue(undefined); mocks.getOpenClawProviderKeyForType.mockImplementation( (type: string, id: string) => `${type}/${id}`, ); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: {}, defaultModel: undefined }); service = new ProviderService(); }); it('hides ALL accounts when activeProviders is empty (config missing/deleted)', async () => { const accounts = [ makeAccount({ id: 'custom-1', vendorId: 'custom' as ProviderAccount['vendorId'] }), makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), makeAccount({ id: 'anthropic-1', vendorId: 'anthropic' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set()); const result = await service.listAccounts(); // All accounts hidden (not deleted) when config is empty expect(result).toEqual([]); expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); }); it('hides stale non-builtin accounts when config has active providers', async () => { const accounts = [ makeAccount({ id: 'moonshot-1', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), makeAccount({ id: 'custom-stale', vendorId: 'custom' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); // Only moonshot is active in config mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); const result = await service.listAccounts(); // custom-stale hidden (not deleted) from display expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0].id).toBe('moonshot-1'); }); it('hides builtin provider accounts when not in activeProviders', async () => { const accounts = [ makeAccount({ id: 'anthropic-1', vendorId: 'anthropic' as ProviderAccount['vendorId'] }), makeAccount({ id: 'openai-1', vendorId: 'openai' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); // Config has moonshot, but NOT anthropic or openai mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); const result = await service.listAccounts(); // Builtin accounts also hidden when not in OpenClaw config expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); expect(result).toEqual([]); }); it('returns empty when no accounts and no active OpenClaw providers', async () => { mocks.listProviderAccounts.mockResolvedValue([]); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set()); const result = await service.listAccounts(); expect(result).toEqual([]); expect(mocks.getActiveOpenClawProviders).toHaveBeenCalled(); expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); }); it('matches accounts by vendorId, id, or openClawKey', async () => { const accounts = [ makeAccount({ id: 'custom-abc', vendorId: 'custom' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); // The openClawKey matches mocks.getOpenClawProviderKeyForType.mockReturnValue('custom/custom-abc'); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['custom/custom-abc'])); const result = await service.listAccounts(); expect(mocks.deleteProviderAccount).not.toHaveBeenCalled(); expect(result).toEqual(accounts); }); it('imports new providers from OpenClaw config not yet in ClawX store', async () => { const accounts = [ makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot', 'siliconflow'])); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: { moonshot: { baseUrl: 'https://api.moonshot.cn/v1' }, siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1' }, }, defaultModel: undefined, }); const result = await service.listAccounts(); // moonshot already exists, siliconflow should be imported expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(1); expect(mocks.saveProviderAccount).toHaveBeenCalledWith( expect.objectContaining({ id: 'siliconflow' }), ); expect(result).toHaveLength(2); expect(result.map((a: ProviderAccount) => a.id)).toContain('siliconflow'); }); it('does not import providers already in ClawX store', async () => { const accounts = [ makeAccount({ id: 'moonshot', vendorId: 'moonshot' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['moonshot'])); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: { moonshot: { baseUrl: 'https://api.moonshot.cn/v1' }, }, defaultModel: undefined, }); const result = await service.listAccounts(); expect(mocks.saveProviderAccount).not.toHaveBeenCalled(); expect(result).toHaveLength(1); }); it('does not create duplicate when account id differs but vendorId matches', async () => { // User added openrouter via UI → id is "openrouter-uuid", vendorId is "openrouter" // openclaw.json has "openrouter" entry → should NOT import because vendorId matches const accounts = [ makeAccount({ id: 'openrouter-uuid-1234', vendorId: 'openrouter' as ProviderAccount['vendorId'] }), ]; mocks.listProviderAccounts.mockResolvedValue(accounts); mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openrouter'])); mocks.getOpenClawProvidersConfig.mockResolvedValue({ providers: { openrouter: { baseUrl: 'https://openrouter.ai/api/v1' }, }, defaultModel: 'openrouter/openai/gpt-5.4', }); const result = await service.listAccounts(); // Should NOT create a duplicate "openrouter" account expect(mocks.saveProviderAccount).not.toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0].id).toBe('openrouter-uuid-1234'); }); });