Files
fusion/packages/dashboard/app/hooks/__tests__/useNodeSettingsSync.test.ts
Fusion a4044e2b02 feat(FN-2456): persist task token usage on task records
- Add schema v44 migration to persist task-level token usage totals and first/last usage timestamps on tasks
- Extend core task types, store create/update flows, and exports to round-trip token usage data
- Add migration and TaskStore regression tests for token usage persistence, null clearing, and reinitialization behavior
- Update dashboard async handling and tests to prevent post-unmount state updates and reduce flaky assertion timing
2026-04-24 09:44:44 -07:00

677 lines
21 KiB
TypeScript

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 waitFor(() => {
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 waitFor(() => {
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 waitFor(() => {
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();
});
});