Files
DeskClaw/tests/unit/provider-service-stale-cleanup.test.ts
2026-03-23 19:11:53 +08:00

235 lines
8.6 KiB
TypeScript

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> = {}): 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<string>());
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');
});
});