692 lines
21 KiB
TypeScript
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);
|
|
});
|
|
});
|