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.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user