Files
kandev/apps/web/lib/ws/handlers/github.test.ts
T
2026-08-26 15:54:59 +00:00

215 lines
6.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createAppStore } from "@/lib/state/store";
import type {
GitHubStatus,
TaskCIAutomationOptions,
TaskCIPRAutomationState,
} from "@/lib/types/github";
import { registerGitHubHandlers } from "./github";
const baseStatus: GitHubStatus = {
authenticated: true,
username: "octocat",
auth_method: "pat",
token_configured: true,
required_scopes: ["repo"],
};
const DEFAULT_CI_AUTO_FIX_PROMPT = "Default prompt";
const TASK_ID = "task-1";
const INITIAL_UPDATED_AT = "2026-08-11T10:00:00Z";
const NEWER_UPDATED_AT = "2026-08-11T10:01:00Z";
const ACTIVE_WORKSPACE_ID = "workspace-b";
const FOREIGN_WORKSPACE_ID = "workspace-a";
const EXISTING_ASSOCIATION_ID = "association-b";
function seedTaskPRScope(store: ReturnType<typeof createAppStore>) {
store.getState().setActiveWorkspace(ACTIVE_WORKSPACE_ID);
store.getState().setTaskPRs(
{ [TASK_ID]: [{ id: EXISTING_ASSOCIATION_ID, task_id: TASK_ID } as never] },
{
workspaceId: ACTIVE_WORKSPACE_ID,
workspaceContextGeneration: store.getState().workspaceContextGeneration,
},
);
}
describe("registerGitHubHandlers CI options", () => {
it("does not let a delayed CI-options event replace newer state", () => {
const store = createAppStore();
const handler = registerGitHubHandlers(store)["github.task_ci_options.updated"]!;
const options = (
updatedAt: string,
enabled: boolean,
prStates: TaskCIPRAutomationState[] = [],
): TaskCIAutomationOptions => ({
task_id: TASK_ID,
auto_fix_enabled: enabled,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: DEFAULT_CI_AUTO_FIX_PROMPT,
using_default_prompt: true,
updated_at: updatedAt,
pr_states: prStates,
pr_options: [],
});
handler({ payload: options(NEWER_UPDATED_AT, true) } as Parameters<typeof handler>[0]);
handler({ payload: options(INITIAL_UPDATED_AT, false) } as Parameters<typeof handler>[0]);
expect(store.getState().taskCIAutomation.byTaskId[TASK_ID]?.auto_fix_enabled).toBe(true);
});
it("applies a newer CI state event when task options are unchanged", () => {
const store = createAppStore();
const handler = registerGitHubHandlers(store)["github.task_ci_options.updated"]!;
const prState: TaskCIPRAutomationState = {
task_id: TASK_ID,
repository_id: "repo-1",
pr_number: 42,
last_fix_signature: "check-failure",
last_fix_checkpoint_json: "{}",
last_fix_enqueued_at: NEWER_UPDATED_AT,
last_fix_session_id: "session-1",
auto_fix_round_count: 1,
auto_fix_exhausted_at: null,
last_merge_signature: "",
last_merge_attempt_at: null,
review_request_initialized: false,
last_review_requested: false,
last_observed_pr_state: "open",
last_lifecycle_event: "",
last_lifecycle_prompt_at: null,
last_lifecycle_session_id: null,
last_error: "Tests are failing",
created_at: NEWER_UPDATED_AT,
updated_at: NEWER_UPDATED_AT,
};
const initial: TaskCIAutomationOptions = {
task_id: TASK_ID,
auto_fix_enabled: true,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: DEFAULT_CI_AUTO_FIX_PROMPT,
using_default_prompt: true,
updated_at: INITIAL_UPDATED_AT,
pr_states: [],
pr_options: [],
};
const updated: TaskCIAutomationOptions = {
...initial,
updated_at: NEWER_UPDATED_AT,
pr_states: [prState],
};
handler({ payload: initial } as Parameters<typeof handler>[0]);
handler({ payload: updated } as Parameters<typeof handler>[0]);
expect(store.getState().taskCIAutomation.byTaskId[TASK_ID]).toEqual(updated);
});
});
describe("registerGitHubHandlers", () => {
it("ignores a task PR update owned by another workspace", () => {
const store = createAppStore();
seedTaskPRScope(store);
const handler = registerGitHubHandlers(store)["github.task_pr.updated"]!;
handler({
payload: {
id: "association-a",
task_id: TASK_ID,
workspace_id: FOREIGN_WORKSPACE_ID,
},
} as Parameters<typeof handler>[0]);
expect(store.getState().taskPRs.byTaskId[TASK_ID]?.map((pr) => pr.id)).toEqual([
EXISTING_ASSOCIATION_ID,
]);
});
it("ignores an unattributed task PR update", () => {
const store = createAppStore();
seedTaskPRScope(store);
const handler = registerGitHubHandlers(store)["github.task_pr.updated"]!;
handler({
payload: {
id: "association-without-workspace",
task_id: TASK_ID,
},
} as Parameters<typeof handler>[0]);
expect(store.getState().taskPRs.byTaskId[TASK_ID]?.map((pr) => pr.id)).toEqual([
EXISTING_ASSOCIATION_ID,
]);
});
it("removes the detached PR association without touching sibling PRs", () => {
const store = createAppStore();
const first = {
id: "association-1",
task_id: TASK_ID,
owner: "acme",
repo: "kandev",
pr_number: 1,
};
const sibling = { ...first, id: "association-2", pr_number: 2 };
store.getState().setTaskPRs({ [TASK_ID]: [first as never, sibling as never] });
const handler = registerGitHubHandlers(store)["github.task_pr.deleted"]!;
handler({
payload: {
workspace_id: "workspace-1",
task_id: TASK_ID,
association_id: "association-1",
},
} as Parameters<typeof handler>[0]);
expect(store.getState().taskPRs.byTaskId[TASK_ID]?.map((pr) => pr.id)).toEqual([
"association-2",
]);
});
it("applies unscoped rate-limit events only to legacy shared connections", () => {
const store = createAppStore();
store.getState().resetGitHubStatus("legacy-workspace");
store.getState().setGitHubStatus("legacy-workspace", {
...baseStatus,
automation: {
workspace_id: "legacy-workspace",
source: "legacy_shared",
github_host: "github.com",
status: "active",
credential_generation: 1,
},
});
store.getState().resetGitHubStatus("pat-workspace");
store.getState().setGitHubStatus("pat-workspace", { ...baseStatus });
const handler = registerGitHubHandlers(store)["github.rate_limit.updated"]!;
handler({
payload: {
trigger: "core",
snapshots: [
{
resource: "core",
remaining: 0,
limit: 5000,
reset_at: "2030-01-01T00:00:00Z",
updated_at: "2026-05-04T12:00:00Z",
},
],
},
} as Parameters<typeof handler>[0]);
expect(
store.getState().githubStatus.byWorkspaceId["legacy-workspace"]?.status?.rate_limit?.core
?.remaining,
).toBe(0);
expect(
store.getState().githubStatus.byWorkspaceId["pat-workspace"]?.status?.rate_limit,
).toBeUndefined();
});
});