mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
* 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>
238 lines
7.7 KiB
TypeScript
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);
|
|
});
|
|
});
|