Files
multica/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
Multica Eve 423a5c59cb MUL-5200: unify working-agent filters across issue views (#5819)
* fix(issues): query workspace working agents independently

Co-authored-by: multica-agent <github@multica.ai>

* feat(agents): filter working agents by source type

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): scope working agents to My Issues

Co-authored-by: multica-agent <github@multica.ai>

* test(agents): cover My Issues squad relations

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): unify working-agent filters across views

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): preserve empty working-agent filters

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 16:54:41 +08:00

309 lines
11 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { WSClient } from "../api/ws-client";
import { defaultStorage } from "../platform/storage";
import { issueKeys } from "../issues/queries";
import { workspaceWorkingAgentsKeys } from "../agents/queries";
import { workspaceKeys } from "../workspace/queries";
import {
markWorkspaceDeletePending,
unmarkWorkspaceDeletePending,
} from "../workspace/pending-delete";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
vi.mock("../platform/workspace-storage", () => ({
getCurrentWsId: () => "ws-1",
getCurrentSlug: () => "test-ws",
}));
vi.mock("../paths", () => ({
useHasOnboarded: () => true,
resolvePostAuthDestination: () => "/",
}));
function createMockWs(): WSClient {
return {
on: vi.fn(() => () => {}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
} as unknown as WSClient;
}
function createStores(): RealtimeSyncStores {
return {
authStore: Object.assign(() => ({}), {
getState: () => ({ user: { id: "u1" } }),
subscribe: () => () => {},
setState: () => {},
destroy: () => {},
}),
} as unknown as RealtimeSyncStores;
}
function createWrapper(qc: QueryClient) {
// Named function (not arrow) so react/display-name lint rule passes —
// anonymous render-fn components break that rule even in test files.
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useRealtimeSync — ws instance change", () => {
let qc: QueryClient;
let stores: RealtimeSyncStores;
let invalidateSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
stores = createStores();
invalidateSpy = vi.spyOn(qc, "invalidateQueries");
});
it("skips invalidation on first non-null ws instance", () => {
const ws = createMockWs();
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
// The main effect calls invalidateQueries for its own setup, but the
// ws-instance-change effect should NOT have fired invalidation.
// The only invalidateQueries calls should come from the main effect's
// event handlers, not from the instance-change effect.
// We verify by checking that no call was made with workspaceKeys.list()
// pattern from the instance-change path (it logs a specific message).
// Simpler: count calls — first mount with a ws should not trigger the
// workspace-scoped bulk invalidation.
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("does not invalidate when ws goes from instance to null", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("invalidates exactly once when a new ws instance appears after null gap", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
// Simulate workspace switch: ws -> null -> new ws
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
const ws2 = createMockWs();
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (16 workspace-scoped [incl. property definitions] + 6 per-issue
// prefixes + the workspace working-agents projection + 5 per-chat
// prefixes + 1 workspaceKeys.list() + 1 cross-workspace inbox unread
// summary = 30 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(30);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
// Rerender with same instance
rerender({ ws: ws1 });
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("invalidates chat, pins, labels, and invitations queries on ws instance change", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["chat", "ws-1"]);
expect(calls).toContainEqual(["labels", "ws-1"]);
expect(calls).toContainEqual(["workspaces", "ws-1", "invitations"]);
});
it("invalidates per-issue caches (no wsId in key) on ws instance change", () => {
// These keys are not under the ["issues", wsId] prefix, so they need
// their own invalidation on recovery — otherwise events missed while
// disconnected leave them stale forever (staleTime: Infinity, #3953).
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["issues", "timeline"]);
expect(calls).toContainEqual(["issues", "reactions"]);
expect(calls).toContainEqual(["issues", "subscribers"]);
expect(calls).toContainEqual(["issues", "usage"]);
expect(calls).toContainEqual(["issues", "attachments"]);
expect(calls).toContainEqual(["issues", "tasks"]);
});
it("invalidates per-chat-session caches (no wsId in key) on ws instance change", () => {
// These keys are not under the ["chat", wsId] prefix, so they need their
// own recovery invalidation when reconnecting after missed chat/task events.
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["chat", "messages"]);
expect(calls).toContainEqual(["chat", "messages-page"]);
expect(calls).toContainEqual(["chat", "pending-task"]);
expect(calls).toContainEqual(["task-messages"]);
});
});
describe("useRealtimeSync — Table server membership invalidation", () => {
let qc: QueryClient;
let stores: RealtimeSyncStores;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
stores = createStores();
});
afterEach(() => {
vi.useRealTimers();
});
it("invalidates Table queries after a task lifecycle event", () => {
vi.useFakeTimers();
const ws = createMockWs();
const invalidate = vi.spyOn(qc, "invalidateQueries");
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
const onAny = vi.mocked(ws.onAny).mock.calls[0]?.[0];
expect(onAny).toBeDefined();
onAny!({ type: "task:completed", payload: {} } as never);
vi.advanceTimersByTime(100);
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.tableAll("ws-1"),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceWorkingAgentsKeys.all("ws-1"),
});
});
it("invalidates Table queries after a property definition changes", () => {
const ws = createMockWs();
const invalidate = vi.spyOn(qc, "invalidateQueries");
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
const propertyUpdated = vi
.mocked(ws.on)
.mock.calls.find(([event]) => event === "property:updated")?.[1];
expect(propertyUpdated).toBeDefined();
(propertyUpdated as (payload: unknown) => void)({});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.tableAll("ws-1"),
});
});
});
describe("useRealtimeSync — workspace:deleted self-initiated suppression", () => {
let qc: QueryClient;
let stores: RealtimeSyncStores;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
stores = createStores();
});
afterEach(() => {
unmarkWorkspaceDeletePending("ws-2");
localStorage.clear();
});
// getCurrentWsId is mocked to "ws-1" at module level, so deleting "ws-2"
// never enters the relocate branch — these tests only exercise the
// storage-cleanup path, which is the observable difference between a
// handled and a suppressed event.
const dispatchWorkspaceDeleted = (ws: WSClient, workspaceId: string) => {
const call = vi
.mocked(ws.on)
.mock.calls.find(([event]) => event === "workspace:deleted");
expect(call).toBeDefined();
(call![1] as (p: unknown) => void)({ workspace_id: workspaceId });
};
it("ignores the event for a delete this client initiated", () => {
const ws = createMockWs();
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
qc.setQueryData(workspaceKeys.list(), [{ id: "ws-2", slug: "delete-me" }]);
defaultStorage.setItem("multica_issue_draft:delete-me", "draft");
markWorkspaceDeletePending("ws-2");
dispatchWorkspaceDeleted(ws, "ws-2");
// useDeleteWorkspace.onSuccess owns cleanup for self-initiated deletes;
// the handler must not have touched storage.
expect(defaultStorage.getItem("multica_issue_draft:delete-me")).toBe("draft");
});
it("still cleans up for a delete initiated elsewhere", () => {
const ws = createMockWs();
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
qc.setQueryData(workspaceKeys.list(), [{ id: "ws-2", slug: "delete-me" }]);
defaultStorage.setItem("multica_issue_draft:delete-me", "draft");
dispatchWorkspaceDeleted(ws, "ws-2");
expect(defaultStorage.getItem("multica_issue_draft:delete-me")).toBeNull();
});
});