344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import { ToastProvider } from "@/components/toast-provider";
|
|
import { getJiraTicket } from "@/lib/api/domains/jira-api";
|
|
import { getLinearIssue } from "@/lib/api/domains/linear-api";
|
|
import { getSentryIssue } from "@/lib/api/domains/sentry-api";
|
|
import { updateTask } from "@/lib/api/domains/kanban-api";
|
|
import { useSentryInstances } from "@/hooks/domains/sentry/use-sentry-availability";
|
|
import type { SentryAvailability } from "@/hooks/domains/sentry/use-sentry-availability";
|
|
import type { SentryConfig } from "@/lib/types/sentry";
|
|
import { TaskExternalLinkDialog } from "./task-external-link-dialog";
|
|
|
|
vi.mock("@/lib/api/domains/jira-api", () => ({
|
|
getJiraTicket: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/domains/linear-api", () => ({
|
|
getLinearIssue: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/domains/sentry-api", () => ({
|
|
getSentryIssue: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/domains/kanban-api", () => ({
|
|
updateTask: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/hooks/domains/sentry/use-sentry-availability", () => ({
|
|
useSentryInstances: vi.fn(),
|
|
isHealthySentryInstance: (i: SentryConfig) => i.hasSecret && i.lastOk,
|
|
}));
|
|
|
|
const INPUT_TEST_ID = "task-external-link-input";
|
|
const SUBMIT_TEST_ID = "task-external-link-submit";
|
|
const ERROR_TEST_ID = "task-external-link-error";
|
|
const INSTANCE_SELECT_TEST_ID = "sentry-link-instance-select";
|
|
const NO_INSTANCE_TEST_ID = "sentry-link-no-instance";
|
|
const TASK_ID = "task-1";
|
|
const WORKSPACE_ID = "workspace-1";
|
|
const TASK_TITLE = "Fix login";
|
|
|
|
function instance(id: string, name: string): SentryConfig {
|
|
return {
|
|
id,
|
|
workspaceId: WORKSPACE_ID,
|
|
name,
|
|
authMethod: "auth_token",
|
|
url: "https://sentry.io",
|
|
hasSecret: true,
|
|
lastOk: true,
|
|
lastError: "",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
};
|
|
}
|
|
|
|
function mockSentryInstances(state: SentryAvailability["state"], healthy: SentryConfig[]) {
|
|
vi.mocked(useSentryInstances).mockReturnValue({
|
|
loading: false,
|
|
instances: healthy,
|
|
healthy,
|
|
available: healthy.length > 0,
|
|
state,
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function renderDialog(provider: "jira" | "linear" | "sentry" = "jira") {
|
|
return render(
|
|
<StateProvider>
|
|
<ToastProvider>
|
|
<TaskExternalLinkDialog
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
provider={provider}
|
|
task={{ id: TASK_ID, title: TASK_TITLE }}
|
|
workspaceId={WORKSPACE_ID}
|
|
/>
|
|
</ToastProvider>
|
|
</StateProvider>,
|
|
);
|
|
}
|
|
|
|
describe("TaskExternalLinkDialog — Jira and Linear", () => {
|
|
it("validates and links a Jira ticket by updating the task title", async () => {
|
|
vi.mocked(getJiraTicket).mockResolvedValue({ key: "PROJ-12" } as never);
|
|
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "PROJ-12: Fix login" } as never);
|
|
|
|
renderDialog("jira");
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "PROJ-12" },
|
|
});
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(getJiraTicket).toHaveBeenCalledWith("PROJ-12", { workspaceId: WORKSPACE_ID });
|
|
});
|
|
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "PROJ-12: Fix login" });
|
|
});
|
|
|
|
it("validates and links a Linear issue by updating the task title", async () => {
|
|
vi.mocked(getLinearIssue).mockResolvedValue({ identifier: "ENG-20" } as never);
|
|
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "ENG-20: Fix login" } as never);
|
|
|
|
renderDialog("linear");
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "eng-20" },
|
|
});
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(getLinearIssue).toHaveBeenCalledWith("ENG-20", { workspaceId: WORKSPACE_ID });
|
|
});
|
|
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "ENG-20: Fix login" });
|
|
});
|
|
});
|
|
|
|
describe("TaskExternalLinkDialog — long task titles", () => {
|
|
it("caps the renamed title at the 60-character limit when linking a Linear issue", async () => {
|
|
const longTitle = "x".repeat(60);
|
|
vi.mocked(getLinearIssue).mockResolvedValue({ identifier: "ENG-20" } as never);
|
|
vi.mocked(updateTask).mockResolvedValue({
|
|
id: TASK_ID,
|
|
title: "ENG-20: " + "x".repeat(51) + "…",
|
|
} as never);
|
|
|
|
render(
|
|
<StateProvider>
|
|
<ToastProvider>
|
|
<TaskExternalLinkDialog
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
provider="linear"
|
|
task={{ id: TASK_ID, title: longTitle }}
|
|
workspaceId={WORKSPACE_ID}
|
|
/>
|
|
</ToastProvider>
|
|
</StateProvider>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "ENG-20" },
|
|
});
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(getLinearIssue).toHaveBeenCalledWith("ENG-20", { workspaceId: WORKSPACE_ID });
|
|
});
|
|
expect(updateTask).toHaveBeenCalledTimes(1);
|
|
const title = vi.mocked(updateTask).mock.calls[0][1].title as string;
|
|
expect(Array.from(title)).toHaveLength(60);
|
|
expect(title.startsWith("ENG-20: ")).toBe(true);
|
|
expect(title.endsWith("…")).toBe(true);
|
|
});
|
|
|
|
it("caps the renamed title when a Sentry shortId rewrites the key", async () => {
|
|
mockSentryInstances("single", [instance("inst-1", "Production")]);
|
|
vi.mocked(getSentryIssue).mockResolvedValue({ shortId: "API-42" } as never);
|
|
vi.mocked(updateTask).mockResolvedValue({
|
|
id: TASK_ID,
|
|
title: "API-42: " + "x".repeat(51) + "…",
|
|
} as never);
|
|
|
|
const longTitle = "x".repeat(60);
|
|
render(
|
|
<StateProvider>
|
|
<ToastProvider>
|
|
<TaskExternalLinkDialog
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
provider="sentry"
|
|
task={{ id: TASK_ID, title: longTitle }}
|
|
workspaceId={WORKSPACE_ID}
|
|
/>
|
|
</ToastProvider>
|
|
</StateProvider>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "https://sentry.io/organizations/acme/issues/123456/" },
|
|
});
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(getSentryIssue).toHaveBeenCalledWith(WORKSPACE_ID, "inst-1", "123456");
|
|
});
|
|
expect(updateTask).toHaveBeenCalledTimes(1);
|
|
const title = vi.mocked(updateTask).mock.calls[0][1].title as string;
|
|
expect(Array.from(title)).toHaveLength(60);
|
|
expect(title.startsWith("API-42: ")).toBe(true);
|
|
expect(title.endsWith("…")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("TaskExternalLinkDialog — Sentry linking", () => {
|
|
it("auto-selects the sole healthy Sentry instance and links against it", async () => {
|
|
mockSentryInstances("single", [instance("inst-1", "Production")]);
|
|
vi.mocked(getSentryIssue).mockResolvedValue({ shortId: "API-42" } as never);
|
|
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "API-42: Fix login" } as never);
|
|
|
|
renderDialog("sentry");
|
|
|
|
// The sole instance auto-selects, so no picker is shown.
|
|
expect(screen.queryByTestId(INSTANCE_SELECT_TEST_ID)).toBeNull();
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "https://sentry.io/organizations/acme/issues/123456/" },
|
|
});
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
// The chosen instance id is threaded into the strict browse contract.
|
|
await waitFor(() => {
|
|
expect(getSentryIssue).toHaveBeenCalledWith(WORKSPACE_ID, "inst-1", "123456");
|
|
});
|
|
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "API-42: Fix login" });
|
|
});
|
|
|
|
it("prompts for a Sentry instance when several are healthy and blocks submit", () => {
|
|
mockSentryInstances("multi", [
|
|
instance("inst-1", "Production"),
|
|
instance("inst-2", "Self-hosted"),
|
|
]);
|
|
|
|
renderDialog("sentry");
|
|
|
|
// A picker is shown and submit stays disabled until an instance is chosen.
|
|
expect(screen.getByTestId(INSTANCE_SELECT_TEST_ID)).toBeTruthy();
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-1" } });
|
|
const submit = screen.getByTestId(SUBMIT_TEST_ID) as HTMLButtonElement;
|
|
expect(submit.disabled).toBe(true);
|
|
fireEvent.click(submit);
|
|
expect(getSentryIssue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("explains and blocks linking when the workspace has no healthy Sentry instance", () => {
|
|
mockSentryInstances("empty", []);
|
|
|
|
renderDialog("sentry");
|
|
|
|
expect(screen.getByTestId(NO_INSTANCE_TEST_ID)).toBeTruthy();
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-1" } });
|
|
const submit = screen.getByTestId(SUBMIT_TEST_ID) as HTMLButtonElement;
|
|
expect(submit.disabled).toBe(true);
|
|
fireEvent.click(submit);
|
|
expect(getSentryIssue).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("TaskExternalLinkDialog — validation", () => {
|
|
it("shows an inline validation error for invalid input", async () => {
|
|
renderDialog("jira");
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "not a key" },
|
|
});
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
expect((await screen.findByTestId(ERROR_TEST_ID)).textContent).toMatch(
|
|
/Paste a Jira ticket URL or key/i,
|
|
);
|
|
expect(updateTask).not.toHaveBeenCalled();
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
|
|
target: { value: "PROJ-12" },
|
|
});
|
|
|
|
expect(screen.queryByTestId(ERROR_TEST_ID)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("TaskExternalLinkDialog — Sentry availability", () => {
|
|
it("clears a selected Sentry instance when it is no longer healthy", async () => {
|
|
mockSentryInstances("multi", [
|
|
instance("inst-1", "Production"),
|
|
instance("inst-2", "Self-hosted"),
|
|
]);
|
|
renderDialog("sentry");
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-1" } });
|
|
fireEvent.click(screen.getByTestId(INSTANCE_SELECT_TEST_ID));
|
|
fireEvent.click(await screen.findByRole("option", { name: "Production" }));
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId(SUBMIT_TEST_ID) as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
mockSentryInstances("multi", [
|
|
instance("inst-2", "Self-hosted"),
|
|
instance("inst-3", "Staging"),
|
|
]);
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-2" } });
|
|
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId(SUBMIT_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TaskExternalLinkDialog — Sentry sole-instance swap", () => {
|
|
it("retargets the link when a poll swaps the sole healthy instance (state stays single)", async () => {
|
|
mockSentryInstances("single", [instance("inst-1", "Alpha")]);
|
|
vi.mocked(getSentryIssue).mockResolvedValue({ shortId: "PROJ-2" } as never);
|
|
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "PROJ-2: Fix login" } as never);
|
|
|
|
const view = renderDialog("sentry");
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-1" } });
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId(SUBMIT_TEST_ID) as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
// A polling refresh replaces the sole healthy instance (inst-1 -> inst-2)
|
|
// while availability state remains "single". The effect must retarget the
|
|
// new instance rather than link against the stale inst-1.
|
|
mockSentryInstances("single", [instance("inst-2", "Beta")]);
|
|
view.rerender(
|
|
<StateProvider>
|
|
<ToastProvider>
|
|
<TaskExternalLinkDialog
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
provider="sentry"
|
|
task={{ id: TASK_ID, title: TASK_TITLE }}
|
|
workspaceId={WORKSPACE_ID}
|
|
/>
|
|
</ToastProvider>
|
|
</StateProvider>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-2" } });
|
|
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(getSentryIssue).toHaveBeenCalledWith(WORKSPACE_ID, "inst-2", "PROJ-2");
|
|
});
|
|
});
|
|
});
|