Files
fusion/packages/dashboard/src/__tests__/github-poll.test.ts

692 lines
21 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { GitHubRateLimiter, GitHubPollingService, githubPoller, githubRateLimiter } from "../github-poll.js";
import type { TaskStore } from "@fusion/core";
// Mock the GitHubClient - use vi.hoisted to ensure proper hoisting with vi.mock
const { mockGetBadgeStatusesBatch } = vi.hoisted(() => ({
mockGetBadgeStatusesBatch: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
getBadgeStatusesBatch: (...args: any[]) => mockGetBadgeStatusesBatch(...args),
})),
}));
describe("GitHubRateLimiter", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("allows requests within the rate limit", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 3, windowMs: 60000 });
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
});
it("denies requests when rate limit is exceeded", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
limiter.canMakeRequest("owner/repo");
limiter.canMakeRequest("owner/repo");
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
});
it("resets rate limit after window expires", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
limiter.canMakeRequest("owner/repo");
limiter.canMakeRequest("owner/repo");
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
// Advance time past the window
vi.advanceTimersByTime(61000);
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
});
it("tracks different repositories independently", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
limiter.canMakeRequest("owner/repo1");
limiter.canMakeRequest("owner/repo1");
// repo1 is at limit
expect(limiter.canMakeRequest("owner/repo1")).toBe(false);
// repo2 is not affected
expect(limiter.canMakeRequest("owner/repo2")).toBe(true);
expect(limiter.canMakeRequest("owner/repo2")).toBe(true);
expect(limiter.canMakeRequest("owner/repo2")).toBe(false);
});
it("returns null reset time when no requests have been made", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
expect(limiter.getResetTime("owner/repo")).toBeNull();
});
it("returns correct reset time after requests", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
const before = Date.now();
limiter.canMakeRequest("owner/repo");
const resetTime = limiter.getResetTime("owner/repo");
expect(resetTime).not.toBeNull();
expect(resetTime!.getTime()).toBeGreaterThan(before);
expect(resetTime!.getTime()).toBeLessThanOrEqual(before + 60000);
});
it("uses default values when not specified", () => {
const limiter = new GitHubRateLimiter();
// Default is 90 requests per hour
for (let i = 0; i < 90; i++) {
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
}
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
});
});
describe("GitHubPollingService", () => {
let service: GitHubPollingService;
let mockStore: TaskStore;
let mockUpdatePrInfo: Mock;
let mockUpdateIssueInfo: Mock;
let mockGetTask: Mock;
beforeEach(() => {
vi.useFakeTimers();
mockUpdatePrInfo = vi.fn();
mockUpdateIssueInfo = vi.fn();
mockGetTask = vi.fn();
mockStore = {
getTask: mockGetTask,
updatePrInfo: mockUpdatePrInfo,
updateIssueInfo: mockUpdateIssueInfo,
} as unknown as TaskStore;
service = new GitHubPollingService({
store: mockStore,
token: "test-token",
pollingIntervalMs: 60_000,
rateLimiter: new GitHubRateLimiter(),
});
mockGetBadgeStatusesBatch.mockReset();
});
afterEach(() => {
service.stop();
service.reset();
vi.useRealTimers();
});
describe("configure", () => {
it("updates store, token, and polling interval", () => {
const newStore = { getTask: vi.fn() } as unknown as TaskStore;
service.configure({
store: newStore,
token: "new-token",
pollingIntervalMs: 30_000,
});
// Verify by checking the service behavior uses new config
expect(service.getWatchedTaskIds()).toEqual([]);
});
it("restarts timer when interval changes while running", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.start();
expect(service["timer"]).not.toBeNull();
const originalTimer = service["timer"];
service.configure({ pollingIntervalMs: 30_000 });
// Timer should have been restarted with new interval
expect(service["timer"]).not.toBe(originalTimer);
});
it("does not restart timer if not running", () => {
service.configure({ pollingIntervalMs: 30_000 });
expect(service["timer"]).toBeNull();
});
});
describe("start/stop", () => {
it("begins polling when watches exist", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.start();
expect(service["enabled"]).toBe(true);
expect(service["timer"]).not.toBeNull();
});
it("does nothing when no watches", () => {
service.start();
expect(service["timer"]).toBeNull();
});
it("clears timer on stop", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.start();
expect(service["timer"]).not.toBeNull();
service.stop();
expect(service["timer"]).toBeNull();
expect(service["enabled"]).toBe(false);
});
it("multiple start calls are safe", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.start();
const timer1 = service["timer"];
service.start();
const timer2 = service["timer"];
expect(timer1).toBe(timer2);
});
it("stop is safe when not running", () => {
expect(() => service.stop()).not.toThrow();
});
});
describe("watchTask", () => {
it("adds watch for task", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
const watch = service.getWatch("FN-001");
expect(watch).toBeDefined();
expect(watch?.pr).toEqual({
taskId: "FN-001",
type: "pr",
owner: "owner",
repo: "repo",
number: 1,
});
});
it("replaces existing watch of same type", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.watchTask("FN-001", "pr", "owner", "repo", 2);
const watch = service.getWatch("FN-001");
expect(watch?.pr?.number).toBe(2);
});
it("keeps other watch type when replacing", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.watchTask("FN-001", "issue", "owner", "repo", 10);
const watch = service.getWatch("FN-001");
expect(watch?.pr?.number).toBe(1);
expect(watch?.issue?.number).toBe(10);
});
});
describe("replaceTaskWatches", () => {
it("handles multiple watch types", () => {
service.replaceTaskWatches("FN-001", [
{ taskId: "FN-001", type: "pr", owner: "owner", repo: "repo", number: 1 },
{ taskId: "FN-001", type: "issue", owner: "owner", repo: "repo", number: 10 },
]);
const watch = service.getWatch("FN-001");
expect(watch?.pr?.number).toBe(1);
expect(watch?.issue?.number).toBe(10);
});
it("unwatches when empty array", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.replaceTaskWatches("FN-001", []);
expect(service.getWatch("FN-001")).toBeUndefined();
});
it("filters invalid watches", () => {
service.replaceTaskWatches("FN-001", [
{ taskId: "FN-001", type: "pr", owner: "", repo: "repo", number: 1 }, // invalid - empty owner
{ taskId: "FN-001", type: "issue", owner: "owner", repo: "repo", number: 10 }, // valid
]);
const watch = service.getWatch("FN-001");
expect(watch?.pr).toBeUndefined();
expect(watch?.issue?.number).toBe(10);
});
});
describe("unwatchTask", () => {
it("removes all watches for task", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.watchTask("FN-001", "issue", "owner", "repo", 10);
service.unwatchTask("FN-001");
expect(service.getWatch("FN-001")).toBeUndefined();
});
it("stops polling when no watches remain", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.start();
expect(service["timer"]).not.toBeNull();
service.unwatchTask("FN-001");
expect(service["timer"]).toBeNull();
});
});
describe("unwatchTaskType", () => {
it("removes specific type only", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.watchTask("FN-001", "issue", "owner", "repo", 10);
service.unwatchTaskType("FN-001", "pr");
const watch = service.getWatch("FN-001");
expect(watch?.pr).toBeUndefined();
expect(watch?.issue?.number).toBe(10);
});
it("unwatches task if no types remain", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.unwatchTaskType("FN-001", "pr");
expect(service.getWatch("FN-001")).toBeUndefined();
});
});
describe("reset", () => {
it("clears all watches and stops", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.watchTask("FN-002", "issue", "owner", "repo", 10);
service.start();
service.reset();
expect(service.getWatchedTaskIds()).toEqual([]);
expect(service["timer"]).toBeNull();
});
});
describe("getWatchedTaskIds", () => {
it("returns all watched task IDs", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
service.watchTask("FN-002", "issue", "owner", "repo", 10);
const ids = service.getWatchedTaskIds();
expect(ids).toContain("FN-001");
expect(ids).toContain("FN-002");
expect(ids).toHaveLength(2);
});
});
describe("getWatch", () => {
it("returns watch set for task", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
const watch = service.getWatch("FN-001");
expect(watch?.pr?.owner).toBe("owner");
});
it("returns undefined for unwatched task", () => {
expect(service.getWatch("KB-999")).toBeUndefined();
});
});
describe("getLastCheckedAt", () => {
it("returns timestamp for type", async () => {
// Setup task with PR badge
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
});
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
const checkedAt = service.getLastCheckedAt("FN-001", "pr");
expect(checkedAt).toBeDefined();
expect(new Date(checkedAt!).getTime()).toBeGreaterThan(0);
});
it("returns undefined for unwatched type", () => {
service.watchTask("FN-001", "pr", "owner", "repo", 1);
expect(service.getLastCheckedAt("FN-001", "issue")).toBeUndefined();
});
});
describe("pollOnce", () => {
it("batches requests by repo", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
});
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
// Should batch by repo
expect(mockGetBadgeStatusesBatch).toHaveBeenCalledWith(
"owner",
"repo",
expect.arrayContaining([expect.objectContaining({ alias: "pr_1", type: "pr", number: 1 })])
);
});
it("applies rate limiting per repo", async () => {
// Create a custom rate limiter for testing
const rateLimiter = new GitHubRateLimiter({ maxRequests: 1, windowMs: 60000 });
service = new GitHubPollingService({
store: mockStore,
token: "test-token",
rateLimiter,
});
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
});
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
// First poll should work
await service.pollOnce();
expect(mockGetBadgeStatusesBatch).toHaveBeenCalledTimes(1);
// Second poll should be rate limited (same repo)
await service.pollOnce();
// Should not make another request due to rate limiting
expect(mockGetBadgeStatusesBatch).toHaveBeenCalledTimes(1);
});
it("handles missing tasks (ENOENT unwatches)", async () => {
mockGetTask.mockRejectedValue({ code: "ENOENT" });
mockGetBadgeStatusesBatch.mockResolvedValue({});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
// Task should be unwatched after ENOENT
expect(service.getWatch("FN-001")).toBeUndefined();
});
it("updates store when badge fields changed", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Old Title", headBranch: "feat", baseBranch: "main", commentCount: 0 },
});
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "New Title", headBranch: "feat", baseBranch: "main", commentCount: 1 },
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
expect(mockUpdatePrInfo).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ title: "New Title", commentCount: 1 })
);
});
it("skips update when badge unchanged", async () => {
const prInfo = { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Same Title", headBranch: "feat", baseBranch: "main", commentCount: 0 };
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo,
});
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo,
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
expect(mockUpdatePrInfo).not.toHaveBeenCalled();
});
it("handles PR status normalization", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
});
// PR status can be "open", "closed", or "merged"
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "merged", title: "Test", headBranch: "feat", baseBranch: "main", commentCount: 0 },
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
expect(mockUpdatePrInfo).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ status: "merged" })
);
});
it("does nothing when store is not configured", async () => {
service = new GitHubPollingService({ token: "test-token" });
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await expect(service.pollOnce()).resolves.toBeUndefined();
});
it("does nothing when already polling", async () => {
service["isPolling"] = true;
await expect(service.pollOnce()).resolves.toBeUndefined();
});
it("does nothing when no watches", async () => {
await expect(service.pollOnce()).resolves.toBeUndefined();
});
});
describe("badge field comparison", () => {
it("detects PR badge changes (url, number, status, title, headBranch, baseBranch, commentCount, lastCommentAt)", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Test",
headBranch: "feat",
baseBranch: "main",
commentCount: 0,
lastCommentAt: undefined,
},
});
// Each field change should trigger update
const fieldChanges = [
{ field: "url", newValue: "https://github.com/owner/repo/pull/2" },
{ field: "number", newValue: 2 },
{ field: "status", newValue: "closed" },
{ field: "title", newValue: "New Title" },
{ field: "headBranch", newValue: "feature" },
{ field: "baseBranch", newValue: "develop" },
{ field: "commentCount", newValue: 1 },
{ field: "lastCommentAt", newValue: "2024-01-01T00:00:00Z" },
];
for (const change of fieldChanges) {
mockUpdatePrInfo.mockClear();
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Test",
headBranch: "feat",
baseBranch: "main",
commentCount: 0,
[change.field]: change.newValue,
},
},
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
expect(mockUpdatePrInfo).toHaveBeenCalled();
}
});
it("detects issue badge changes (url, number, state, title, stateReason)", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
issueInfo: {
url: "https://github.com/owner/repo/issues/1",
number: 1,
state: "open",
title: "Test Issue",
stateReason: undefined,
},
});
const fieldChanges = [
{ field: "url", newValue: "https://github.com/owner/repo/issues/2" },
{ field: "number", newValue: 2 },
{ field: "state", newValue: "closed" },
{ field: "title", newValue: "New Issue Title" },
{ field: "stateReason", newValue: "completed" },
];
for (const change of fieldChanges) {
mockUpdateIssueInfo.mockClear();
mockGetBadgeStatusesBatch.mockResolvedValue({
issue_1: {
type: "issue",
issueInfo: {
url: "https://github.com/owner/repo/issues/1",
number: 1,
state: "open",
title: "Test Issue",
[change.field]: change.newValue,
},
},
});
service.watchTask("FN-001", "issue", "owner", "repo", 1);
await service.pollOnce();
expect(mockUpdateIssueInfo).toHaveBeenCalled();
}
});
});
describe("unwatch when badge removed", () => {
it("unwatches PR when task has no prInfo", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
// No prInfo
});
mockGetBadgeStatusesBatch.mockResolvedValue({
pr_1: null,
});
service.watchTask("FN-001", "pr", "owner", "repo", 1);
await service.pollOnce();
expect(service.getWatch("FN-001")?.pr).toBeUndefined();
});
it("unwatches issue when task has no issueInfo", async () => {
mockGetTask.mockResolvedValue({
id: "FN-001",
// No issueInfo
});
mockGetBadgeStatusesBatch.mockResolvedValue({
issue_1: null,
});
service.watchTask("FN-001", "issue", "owner", "repo", 1);
await service.pollOnce();
expect(service.getWatch("FN-001")?.issue).toBeUndefined();
});
});
});
describe("githubPoller singleton", () => {
it("is a singleton instance", () => {
expect(githubPoller).toBeInstanceOf(GitHubPollingService);
});
it("can be started and stopped", () => {
// Should not throw
githubPoller.start();
githubPoller.stop();
});
});
describe("githubRateLimiter singleton", () => {
it("is a singleton instance", () => {
expect(githubRateLimiter).toBeInstanceOf(GitHubRateLimiter);
});
});