From 1a2387136796f529ddfc6509f6e6595706e36102 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:22:06 +0200 Subject: [PATCH] feat(federation): trigger DM failover on setInstanceStatus transition When an instance transitions from 'connected' to 'disconnected' or 'error', fire failoverDmOriginsFromDisconnected for that origin. Dynamic import preserves the circular-dep-safe resolver pattern used elsewhere in instanceStore. Fire-and-forget; the failover utility reads fresh state at call time. --- .../src/stores/instanceStore.failover.test.ts | 70 +++++++++++++++++++ packages/web/src/stores/instanceStore.ts | 8 +++ 2 files changed, 78 insertions(+) create mode 100644 packages/web/src/stores/instanceStore.failover.test.ts diff --git a/packages/web/src/stores/instanceStore.failover.test.ts b/packages/web/src/stores/instanceStore.failover.test.ts new file mode 100644 index 00000000..5952fbf1 --- /dev/null +++ b/packages/web/src/stores/instanceStore.failover.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockFailover = vi.fn(); +vi.mock('../utils/dmOriginFailover', () => ({ + failoverDmOriginsFromDisconnected: (o: string) => mockFailover(o), +})); +vi.mock('../hooks/useWebSocket', () => ({ + connectInstance: vi.fn(), + disconnectInstance: vi.fn(), + disconnectAllRemote: vi.fn(), +})); +vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: vi.fn() })); +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn() }) }, +})); +// Stub authStore to avoid localStorage access during module init +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { getState: () => ({ user: null, token: null }), setState: vi.fn(), subscribe: vi.fn() } + ), +})); + +import { useInstanceStore } from './instanceStore'; +import type { ConnectedInstance } from './instanceStore'; + +function inst(origin: string, status: ConnectedInstance['status']): ConnectedInstance { + return { + origin, label: origin, token: 'tok', username: 'u', status, + user: { id: 'u', username: 'u' } as any, + api: {} as any, + }; +} + +beforeEach(() => { + mockFailover.mockClear(); + useInstanceStore.setState({ instances: [], registry: new Map(), registryUpdatedAt: 0 }); +}); + +describe('instanceStore failover triggers', () => { + it('fires failover on connected → disconnected transition', async () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected'); + await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + }); + + it('fires failover on connected → error transition', async () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); + await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + }); + + it('does not fire on connecting → connected', () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'connecting')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'connected'); + expect(mockFailover).not.toHaveBeenCalled(); + }); + + it('does not fire on disconnected → error (no connected source)', () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'disconnected')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); + expect(mockFailover).not.toHaveBeenCalled(); + }); + + it('does not fire when instance is not in the list', () => { + useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected'); + expect(mockFailover).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 38ad36b3..7bec2e76 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -428,11 +428,19 @@ export const useInstanceStore = create((set, get) => ({ }, setInstanceStatus: (origin, status, error) => { + const prev = get().instances.find(i => i.origin === origin)?.status; set((state) => ({ instances: state.instances.map(i => i.origin === origin ? { ...i, status, error } : i ), })); + if (prev === 'connected' && (status === 'disconnected' || status === 'error')) { + // Dynamic import keeps the circular-dep-safe resolver pattern used elsewhere + // in this file. Fire-and-forget: failover reads state at call time. + import('../utils/dmOriginFailover').then(({ failoverDmOriginsFromDisconnected }) => { + failoverDmOriginsFromDisconnected(origin); + }); + } }, disconnectInstance: (origin: string) => {