Files
backspace/packages/web/src/stores/instanceStore.registrySync.test.ts
T
Jannis Braun 06498836bb fix(federation): gate registry PUT on successful initial GET
Without a sync-ready gate, a transient GET failure during autoConnectAll
left the local registry Map empty/incomplete while `set()` still computed
`registryUpdatedAt = Date.now()`. The trailing `syncRegistry()` would then
PUT the empty payload with a fresh timestamp; the server's LWW guard
accepted it and legitimate registry rows were wiped — including
remote-instance entries the user never explicitly removed.

Add `_registrySyncReady` flag, set true only after a successful initial
GET. `syncRegistry()` short-circuits while false, so the degraded mode
(GET failed) is display-only: the Map is still populated locally from
\`replicatedInstances\` synthesis (status \`auth_expired\`) so the
Connections UI shows the user's known remotes, but mutations don't push.
On the next session where GET succeeds, localStorage cached tokens
reseed the registry and \`syncRegistry()\` pushes the merged authoritative
state — no data lost, sync deferred until we have a complete picture.

\`reset()\` clears the flag alongside the registry. Spec updated with the
sync-ready gate and degraded-mode behavior. Unit tests cover the gate
on initial fail, mutations during degraded mode, recovery on next
successful GET, and the synthesis fallback for empty replicatedInstances.
2026-05-05 00:20:51 +02:00

142 lines
4.6 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
const getFederationRegistry = vi.fn();
const putFederationRegistry = vi.fn(async () => ({ ok: true, updatedAt: 1 }));
const ensurePeered = vi.fn(async () => ({ peeringStatus: 'active' }));
vi.mock('../api/client', () => ({
api: {
users: {
getFederationRegistry: () => getFederationRegistry(),
putFederationRegistry: (data: unknown) => putFederationRegistry(data),
me: vi.fn(),
},
federation: {
ensurePeered: (data: unknown) => ensurePeered(data),
},
},
createApiClient: () => ({}),
}));
vi.mock('../hooks/useWebSocket', () => ({
connectInstance: vi.fn(),
disconnectInstance: vi.fn(),
disconnectAllRemote: vi.fn(),
}));
vi.mock('../utils/dmOriginFailover', () => ({
failoverDmOriginsFromDisconnected: vi.fn(),
}));
vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: vi.fn() }));
vi.mock('../audio/AudioManager', () => ({
AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn() }) },
}));
const mockUser = {
id: 'user-1',
username: 'youruser',
homeInstance: null,
homeUserId: 'user-1',
replicatedInstances: [
{ origin: 'https://orbit.example', username: 'youruser@nova.example' },
],
};
vi.mock('./authStore', () => ({
useAuthStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ user: mockUser, token: 'tok' }),
{ getState: () => ({ user: mockUser, token: 'tok' }), setState: vi.fn(), subscribe: vi.fn() }
),
}));
import { useInstanceStore } from './instanceStore';
beforeEach(() => {
getFederationRegistry.mockReset();
putFederationRegistry.mockClear();
ensurePeered.mockClear();
localStorage.clear();
useInstanceStore.setState({
instances: [],
registry: new Map(),
registryUpdatedAt: 0,
_autoConnectDone: false,
_registrySyncReady: false,
pendingSyncOrigins: [],
});
});
describe('instanceStore registry sync gating', () => {
it('does NOT PUT registry when initial GET fails (prevents empty-clobber)', async () => {
getFederationRegistry.mockRejectedValueOnce(new Error('network'));
await useInstanceStore.getState().autoConnectAll();
expect(putFederationRegistry).not.toHaveBeenCalled();
expect(useInstanceStore.getState()._registrySyncReady).toBe(false);
});
it('synthesizes replicatedInstances entries when GET fails so UI is not empty', async () => {
getFederationRegistry.mockRejectedValueOnce(new Error('network'));
await useInstanceStore.getState().autoConnectAll();
const reg = useInstanceStore.getState().registry;
expect(reg.has('https://orbit.example')).toBe(true);
expect(reg.get('https://orbit.example')?.status).toBe('auth_expired');
});
it('PUTs registry after a successful initial GET and flips _registrySyncReady', async () => {
getFederationRegistry.mockResolvedValueOnce({
registry: [{
origin: 'https://orbit.example',
label: 'Orbit',
username: 'youruser@nova.example',
remoteUserId: 'remote-1',
status: 'auth_expired',
addedAt: 1,
lastConnectedAt: 1,
disconnectedAt: null,
errorMessage: null,
}],
updatedAt: 100,
});
await useInstanceStore.getState().autoConnectAll();
expect(useInstanceStore.getState()._registrySyncReady).toBe(true);
expect(putFederationRegistry).toHaveBeenCalledTimes(1);
const payload = putFederationRegistry.mock.calls[0]![0] as { registry: unknown[] };
expect(payload.registry).toHaveLength(1);
});
it('syncRegistry no-ops while _registrySyncReady is false (post-failure mutation)', async () => {
getFederationRegistry.mockRejectedValueOnce(new Error('network'));
await useInstanceStore.getState().autoConnectAll();
putFederationRegistry.mockClear();
useInstanceStore.setState({
registry: new Map([['https://other.example', {
origin: 'https://other.example',
label: 'Other', username: 'u', remoteUserId: '',
status: 'connected' as const,
addedAt: 1, lastConnectedAt: 1, disconnectedAt: null, errorMessage: null,
}]]),
registryUpdatedAt: Date.now(),
});
await useInstanceStore.getState().syncRegistry();
expect(putFederationRegistry).not.toHaveBeenCalled();
});
it('reset clears _registrySyncReady', async () => {
getFederationRegistry.mockResolvedValueOnce({ registry: [], updatedAt: 1 });
await useInstanceStore.getState().autoConnectAll();
expect(useInstanceStore.getState()._registrySyncReady).toBe(true);
useInstanceStore.getState().reset();
expect(useInstanceStore.getState()._registrySyncReady).toBe(false);
});
});