Files
openchamber/packages/web/server/lib/github/pr-status.test.js
T

183 lines
5.7 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, mock, test, vi } from 'bun:test';
const listMock = mock(async () => ({ data: [] }));
mock.module('../git/index.js', () => ({
getRemotes: async () => [],
getStatus: async () => null,
}));
mock.module('./repo/index.js', () => ({
resolveGitHubRepoFromDirectory: async () => null,
}));
mock.module('./rate-limit.js', () => ({
noteIfGitHubRateLimit: () => {},
}));
const { findBranchPrCandidates, invalidateRepoPullsCache } = await import('./pr-status.js');
const openPr = {
number: 15,
state: 'open',
head: {
ref: 'feature',
label: 'acme:feature',
user: { login: 'acme' },
repo: { owner: { login: 'acme' }, name: 'app' },
},
};
const mergedPr = {
number: 12,
state: 'closed',
merged_at: '2026-01-01T00:00:00Z',
head: {
ref: 'feature',
label: 'acme:feature',
user: { login: 'acme' },
repo: { owner: { login: 'acme' }, name: 'app' },
},
};
const olderMergedPr = {
...mergedPr,
number: 7,
merged_at: '2025-11-01T00:00:00Z',
};
const call = (overrides = {}) => findBranchPrCandidates({
octokit: { rest: { pulls: { list: listMock } } },
target: { repo: { owner: 'acme', repo: 'app' }, remoteName: 'origin' },
branch: 'feature',
sourceCandidates: [{ repo: { owner: 'acme', repo: 'app' } }],
force: true,
includeHistory: true,
...overrides,
});
describe('findBranchPrCandidates', () => {
beforeEach(() => {
listMock.mockReset();
invalidateRepoPullsCache('acme', 'app');
});
afterEach(() => {
vi.useRealTimers();
});
test('an open PR wins and no history lookup is spent', async () => {
listMock.mockImplementation(async ({ state }) => (
state === 'open' ? { data: [openPr] } : { data: [mergedPr] }
));
const { open, historical } = await call();
expect(open?.number).toBe(15);
expect(historical).toBeNull();
expect(listMock.mock.calls.every((entry) => entry[0]?.state === 'open')).toBe(true);
});
test('an open PR still wins when the shared open list missed it', async () => {
// A repo with more than one page of open PRs: the shared list is incomplete,
// so the per-head query is the one that must find the open PR.
listMock.mockImplementation(async ({ head }) => (
head ? { data: [mergedPr, openPr] } : { data: new Array(100).fill(null).map((_, index) => ({ number: index, state: 'open', head: { ref: 'other' } })) }
));
const { open, historical } = await call();
expect(open?.number).toBe(15);
expect(historical).toBeNull();
});
test('returns the branch history when no open PR exists', async () => {
listMock.mockImplementation(async ({ head }) => (
head ? { data: [olderMergedPr, mergedPr] } : { data: [] }
));
const { open, historical } = await call();
expect(open).toBeNull();
// The newest past PR for the head is the relevant record.
expect(historical?.number).toBe(12);
});
test('returns no history for a branch that never had a PR', async () => {
listMock.mockImplementation(async () => ({ data: [] }));
const { open, historical } = await call();
expect(open).toBeNull();
expect(historical).toBeNull();
expect(listMock.mock.calls.some((entry) => entry[0]?.state === 'all')).toBe(true);
});
test('spends no call on history for a secondary target', async () => {
listMock.mockImplementation(async ({ head }) => (
head ? { data: [mergedPr] } : { data: [] }
));
const { open, historical } = await call({ includeHistory: false });
expect(open).toBeNull();
expect(historical).toBeNull();
// The complete open list already answered the only question that matters
// for a secondary repo in the fork network.
expect(listMock.mock.calls).toHaveLength(1);
expect(listMock.mock.calls[0]?.[0]?.state).toBe('open');
});
test('reuses the cached history instead of re-querying every poll', async () => {
listMock.mockImplementation(async ({ head }) => (
head ? { data: [mergedPr] } : { data: [] }
));
await call();
const callsAfterFirst = listMock.mock.calls.length;
// A non-forced poll is answered entirely from the shared open list cache
// plus the remembered history — no extra GitHub call.
const { open, historical } = await call({ force: false });
expect(open).toBeNull();
expect(historical?.number).toBe(12);
expect(listMock.mock.calls.length).toBe(callsAfterFirst);
});
test('a found record outlives the shorter "no history" window', async () => {
const startedAt = Date.now();
listMock.mockImplementation(async ({ head }) => (
head ? { data: [mergedPr] } : { data: [] }
));
await call();
const callsAfterFirst = listMock.mock.calls.length;
// Past the "no history" expiry, but far short of the found-record one. The
// shared open list is re-fetched; the history answer is not re-queried.
vi.useFakeTimers();
vi.setSystemTime(new Date(startedAt + 30 * 60 * 1000));
const { historical } = await call({ force: false });
expect(historical?.number).toBe(12);
expect(listMock.mock.calls.length).toBe(callsAfterFirst + 1);
expect(listMock.mock.calls.at(-1)?.[0]?.state).toBe('open');
});
test('re-queries a branch with no history once its shorter window passes', async () => {
const startedAt = Date.now();
listMock.mockImplementation(async () => ({ data: [] }));
await call();
const callsAfterFirst = listMock.mock.calls.length;
vi.useFakeTimers();
vi.setSystemTime(new Date(startedAt + 30 * 60 * 1000));
await call({ force: false });
expect(listMock.mock.calls.some((entry) => entry[0]?.state === 'all')).toBe(true);
expect(listMock.mock.calls.length).toBeGreaterThan(callsAfterFirst + 1);
});
});