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
+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");
});
});