Files
multica/packages/core/workspace/mutations.test.tsx
Multica Eve ac62f72c2a MUL-4480: make daemon workspace sync event-driven (#5354)
* feat(daemon): make workspace sync event-driven

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

* fix(daemon): preserve trailing workspace changes

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

* fix(workspace): reconcile failed creates

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-14 11:56:30 +08:00

238 lines
7.7 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { defaultStorage } from "../platform/storage";
import type { Workspace } from "../types";
import { useCreateWorkspace, useDeleteWorkspace } from "./mutations";
import { workspaceKeys } from "./queries";
import {
isWorkspaceDeletePending,
unmarkWorkspaceDeletePending,
} from "./pending-delete";
function createWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
const makeWorkspace = (id: string, slug: string): Workspace => ({
id,
name: slug,
slug,
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "MUL",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
});
describe("useCreateWorkspace", () => {
let qc: QueryClient;
let createWorkspace: ReturnType<
typeof vi.fn<(data: { name: string; slug: string }) => Promise<Workspace>>
>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createWorkspace = vi.fn();
setApiInstance({ createWorkspace } as unknown as ApiClient);
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
makeWorkspace("ws-1", "existing"),
]);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("seeds the successful response without invalidating the workspace list", async () => {
const created = makeWorkspace("ws-2", "created");
createWorkspace.mockResolvedValue(created);
const { result } = renderHook(() => useCreateWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ name: "Created", slug: "created" });
});
expect(
qc
.getQueryData<Workspace[]>(workspaceKeys.list())
?.map((workspace) => workspace.id),
).toEqual(["ws-1", "ws-2"]);
expect(qc.getQueryState(workspaceKeys.list())?.isInvalidated).toBe(false);
});
it("invalidates the workspace list when create fails", async () => {
createWorkspace.mockRejectedValue(new Error("response lost"));
const { result } = renderHook(() => useCreateWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await expect(
result.current.mutateAsync({ name: "Created", slug: "created" }),
).rejects.toThrow("response lost");
});
expect(qc.getQueryState(workspaceKeys.list())?.isInvalidated).toBe(true);
expect(
qc
.getQueryData<Workspace[]>(workspaceKeys.list())
?.map((workspace) => workspace.id),
).toEqual(["ws-1"]);
});
});
describe("useDeleteWorkspace", () => {
let qc: QueryClient;
let deleteWorkspace: ReturnType<typeof vi.fn<(id: string) => Promise<void>>>;
let listWorkspaces: ReturnType<typeof vi.fn<() => Promise<Workspace[]>>>;
const serverList = () => [
makeWorkspace("ws-1", "keep-me"),
makeWorkspace("ws-2", "delete-me"),
];
const seedList = () => {
qc.setQueryData<Workspace[]>(workspaceKeys.list(), serverList());
};
const cachedList = () =>
qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
deleteWorkspace = vi.fn().mockResolvedValue(undefined);
listWorkspaces = vi.fn().mockResolvedValue(serverList());
setApiInstance({ deleteWorkspace, listWorkspaces } as unknown as ApiClient);
});
afterEach(() => {
qc.clear();
// The self-initiated marker is module state and is intentionally KEPT
// after a successful delete (it suppresses the WS echo); reset it so
// tests stay independent.
unmarkWorkspaceDeletePending("ws-2");
localStorage.clear();
vi.restoreAllMocks();
});
it("leaves the list cache untouched while the DELETE is pending (no optimistic removal)", async () => {
seedList();
// Hold the DELETE open to observe the pending window. The flow awaits
// the mutation with the dialog in a loading state, so the cache must
// keep reflecting server truth: the workspace still exists.
let resolveDelete!: () => void;
deleteWorkspace.mockReturnValue(
new Promise<void>((resolve) => {
resolveDelete = resolve;
}),
);
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
let mutationDone: Promise<void>;
await act(async () => {
mutationDone = result.current.mutateAsync("ws-2");
await Promise.resolve();
});
expect(deleteWorkspace).toHaveBeenCalledWith("ws-2");
expect(cachedList().map((w) => w.id)).toEqual(["ws-1", "ws-2"]);
await act(async () => {
resolveDelete();
await mutationDone;
});
});
it("invalidates the workspace list after a successful delete", async () => {
seedList();
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync("ws-2");
});
expect(qc.getQueryState(workspaceKeys.list())?.isInvalidated).toBe(true);
});
it("clears the deleted slug's workspace-scoped storage on success", async () => {
seedList();
// The realtime `workspace:deleted` handler skips self-initiated deletes,
// so the mutation owns this cleanup; the slug is captured from the list
// cache before the mutation fires.
defaultStorage.setItem("multica_issue_draft:delete-me", "draft");
defaultStorage.setItem("multica_issue_draft:keep-me", "draft");
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync("ws-2");
});
expect(defaultStorage.getItem("multica_issue_draft:delete-me")).toBeNull();
expect(defaultStorage.getItem("multica_issue_draft:keep-me")).toBe("draft");
});
it("leaves storage and cache untouched when the DELETE fails", async () => {
seedList();
deleteWorkspace.mockRejectedValue(new Error("boom"));
defaultStorage.setItem("multica_issue_draft:delete-me", "draft");
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await expect(result.current.mutateAsync("ws-2")).rejects.toThrow("boom");
});
// No optimistic write happened, so there is nothing to roll back.
expect(defaultStorage.getItem("multica_issue_draft:delete-me")).toBe("draft");
expect(cachedList().map((w) => w.id)).toEqual(["ws-1", "ws-2"]);
});
it("keeps the self-initiated marker after success and lifts it after failure", async () => {
seedList();
const { result } = renderHook(() => useDeleteWorkspace(), {
wrapper: createWrapper(qc),
});
// Success: the id is gone for good; the kept marker suppresses the WS
// echo of our own delete whenever it arrives.
await act(async () => {
await result.current.mutateAsync("ws-2");
});
expect(isWorkspaceDeletePending("ws-2")).toBe(true);
// Failure: the workspace still exists, so a later external delete of
// the same id must be handled by the realtime handler again.
unmarkWorkspaceDeletePending("ws-2");
deleteWorkspace.mockRejectedValue(new Error("boom"));
await act(async () => {
await expect(result.current.mutateAsync("ws-2")).rejects.toThrow("boom");
});
expect(isWorkspaceDeletePending("ws-2")).toBe(false);
});
});