/**
* @vitest-environment jsdom
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { issueKeys } from "../issues/queries";
import type {
Issue,
IssueLabelsResponse,
Label,
ListLabelsResponse,
} from "../types";
import { useAttachLabel, useDetachLabel } from "./mutations";
import { labelKeys } from "./queries";
vi.mock("../hooks", () => ({ useWorkspaceId: () => "ws-1" }));
const labelA: Label = {
id: "label-a",
workspace_id: "ws-1",
name: "bug",
color: "#ef4444",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const labelB: Label = {
id: "label-b",
workspace_id: "ws-1",
name: "feature",
color: "#22c55e",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const issue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Child",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: "parent-1",
project_id: null,
position: 1,
stage: null,
start_date: null,
due_date: null,
labels: [labelA],
metadata: {},
properties: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
function wrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return {children};
};
}
function deferred() {
let resolve!: (value: T) => void;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function startStaleChildrenFetch(qc: QueryClient, labels: Label[]) {
const childKey = issueKeys.children("ws-1", "parent-1");
const response = deferred();
const fetch = qc
.fetchQuery({
queryKey: childKey,
queryFn: () => response.promise,
})
.catch(() => undefined);
return { childKey, response, fetch, staleIssue: { ...issue, labels } };
}
describe("issue label mutations", () => {
afterEach(() => vi.restoreAllMocks());
it("prevents an older children fetch from overwriting an optimistic attach", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const childKey = issueKeys.children("ws-1", "parent-1");
qc.setQueryData(childKey, [issue]);
qc.setQueryData(labelKeys.byIssue("ws-1", issue.id), {
labels: [labelA],
});
qc.setQueryData(labelKeys.list("ws-1"), {
labels: [labelA, labelB],
total: 2,
});
const stale = startStaleChildrenFetch(qc, [labelA]);
await waitFor(() =>
expect(qc.getQueryState(childKey)?.fetchStatus).toBe("fetching"),
);
const write = deferred();
setApiInstance({
attachLabel: vi.fn(() => write.promise),
} as unknown as ApiClient);
const { result } = renderHook(() => useAttachLabel(issue.id), {
wrapper: wrapper(qc),
});
act(() => result.current.mutate(labelB.id));
await waitFor(() =>
expect(qc.getQueryData(childKey)?.[0]?.labels).toEqual([
labelA,
labelB,
]),
);
stale.response.resolve([stale.staleIssue]);
await stale.fetch;
expect(qc.getQueryData(childKey)?.[0]?.labels).toEqual([
labelA,
labelB,
]);
await act(async () => {
write.resolve({ labels: [labelA, labelB] });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
qc.clear();
});
it("prevents an older children fetch from overwriting an optimistic detach", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const childKey = issueKeys.children("ws-1", "parent-1");
const labeledIssue = { ...issue, labels: [labelA, labelB] };
qc.setQueryData(childKey, [labeledIssue]);
qc.setQueryData(labelKeys.byIssue("ws-1", issue.id), {
labels: [labelA, labelB],
});
const stale = startStaleChildrenFetch(qc, [labelA, labelB]);
await waitFor(() =>
expect(qc.getQueryState(childKey)?.fetchStatus).toBe("fetching"),
);
const write = deferred();
setApiInstance({
detachLabel: vi.fn(() => write.promise),
} as unknown as ApiClient);
const { result } = renderHook(() => useDetachLabel(issue.id), {
wrapper: wrapper(qc),
});
act(() => result.current.mutate(labelB.id));
await waitFor(() =>
expect(qc.getQueryData(childKey)?.[0]?.labels).toEqual([labelA]),
);
stale.response.resolve([stale.staleIssue]);
await stale.fetch;
expect(qc.getQueryData(childKey)?.[0]?.labels).toEqual([labelA]);
await act(async () => {
write.resolve({ labels: [labelA] });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
qc.clear();
});
});