Files
kandev/apps/web/components/integrations/integrations-menu.test.ts
T
João Salavisa 83e01477df refactor: extract shared shapes between jira and linear integrations (#759)
* refactor: extract shared shapes between jira and linear integrations

Factors out the duplicated logic between internal/jira + internal/linear
(backend) and components/jira + components/linear (frontend) into reusable
shared shapes. Follow-up to PR #736.

Backend:
- New internal/integrations/secretadapter wraps secrets.SecretStore with
  the upsert API both jira.SecretStore and linear.SecretStore expect;
  drops both adapter copies from cmd/kandev/services.go.
- New internal/integrations/healthpoll owns the 90s auth-health loop via
  a Prober interface; linear/poller.go is a thin wrapper, jira/poller.go
  keeps only its (jira-specific) issue-watch loop.
- provideServices split into initGitHubService / initJiraService /
  initLinearService — now 75 lines (was ~125, exceeded the 80-line limit).

Frontend:
- New hooks/domains/integrations/use-integration-availability.ts and
  use-integration-enabled.ts; jira/linear hooks reduce to one-line
  delegates and move from components/<x>/ to hooks/domains/<x>/ per the
  project convention Greptile flagged on PR #736.
- New components/integrations/{auth-status-banner,auth-error-message,
  validated-popover}.tsx; the four link/import-bar wrappers drop from
  ~110 lines to ~35 each, and JiraLinkButton picks up the
  clear-error-on-close behaviour LinearLinkButton already had.

Docs:
- New 'Adding a new third-party integration' section in CLAUDE.md/AGENTS.md
  documenting the shared shapes and intentional Jira/Linear divergence
  (transition vs state, JQL vs filter object, eventBus only on jira).

* fix: address PR #759 review feedback

- validated-popover: outline-with-label trigger now sets type="button" and
  honours triggerDisabled, matching ghost-icon (Greptile, Claude, CodeRabbit).
- jira-link-button / linear-link-button: move updateTask() into the awaited
  fetch path so a failed title update surfaces as the popover's inline error
  instead of an unhandled rejection (CodeRabbit).
- use-integration-availability: monotonic requestId race guard prevents a
  slow earlier probe from overwriting a newer auth result (CodeRabbit).
- use-integration-enabled: drop unnecessary async on synchronous init
  (Claude).
- jira/linear poller smoke tests: wrap in testing/synctest instead of
  time.After(2s) per backend testing convention (CodeRabbit nitpick).

Skipped with reasoning on the threads:
- secretadapter Set TOCTOU — single-process SQLite + single-writer per
  workspace; constraint violation on conflict surfaces as an error to the
  caller, not silent corruption.
- healthpoll SetInterval validation — the function isn't called from
  production code today; a panic on misuse would be a footgun and silent
  ignore would just hide bugs differently.

* fix: address PR #759 round-2 review feedback

- jira-settings / linear-settings: drop the local STATUS_REFRESH_MS = 90_000
  copies and import the shared INTEGRATION_STATUS_REFRESH_MS so changing the
  cadence only touches one file (Claude).
- healthpoll: rename Poller.ProbeAll → probeAll. The only callers are the
  loop and the in-package tests, so widening the public API wasn't buying
  anything (Claude).

* fix: invalidate ValidatedPopover in-flight submits on close

If the user clicked Submit and dismissed the popover before the request
settled, the next time they re-opened it would inherit `loading=true` (the
disabled-while-loading submit button was stuck) and a late rejection from
the previous session could repopulate `error` in the new one.

Track the submit via a `useRef` token, bump it on close, and ignore any
result/rejection whose token no longer matches. Also reset `loading` and
`error` on close so the next open starts clean. Adds a regression test
covering both the late-resolution and late-rejection paths.

Addresses CodeRabbit review on PR #759.

* ci: trigger workflow re-run

GitHub Actions did not dispatch the pull_request workflows on the previous
two pushes (1d869f0e, 0ab6c034); only pull_request_target and CodeQL fired.
Empty commit to nudge synchronize event.

* fix: address PR #759 round-3 review feedback

- jira.Poller.Stop: add nil-receiver guard so callers using the
  defer-Stop-after-NewPoller pattern don't panic when initJiraService
  returned nil (Greptile + Claude). Matches linear.Poller's guard.
- healthpoll TestStart_RunsImmediateProbe + TestStart_IsIdempotent:
  rewrap in testing/synctest with synctest.Wait() instead of
  time.After(2s) wall-clock waits. Drops the now-unused probedAll signal
  channel from fakeProber (Claude).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-02 11:50:19 +01:00

139 lines
4.0 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getAvailableIntegrationLinks,
getGitHubIntegrationStatus,
IntegrationsMenu,
} from "./integrations-menu";
import type { GitHubStatus } from "@/lib/types/github";
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
const useJiraAvailableMock = vi.hoisted(() => vi.fn());
const useLinearAvailableMock = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: useGitHubStatusMock,
}));
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
useJiraAvailable: useJiraAvailableMock,
}));
vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({
useLinearAvailable: useLinearAvailableMock,
}));
function status(overrides: Partial<GitHubStatus>): GitHubStatus {
return {
authenticated: false,
username: "",
auth_method: "none",
token_configured: false,
required_scopes: [],
...overrides,
};
}
function mockAvailability({
githubReady,
jiraAvailable,
linearAvailable,
}: {
githubReady: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
}) {
useGitHubStatusMock.mockReturnValue({
status: githubReady ? status({ token_configured: true }) : status({}),
loading: false,
});
useJiraAvailableMock.mockReturnValue(jiraAvailable);
useLinearAvailableMock.mockReturnValue(linearAvailable);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("getGitHubIntegrationStatus", () => {
it("shows checking while GitHub status is loading and not configured", () => {
expect(getGitHubIntegrationStatus(status({}), true)).toEqual({
ready: false,
label: "Checking",
});
});
it("treats a configured token as ready even before live auth is green", () => {
expect(getGitHubIntegrationStatus(status({ token_configured: true }), false)).toEqual({
ready: true,
label: "Configured",
});
});
it("uses the GitHub page for authenticated status", () => {
expect(getGitHubIntegrationStatus(status({ authenticated: true }), false)).toEqual({
ready: true,
label: "Connected",
});
});
it("shows setup only when no auth or token is configured", () => {
expect(getGitHubIntegrationStatus(status({}), false)).toEqual({
ready: false,
label: "Setup",
});
});
});
describe("getAvailableIntegrationLinks", () => {
it("returns only configured integration destinations", () => {
expect(
getAvailableIntegrationLinks({
githubReady: true,
jiraAvailable: false,
linearAvailable: true,
}),
).toEqual([
{ id: "github", label: "GitHub", href: "/github" },
{ id: "linear", label: "Linear", href: "/linear" },
]);
});
it("returns no setup destinations when integrations are unavailable", () => {
expect(
getAvailableIntegrationLinks({
githubReady: false,
jiraAvailable: false,
linearAvailable: false,
}),
).toEqual([]);
});
});
describe("IntegrationsMenu", () => {
it("opens configured integration links on hover", async () => {
mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: false });
render(createElement(IntegrationsMenu, { workspaceId: "workspace-1" }));
const trigger = screen.getByRole("button", { name: "Integrations" });
expect(screen.queryByText("GitHub")).toBeNull();
fireEvent.pointerEnter(trigger);
expect(await screen.findByText("GitHub")).toBeTruthy();
expect(screen.getByText("Jira")).toBeTruthy();
expect(screen.queryByText("Linear")).toBeNull();
});
it("does not render when no integrations are configured", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
render(createElement(IntegrationsMenu, { workspaceId: "workspace-1" }));
expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull();
});
});