Files
kandev/apps/web/hooks/domains/github/use-repo-merge-methods.test.ts
T
João Salavisa b8a09bc5ea fix(github): pick allowed merge method so squash-only repos stop 405ing (#999)
* fix(github): pick allowed merge method so squash-only repos stop 405ing

The merge button sent an empty merge_method to GitHub, which defaults to
"merge" regardless of repo settings — squash-only / rebase-only repos
returned 405 "Merge commits are not allowed on this repository". The
backend now resolves an empty method to the first allowed one via a new
GetRepoMergeMethods endpoint (5-min cache), and the frontend renders a
GitHub-style split button so users can pick among the allowed methods.

* fix(github): address PR #999 review feedback

- pr-merge-button: extract repo-methods fetch to useRepoMergeMethods
  hook (Claude bot); hide button while methods are loading so the
  primary label doesn't flicker (Claude bot); drop e.preventDefault on
  dropdown items so Radix closes the menu on selection (Greptile P1).
- gh_client / pat_client: treat missing allow_* fields as false instead
  of true so a permission-gated response can't pick a disallowed merge
  method (cubic P2).
- service.MergePR: only overwrite mergeMethod when pickDefaultMergeMethod
  returns non-empty, so a degenerate all-false config falls through to
  GitHub's default with the real error (Claude bot).

* fix(github): don't hide merge button on lookup failure

Round-2 PR feedback (Claude, Greptile, CodeRabbit, cubic): the
`if (!methods) return null` guard added in the previous round to
avoid label flicker turned a transient repo-methods lookup failure
into a permanent UI lockout — the button would stay hidden until
unmount/remount.

- pr-merge-button: revert the null-guard; treat null as "use backend
  resolver", render the plain primary button without a dropdown.
- use-repo-merge-methods: add a 5-min frontend TTL to match the
  backend cache so admin merge-strategy flips are visible without a
  page reload. Catch block now triggers a rerender, and co-waiting
  consumers attach both fulfilled/rejected handlers to avoid silent
  rejection swallowing.

* test(github): cover useRepoMergeMethods + silence rejection warning

Round-3 PR feedback (Claude):

- Swallow rejections in the originating fetch chain instead of
  rethrowing, so the first consumer's promise never escapes the hook
  as an unhandled rejection. Coalesced consumers already re-render to
  null, which is the same state a fresh "not loaded" call would show.
- Add use-repo-merge-methods.test.ts covering: null-on-missing-params,
  loading then resolution, cache hit on remount, concurrent-fetch
  coalescing, retry-on-failure (no cache poisoning, no unhandled
  rejection), and TTL-expiry refetch via fake timers.

* test(github): cover httpGetRepoMergeMethods error branches

Round-4 PR feedback (Claude): the new endpoint had only happy-path
coverage. Add tests matching httpMergePR's existing shape:

- nil client → 503 (ErrNoClient)
- GitHubAPIError{404} → 404
- generic upstream error → 500

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 23:22:31 +01:00

135 lines
5.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
const getRepoMergeMethodsMock = vi.fn();
vi.mock("@/lib/api/domains/github-api", () => ({
getRepoMergeMethods: (...args: unknown[]) => getRepoMergeMethodsMock(...args),
}));
// Module-level cache survives across tests in the same module — give each
// test a unique owner so the cache state from one doesn't leak into the next.
let repoCounter = 0;
function uniqueRepo() {
repoCounter += 1;
return { owner: `owner-${repoCounter}`, repo: `repo-${repoCounter}` };
}
// Import after mocks so the hook picks up the mocked module.
import { useRepoMergeMethods } from "./use-repo-merge-methods";
afterEach(() => {
cleanup();
getRepoMergeMethodsMock.mockReset();
vi.useRealTimers();
});
describe("useRepoMergeMethods", () => {
it("returns null when owner or repo are missing", () => {
const { result } = renderHook(() => useRepoMergeMethods(null, null));
expect(result.current).toBeNull();
expect(getRepoMergeMethodsMock).not.toHaveBeenCalled();
});
it("returns null while in flight, then the resolved value after settling", async () => {
const { owner, repo } = uniqueRepo();
const methods = { merge: false, squash: true, rebase: false };
getRepoMergeMethodsMock.mockResolvedValueOnce(methods);
const { result } = renderHook(() => useRepoMergeMethods(owner, repo));
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual(methods));
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1);
expect(getRepoMergeMethodsMock).toHaveBeenCalledWith(owner, repo);
});
it("returns the cached value on subsequent mounts without refetching", async () => {
const { owner, repo } = uniqueRepo();
const methods = { merge: true, squash: false, rebase: false };
getRepoMergeMethodsMock.mockResolvedValueOnce(methods);
const first = renderHook(() => useRepoMergeMethods(owner, repo));
await waitFor(() => expect(first.result.current).toEqual(methods));
first.unmount();
const second = renderHook(() => useRepoMergeMethods(owner, repo));
// Cache hit: synchronously returns the stored value on first render.
expect(second.result.current).toEqual(methods);
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1);
});
it("coalesces concurrent fetches for the same repo into a single request", async () => {
const { owner, repo } = uniqueRepo();
const methods = { merge: true, squash: true, rebase: true };
getRepoMergeMethodsMock.mockResolvedValueOnce(methods);
const first = renderHook(() => useRepoMergeMethods(owner, repo));
const second = renderHook(() => useRepoMergeMethods(owner, repo));
await waitFor(() => {
expect(first.result.current).toEqual(methods);
expect(second.result.current).toEqual(methods);
});
// Only one upstream call despite two consumers mounting back-to-back.
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1);
});
it("returns null on fetch failure without throwing or poisoning the cache", async () => {
const { owner, repo } = uniqueRepo();
getRepoMergeMethodsMock
.mockRejectedValueOnce(new Error("network down"))
.mockResolvedValueOnce({ merge: false, squash: true, rebase: false });
// Catch unhandled rejections so a regression here (rejection escaping
// the hook) fails this test instead of leaking into the test runner.
const unhandled: unknown[] = [];
const onRejection = (event: PromiseRejectionEvent) => {
unhandled.push(event.reason);
event.preventDefault();
};
window.addEventListener("unhandledrejection", onRejection);
const first = renderHook(() => useRepoMergeMethods(owner, repo));
await waitFor(() => expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1));
expect(first.result.current).toBeNull();
first.unmount();
// Failed fetch must NOT have cached null — the next mount retries.
const second = renderHook(() => useRepoMergeMethods(owner, repo));
await waitFor(() => expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(second.result.current).toEqual({ merge: false, squash: true, rebase: false }),
);
window.removeEventListener("unhandledrejection", onRejection);
expect(unhandled).toEqual([]);
});
it("refetches after the cache entry expires", async () => {
const { owner, repo } = uniqueRepo();
const first = { merge: false, squash: true, rebase: false };
const second = { merge: true, squash: false, rebase: true };
getRepoMergeMethodsMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
vi.useFakeTimers({ shouldAdvanceTime: true });
const start = new Date("2026-01-01T00:00:00Z");
vi.setSystemTime(start);
const firstMount = renderHook(() => useRepoMergeMethods(owner, repo));
await waitFor(() => expect(firstMount.result.current).toEqual(first));
firstMount.unmount();
// Move past the 5-minute TTL.
act(() => {
vi.setSystemTime(new Date(start.getTime() + 6 * 60 * 1000));
});
const secondMount = renderHook(() => useRepoMergeMethods(owner, repo));
// Expired entry should be evicted on read → null while refetch is in flight.
expect(secondMount.result.current).toBeNull();
await waitFor(() => expect(secondMount.result.current).toEqual(second));
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(2);
});
});