Files
kandev/apps/web/components/github/pr-detail-panel.state-sync.test.ts
2026-08-26 15:54:59 +00:00

202 lines
6.3 KiB
TypeScript

import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { createElement, useEffect } from "react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider, useAppStore } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import { afterEach, describe, it, expect, vi } from "vitest";
import { PRDetailContent } from "./pr-detail-panel";
import type { TaskPR, PRFeedback, GitHubPR } from "@/lib/types/github";
const feedbackMocks = vi.hoisted(() => ({
refresh: vi.fn(),
value: null as PRFeedback | null,
}));
vi.mock("@/hooks/domains/github/use-pr-feedback", () => ({
usePRFeedback: () => ({
feedback: feedbackMocks.value,
loading: false,
refresh: feedbackMocks.refresh,
}),
}));
vi.mock("@/lib/state/slices/comments", () => ({
isPRFeedbackComment: () => false,
useCommentsStore: (
selector: (state: {
addComment: () => void;
pendingForChat: string[];
byId: object;
}) => unknown,
) => selector({ addComment: vi.fn(), pendingForChat: [], byId: {} }),
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: () => ({ status: null }),
}));
vi.mock("./pr-merge-button", () => ({ PRMergeButton: () => null }));
vi.mock("./pr-mergeability-notice", () => ({
PRMergeabilityNotice: () => null,
buildConflictResolutionMessage: () => "",
}));
vi.mock("./pr-checks-section", () => ({ ChecksSection: () => null }));
vi.mock("./pr-comments-section", () => ({ CommentsSection: () => null }));
afterEach(() => {
cleanup();
feedbackMocks.refresh.mockReset();
feedbackMocks.value = null;
});
function makeTaskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task",
owner: "o",
repo: "r",
pr_number: 1,
pr_url: "",
pr_title: "Test PR",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "",
checks_state: "",
mergeable_state: "",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
checks_passing: 0,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
function makeFeedback(pr: Partial<GitHubPR> = {}): PRFeedback {
return {
pr: {
number: 1,
title: "Test PR",
url: "",
html_url: "",
state: "open",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
repo_owner: "o",
repo_name: "r",
draft: false,
mergeable: true,
additions: 0,
deletions: 0,
...pr,
} as GitHubPR,
reviews: [],
comments: [],
checks: [],
has_issues: false,
};
}
const TEST_INITIAL_STATE = {
workspaces: { activeId: "workspace-1" },
} as unknown as Partial<AppState>;
// Seeds the store the way the boot payload / WS sync would, so the probe below
// starts from a real row rather than an empty map.
function SeedTaskPR({ taskPR }: { taskPR: TaskPR }) {
const setTaskPRs = useAppStore((s) => s.setTaskPRs);
useEffect(() => {
setTaskPRs({ [taskPR.task_id]: [taskPR] });
}, [setTaskPRs, taskPR]);
return null;
}
function renderPRDetail(taskPR: TaskPR) {
return render(
createElement(StateProvider, {
initialState: TEST_INITIAL_STATE,
children: createElement(
TooltipProvider,
undefined,
createElement(PRDetailContent, { taskPR, sessionId: "session-1" }),
),
}),
);
}
// Renders the panel alongside a probe that reports the live store row, so a
// test can assert what the panel did (or did not) write back into it.
function renderPRDetailWithStoreProbe(taskPR: TaskPR) {
let read: () => TaskPR | undefined = () => undefined;
function Probe() {
const byTaskId = useAppStore((s) => s.taskPRs.byTaskId);
read = () => byTaskId[taskPR.task_id]?.[0];
return null;
}
const view = render(
createElement(StateProvider, {
initialState: TEST_INITIAL_STATE,
children: createElement(
TooltipProvider,
undefined,
createElement(SeedTaskPR, { taskPR }),
createElement(Probe),
createElement(PRDetailContent, { taskPR, sessionId: "session-1" }),
),
}),
);
return { ...view, readStoreRow: () => read() };
}
describe("PRDetailContent merged state", () => {
// The panel reads live state straight off the feedback response, so a merge
// observed by the fetch paints immediately.
it("renders the merged badge from live feedback", () => {
feedbackMocks.value = makeFeedback({ state: "merged" });
renderPRDetail(makeTaskPR({ state: "open" }));
expect(screen.getByText("merged")).toBeTruthy();
});
// And it keeps rendering merged once the row itself has converged — which is
// what survives a reload, since the store rehydrates from github_task_prs.
it("renders the merged badge from a converged store row before feedback loads", () => {
feedbackMocks.value = null;
renderPRDetail(makeTaskPR({ state: "merged", merged_at: "2026-01-01T00:00:00Z" }));
expect(screen.getByText("merged")).toBeTruthy();
});
// Regression guard for the collapse: the panel used to patch state /
// merged_at / closed_at / additions / deletions / mergeable_state into the
// Zustand row itself (useSyncLivePRState). That write never reached the DB,
// so this tab went purple while the kanban card stayed green and a reload
// undid it. The backend now persists during the feedback fetch and the
// github.task_pr.updated handler applies it — the panel must stay read-only.
it("does not write live PR state back into the store", async () => {
feedbackMocks.value = makeFeedback({
state: "merged",
merged_at: "2026-01-01T00:00:00Z",
additions: 42,
deletions: 7,
});
const stale = makeTaskPR({ state: "open", additions: 0, deletions: 0 });
const { readStoreRow } = renderPRDetailWithStoreProbe(stale);
await waitFor(() => expect(readStoreRow()).toBeTruthy());
const row = readStoreRow();
expect(row?.state).toBe("open");
expect(row?.merged_at).toBeNull();
expect(row?.additions).toBe(0);
expect(row?.deletions).toBe(0);
});
});