feat: initial prototype attempt
Node.js CI / CI (push) Failing after 7s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 50s

This commit is contained in:
2026-02-03 17:13:57 -08:00
parent 729bd4b472
commit 5bc2cfbe43
26 changed files with 7982 additions and 19 deletions
+71
View File
@@ -0,0 +1,71 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("config", () => {
const originalEnvironment = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnvironment };
});
afterEach(() => {
process.env = originalEnvironment;
});
it("should have the correct default values", async() => {
expect.assertions(5);
process.env.GITEA_TOKEN = "test-token";
const { config } = await import("../src/config.js");
expect(config.checkInterval).toBe("0 7 * * *");
expect(config.giteaOrg).toBe("nhcarrigan");
expect(config.giteaUrl).toBe("https://git.nhcarrigan.com");
expect(config.npmRegistryUrl).toBe("https://registry.npmjs.org");
expect(config.prBranchPrefix).toBe("dependencies/update-");
});
it("should use GITEA_TOKEN from environment", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "my-secret-token";
const { config } = await import("../src/config.js");
expect(config.giteaToken).toBe("my-secret-token");
});
it("should default giteaToken to empty string when not set", async() => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
const { config } = await import("../src/config.js");
expect(config.giteaToken).toBe("");
});
it("should not throw when GITEA_TOKEN is set", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "valid-token";
const { validateConfig } = await import("../src/config.js");
expect(() => {
validateConfig();
}).not.toThrow();
});
it("should throw when GITEA_TOKEN is empty", async() => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
const { validateConfig } = await import("../src/config.js");
expect(() => {
validateConfig();
}).toThrow("GITEA_TOKEN is required");
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
import { describe, expect, it } from "vitest";
describe("index", () => {
it("should export from the module", () => {
expect.assertions(1);
expect(true).toBeTruthy();
});
});
@@ -0,0 +1,294 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names and destructured imports */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@nhcarrigan/logger", () => {
return {
Logger: class MockLogger {
public error = vi.fn();
public log = vi.fn();
},
};
});
interface MockNpmService {
getPackageChangelog: ReturnType<typeof vi.fn>;
getPackageInfo: ReturnType<typeof vi.fn>;
}
const createMockNpmService = (): MockNpmService => {
return {
getPackageChangelog: vi.fn(),
getPackageInfo: vi.fn(),
};
};
describe("dependencyAnalyzerService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should return empty array when no dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({});
expect(result).toStrictEqual([]);
});
it("should find updates for dependencies", async() => {
expect.assertions(2);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "2.0.0" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "1.0.0",
},
});
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
packageName: "test-package",
type: "dependencies",
});
});
it("should skip file: protocol dependencies", async() => {
expect.assertions(2);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"local-package": "file:../local-package",
},
});
expect(result).toStrictEqual([]);
expect(mockNpmService.getPackageInfo).not.toHaveBeenCalled();
});
it("should skip git: protocol dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"git-package": "git:github.com/user/repo",
},
});
expect(result).toStrictEqual([]);
});
it("should skip http: protocol dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"http-package": "http://example.com/package.tgz",
},
});
expect(result).toStrictEqual([]);
});
it("should skip https: protocol dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"https-package": "https://example.com/package.tgz",
},
});
expect(result).toStrictEqual([]);
});
it("should skip github: shorthand dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"github-package": "github:user/repo",
},
});
expect(result).toStrictEqual([]);
});
it("should skip * version dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"star-package": "*",
},
});
expect(result).toStrictEqual([]);
});
it("should skip latest version dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"latest-package": "latest",
},
});
expect(result).toStrictEqual([]);
});
it("should not include packages that are already up-to-date", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "1.0.0" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "1.0.0",
},
});
expect(result).toStrictEqual([]);
});
it("should handle packages not found on npm", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue(null);
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"non-existent": "1.0.0",
},
});
expect(result).toStrictEqual([]);
});
it("should handle version prefixes like ^", async() => {
expect.assertions(2);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "2.0.0" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "^1.0.0",
},
});
expect(result).toHaveLength(1);
expect(result[0]?.currentVersion).toBe("^1.0.0");
});
it("should handle npm errors gracefully", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockRejectedValue(new Error("Network error"));
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "1.0.0",
},
});
expect(result).toStrictEqual([]);
});
it("should handle semver comparison errors", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "invalid-version" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "also-invalid",
},
});
expect(result).toStrictEqual([]);
});
});
+366
View File
@@ -0,0 +1,366 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names and destructured imports */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
/* eslint-disable stylistic/max-len -- Test files have long import paths */
/* eslint-disable max-lines -- Test suites require many test cases */
/* eslint-disable vitest/no-conditional-in-test -- Discriminated unions require type narrowing */
/* eslint-disable vitest/no-conditional-expect -- Discriminated unions require type narrowing */
import { readFile, rm, writeFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Logger } from "@nhcarrigan/logger";
const mockExecAsync = vi.fn();
vi.mock("node:child_process", () => {
return {
exec: vi.fn(),
};
});
vi.mock("node:fs/promises", () => {
return {
readFile: vi.fn(),
rm: vi.fn(),
writeFile: vi.fn(),
};
});
vi.mock("node:os", () => {
return {
tmpdir: vi.fn(() => {
return "/tmp";
}),
};
});
vi.mock("node:util", () => {
return {
promisify: vi.fn(() => {
return mockExecAsync;
}),
};
});
interface MockClonedRepo {
cleanup: ReturnType<typeof vi.fn>;
path: string;
repoName: string;
}
const createMockClonedRepo = (): MockClonedRepo => {
return {
cleanup: vi.fn(),
path: "/tmp/minori-test-repo-123",
repoName: "test-repo",
};
};
const createMockLogger = (): Logger => {
return {
error: vi.fn(),
log: vi.fn(),
} as unknown as Logger;
};
describe("gitService", () => {
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockLogger: Logger;
beforeEach(() => {
vi.clearAllMocks();
process.env.GITEA_TOKEN = "test-token";
mockLogger = createMockLogger();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should clone a repository to a temporary directory", async() => {
expect.assertions(3);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
const { cloneRepository } = await import("../../src/services/gitService.js");
const result = await cloneRepository(
mockLogger,
"test-repo",
"test-token",
);
expect(result.repoName).toBe("test-repo");
expect(result.path).toMatch(/^\/tmp\/minori-test-repo-\d+$/u);
expect(typeof result.cleanup).toBe("function");
});
it("should configure git user email and name", async() => {
expect.assertions(1);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
const { cloneRepository } = await import("../../src/services/gitService.js");
await cloneRepository(mockLogger, "test-repo", "test-token");
expect(mockExecAsync).toHaveBeenCalledWith(
expect.stringContaining("git clone"),
);
});
it("should cleanup temporary directory when cleanup is called", async() => {
expect.assertions(1);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(rm).mockResolvedValue(undefined);
const { cloneRepository } = await import("../../src/services/gitService.js");
const result = await cloneRepository(
mockLogger,
"test-repo",
"test-token",
);
await result.cleanup();
expect(rm).toHaveBeenCalledWith(
result.path,
{ force: true, recursive: true },
);
});
it("should create a new branch when it does not exist", async() => {
expect.assertions(2);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: {
"test-package": "1.0.0",
},
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("created");
if (result.status === "created") {
expect(result.branchName).toBe("dependencies/update-test-package");
}
});
it("should update an existing branch when behind", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git branch -r")) {
return Promise.resolve({
stderr: "",
stdout: " origin/dependencies/update-test-package\n",
});
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "1.5.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("updated");
});
it("should skip when branch is already up-to-date", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git branch -r")) {
return Promise.resolve({
stderr: "",
stdout: " origin/dependencies/update-test-package\n",
});
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "2.0.0" },
}));
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("up-to-date");
});
it("should fail when package is not found", async() => {
expect.assertions(2);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: {},
}));
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("failed");
if (result.status === "failed") {
expect(result.error).toContain("not found");
}
});
it("should handle git command errors", async() => {
expect.assertions(1);
const error = new Error("Git command failed") as Error & { stderr: string };
error.stderr = "fatal: error";
mockExecAsync.mockRejectedValueOnce(error);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("failed");
});
it("should update devDependencies", async() => {
expect.assertions(1);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
devDependencies: {
"test-package": "1.0.0",
},
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("created");
});
it("should handle cleanup errors gracefully", async() => {
expect.assertions(1);
const error = new Error("Git command failed");
mockExecAsync.
mockRejectedValueOnce(error).
mockRejectedValueOnce(new Error("Cleanup failed"));
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("failed");
});
it("should log git stderr when it does not contain warnings", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git fetch")) {
return Promise.resolve({ stderr: "some error message", stdout: "" });
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "1.0.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(mockLogger.log).toHaveBeenCalledWith(
"debug",
expect.stringContaining("Git stderr"),
);
});
it("should log 'unknown' when package not found on existing branch", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git branch -r")) {
return Promise.resolve({
stderr: "",
stdout: " origin/dependencies/update-test-package\n",
});
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "other-package": "1.0.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(mockLogger.log).toHaveBeenCalledWith(
"info",
expect.stringContaining("unknown"),
);
});
it("should update both dependencies and devDependencies", async() => {
expect.assertions(3);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "1.0.0" },
devDependencies: { "test-package": "1.0.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("created");
const writeCall = vi.mocked(writeFile).mock.calls[0];
const writtenContent = JSON.parse(writeCall?.[1] as string) as {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
};
expect(writtenContent.dependencies["test-package"]).toBe("2.0.0");
expect(writtenContent.devDependencies["test-package"]).toBe("2.0.0");
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic imports */
/* eslint-disable @typescript-eslint/naming-convention -- Environment variables and Gitea API format */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
/* eslint-disable vitest/require-to-throw-message -- Generic throw assertion */
import axios, { AxiosError, type AxiosResponse } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GiteaService } from "../../src/services/giteaService.js";
vi.mock("axios", async() => {
const actualAxios = await vi.importActual<typeof import("axios")>("axios");
return {
AxiosError: actualAxios.AxiosError,
default: {
create: vi.fn(() => {
return {
get: vi.fn(),
post: vi.fn(),
};
}),
},
isAxiosError: actualAxios.isAxiosError,
};
});
interface MockRepository {
archived: boolean;
cloneUrl: string;
defaultBranch: string;
disabled: boolean;
fullName: string;
id: number;
mirror: boolean;
name: string;
}
const createMockRepository = (
overrides: Partial<MockRepository> & { name: string; id: number },
): Record<string, unknown> => {
return {
archived: overrides.archived ?? false,
clone_url: overrides.cloneUrl ?? "url",
default_branch: overrides.defaultBranch ?? "main",
disabled: overrides.disabled ?? false,
full_name: overrides.fullName ?? `nhcarrigan/${overrides.name}`,
id: overrides.id,
mirror: overrides.mirror ?? false,
name: overrides.name,
};
};
describe("giteaService", () => {
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let giteaService: GiteaService;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockGet: ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockPost: ReturnType<typeof vi.fn>;
const originalEnvironment = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnvironment, GITEA_TOKEN: "test-token" };
mockGet = vi.fn();
mockPost = vi.fn();
vi.mocked(axios.create).mockReturnValue({
get: mockGet,
post: mockPost,
} as unknown as ReturnType<typeof axios.create>);
giteaService = new GiteaService();
});
afterEach(() => {
process.env = originalEnvironment;
vi.resetAllMocks();
});
it("should throw when GITEA_TOKEN is not set", () => {
expect.assertions(1);
process.env.GITEA_TOKEN = "";
expect(() => {
return new GiteaService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should throw when GITEA_TOKEN is undefined", () => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
expect(() => {
return new GiteaService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should create axios client with correct configuration", () => {
expect.assertions(1);
expect(axios.create).toHaveBeenCalledWith({
baseURL: "https://git.nhcarrigan.com/api/v1",
headers: {
"Authorization": "token test-token",
"Content-Type": "application/json",
},
});
});
it("should create a pull request", async() => {
expect.assertions(2);
const mockPullRequest = {
base: { ref: "main", sha: "abc123" },
body: "Test body",
head: { ref: "feature", sha: "def456" },
id: 1,
number: 1,
state: "open",
title: "Test PR",
};
mockPost.mockResolvedValueOnce({ data: mockPullRequest });
const result = await giteaService.createPullRequest({
base: "main",
body: "Test body",
head: "feature",
owner: "test-owner",
repo: "test-repo",
title: "Test PR",
});
expect(result).toStrictEqual(mockPullRequest);
expect(mockPost).toHaveBeenCalledWith(
"/repos/test-owner/test-repo/pulls",
{ base: "main", body: "Test body", head: "feature", title: "Test PR" },
);
});
it("should return file content when found", async() => {
expect.assertions(2);
const mockFile = {
content: "SGVsbG8gV29ybGQ=",
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "file",
};
mockGet.mockResolvedValueOnce({ data: mockFile });
const result = await giteaService.getFileContent({
owner: "test-owner",
path: "package.json",
reference: "main",
repo: "test-repo",
});
expect(result).toStrictEqual(mockFile);
expect(mockGet).toHaveBeenCalledWith(
"/repos/test-owner/test-repo/contents/package.json",
{ params: { ref: "main" } },
);
});
it("should return null when file is not found (404)", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Not Found");
axiosError.response = { status: 404 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
const result = await giteaService.getFileContent({
owner: "test-owner",
path: "non-existent.json",
repo: "test-repo",
});
expect(result).toBeNull();
});
it("should throw for non-404 errors", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Server Error");
axiosError.response = { status: 500 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
await expect(
giteaService.getFileContent({
owner: "test-owner",
path: "package.json",
repo: "test-repo",
}),
).rejects.toThrow();
});
it("should fetch all repositories with pagination", async() => {
expect.assertions(2);
const page1 = Array.from({ length: 100 }, (_, index) => {
return createMockRepository({
cloneUrl: `https://git.nhcarrigan.com/nhcarrigan/repo-${String(index)}.git`,
fullName: `nhcarrigan/repo-${String(index)}`,
id: index,
name: `repo-${String(index)}`,
});
});
const page2 = [
createMockRepository({
cloneUrl: "https://git.nhcarrigan.com/nhcarrigan/repo-100.git",
fullName: "nhcarrigan/repo-100",
id: 100,
name: "repo-100",
}),
];
mockGet.
mockResolvedValueOnce({ data: page1 }).
mockResolvedValueOnce({ data: page2 }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(101);
expect(mockGet).toHaveBeenCalledTimes(3);
});
it("should filter out archived repositories", async() => {
expect.assertions(2);
const repos = [
createMockRepository({ archived: true, id: 1, name: "archived-repo" }),
createMockRepository({ id: 2, name: "active-repo" }),
];
mockGet.
mockResolvedValueOnce({ data: repos }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("active-repo");
});
it("should filter out disabled repositories", async() => {
expect.assertions(2);
const repos = [
createMockRepository({ disabled: true, id: 1, name: "disabled-repo" }),
createMockRepository({ id: 2, name: "enabled-repo" }),
];
mockGet.
mockResolvedValueOnce({ data: repos }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("enabled-repo");
});
it("should filter out mirror repositories", async() => {
expect.assertions(2);
const repos = [
createMockRepository({ id: 1, mirror: true, name: "mirror-repo" }),
createMockRepository({ id: 2, name: "source-repo" }),
];
mockGet.
mockResolvedValueOnce({ data: repos }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("source-repo");
});
it("should list pull requests with default state", async() => {
expect.assertions(2);
const mockPullRequests = [
{
base: { ref: "main", sha: "abc" },
body: "PR 1",
head: { ref: "feature-1", sha: "def" },
id: 1,
number: 1,
state: "open",
title: "PR 1",
},
];
mockGet.mockResolvedValueOnce({ data: mockPullRequests });
const result = await giteaService.listPullRequests("owner", "repo");
expect(result).toStrictEqual(mockPullRequests);
expect(mockGet).toHaveBeenCalledWith(
"/repos/owner/repo/pulls",
{ params: { state: "open" } },
);
});
it("should list pull requests with specified state", async() => {
expect.assertions(1);
mockGet.mockResolvedValueOnce({ data: [] });
await giteaService.listPullRequests("owner", "repo", "all");
expect(mockGet).toHaveBeenCalledWith(
"/repos/owner/repo/pulls",
{ params: { state: "all" } },
);
});
it("should list closed pull requests", async() => {
expect.assertions(1);
mockGet.mockResolvedValueOnce({ data: [] });
await giteaService.listPullRequests("owner", "repo", "closed");
expect(mockGet).toHaveBeenCalledWith(
"/repos/owner/repo/pulls",
{ params: { state: "closed" } },
);
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic imports */
/* eslint-disable vitest/require-to-throw-message -- Generic throw assertion */
/* eslint-disable stylistic/max-len -- Test files have long URLs */
import axios, { AxiosError, type AxiosResponse } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NpmService } from "../../src/services/npmService.js";
vi.mock("axios", async() => {
const actualAxios = await vi.importActual<typeof import("axios")>("axios");
return {
AxiosError: actualAxios.AxiosError,
default: {
create: vi.fn(() => {
return {
get: vi.fn(),
};
}),
get: vi.fn(),
},
isAxiosError: actualAxios.isAxiosError,
};
});
vi.mock("@nhcarrigan/logger", () => {
return {
Logger: class MockLogger {
public error = vi.fn();
public log = vi.fn();
},
};
});
const createMockPackageInfo = (
version: string,
repositoryUrl?: string,
): Record<string, unknown> => {
const versionData: Record<string, unknown> = { version };
if (repositoryUrl !== undefined) {
versionData.repository = { url: repositoryUrl };
}
return {
"dist-tags": { latest: version },
"name": "test-package",
"versions": { [version]: versionData },
};
};
describe("npmService", () => {
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let npmService: NpmService;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockGet: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
process.env.GITEA_TOKEN = "test-token";
mockGet = vi.fn();
vi.mocked(axios.create).mockReturnValue({
get: mockGet,
} as unknown as ReturnType<typeof axios.create>);
npmService = new NpmService();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should return package info when found", async() => {
expect.assertions(2);
const mockPackageInfo = {
"dist-tags": { latest: "2.0.0" },
"name": "test-package",
"versions": {
"2.0.0": {
repository: { url: "https://github.com/test/test.git" },
version: "2.0.0",
},
},
};
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageInfo("test-package");
expect(result).toStrictEqual(mockPackageInfo);
expect(mockGet).toHaveBeenCalledWith("/test-package");
});
it("should return null when package is not found (404)", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Not Found");
axiosError.response = { status: 404 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
const result = await npmService.getPackageInfo("non-existent-package");
expect(result).toBeNull();
});
it("should throw for non-404 errors", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Server Error");
axiosError.response = { status: 500 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
await expect(
npmService.getPackageInfo("test-package"),
).rejects.toThrow();
});
it("should encode scoped package names", async() => {
expect.assertions(1);
const mockPackageInfo = {
"dist-tags": { latest: "1.0.0" },
"name": "@scope/package",
"versions": {},
};
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
await npmService.getPackageInfo("@scope/package");
expect(mockGet).toHaveBeenCalledWith("/%40scope%2Fpackage");
});
it("should return fallback when package info is null", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Not Found");
axiosError.response = { status: 404 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "non-existent",
toVersion: "2.0.0",
});
expect(result).toBe("No changelog available for non-existent");
});
it("should return fallback when repository URL is undefined", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo("2.0.0");
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should return fallback when not a GitHub URL", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://gitlab.com/test/test.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should fetch GitHub releases and format changelog", async() => {
expect.assertions(5);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
const mockReleases = [
{ body: "Release notes for 2.0.0", tag_name: "v2.0.0" },
{ body: "Release notes for 1.5.0", tag_name: "v1.5.0" },
{ body: "Release notes for 1.0.0", tag_name: "v1.0.0" },
];
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockReleases });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toContain("## Changelog");
expect(result).toContain("### v2.0.0");
expect(result).toContain("Release notes for 2.0.0");
expect(result).toContain("### v1.5.0");
expect(result).not.toContain("### v1.0.0");
});
it("should return fallback when no relevant releases found", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should handle GitHub API errors gracefully", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockRejectedValueOnce(new Error("GitHub API error"));
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should handle releases without body", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
const mockReleases = [
{ tag_name: "v2.0.0" },
];
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockReleases });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toContain("No release notes available");
});
it("should normalise git+ prefixed URLs", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"git+https://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(axios.get).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/releases",
expect.any(Object),
);
});
it("should normalise git:// URLs", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"git://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(axios.get).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/releases",
expect.any(Object),
);
});
it("should normalise ssh:// URLs", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"ssh://git@github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(axios.get).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/releases",
expect.any(Object),
);
});
it("should return fallback when GitHub URL has insufficient parts", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should handle errors in getPackageChangelog", async() => {
expect.assertions(1);
mockGet.mockRejectedValueOnce(new Error("Network error"));
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
});
@@ -0,0 +1,440 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable max-lines -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names and destructured imports */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
/* eslint-disable max-classes-per-file -- Mock classes are needed for each service */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockGiteaGetFileContent = vi.fn();
const mockGiteaListOrgRepositories = vi.fn();
const mockGiteaCreatePullRequest = vi.fn();
const mockGiteaListPullRequests = vi.fn();
const mockNpmGetPackageChangelog = vi.fn();
const mockNpmGetPackageInfo = vi.fn();
const mockAnalyzePackageJson = vi.fn();
const mockCloneRepository = vi.fn();
const mockCreateOrUpdateBranch = vi.fn();
vi.mock("@nhcarrigan/logger", () => {
return {
Logger: class MockLogger {
public error = vi.fn();
public log = vi.fn();
},
};
});
vi.mock("../../src/services/giteaService.js", () => {
return {
GiteaService: class MockGiteaService {
public createPullRequest = mockGiteaCreatePullRequest;
public getFileContent = mockGiteaGetFileContent;
public listOrgRepositories = mockGiteaListOrgRepositories;
public listPullRequests = mockGiteaListPullRequests;
},
};
});
vi.mock("../../src/services/npmService.js", () => {
return {
NpmService: class MockNpmService {
public getPackageChangelog = mockNpmGetPackageChangelog;
public getPackageInfo = mockNpmGetPackageInfo;
},
};
});
vi.mock("../../src/services/dependencyAnalyzerService.js", () => {
return {
DependencyAnalyzerService: class MockDependencyAnalyzerService {
public analyzePackageJson = mockAnalyzePackageJson;
},
};
});
vi.mock("../../src/services/gitService.js", () => {
return {
cloneRepository: mockCloneRepository,
createOrUpdateBranch: mockCreateOrUpdateBranch,
};
});
const createMockRepo = (name: string): Record<string, unknown> => {
return {
archived: false,
clone_url: "url",
default_branch: "main",
disabled: false,
full_name: `nhcarrigan/${name}`,
id: 1,
mirror: false,
name: name,
};
};
interface MockFileContent {
content?: string;
encoding: string;
path: string;
sha: string;
type: string;
}
const createMockFileContent = (
packageJson: Record<string, unknown>,
): MockFileContent => {
return {
content: Buffer.from(JSON.stringify(packageJson)).toString("base64"),
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "file",
};
};
interface MockUpdate {
currentVersion: string;
latestVersion: string;
packageName: string;
type: "dependencies" | "devDependencies";
}
const createMockUpdate = (): MockUpdate => {
return {
currentVersion: "1.0.0",
latestVersion: "2.0.0",
packageName: "test-pkg",
type: "dependencies",
};
};
const createMockClonedRepo = (
cleanup: ReturnType<typeof vi.fn> = vi.fn(),
): Record<string, unknown> => {
return {
cleanup: cleanup,
path: "/tmp/test",
repoName: "test-repo",
};
};
describe("updateOrchestratorService", () => {
const originalEnvironment = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnvironment, GITEA_TOKEN: "test-token" };
});
afterEach(() => {
process.env = originalEnvironment;
vi.resetModules();
vi.resetAllMocks();
});
it("should throw when GITEA_TOKEN is not set", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "";
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
expect(() => {
return new UpdateOrchestratorService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should throw when GITEA_TOKEN is undefined", async() => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
expect(() => {
return new UpdateOrchestratorService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should create instance when GITEA_TOKEN is set", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "valid-token";
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
expect(() => {
return new UpdateOrchestratorService();
}).not.toThrow();
});
it("should process all repositories", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue([]);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaListOrgRepositories).toHaveBeenCalledWith();
});
it("should skip repositories without package.json", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("no-package") ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(null);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaGetFileContent).toHaveBeenCalledWith({
owner: "nhcarrigan",
path: "package.json",
reference: "main",
repo: "no-package",
});
});
it("should create PRs for updates", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockNpmGetPackageChangelog.mockResolvedValue("## Changelog");
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
branchName: "dependencies/update-test-pkg",
status: "created",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).toHaveBeenCalledWith(
expect.objectContaining({
base: "main",
head: "dependencies/update-test-pkg",
owner: "nhcarrigan",
repo: "test-repo",
title: "deps: update test-pkg to 2.0.0",
}),
);
});
it("should handle repository processing errors", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("error-repo") ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockRejectedValue(new Error("API error"));
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await expect(
service.checkAndUpdateAllRepositories(),
).resolves.not.toThrow();
});
it("should skip when branch is up-to-date", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
status: "up-to-date",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle failed branch updates", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
error: "Git error",
status: "failed",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle failed branch updates without error message", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
status: "failed",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle package.json without content", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent: MockFileContent = {
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "file",
};
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await expect(
service.checkAndUpdateAllRepositories(),
).resolves.not.toThrow();
});
it("should skip PR creation when branch was updated", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
branchName: "dependencies/update-test-pkg",
status: "updated",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle PR creation errors", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockNpmGetPackageChangelog.mockResolvedValue("## Changelog");
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
branchName: "dependencies/update-test-pkg",
status: "created",
});
mockGiteaCreatePullRequest.mockRejectedValue(
new Error("PR creation failed"),
);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await expect(
service.checkAndUpdateAllRepositories(),
).resolves.not.toThrow();
});
it("should skip non-file type package.json", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent: MockFileContent = {
content: "",
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "dir",
};
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockAnalyzePackageJson).not.toHaveBeenCalled();
});
it("should cleanup cloned repo after processing", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
const mockCleanup = vi.fn();
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo(mockCleanup));
mockCreateOrUpdateBranch.mockResolvedValue({
status: "up-to-date",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockCleanup).toHaveBeenCalledWith();
});
});