feat(FN-1824): merge fusion/fn-1824

This commit is contained in:
gsxdsm
2026-04-16 07:23:06 -07:00
parent c8f42856f5
commit f6da5f317a
5 changed files with 1207 additions and 2 deletions

View File

@@ -4,18 +4,26 @@ import {
fetchRemoteNodeProjects,
fetchRemoteNodeTasks,
fetchRemoteNodeProjectHealth,
fetchNodeSettings,
pushNodeSettings,
pullNodeSettings,
fetchNodeSettingsSyncStatus,
syncNodeAuth,
} from "./api-node";
import * as apiModule from "./api";
vi.mock("./api", () => ({
proxyApi: vi.fn(),
api: vi.fn(),
}));
const mockProxyApi = vi.mocked(apiModule.proxyApi);
const mockApi = vi.mocked(apiModule.api);
describe("api-node", () => {
beforeEach(() => {
mockProxyApi.mockReset();
mockApi.mockReset();
});
describe("fetchRemoteNodeHealth", () => {
@@ -225,4 +233,171 @@ describe("api-node", () => {
await expect(fetchRemoteNodeProjects("node_abc")).rejects.toThrow("404 Not Found");
});
});
// ── Node Settings Sync API ──────────────────────────────────────────────
describe("fetchNodeSettings", () => {
it("calls api with correct path and returns settings", async () => {
const mockSettings = { global: { theme: "dark" }, project: { maxConcurrent: 4 } };
mockApi.mockResolvedValueOnce(mockSettings);
const result = await fetchNodeSettings("node_abc");
expect(mockApi).toHaveBeenCalledTimes(1);
expect(mockApi).toHaveBeenCalledWith("/nodes/node_abc/settings");
expect(result).toEqual(mockSettings);
});
it("encodes nodeId with special characters in URL", async () => {
mockApi.mockResolvedValueOnce({ global: {}, project: {} });
await fetchNodeSettings("node/abc+def");
expect(mockApi).toHaveBeenCalledWith("/nodes/node%2Fabc%2Bdef/settings");
});
it("propagates errors from api", async () => {
mockApi.mockRejectedValueOnce(new Error("Node unreachable"));
await expect(fetchNodeSettings("node_abc")).rejects.toThrow("Node unreachable");
});
});
describe("pushNodeSettings", () => {
it("calls api with POST method and correct path", async () => {
const mockResult = { success: true, syncedFields: ["theme", "maxConcurrent"] };
mockApi.mockResolvedValueOnce(mockResult);
const result = await pushNodeSettings("node_abc");
expect(mockApi).toHaveBeenCalledTimes(1);
expect(mockApi).toHaveBeenCalledWith(
"/nodes/node_abc/settings/push",
{ method: "POST", body: JSON.stringify({}) },
);
expect(result).toEqual(mockResult);
});
it("encodes nodeId with special characters in URL", async () => {
mockApi.mockResolvedValueOnce({ success: true, syncedFields: [] });
await pushNodeSettings("node/abc+def");
expect(mockApi).toHaveBeenCalledWith(
"/nodes/node%2Fabc%2Bdef/settings/push",
{ method: "POST", body: JSON.stringify({}) },
);
});
it("propagates errors from api", async () => {
mockApi.mockRejectedValueOnce(new Error("Push failed"));
await expect(pushNodeSettings("node_abc")).rejects.toThrow("Push failed");
});
});
describe("pullNodeSettings", () => {
it("calls api with POST method, correct path, and last-write-wins conflict resolution", async () => {
const mockResult = { success: true, appliedFields: ["theme"], skippedFields: [] };
mockApi.mockResolvedValueOnce(mockResult);
const result = await pullNodeSettings("node_abc");
expect(mockApi).toHaveBeenCalledTimes(1);
expect(mockApi).toHaveBeenCalledWith(
"/nodes/node_abc/settings/pull",
{ method: "POST", body: JSON.stringify({ conflictResolution: "last-write-wins" }) },
);
expect(result).toEqual(mockResult);
});
it("encodes nodeId with special characters in URL", async () => {
mockApi.mockResolvedValueOnce({ success: true, appliedFields: [], skippedFields: [] });
await pullNodeSettings("node/abc+def");
expect(mockApi).toHaveBeenCalledWith(
"/nodes/node%2Fabc%2Bdef/settings/pull",
{ method: "POST", body: JSON.stringify({ conflictResolution: "last-write-wins" }) },
);
});
it("propagates errors from api", async () => {
mockApi.mockRejectedValueOnce(new Error("Pull failed"));
await expect(pullNodeSettings("node_abc")).rejects.toThrow("Pull failed");
});
});
describe("fetchNodeSettingsSyncStatus", () => {
it("calls api with correct path and returns sync status", async () => {
const mockStatus = {
lastSyncAt: "2026-04-01T00:00:00.000Z",
lastSyncDirection: "sync",
localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: true,
diff: { global: ["theme"], project: [] },
};
mockApi.mockResolvedValueOnce(mockStatus);
const result = await fetchNodeSettingsSyncStatus("node_abc");
expect(mockApi).toHaveBeenCalledTimes(1);
expect(mockApi).toHaveBeenCalledWith("/nodes/node_abc/settings/sync-status");
expect(result).toEqual(mockStatus);
});
it("encodes nodeId with special characters in URL", async () => {
mockApi.mockResolvedValueOnce({
lastSyncAt: null,
lastSyncDirection: null,
localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: false,
diff: { global: [], project: [] },
});
await fetchNodeSettingsSyncStatus("node/abc+def");
expect(mockApi).toHaveBeenCalledWith("/nodes/node%2Fabc%2Bdef/settings/sync-status");
});
it("propagates errors from api", async () => {
mockApi.mockRejectedValueOnce(new Error("Sync status check failed"));
await expect(fetchNodeSettingsSyncStatus("node_abc")).rejects.toThrow("Sync status check failed");
});
});
describe("syncNodeAuth", () => {
it("calls api with POST method and correct path", async () => {
const mockResult = { success: true, syncedProviders: ["openai", "anthropic"] };
mockApi.mockResolvedValueOnce(mockResult);
const result = await syncNodeAuth("node_abc");
expect(mockApi).toHaveBeenCalledTimes(1);
expect(mockApi).toHaveBeenCalledWith(
"/nodes/node_abc/auth/sync",
{ method: "POST", body: JSON.stringify({}) },
);
expect(result).toEqual(mockResult);
});
it("encodes nodeId with special characters in URL", async () => {
mockApi.mockResolvedValueOnce({ success: true, syncedProviders: [] });
await syncNodeAuth("node/abc+def");
expect(mockApi).toHaveBeenCalledWith(
"/nodes/node%2Fabc%2Bdef/auth/sync",
{ method: "POST", body: JSON.stringify({}) },
);
});
it("propagates errors from api", async () => {
mockApi.mockRejectedValueOnce(new Error("Auth sync failed"));
await expect(syncNodeAuth("node_abc")).rejects.toThrow("Auth sync failed");
});
});
});

View File

@@ -5,7 +5,7 @@
import type { ProjectInfo } from "./api";
import type { ProjectHealth, Task } from "@fusion/core";
import { proxyApi } from "./api";
import { api, proxyApi } from "./api";
/** Health information for a remote node */
export interface RemoteNodeHealth {
@@ -46,3 +46,72 @@ export async function fetchRemoteNodeProjectHealth(
nodeId,
});
}
// ── Node Settings Sync API ──────────────────────────────────────────────────────
/** Settings scopes returned by GET /api/nodes/:id/settings */
export interface NodeSettingsScopes {
global: Record<string, unknown>;
project: Record<string, unknown>;
}
/** Result from settings push/pull operations */
export interface NodeSettingsSyncResult {
success: boolean;
syncedFields?: string[];
appliedFields?: string[];
skippedFields?: string[];
error?: string;
}
/** Sync status returned by GET /api/nodes/:id/settings/sync-status */
export interface NodeSettingsSyncStatus {
lastSyncAt: string | null;
lastSyncDirection: string | null;
localUpdatedAt: string;
remoteReachable: boolean;
diff: {
global: string[];
project: string[];
};
}
/** Result from auth sync operation */
export interface NodeAuthSyncResult {
success: boolean;
syncedProviders: string[];
}
/** Fetch settings from a remote node */
export async function fetchNodeSettings(nodeId: string): Promise<NodeSettingsScopes> {
return api<NodeSettingsScopes>(`/nodes/${encodeURIComponent(nodeId)}/settings`);
}
/** Push local settings to a remote node */
export async function pushNodeSettings(nodeId: string): Promise<NodeSettingsSyncResult> {
return api<NodeSettingsSyncResult>(`/nodes/${encodeURIComponent(nodeId)}/settings/push`, {
method: "POST",
body: JSON.stringify({}),
});
}
/** Pull settings from a remote node and apply locally */
export async function pullNodeSettings(nodeId: string): Promise<NodeSettingsSyncResult> {
return api<NodeSettingsSyncResult>(`/nodes/${encodeURIComponent(nodeId)}/settings/pull`, {
method: "POST",
body: JSON.stringify({ conflictResolution: "last-write-wins" }),
});
}
/** Get the sync status for a node (last sync time, diff summary, etc.) */
export async function fetchNodeSettingsSyncStatus(nodeId: string): Promise<NodeSettingsSyncStatus> {
return api<NodeSettingsSyncStatus>(`/nodes/${encodeURIComponent(nodeId)}/settings/sync-status`);
}
/** Synchronize model auth credentials with a remote node */
export async function syncNodeAuth(nodeId: string): Promise<NodeAuthSyncResult> {
return api<NodeAuthSyncResult>(`/nodes/${encodeURIComponent(nodeId)}/auth/sync`, {
method: "POST",
body: JSON.stringify({}),
});
}

View File

@@ -74,7 +74,7 @@ function buildApiUrl(path: string): string {
return `/api${path}`;
}
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const url = buildApiUrl(path);
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },

View File

@@ -0,0 +1,682 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useNodeSettingsSync } from "../useNodeSettingsSync";
import * as apiNode from "../../api-node";
import type { NodeSettingsSyncStatus, NodeSettingsSyncResult, NodeAuthSyncResult } from "../../api-node";
vi.mock("../../api-node", () => ({
fetchNodeSettingsSyncStatus: vi.fn(),
pushNodeSettings: vi.fn(),
pullNodeSettings: vi.fn(),
syncNodeAuth: vi.fn(),
}));
const mockFetchNodeSettingsSyncStatus = vi.mocked(apiNode.fetchNodeSettingsSyncStatus);
const mockPushNodeSettings = vi.mocked(apiNode.pushNodeSettings);
const mockPullNodeSettings = vi.mocked(apiNode.pullNodeSettings);
const mockSyncNodeAuth = vi.mocked(apiNode.syncNodeAuth);
function makeSyncStatus(overrides: Partial<NodeSettingsSyncStatus> = {}): NodeSettingsSyncStatus {
return {
lastSyncAt: "2026-04-01T00:00:00.000Z",
lastSyncDirection: "sync",
localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: true,
diff: { global: [], project: [] },
...overrides,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useNodeSettingsSync", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchNodeSettingsSyncStatus.mockReset();
mockPushNodeSettings.mockReset();
mockPullNodeSettings.mockReset();
mockSyncNodeAuth.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
// ── Initial state ─────────────────────────────────────────────────────────
it("returns empty syncStatusMap, loading=false, no error when no nodes are tracked", async () => {
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
expect(result.current.syncStatusMap).toEqual({});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.actionLoading).toEqual({});
});
it("makes no API calls when no nodes are tracked", async () => {
renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
});
// ── Track node ─────────────────────────────────────────────────────────────
it("immediately fetches sync status when a node is tracked", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledWith("node_1");
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
});
it("tracks multiple nodes and fetches status for each", async () => {
mockFetchNodeSettingsSyncStatus
.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }))
.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-02T00:00:00.000Z" }));
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
result.current.trackNode("node_2");
});
await act(async () => {
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(2);
expect(result.current.syncStatusMap.node_1).toBeDefined();
expect(result.current.syncStatusMap.node_2).toBeDefined();
});
// ── Untrack node ───────────────────────────────────────────────────────────
it("removes node from syncStatusMap when untracked", async () => {
mockFetchNodeSettingsSyncStatus
.mockResolvedValueOnce(makeSyncStatus())
.mockResolvedValueOnce(makeSyncStatus());
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
result.current.trackNode("node_2");
});
await act(async () => {
await flushPromises();
});
expect(result.current.syncStatusMap.node_1).toBeDefined();
expect(result.current.syncStatusMap.node_2).toBeDefined();
await act(async () => {
result.current.untrackNode("node_1");
});
expect(result.current.syncStatusMap.node_1).toBeUndefined();
expect(result.current.syncStatusMap.node_2).toBeDefined();
});
// ── Loading contract (FN-1734) ───────────────────────────────────────────────
it("sets loading=true during initial fetch and false after completion", async () => {
// Track a node first, then resolve the pending promise after checking the transition
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// After trackNode and flush, loading should be false (fetch completed)
expect(result.current.loading).toBe(false);
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus());
});
it("does NOT set loading to true during background polling refreshes (FN-1734 regression)", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
expect(result.current.loading).toBe(false);
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus());
// Advance timer for polling refresh (30 seconds)
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
await flushPromises();
});
// loading should still be false (regression: previously was set to true)
expect(result.current.loading).toBe(false);
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(2);
});
// ── Polling ───────────────────────────────────────────────────────────────
it("polls fetchNodeSettingsSyncStatus every 30 seconds for tracked nodes", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// Initial fetch: 1 call
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(1);
// Advance 30 seconds
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(2);
// Advance another 30 seconds
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(3);
});
it("clears polling interval on unmount", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const { result, unmount } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// Reset call count to check post-unmount calls
mockFetchNodeSettingsSyncStatus.mockClear();
unmount();
// Advance 60 seconds worth of polling
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
});
// Should NOT have any new calls after unmount
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
});
it("stops polling when all nodes are untracked", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// Initial fetch
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledTimes(1);
// Untrack the only node
await act(async () => {
result.current.untrackNode("node_1");
});
// Clear call count
mockFetchNodeSettingsSyncStatus.mockClear();
// Advance 60 seconds
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
});
// No polling should have happened
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
});
// ── Push action ─────────────────────────────────────────────────────────────
it("pushSettings calls pushNodeSettings and sets actionLoading during the call", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const pushDeferred = deferred<{ success: boolean; syncedFields: string[] }>();
mockPushNodeSettings.mockReturnValue(pushDeferred.promise);
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// Reset after tracking
mockFetchNodeSettingsSyncStatus.mockClear();
// Start push but don't resolve yet
await act(async () => {
const promise = result.current.pushSettings("node_1");
void promise; // We don't await yet - we'll resolve the deferred manually
});
expect(mockPushNodeSettings).toHaveBeenCalledWith("node_1");
expect(result.current.actionLoading.node_1).toBe(true);
// Resolve the deferred
pushDeferred.resolve({ success: true, syncedFields: ["theme"] });
await act(async () => {
await flushPromises();
});
expect(result.current.actionLoading.node_1).toBeUndefined();
});
it("pushSettings refreshes sync status after completion", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
mockPushNodeSettings.mockResolvedValue({ success: true, syncedFields: ["theme"] });
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
mockFetchNodeSettingsSyncStatus.mockClear();
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus({ lastSyncAt: "2026-04-02T00:00:00.000Z" }));
await act(async () => {
await result.current.pushSettings("node_1");
});
await act(async () => {
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledWith("node_1");
});
// ── Pull action ─────────────────────────────────────────────────────────────
it("pullSettings calls pullNodeSettings and sets actionLoading during the call", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const pullDeferred = deferred<{ success: boolean; appliedFields: string[]; skippedFields: string[] }>();
mockPullNodeSettings.mockReturnValue(pullDeferred.promise);
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
mockFetchNodeSettingsSyncStatus.mockClear();
// Start pull but don't resolve yet
await act(async () => {
const promise = result.current.pullSettings("node_1");
void promise;
});
expect(mockPullNodeSettings).toHaveBeenCalledWith("node_1");
expect(result.current.actionLoading.node_1).toBe(true);
// Resolve the deferred
pullDeferred.resolve({ success: true, appliedFields: ["theme"], skippedFields: [] });
await act(async () => {
await flushPromises();
});
expect(result.current.actionLoading.node_1).toBeUndefined();
});
it("pullSettings refreshes sync status after completion", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
mockPullNodeSettings.mockResolvedValue({ success: true, appliedFields: [], skippedFields: [] });
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
mockFetchNodeSettingsSyncStatus.mockClear();
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus({ lastSyncAt: "2026-04-03T00:00:00.000Z" }));
await act(async () => {
await result.current.pullSettings("node_1");
});
await act(async () => {
await flushPromises();
});
expect(mockFetchNodeSettingsSyncStatus).toHaveBeenCalledWith("node_1");
});
// ── Auth sync action ─────────────────────────────────────────────────────────
it("syncAuth calls syncNodeAuth and sets actionLoading during the call", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
const authDeferred = deferred<NodeAuthSyncResult>();
mockSyncNodeAuth.mockReturnValue(authDeferred.promise);
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// Start auth sync but don't resolve yet
await act(async () => {
const promise = result.current.syncAuth("node_1");
void promise;
});
expect(mockSyncNodeAuth).toHaveBeenCalledWith("node_1");
expect(result.current.actionLoading.node_1).toBe(true);
// Resolve the deferred
authDeferred.resolve({ success: true, syncedProviders: ["openai"] });
await act(async () => {
await flushPromises();
});
expect(result.current.actionLoading.node_1).toBeUndefined();
});
// ── Action error handling ───────────────────────────────────────────────────
it("action error re-throws, clears actionLoading, and sets error", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
mockPushNodeSettings.mockRejectedValue(new Error("Push failed"));
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
await expect(result.current.pushSettings("node_1")).rejects.toThrow("Push failed");
await act(async () => {
await flushPromises();
});
expect(result.current.error).toBe("Push failed");
expect(result.current.actionLoading).toEqual({});
});
it("action error for pull clears actionLoading and sets error", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
mockPullNodeSettings.mockRejectedValue(new Error("Pull failed"));
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
await expect(result.current.pullSettings("node_1")).rejects.toThrow("Pull failed");
await act(async () => {
await flushPromises();
});
expect(result.current.error).toBe("Pull failed");
expect(result.current.actionLoading).toEqual({});
});
it("action error for auth sync clears actionLoading and sets error", async () => {
mockFetchNodeSettingsSyncStatus.mockResolvedValue(makeSyncStatus());
mockSyncNodeAuth.mockRejectedValue(new Error("Auth sync failed"));
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
await expect(result.current.syncAuth("node_1")).rejects.toThrow("Auth sync failed");
await act(async () => {
await flushPromises();
});
expect(result.current.error).toBe("Auth sync failed");
expect(result.current.actionLoading).toEqual({});
});
// ── Polling error handling ─────────────────────────────────────────────────
it("preserves existing data when polling fails, sets error, keeps loading=false", async () => {
// Initial successful fetch
mockFetchNodeSettingsSyncStatus
.mockResolvedValueOnce(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }))
// Polling failure
.mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
await act(async () => {
await flushPromises();
});
// Verify initial data is present
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
// Advance timer to trigger polling
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
await flushPromises();
});
// Data should still be present (stale but visible)
expect(result.current.syncStatusMap.node_1).toEqual(makeSyncStatus({ lastSyncAt: "2026-04-01T00:00:00.000Z" }));
// Error should be set
expect(result.current.error).toBe("Network error");
// Loading should be false (no loading state for background polling)
expect(result.current.loading).toBe(false);
});
// ── Empty tracking ─────────────────────────────────────────────────────────
it("makes no API calls when all nodes are untracked", async () => {
const pending = deferred<NodeSettingsSyncStatus>();
mockFetchNodeSettingsSyncStatus.mockReturnValue(pending.promise);
const { result } = renderHook(() => useNodeSettingsSync());
await act(async () => {
await flushPromises();
});
await act(async () => {
result.current.trackNode("node_1");
});
// Let initial fetch start
await act(async () => {
await flushPromises();
});
mockFetchNodeSettingsSyncStatus.mockClear();
// Untrack the node
await act(async () => {
result.current.untrackNode("node_1");
});
// Advance timers
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
});
// No polling should have happened
expect(mockFetchNodeSettingsSyncStatus).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,279 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
fetchNodeSettingsSyncStatus,
pushNodeSettings,
pullNodeSettings,
syncNodeAuth,
type NodeSettingsSyncStatus,
type NodeSettingsSyncResult,
type NodeAuthSyncResult,
} from "../api-node";
export interface UseNodeSettingsSyncResult {
/** Per-node sync status keyed by nodeId */
syncStatusMap: Record<string, NodeSettingsSyncStatus>;
/** Loading state — true ONLY during initial load, false during background polling */
loading: boolean;
/** Per-node loading states for push/pull/auth actions */
actionLoading: Record<string, boolean>;
/** Error if any */
error: string | null;
/** Manually refresh sync status for all tracked nodes */
refresh: () => Promise<void>;
/** Start tracking a node for sync status polling */
trackNode: (nodeId: string) => void;
/** Stop tracking a node */
untrackNode: (nodeId: string) => void;
/** Push local settings to a remote node */
pushSettings: (nodeId: string) => Promise<NodeSettingsSyncResult>;
/** Pull settings from a remote node */
pullSettings: (nodeId: string) => Promise<NodeSettingsSyncResult>;
/** Sync auth credentials with a remote node */
syncAuth: (nodeId: string) => Promise<NodeAuthSyncResult>;
}
const POLL_INTERVAL_MS = 30_000; // 30 seconds
/**
* Hook for managing per-node settings synchronization state.
*
* Automatically polls sync status for all tracked nodes every 30 seconds.
* Stops polling when component unmounts.
*
* Loading behavior: `loading` is true only during the initial fetch.
* Background polling updates do NOT set `loading` to true, so the UI
* keeps previously loaded data visible during refreshes. This prevents
* skeleton flicker and scroll position resets during periodic updates (FN-1734).
*/
export function useNodeSettingsSync(): UseNodeSettingsSyncResult {
const [syncStatusMap, setSyncStatusMap] = useState<Record<string, NodeSettingsSyncStatus>>({});
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [error, setError] = useState<string | null>(null);
// Track which nodes are being monitored
const trackedNodesRef = useRef<Set<string>>(new Set());
// Track if initial load is complete
const initialLoadCompleteRef = useRef(false);
// Abort controller for cancelling in-flight requests
const abortRef = useRef<AbortController | null>(null);
// Polling interval ref
const intervalRef = useRef<NodeJS.Timeout | null>(null);
/**
* Fetch sync status for a single node and update state.
* Does NOT set loading=true (called during polling and initial fetch).
*/
const fetchNodeStatus = useCallback(async (nodeId: string, isInitial: boolean): Promise<void> => {
try {
const status = await fetchNodeSettingsSyncStatus(nodeId);
setSyncStatusMap((prev) => ({
...prev,
[nodeId]: status,
}));
setError(null);
} catch (err) {
// Keep stale data visible during polling failures
console.error(`Failed to fetch sync status for node ${nodeId}:`, err);
setError(err instanceof Error ? err.message : "Failed to fetch sync status");
}
}, []);
/**
* Refresh sync status for all tracked nodes.
* Sets loading=true only for initial fetch, not for background refreshes.
*/
const refresh = useCallback(async () => {
const trackedNodes = Array.from(trackedNodesRef.current);
if (trackedNodes.length === 0) return;
// Cancel any in-flight requests
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
const isInitial = !initialLoadCompleteRef.current;
if (isInitial) {
setLoading(true);
}
setError(null);
try {
// Fetch status for all tracked nodes concurrently
const results = await Promise.allSettled(
trackedNodes.map((nodeId) => fetchNodeStatus(nodeId, isInitial))
);
// Mark initial load complete
initialLoadCompleteRef.current = true;
// Check if any failed
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
setError("Some sync status requests failed");
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
}
setError(err instanceof Error ? err.message : "Failed to fetch sync status");
initialLoadCompleteRef.current = true;
} finally {
setLoading(false);
}
}, [fetchNodeStatus]);
/**
* Start polling sync status for all tracked nodes.
*/
const startPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
}, [refresh]);
/**
* Stop polling.
*/
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Initial fetch and polling setup
useEffect(() => {
void refresh();
startPolling();
return () => {
stopPolling();
if (abortRef.current) {
abortRef.current.abort();
}
};
}, [refresh, startPolling, stopPolling]);
/**
* Start tracking a node for sync status polling.
* Immediately fetches status for the newly tracked node.
*/
const trackNode = useCallback((nodeId: string) => {
if (trackedNodesRef.current.has(nodeId)) return;
trackedNodesRef.current.add(nodeId);
void fetchNodeStatus(nodeId, !initialLoadCompleteRef.current);
}, [fetchNodeStatus]);
/**
* Stop tracking a node.
* Removes its entry from syncStatusMap and stops polling for it.
*/
const untrackNode = useCallback((nodeId: string) => {
trackedNodesRef.current.delete(nodeId);
setSyncStatusMap((prev) => {
const next = { ...prev };
delete next[nodeId];
return next;
});
// If no more tracked nodes, stop polling
if (trackedNodesRef.current.size === 0) {
stopPolling();
}
}, [stopPolling]);
/**
* Push local settings to a remote node.
* Sets per-node actionLoading during the call, updates syncStatusMap on completion.
*/
const pushSettings = useCallback(async (nodeId: string): Promise<NodeSettingsSyncResult> => {
setActionLoading((prev) => ({ ...prev, [nodeId]: true }));
setError(null);
try {
const result = await pushNodeSettings(nodeId);
// Refresh sync status after push
void fetchNodeStatus(nodeId, false);
if (!result.success && result.error) {
setError(result.error);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : "Push settings failed";
setError(message);
throw err;
} finally {
setActionLoading((prev) => {
const next = { ...prev };
delete next[nodeId];
return next;
});
}
}, [fetchNodeStatus]);
/**
* Pull settings from a remote node.
* Sets per-node actionLoading during the call, updates syncStatusMap on completion.
*/
const pullSettings = useCallback(async (nodeId: string): Promise<NodeSettingsSyncResult> => {
setActionLoading((prev) => ({ ...prev, [nodeId]: true }));
setError(null);
try {
const result = await pullNodeSettings(nodeId);
// Refresh sync status after pull
void fetchNodeStatus(nodeId, false);
if (!result.success && result.error) {
setError(result.error);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : "Pull settings failed";
setError(message);
throw err;
} finally {
setActionLoading((prev) => {
const next = { ...prev };
delete next[nodeId];
return next;
});
}
}, [fetchNodeStatus]);
/**
* Sync auth credentials with a remote node.
* Sets per-node actionLoading during the call.
*/
const syncAuth = useCallback(async (nodeId: string): Promise<NodeAuthSyncResult> => {
setActionLoading((prev) => ({ ...prev, [nodeId]: true }));
setError(null);
try {
return await syncNodeAuth(nodeId);
} catch (err) {
const message = err instanceof Error ? err.message : "Auth sync failed";
setError(message);
throw err;
} finally {
setActionLoading((prev) => {
const next = { ...prev };
delete next[nodeId];
return next;
});
}
}, []);
return {
syncStatusMap,
loading,
actionLoading,
error,
refresh,
trackNode,
untrackNode,
pushSettings,
pullSettings,
syncAuth,
};
}