diff --git a/tests/mutator/branch/branches.test.ts b/tests/mutator/branch/branches.test.ts new file mode 100644 index 0000000..4689856 --- /dev/null +++ b/tests/mutator/branch/branches.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import { BranchService } from "../../../src/mutator/branch/branches"; +import type { GraphQLClient } from "../../../src/mutator/branch/client"; + +const fakeClient = { execute: async () => ({}) } as unknown as GraphQLClient; + +describe("BranchService.filterBranches", () => { + const service = new BranchService(fakeClient, "owner", "repo"); + + test("returns all branches when none match exclude patterns", () => { + const branches = [ + { name: "main", headOid: "abc" }, + { name: "develop", headOid: "def" }, + { name: "feature/test", headOid: "ghi" }, + ]; + expect(service.filterBranches(branches)).toEqual(branches); + }); + + test("filters out dependabot branches", () => { + const branches = [ + { name: "main", headOid: "abc" }, + { name: "dependabot/npm/express-4.18.0", headOid: "def" }, + { name: "dependabot/pip/requests", headOid: "ghi" }, + ]; + expect(service.filterBranches(branches)).toEqual([{ name: "main", headOid: "abc" }]); + }); + + test("filters out copilot branches", () => { + const branches = [ + { name: "main", headOid: "abc" }, + { name: "copilot/github-copilot-auto", headOid: "def" }, + { name: "copilot/initialize-github-copilot", headOid: "ghi" }, + ]; + expect(service.filterBranches(branches)).toEqual([{ name: "main", headOid: "abc" }]); + }); + + test("does not filter plain string containing 'dependabot' outside prefix", () => { + const branches = [{ name: "my-dependabot-wrapper", headOid: "abc" }]; + expect(service.filterBranches(branches)).toEqual(branches); + }); + + test("filters using extra exclude prefixes", () => { + const branches = [ + { name: "main", headOid: "abc" }, + { name: "release/v1.0", headOid: "def" }, + ]; + expect(service.filterBranches(branches, ["release/"])).toEqual([{ name: "main", headOid: "abc" }]); + }); + + test("supports combined default and extra patterns", () => { + const branches = [ + { name: "main", headOid: "abc" }, + { name: "dependabot/npm/express", headOid: "def" }, + { name: "release/v1.0", headOid: "ghi" }, + ]; + expect(service.filterBranches(branches, ["release/"])).toEqual([{ name: "main", headOid: "abc" }]); + }); + + test("empty branches returns empty array", () => { + expect(service.filterBranches([])).toEqual([]); + }); +}); \ No newline at end of file diff --git a/tests/mutator/branch/client.test.ts b/tests/mutator/branch/client.test.ts new file mode 100644 index 0000000..2a39e84 --- /dev/null +++ b/tests/mutator/branch/client.test.ts @@ -0,0 +1,116 @@ +import { afterAll, describe, expect, mock, test } from "bun:test"; + +import type { GraphQLResponse } from "../../../src/mutator/branch/client"; +import { GraphQLClient } from "../../../src/mutator/branch/client"; + +const originalFetch = globalThis.fetch; + +function mockFetch(response: Partial & { jsonData?: unknown }) { + globalThis.fetch = mock(() => + Promise.resolve({ + ok: response.ok ?? true, + status: response.status ?? 200, + statusText: response.statusText ?? "OK", + json: mock(() => Promise.resolve(response.jsonData ?? { data: {} })), + text: mock(() => + Promise.resolve(JSON.stringify(response.jsonData ?? { data: {} })), + ), + }), + ) as any; +} + +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +describe("GraphQLClient", () => { + test("throws when constructed without token", () => { + expect(() => new GraphQLClient("")).toThrow( + "A GitHub token is required to construct a GraphQLClient.", + ); + }); + + test("throws on non-2xx response", async () => { + mockFetch({ ok: false, status: 401, statusText: "Unauthorized" }); + const client = new GraphQLClient("fake-token"); + await expect(client.execute("query { viewer { login } }")).rejects.toThrow( + "GitHub GraphQL request failed (status=401)", + ); + }); + + test("throws on GraphQL errors in execute()", async () => { + mockFetch({ + ok: true, + jsonData: { errors: [{ message: "Something went wrong" }] }, + }); + const client = new GraphQLClient("fake-token"); + await expect(client.execute("query { x }")).rejects.toThrow( + "GraphQL errors: Something went wrong", + ); + }); + + test("throws when no data returned in execute()", async () => { + mockFetch({ + ok: true, + jsonData: { errors: [] }, + }); + const client = new GraphQLClient("fake-token"); + await expect(client.execute("query { x }")).rejects.toThrow( + "No data returned from GitHub API", + ); + }); + + test("returns data on successful query", async () => { + mockFetch({ + ok: true, + jsonData: { data: { viewer: { login: "testuser" } } }, + }); + const client = new GraphQLClient("fake-token"); + const result = await client.execute("query { viewer { login } }"); + expect(result).toEqual({ viewer: { login: "testuser" } }); + }); + + test("executeWithPartial returns data + errors without throwing", async () => { + mockFetch({ + ok: true, + jsonData: { + data: { b0: { commit: { oid: "abc" } }, b1: null }, + errors: [{ message: "Branch protected", path: ["b1"] }], + }, + }); + const client = new GraphQLClient("fake-token"); + const result = await client.executeWithPartial("mutation { ... }"); + expect(result.data).toBeDefined(); + expect(result.errors).toHaveLength(1); + expect(result.errors![0].message).toBe("Branch protected"); + }); + + test("executeWithPartial throws on non-2xx", async () => { + mockFetch({ ok: false, status: 500, statusText: "Internal Server Error" }); + const client = new GraphQLClient("fake-token"); + await expect(client.executeWithPartial("query { x }")).rejects.toThrow( + "GitHub GraphQL request failed (status=500)", + ); + }); + + test("sends correct headers and body", async () => { + mockFetch({ ok: true, jsonData: { data: {} } }); + const client = new GraphQLClient("ghp_token123"); + await client.execute("query Test($x: String!) { node(id: $x) }", { + x: "abc", + }); + + const fetchMock = globalThis.fetch as any; + const call = fetchMock.mock.calls[0]; + const [url, options] = call; + + expect(url).toBe("https://api.github.com/graphql"); + expect(options.method).toBe("POST"); + expect(options.headers.Authorization).toBe("Bearer ghp_token123"); + expect(options.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(options.body); + expect(body.query).toBe("query Test($x: String!) { node(id: $x) }"); + expect(body.variables).toEqual({ x: "abc" }); + }); +}); diff --git a/tests/mutator/branch/commits.test.ts b/tests/mutator/branch/commits.test.ts new file mode 100644 index 0000000..bc75073 --- /dev/null +++ b/tests/mutator/branch/commits.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; + +import { CommitService } from "../../../src/mutator/branch/commits"; + +function fakeClient(result: unknown) { + return { executeWithPartial: () => Promise.resolve(result) } as any; +} + +function failingClient(error: unknown) { + return { executeWithPartial: () => Promise.reject(error) } as any; +} + +describe("CommitService", () => { + describe("pushBatched", () => { + test("returns empty array for empty commits", async () => { + const svc = new CommitService(fakeClient({}), "owner", "repo"); + const result = await svc.pushBatched([]); + expect(result).toEqual([]); + }); + + test("returns failures for commits with no files", async () => { + const svc = new CommitService(fakeClient({}), "owner", "repo"); + const result = await svc.pushBatched([ + { branchName: "main", expectedHeadOid: "abc", files: [], commitHeadline: "test" }, + ]); + expect(result[0].success).toBe(false); + expect(result[0].error).toBe("No file changes provided."); + }); + + test("returns success for batched commits", async () => { + const client = fakeClient({ + data: { + b0: { commit: { oid: "abc123", url: "url" } }, + b1: { commit: { oid: "def456", url: "url" } }, + }, + }); + const svc = new CommitService(client, "owner", "repo"); + const result = await svc.pushBatched([ + { branchName: "main", expectedHeadOid: "eee", files: [{ path: "README.md", content: "hello" }], commitHeadline: "update main" }, + { branchName: "develop", expectedHeadOid: "fff", files: [{ path: "README.md", content: "world" }], commitHeadline: "update develop" }, + ]); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ branch: "main", success: true, commitOid: "abc123" }); + expect(result[1]).toEqual({ branch: "develop", success: true, commitOid: "def456" }); + }); + + test("handles partial failures in batched results", async () => { + const client = fakeClient({ + data: { b0: { commit: { oid: "abc123", url: "url" } }, b1: null }, + errors: [{ message: "Branch is protected", path: ["b1"] }], + }); + const svc = new CommitService(client, "owner", "repo"); + const result = await svc.pushBatched([ + { branchName: "branch-a", expectedHeadOid: "a", files: [{ path: "x.txt", content: "ok" }], commitHeadline: "ok" }, + { branchName: "branch-b", expectedHeadOid: "b", files: [{ path: "y.txt", content: "fail" }], commitHeadline: "fail" }, + ]); + expect(result[0].success).toBe(true); + expect(result[1].success).toBe(false); + expect(result[1].error).toBe("Branch is protected"); + }); + + test("handles transport failure — all commits fail", async () => { + const svc = new CommitService(failingClient(new Error("Network down")), "owner", "repo"); + const result = await svc.pushBatched([ + { branchName: "main", expectedHeadOid: "eee", files: [{ path: "README.md", content: "hello" }], commitHeadline: "test" }, + ]); + expect(result[0].success).toBe(false); + expect(result[0].error).toBe("Network down"); + }); + }); + + describe("pushChunked", () => { + test("throws for invalid chunkSize", async () => { + const svc = new CommitService(fakeClient({}), "owner", "repo"); + await expect(svc.pushChunked([], 0)).rejects.toThrow("pushChunked requires chunkSize >= 1"); + }); + + test("splits commits into chunks", async () => { + const client = fakeClient({ data: { b0: { commit: { oid: "ok", url: "url" } } } }); + const svc = new CommitService(client, "owner", "repo"); + const commits = Array.from({ length: 5 }, (_, i) => ({ + branchName: `branch-${i}`, + expectedHeadOid: "abc", + files: [{ path: "README.md", content: "content" }], + commitHeadline: `commit ${i}`, + })); + expect(await svc.pushChunked(commits, 2)).toHaveLength(5); + }); + + test("calls onChunk callback for each chunk", async () => { + const client = fakeClient({ data: { b0: { commit: { oid: "ok", url: "url" } } } }); + const svc = new CommitService(client, "owner", "repo"); + const chunks: any[] = []; + const commits = Array.from({ length: 3 }, (_, i) => ({ + branchName: `branch-${i}`, + expectedHeadOid: "abc", + files: [{ path: "README.md", content: "content" }], + commitHeadline: `commit ${i}`, + })); + await svc.pushChunked(commits, 1, (r: any) => chunks.push(r)); + expect(chunks).toHaveLength(3); + }); + }); +}); \ No newline at end of file