Files
multica/packages/views/issues/hooks/use-issue-timeline.test.tsx
2026-06-15 15:12:45 +08:00

449 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// Mock @multica/core/issues/mutations to mimic TanStack Query v5's contract:
// useMutation returns a fresh result wrapper on every render, but the
// `mutate` / `mutateAsync` functions inside it are stable across renders.
// This is exactly the shape that previously fooled the original deps lists
// in useIssueTimeline — guarding against a regression here means future code
// can't accidentally pull the whole mutation result into a useCallback dep.
const stableHandles = vi.hoisted(() => ({
createMutateAsync: vi.fn(async () => ({})),
updateMutateAsync: vi.fn(async () => ({})),
deleteMutateAsync: vi.fn(async () => ({})),
resolveMutateAsync: vi.fn(async () => ({})),
toggleMutate: vi.fn(),
}));
// WS event registry — captured handlers per event name so tests can simulate
// server pushes by invoking them directly.
const wsHandlers = vi.hoisted(() => new Map<string, (payload: unknown) => void>());
vi.mock("@multica/core/issues/mutations", () => ({
useCreateComment: () => ({
mutateAsync: stableHandles.createMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useUpdateComment: () => ({
mutateAsync: stableHandles.updateMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useDeleteComment: () => ({
mutateAsync: stableHandles.deleteMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useResolveComment: () => ({
mutateAsync: stableHandles.resolveMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useToggleCommentReaction: () => ({
mutateAsync: vi.fn(),
mutate: stableHandles.toggleMutate,
isPending: false,
}),
}));
vi.mock("@multica/core/issues/queries", () => ({
issueTimelineOptions: (id: string) => ({
queryKey: ["issues", "timeline", id],
queryFn: () => Promise.resolve([]),
}),
issueKeys: {
timeline: (id: string) => ["issues", "timeline", id],
},
}));
// Hoisted state controllable from tests — represents what useQuery would
// return for the current render.
const queryState = vi.hoisted(() => ({
data: undefined as unknown,
isLoading: false,
}));
// Track the latest cache-update fn the hook hands to setQueryData so tests
// can assert what would have been written.
const cacheUpdates = vi.hoisted(() => ({
last: null as unknown,
}));
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: () => ({
data: queryState.data,
isLoading: queryState.isLoading,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn((_key: unknown, updater: unknown) => {
cacheUpdates.last = typeof updater === "function"
? (updater as (old: unknown) => unknown)(queryState.data)
: updater;
}),
getQueryData: vi.fn(),
cancelQueries: vi.fn(),
}),
useMutationState: () => [],
};
});
vi.mock("@multica/core/realtime", () => ({
useWSEvent: (event: string, handler: (payload: unknown) => void) => {
wsHandlers.set(event, handler);
},
useWSReconnect: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
import { useIssueTimeline } from "./use-issue-timeline";
describe("useIssueTimeline", () => {
beforeEach(() => {
wsHandlers.clear();
stableHandles.createMutateAsync.mockClear();
stableHandles.updateMutateAsync.mockClear();
stableHandles.deleteMutateAsync.mockClear();
stableHandles.resolveMutateAsync.mockClear();
stableHandles.toggleMutate.mockClear();
queryState.data = [];
queryState.isLoading = false;
cacheUpdates.last = null;
});
// CommentCard is wrapped in React.memo (perf fix for long timelines, see
// multica#1968). The memo only pays off if the callbacks passed down keep
// the same identity across unrelated parent re-renders. TanStack Query v5
// returns a *new* mutation result wrapper on every render, so a useCallback
// listing the whole mutation object as a dep flips its identity every time
// — that is the exact regression this test guards against.
it("submitReply / editComment / deleteComment / toggleReaction keep identity across unrelated re-renders", () => {
const { result, rerender } = renderHook(() => useIssueTimeline("issue-1", "user-1"));
const first = {
submitComment: result.current.submitComment,
submitReply: result.current.submitReply,
editComment: result.current.editComment,
deleteComment: result.current.deleteComment,
toggleReaction: result.current.toggleReaction,
};
rerender();
rerender();
expect(result.current.submitReply).toBe(first.submitReply);
expect(result.current.editComment).toBe(first.editComment);
expect(result.current.deleteComment).toBe(first.deleteComment);
expect(result.current.toggleReaction).toBe(first.toggleReaction);
expect(result.current.submitComment).toBe(first.submitComment);
});
it("returns the timeline as a flat array directly from the query cache", () => {
queryState.data = [
{ type: "comment", id: "c1", actor_type: "member", actor_id: "u", created_at: "2026-05-06T01:00:00Z" },
{ type: "comment", id: "c2", actor_type: "member", actor_id: "u", created_at: "2026-05-06T02:00:00Z" },
{ type: "comment", id: "c3", actor_type: "member", actor_id: "u", created_at: "2026-05-06T03:00:00Z" },
];
const { result } = renderHook(() => useIssueTimeline("issue-1", "user-1"));
expect(result.current.timeline.map((e) => e.id)).toEqual(["c1", "c2", "c3"]);
});
it("passes suppressed agent ids through editComment", async () => {
const { result } = renderHook(() => useIssueTimeline("issue-1", "user-1"));
await act(async () => {
await result.current.editComment("comment-1", "updated", ["attachment-1"], ["agent-1"]);
});
expect(stableHandles.updateMutateAsync).toHaveBeenCalledWith({
commentId: "comment-1",
content: "updated",
attachmentIds: ["attachment-1"],
suppressAgentIds: ["agent-1"],
});
});
it("comment:created appends the new entry to the cache", () => {
queryState.data = [];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
comment: {
id: "new-c",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "hi",
parent_id: null,
created_at: "2026-05-06T05:00:00Z",
updated_at: "2026-05-06T05:00:00Z",
type: "comment",
reactions: [],
attachments: [],
},
});
});
const updated = cacheUpdates.last as Array<{ id: string }>;
expect(updated.map((e) => e.id)).toEqual(["new-c"]);
});
it("comment:created inserts at the correct sorted position by created_at", () => {
queryState.data = [
{ type: "comment", id: "c1", actor_type: "member", actor_id: "u", created_at: "2026-05-06T01:00:00Z" },
{ type: "comment", id: "c3", actor_type: "member", actor_id: "u", created_at: "2026-05-06T03:00:00Z" },
];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
comment: {
id: "c2",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "",
parent_id: null,
created_at: "2026-05-06T02:00:00Z",
updated_at: "2026-05-06T02:00:00Z",
type: "comment",
reactions: [],
attachments: [],
},
});
});
const updated = cacheUpdates.last as Array<{ id: string }>;
expect(updated.map((e) => e.id)).toEqual(["c1", "c2", "c3"]);
});
it("comment:created re-sorts when the new entry is oldest", () => {
queryState.data = [
{ type: "comment", id: "c2", actor_type: "member", actor_id: "u", created_at: "2026-05-06T02:00:00Z" },
{ type: "comment", id: "c3", actor_type: "member", actor_id: "u", created_at: "2026-05-06T03:00:00Z" },
];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
comment: {
id: "c1",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
type: "comment",
reactions: [],
attachments: [],
},
});
});
const updated = cacheUpdates.last as Array<{ id: string }>;
expect(updated.map((e) => e.id)).toEqual(["c1", "c2", "c3"]);
});
it("ignores WS events for other issues", () => {
queryState.data = [];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:created");
act(() => {
handler!({
comment: {
id: "x",
issue_id: "different-issue",
author_type: "member",
author_id: "u",
content: "",
parent_id: null,
created_at: "",
updated_at: "",
type: "comment",
reactions: [],
attachments: [],
},
});
});
// setQueryData should not have been invoked for a non-matching issue.
expect(cacheUpdates.last).toBeNull();
});
// The global useRealtimeSync handler now uses refetchType: "none" for
// timeline events, which means useIssueTimeline must own the granular
// cache update for every event that mutates the timeline — including
// comment:resolved / comment:unresolved. Without these handlers the
// resolve toggle on a thread root would only update the cache when the
// user remounts IssueDetail (the stale flag triggers a refetch), so the
// bar/expanded view would lag the click by a navigation cycle.
it("comment:resolved updates the matching entry in place with the new resolved fields", () => {
queryState.data = [
{
type: "comment",
id: "c1",
actor_type: "member",
actor_id: "u",
content: "hello",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
reactions: [],
attachments: [],
resolved_at: null,
resolved_by_type: null,
resolved_by_id: null,
},
{
type: "comment",
id: "c2",
actor_type: "member",
actor_id: "u",
content: "untouched",
parent_id: null,
created_at: "2026-05-06T02:00:00Z",
updated_at: "2026-05-06T02:00:00Z",
reactions: [],
attachments: [],
resolved_at: null,
resolved_by_type: null,
resolved_by_id: null,
},
];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:resolved");
expect(handler).toBeDefined();
act(() => {
handler!({
comment: {
id: "c1",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "hello",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
type: "comment",
reactions: [],
attachments: [],
resolved_at: "2026-05-06T03:00:00Z",
resolved_by_type: "member",
resolved_by_id: "u",
},
});
});
const updated = cacheUpdates.last as Array<{
id: string;
resolved_at: string | null;
resolved_by_type: string | null;
resolved_by_id: string | null;
}>;
expect(updated.map((e) => e.id)).toEqual(["c1", "c2"]);
expect(updated[0]!.resolved_at).toBe("2026-05-06T03:00:00Z");
expect(updated[0]!.resolved_by_type).toBe("member");
expect(updated[0]!.resolved_by_id).toBe("u");
// Sibling entry must not change (identity preserved by .map).
expect(updated[1]!.resolved_at).toBeNull();
});
it("comment:unresolved clears the resolved fields on the matching entry", () => {
queryState.data = [
{
type: "comment",
id: "c1",
actor_type: "member",
actor_id: "u",
content: "hello",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
reactions: [],
attachments: [],
resolved_at: "2026-05-06T03:00:00Z",
resolved_by_type: "member",
resolved_by_id: "u",
},
];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:unresolved");
expect(handler).toBeDefined();
act(() => {
handler!({
comment: {
id: "c1",
issue_id: "issue-1",
author_type: "member",
author_id: "u",
content: "hello",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
type: "comment",
reactions: [],
attachments: [],
resolved_at: null,
resolved_by_type: null,
resolved_by_id: null,
},
});
});
const updated = cacheUpdates.last as Array<{
id: string;
resolved_at: string | null;
}>;
expect(updated[0]!.resolved_at).toBeNull();
});
it("comment:resolved ignores events from other issues", () => {
queryState.data = [
{
type: "comment",
id: "c1",
actor_type: "member",
actor_id: "u",
content: "hello",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
reactions: [],
attachments: [],
resolved_at: null,
resolved_by_type: null,
resolved_by_id: null,
},
];
renderHook(() => useIssueTimeline("issue-1", "user-1"));
const handler = wsHandlers.get("comment:resolved");
act(() => {
handler!({
comment: {
id: "c1",
issue_id: "different-issue",
author_type: "member",
author_id: "u",
content: "hello",
parent_id: null,
created_at: "2026-05-06T01:00:00Z",
updated_at: "2026-05-06T01:00:00Z",
type: "comment",
reactions: [],
attachments: [],
resolved_at: "2026-05-06T03:00:00Z",
resolved_by_type: "member",
resolved_by_id: "u",
},
});
});
expect(cacheUpdates.last).toBeNull();
});
});