Files
kandev/apps/web/app/gitlab/use-gitlab-page-state.test.ts
Henry ffec4aade9 feat(gitlab): filter issues by milestone (#3278)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-02 16:55:31 +00:00

425 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { Issue, IssueSearchPage } from "@/lib/types/gitlab";
import { ISSUE_PRESETS, MR_PRESETS } from "@/components/gitlab/my-gitlab/presets";
import {
__resetSnapshotForTests,
type SavedPreset,
} from "@/components/gitlab/my-gitlab/use-saved-presets";
import { resetKnownProjectsStore } from "@/components/gitlab/my-gitlab/use-known-projects";
import type { SidebarSelection } from "@/components/gitlab/my-gitlab/presets-sidebar";
const searchUserMRsMock = vi.fn<(input: unknown) => Promise<unknown>>();
const searchUserIssuesMock = vi.fn<(input: unknown) => Promise<IssueSearchPage | null>>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
searchUserMRs: (args: unknown) => searchUserMRsMock(args),
searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
}));
const fetchUserSettingsMock = vi.fn();
const updateUserSettingsMock = vi.fn();
vi.mock("@/lib/api/domains/settings-api", () => ({
fetchUserSettings: (...args: unknown[]) => fetchUserSettingsMock(...args),
updateUserSettings: (...args: unknown[]) => updateUserSettingsMock(...args),
}));
import {
buildProjectOptionsResetKey,
trimGitLabMilestone,
useGitLabPageState,
useProjectOptions,
} from "./use-gitlab-page-state";
afterEach(() => cleanup());
const WORKSPACE_ID = "ws-1";
const CREATED_AT = "2026-01-01T00:00:00Z";
function fakeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
milestone: "",
created_at: "",
updated_at: "",
...overrides,
};
}
function issuePage(items: Issue[]): IssueSearchPage {
return { issues: items, total_count: items.length, page: 1, per_page: 25 };
}
beforeEach(() => {
vi.clearAllMocks();
resetKnownProjectsStore();
__resetSnapshotForTests();
fetchUserSettingsMock.mockResolvedValue({ settings: { gitlab_saved_presets: [] } });
updateUserSettingsMock.mockResolvedValue({ settings: {} });
searchUserMRsMock.mockResolvedValue({ mrs: [], total_count: 0, page: 1, per_page: 25 });
searchUserIssuesMock.mockResolvedValue(issuePage([]));
});
describe("trimGitLabMilestone", () => {
it("trims ordinary leading/trailing whitespace", () => {
expect(trimGitLabMilestone(" v1.0 ")).toBe("v1.0");
});
it("trims a leading BOM (\uFEFF) and a trailing NBSP (\u00A0)", () => {
expect(trimGitLabMilestone("\uFEFFv1.0\u00A0")).toBe("v1.0");
});
it("trims a trailing NEL (\u0085), which the bare \\s regex class omits", () => {
expect(trimGitLabMilestone("v1.0\u0085")).toBe("v1.0");
});
it("preserves internal whitespace", () => {
expect(trimGitLabMilestone(" Sprint 42 ")).toBe("Sprint 42");
});
it("returns empty for a whitespace-only value including a NEL", () => {
expect(trimGitLabMilestone(" \u0085 ")).toBe("");
});
// Mirrors the Go side's TestTrimGitLabWhitespace_MatchesNormativeSet
// (client_helpers_milestone_test.go) code point for code point: the
// normative set is Unicode White_Space together with U+FEFF, on BOTH
// sides, and the two languages' own defaults disagree with it and with
// each other in both directions (Scenario 27). Asserting only a handful
// of ad-hoc cases on one side does not establish the cross-language
// agreement the spec requires.
it("matches the normative set for every code point in the Go-side conformance table", () => {
const trimmed = [
0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x0020, 0x0085, 0x00a0, 0x1680, 0x2000, 0x2028,
0x2029, 0x202f, 0x205f, 0x3000, 0xfeff,
];
for (const cp of trimmed) {
expect(trimGitLabMilestone(String.fromCodePoint(cp))).toBe("");
}
// Preserved: not part of Unicode White_Space or U+FEFF.
const preserved = [0x200b, 0x180e, 0x2060, 0x00b7];
for (const cp of preserved) {
const ch = String.fromCodePoint(cp);
expect(trimGitLabMilestone(ch)).toBe(ch);
}
});
});
describe("buildProjectOptionsResetKey", () => {
it("keeps delimiter-containing query and milestone values distinct", () => {
const selection: SidebarSelection = { kind: "issue", source: "preset", id: "assigned" };
const queryWithDelimiter = buildProjectOptionsResetKey(selection, "labels=backend:v1", "Q2");
const milestoneWithDelimiter = buildProjectOptionsResetKey(
selection,
"labels=backend",
"v1:Q2",
);
expect(queryWithDelimiter).not.toBe(milestoneWithDelimiter);
});
});
describe("useProjectOptions — ordinary pagination (key unchanged)", () => {
beforeEach(() => resetKnownProjectsStore());
const SELECTION: SidebarSelection = { kind: "issue", source: "preset", id: "assigned" };
it("keeps projects seen on an earlier page while a same-key page turn is loading", () => {
const items = [fakeIssue({ project_path: "acme/api" })];
const { result, rerender } = renderHook(
(props: { loading: boolean; items: Issue[] }) =>
useProjectOptions({
selection: SELECTION,
committedQuery: "",
milestone: "",
items: props.items,
loading: props.loading,
projectFilter: "",
}),
{ initialProps: { loading: false, items } },
);
expect(result.current).toEqual(["acme/api"]);
// Page 2 is loading: same key, so the accumulator must not clear.
rerender({ loading: true, items: [] });
expect(result.current).toEqual(["acme/api"]);
// Page 2 resolves with a different project; it accumulates on top.
rerender({ loading: false, items: [fakeIssue({ project_path: "acme/web" })] });
expect(result.current).toEqual(["acme/api", "acme/web"]);
});
});
describe("useGitLabPageState", () => {
it("does not leak the previous milestone's projects into the dropdown while the new milestone's fetch is in flight (Scenario 29)", async () => {
searchUserIssuesMock.mockResolvedValueOnce(
issuePage([fakeIssue({ project_path: "acme/api" })]),
);
let resolveNext: (v: IssueSearchPage) => void = () => {};
searchUserIssuesMock.mockReturnValueOnce(
new Promise<IssueSearchPage>((res) => {
resolveNext = res;
}),
);
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
await waitFor(() => expect(result.current.projectOptions).toEqual(["acme/api"]));
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
// The new milestone's fetch is in flight; the previous milestone's
// project must not still be offered.
expect(result.current.projectOptions).toEqual([]);
await act(async () => {
resolveNext(issuePage([fakeIssue({ project_path: "acme/web" })]));
await Promise.resolve();
});
await waitFor(() => expect(result.current.projectOptions).toEqual(["acme/web"]));
expect(result.current.projectOptions).not.toContain("acme/api");
});
it("clears the milestone draft and committed value when the selection changes, including switching to the MR view", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
act(() => result.current.onSelect({ kind: "mr", source: "preset", id: MR_PRESETS[0].value }));
expect(result.current.milestone).toBe("");
expect(result.current.committedMilestone).toBe("");
expect(result.current.showMilestoneFilter).toBe(false);
});
it("onCommitMilestone normalizes the visible input via the trim helper, resets to page 1, and issues exactly one request (Scenario 7)", async () => {
searchUserIssuesMock.mockResolvedValue(issuePage([]));
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
await waitFor(() => expect(result.current.search.loading).toBe(false));
act(() => result.current.search.setPage(3));
await waitFor(() => expect(result.current.search.page).toBe(3));
const callsBeforeCommit = searchUserIssuesMock.mock.calls.length;
act(() => result.current.setMilestone(" Next "));
act(() => result.current.onCommitMilestone());
expect(result.current.milestone).toBe("Next");
expect(result.current.committedMilestone).toBe("Next");
await waitFor(() => expect(result.current.search.page).toBe(1));
// Committing and resetting the page together must fire exactly one
// request for the new value — adding milestone to the existing
// [preset, customQuery, kind] reset effect would instead fire twice
// (once for the stale page, once for page 1); see spec "Client
// function, and where trimming happens".
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(callsBeforeCommit + 1));
const lastCall = searchUserIssuesMock.mock.calls.at(-1)?.[0] as {
page: number;
milestone?: string;
};
expect(lastCall.page).toBe(1);
expect(lastCall.milestone).toBe("Next");
});
it("resets to page 1 when selecting a sidebar preset clears a committed milestone (Scenario 6, clause 3)", async () => {
searchUserIssuesMock.mockResolvedValue(issuePage([]));
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
act(() => result.current.search.setPage(3));
await waitFor(() => expect(result.current.search.page).toBe(3));
// Re-select the SAME preset: effectivePreset/customQuery/kind are all
// unchanged, so useGitLabSearch's `[preset, customQuery, kind]` reset
// effect will not fire on its own — only the milestone clears.
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
expect(result.current.committedMilestone).toBe("");
await waitFor(() => expect(result.current.search.page).toBe(1));
});
});
describe("useGitLabPageState — saved query save", () => {
it("canSaveCurrent and suggestedLabel consider the milestone alongside the query and project filter", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
expect(result.current.canSaveCurrent).toBe(false);
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
expect(result.current.canSaveCurrent).toBe(true);
expect(result.current.suggestedLabel).toBe("Next");
});
it("suggestedLabel precedence ladder: custom query beats project filter beats milestone beats the default (Scenario 25)", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
expect(result.current.suggestedLabel).toBe("Saved query");
act(() => result.current.setMilestone("Next"));
act(() => result.current.onCommitMilestone());
expect(result.current.suggestedLabel).toBe("Next");
act(() => result.current.setProjectFilter("group/app"));
expect(result.current.suggestedLabel).toBe("In group/app");
act(() => result.current.setCustomQuery("state=closed"));
act(() => result.current.commitCustomQuery());
await waitFor(() => expect(result.current.committedQuery).toBe("state=closed"));
expect(result.current.suggestedLabel).toBe("state=closed");
});
it("onConfirmSave writes the committed (trimmed) milestone and the effective preset, never the raw draft", async () => {
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
act(() => result.current.setMilestone(" Next "));
act(() => result.current.onCommitMilestone());
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
await act(async () => {
result.current.onConfirmSave("My saved query");
await Promise.resolve();
});
await waitFor(() => expect(updateUserSettingsMock).toHaveBeenCalled());
const payload = updateUserSettingsMock.mock.calls.at(-1)?.[0] as {
gitlab_saved_presets: SavedPreset[];
};
const saved = payload.gitlab_saved_presets.at(-1);
expect(saved?.label).toBe("My saved query");
expect(saved?.milestone).toBe("Next");
expect(saved?.preset).toBe("assigned");
expect(saved?.customQuery).toBe("");
});
});
describe("useGitLabPageState — saved query restore", () => {
it("restores both the milestone and the effective preset (including a preset-only query with no custom text) when the saved query is selected", async () => {
const created: SavedPreset = {
id: "g_1",
kind: "issue",
label: "Next sprint",
customQuery: "",
projectFilter: "",
milestone: "Next",
preset: "assigned",
createdAt: CREATED_AT,
};
fetchUserSettingsMock.mockResolvedValue({ settings: { gitlab_saved_presets: [created] } });
searchUserIssuesMock.mockResolvedValue(issuePage([fakeIssue({ project_path: "acme/api" })]));
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
await waitFor(() => expect(result.current.savedPresets).toHaveLength(1));
act(() => result.current.onSelect({ kind: "issue", source: "saved", id: "g_1" }));
expect(result.current.committedMilestone).toBe("Next");
expect(result.current.committedQuery).toBe("");
// Preset-only saved query with no custom query text: the original
// preset filter (persisted as `preset`) must still drive the fetch.
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalled());
const lastCall = searchUserIssuesMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(lastCall.filter).toBe("assigned_to_me");
});
it("resets to page 1 when selecting a saved query that differs from the current state only in its milestone (Scenario 6, clause 2)", async () => {
const created: SavedPreset = {
id: "g_1",
kind: "issue",
label: "Next sprint",
customQuery: "",
projectFilter: "",
milestone: "Next",
preset: "assigned",
createdAt: CREATED_AT,
};
fetchUserSettingsMock.mockResolvedValue({ settings: { gitlab_saved_presets: [created] } });
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
await waitFor(() => expect(result.current.savedPresets).toHaveLength(1));
// Same effective preset ("assigned"), same empty custom query, same kind
// as the saved query below — only the milestone will differ, so the
// `[preset, customQuery, kind]` reset effect in useGitLabSearch will not
// fire on its own.
act(() => result.current.onSelect({ kind: "issue", source: "preset", id: "assigned" }));
await waitFor(() => expect(result.current.search.loading).toBe(false));
act(() => result.current.search.setPage(3));
await waitFor(() => expect(result.current.search.page).toBe(3));
act(() => result.current.onSelect({ kind: "issue", source: "saved", id: "g_1" }));
expect(result.current.committedMilestone).toBe("Next");
await waitFor(() => expect(result.current.search.page).toBe(1));
});
});
describe("useGitLabPageState — saved query delete", () => {
it("clears milestone and resets to page 1 only when the deleted saved query was the active selection", async () => {
const other: SavedPreset = {
id: "g_other",
kind: "issue",
label: "Other",
customQuery: "labels=bug",
projectFilter: "",
milestone: "",
preset: "",
createdAt: CREATED_AT,
};
const selected: SavedPreset = {
id: "g_selected",
kind: "issue",
label: "Selected",
customQuery: "",
projectFilter: "",
milestone: "Next",
preset: "assigned",
createdAt: CREATED_AT,
};
fetchUserSettingsMock.mockResolvedValue({
settings: { gitlab_saved_presets: [other, selected] },
});
const { result } = renderHook(() => useGitLabPageState(true, WORKSPACE_ID));
await waitFor(() => expect(result.current.savedPresets).toHaveLength(2));
act(() => result.current.onSelect({ kind: "issue", source: "saved", id: "g_selected" }));
await waitFor(() => expect(result.current.committedMilestone).toBe("Next"));
act(() => result.current.search.setPage(2));
await waitFor(() => expect(result.current.search.page).toBe(2));
// Deleting an unrelated saved query leaves the active scope untouched.
act(() => result.current.onDeleteSaved("g_other"));
expect(result.current.committedMilestone).toBe("Next");
expect(result.current.search.page).toBe(2);
// Deleting the currently-selected saved query clears the scope and
// resets the page in the same batch.
act(() => result.current.onDeleteSaved("g_selected"));
expect(result.current.committedMilestone).toBe("");
await waitFor(() => expect(result.current.search.page).toBe(1));
expect(result.current.selection).toEqual({
kind: "issue",
source: "preset",
id: ISSUE_PRESETS[0]?.value ?? "",
});
});
});