From 884aa7c7f1b1877c35a3ac862974ffd7e2726dec Mon Sep 17 00:00:00 2001 From: Kagura Date: Mon, 23 Mar 2026 16:59:41 +0800 Subject: [PATCH] fix: persist provider display state across restarts (fixes #624) (#633) Co-authored-by: Kagura Chen Co-authored-by: Claude Opus 4.6 --- src/App.tsx | 7 ++ src/stores/providers.ts | 9 +- tests/unit/provider-store-init.test.ts | 116 +++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/unit/provider-store-init.test.ts diff --git a/src/App.tsx b/src/App.tsx index 267a1ad08..5832ec381 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { useSettingsStore } from './stores/settings'; import { useGatewayStore } from './stores/gateway'; +import { useProviderStore } from './stores/providers'; import { applyGatewayTransportPreference } from './lib/api-client'; @@ -94,6 +95,7 @@ function App() { const language = useSettingsStore((state) => state.language); const setupComplete = useSettingsStore((state) => state.setupComplete); const initGateway = useGatewayStore((state) => state.init); + const initProviders = useProviderStore((state) => state.init); useEffect(() => { initSettings(); @@ -111,6 +113,11 @@ function App() { initGateway(); }, [initGateway]); + // Initialize provider snapshot on mount + useEffect(() => { + initProviders(); + }, [initProviders]); + // Redirect to setup wizard if not complete useEffect(() => { if (!setupComplete && !location.pathname.startsWith('/setup')) { diff --git a/src/stores/providers.ts b/src/stores/providers.ts index d7d04f69e..1863bbe7f 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -30,8 +30,9 @@ interface ProviderState { defaultAccountId: string | null; loading: boolean; error: string | null; - + // Actions + init: () => Promise; refreshProviderSnapshot: () => Promise; createAccount: (account: ProviderAccount, apiKey?: string) => Promise; removeAccount: (accountId: string) => Promise; @@ -74,7 +75,11 @@ export const useProviderStore = create((set, get) => ({ defaultAccountId: null, loading: false, error: null, - + + init: async () => { + await get().refreshProviderSnapshot(); + }, + refreshProviderSnapshot: async () => { set({ loading: true, error: null }); diff --git a/tests/unit/provider-store-init.test.ts b/tests/unit/provider-store-init.test.ts new file mode 100644 index 000000000..b3db4f1e9 --- /dev/null +++ b/tests/unit/provider-store-init.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act } from '@testing-library/react'; + +// Mock fetchProviderSnapshot before importing the store +const mockFetchProviderSnapshot = vi.fn(); +vi.mock('@/lib/provider-accounts', () => ({ + fetchProviderSnapshot: (...args: unknown[]) => mockFetchProviderSnapshot(...args), +})); + +// Mock hostApiFetch (used by other store methods) +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: vi.fn(), +})); + +// Import store after mocks are in place +import { useProviderStore } from '@/stores/providers'; + +describe('useProviderStore – init()', () => { + beforeEach(() => { + // Reset the store to initial state + useProviderStore.setState({ + statuses: [], + accounts: [], + vendors: [], + defaultAccountId: null, + loading: false, + error: null, + }); + vi.clearAllMocks(); + }); + + it('init() calls refreshProviderSnapshot and populates state', async () => { + const fakeSnapshot = { + statuses: [{ id: 'anthropic', name: 'Anthropic', hasKey: true, keyMasked: 'sk-***' }], + accounts: [{ id: 'acc-1', name: 'My Anthropic', type: 'anthropic' }], + vendors: [{ id: 'anthropic', displayName: 'Anthropic' }], + defaultAccountId: 'acc-1', + }; + mockFetchProviderSnapshot.mockResolvedValueOnce(fakeSnapshot); + + await act(async () => { + await useProviderStore.getState().init(); + }); + + expect(mockFetchProviderSnapshot).toHaveBeenCalledOnce(); + + const state = useProviderStore.getState(); + expect(state.statuses).toEqual(fakeSnapshot.statuses); + expect(state.accounts).toEqual(fakeSnapshot.accounts); + expect(state.vendors).toEqual(fakeSnapshot.vendors); + expect(state.defaultAccountId).toBe('acc-1'); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('init() sets error state when fetchProviderSnapshot fails', async () => { + mockFetchProviderSnapshot.mockRejectedValueOnce(new Error('Network error')); + + await act(async () => { + await useProviderStore.getState().init(); + }); + + const state = useProviderStore.getState(); + expect(state.error).toBe('Error: Network error'); + expect(state.loading).toBe(false); + expect(state.statuses).toEqual([]); + }); + + it('init() handles snapshot with missing fields gracefully', async () => { + // Backend might return partial data + mockFetchProviderSnapshot.mockResolvedValueOnce({ + statuses: null, + accounts: undefined, + vendors: [], + defaultAccountId: undefined, + }); + + await act(async () => { + await useProviderStore.getState().init(); + }); + + const state = useProviderStore.getState(); + expect(state.statuses).toEqual([]); + expect(state.accounts).toEqual([]); + expect(state.vendors).toEqual([]); + expect(state.defaultAccountId).toBeNull(); + expect(state.loading).toBe(false); + }); + + it('calling init() multiple times re-fetches the snapshot each time', async () => { + const snapshot1 = { + statuses: [], + accounts: [], + vendors: [], + defaultAccountId: null, + }; + const snapshot2 = { + statuses: [{ id: 'openai', name: 'OpenAI', hasKey: true, keyMasked: 'sk-***' }], + accounts: [{ id: 'acc-2', name: 'My OpenAI', type: 'openai' }], + vendors: [{ id: 'openai', displayName: 'OpenAI' }], + defaultAccountId: 'acc-2', + }; + mockFetchProviderSnapshot.mockResolvedValueOnce(snapshot1).mockResolvedValueOnce(snapshot2); + + await act(async () => { + await useProviderStore.getState().init(); + }); + expect(useProviderStore.getState().statuses).toEqual([]); + + await act(async () => { + await useProviderStore.getState().init(); + }); + expect(useProviderStore.getState().statuses).toEqual(snapshot2.statuses); + expect(mockFetchProviderSnapshot).toHaveBeenCalledTimes(2); + }); +});