mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
570 lines
17 KiB
TypeScript
570 lines
17 KiB
TypeScript
import { Command } from "commander";
|
|
import { ResourceInfo } from "../Types/CLITypes";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import * as os from "os";
|
|
|
|
// Mock the ApiClient module before it's imported by ResourceCommands
|
|
const mockExecuteApiRequest: jest.Mock = jest.fn();
|
|
jest.mock("../Core/ApiClient", () => {
|
|
return {
|
|
...jest.requireActual("../Core/ApiClient"),
|
|
executeApiRequest: (...args: unknown[]): unknown => {
|
|
return mockExecuteApiRequest(...args);
|
|
},
|
|
};
|
|
});
|
|
|
|
// Import after mock setup
|
|
import {
|
|
discoverResources,
|
|
registerResourceCommands,
|
|
} from "../Commands/ResourceCommands";
|
|
|
|
const CONFIG_DIR: string = path.join(os.homedir(), ".oneuptime");
|
|
const CONFIG_FILE: string = path.join(CONFIG_DIR, "config.json");
|
|
|
|
describe("ResourceCommands", () => {
|
|
let originalConfigContent: string | null = null;
|
|
|
|
beforeAll(() => {
|
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
originalConfigContent = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (originalConfigContent) {
|
|
fs.writeFileSync(CONFIG_FILE, originalConfigContent, { mode: 0o600 });
|
|
} else if (fs.existsSync(CONFIG_FILE)) {
|
|
fs.unlinkSync(CONFIG_FILE);
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
fs.unlinkSync(CONFIG_FILE);
|
|
}
|
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
jest.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
|
mockExecuteApiRequest.mockReset();
|
|
delete process.env["ONEUPTIME_API_KEY"];
|
|
delete process.env["ONEUPTIME_URL"];
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
delete process.env["ONEUPTIME_API_KEY"];
|
|
delete process.env["ONEUPTIME_URL"];
|
|
});
|
|
|
|
describe("discoverResources", () => {
|
|
let resources: ResourceInfo[];
|
|
|
|
beforeAll(() => {
|
|
resources = discoverResources();
|
|
});
|
|
|
|
it("should discover at least one resource", () => {
|
|
expect(resources.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should discover the Incident resource", () => {
|
|
const incident: ResourceInfo | undefined = resources.find(
|
|
(r: ResourceInfo) => {
|
|
return r.singularName === "Incident";
|
|
},
|
|
);
|
|
expect(incident).toBeDefined();
|
|
expect(incident!.modelType).toBe("database");
|
|
expect(incident!.apiPath).toBe("/incident");
|
|
});
|
|
|
|
it("should discover the Monitor resource", () => {
|
|
const monitor: ResourceInfo | undefined = resources.find(
|
|
(r: ResourceInfo) => {
|
|
return r.singularName === "Monitor";
|
|
},
|
|
);
|
|
expect(monitor).toBeDefined();
|
|
expect(monitor!.modelType).toBe("database");
|
|
});
|
|
|
|
it("should discover the Alert resource", () => {
|
|
const alert: ResourceInfo | undefined = resources.find(
|
|
(r: ResourceInfo) => {
|
|
return r.singularName === "Alert";
|
|
},
|
|
);
|
|
expect(alert).toBeDefined();
|
|
});
|
|
|
|
it("should have kebab-case names for all resources", () => {
|
|
for (const r of resources) {
|
|
expect(r.name).toMatch(/^[a-z][a-z0-9-]*$/);
|
|
}
|
|
});
|
|
|
|
it("should have apiPath for all resources", () => {
|
|
for (const r of resources) {
|
|
expect(r.apiPath).toBeTruthy();
|
|
expect(r.apiPath.startsWith("/")).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("should have valid modelType for all resources", () => {
|
|
for (const r of resources) {
|
|
expect(["database", "analytics"]).toContain(r.modelType);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("registerResourceCommands", () => {
|
|
it("should register commands for all discovered resources", () => {
|
|
const program: Command = new Command();
|
|
program.exitOverride();
|
|
registerResourceCommands(program);
|
|
|
|
const resources: ResourceInfo[] = discoverResources();
|
|
for (const resource of resources) {
|
|
const cmd: Command | undefined = program.commands.find((c: Command) => {
|
|
return c.name() === resource.name;
|
|
});
|
|
expect(cmd).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("should register list, get, create, update, delete, count subcommands for database resources", () => {
|
|
const program: Command = new Command();
|
|
program.exitOverride();
|
|
registerResourceCommands(program);
|
|
|
|
const incidentCmd: Command | undefined = program.commands.find(
|
|
(c: Command) => {
|
|
return c.name() === "incident";
|
|
},
|
|
);
|
|
expect(incidentCmd).toBeDefined();
|
|
|
|
const subcommands: string[] = incidentCmd!.commands.map((c: Command) => {
|
|
return c.name();
|
|
});
|
|
expect(subcommands).toContain("list");
|
|
expect(subcommands).toContain("get");
|
|
expect(subcommands).toContain("create");
|
|
expect(subcommands).toContain("update");
|
|
expect(subcommands).toContain("delete");
|
|
expect(subcommands).toContain("count");
|
|
});
|
|
});
|
|
|
|
describe("resource command actions", () => {
|
|
function createProgramWithResources(): Command {
|
|
const program: Command = new Command();
|
|
program.exitOverride();
|
|
program.configureOutput({
|
|
writeOut: () => {},
|
|
writeErr: () => {},
|
|
});
|
|
program
|
|
.option("--api-key <key>", "API key")
|
|
.option("--url <url>", "URL")
|
|
.option("--context <name>", "Context");
|
|
registerResourceCommands(program);
|
|
return program;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
/*
|
|
* Use env vars for credentials instead of config file to avoid
|
|
* race conditions with other test files that share ~/.oneuptime/config.json
|
|
*/
|
|
process.env["ONEUPTIME_API_KEY"] = "test-key-12345";
|
|
process.env["ONEUPTIME_URL"] = "https://test.oneuptime.com";
|
|
mockExecuteApiRequest.mockResolvedValue({ data: [] });
|
|
});
|
|
|
|
describe("list subcommand", () => {
|
|
it("should call API with list operation", async () => {
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "list"]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].operation).toBe("list");
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].apiPath).toBe(
|
|
"/incident",
|
|
);
|
|
});
|
|
|
|
it("should pass query, limit, skip, sort options", async () => {
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"list",
|
|
"--query",
|
|
'{"status":"active"}',
|
|
"--limit",
|
|
"20",
|
|
"--skip",
|
|
"5",
|
|
"--sort",
|
|
'{"createdAt":-1}',
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
const opts: Record<string, unknown> =
|
|
mockExecuteApiRequest.mock.calls[0][0];
|
|
expect(opts.query).toEqual({ status: "active" });
|
|
expect(opts.limit).toBe(20);
|
|
expect(opts.skip).toBe(5);
|
|
expect(opts.sort).toEqual({ createdAt: -1 });
|
|
});
|
|
|
|
it("should extract data array from response object", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({
|
|
data: [{ _id: "1", name: "Test" }],
|
|
});
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"list",
|
|
"-o",
|
|
"json",
|
|
]);
|
|
|
|
// eslint-disable-next-line no-console
|
|
expect(console.log).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle response that is already an array", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue([{ _id: "1" }]);
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"list",
|
|
"-o",
|
|
"json",
|
|
]);
|
|
|
|
// eslint-disable-next-line no-console
|
|
expect(console.log).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle API errors", async () => {
|
|
mockExecuteApiRequest.mockRejectedValue(
|
|
new Error("API error (500): Server Error"),
|
|
);
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "list"]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("get subcommand", () => {
|
|
it("should call API with read operation and id", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({
|
|
_id: "abc-123",
|
|
name: "Test",
|
|
});
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"get",
|
|
"abc-123",
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
const opts: Record<string, unknown> =
|
|
mockExecuteApiRequest.mock.calls[0][0];
|
|
expect(opts.operation).toBe("read");
|
|
expect(opts.id).toBe("abc-123");
|
|
});
|
|
|
|
it("should support output format flag", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({ _id: "abc-123" });
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"get",
|
|
"abc-123",
|
|
"-o",
|
|
"json",
|
|
]);
|
|
|
|
// eslint-disable-next-line no-console
|
|
expect(console.log).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle get errors", async () => {
|
|
mockExecuteApiRequest.mockRejectedValue(new Error("not found 404"));
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"get",
|
|
"abc-123",
|
|
]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("create subcommand", () => {
|
|
it("should call API with create operation and data", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({ _id: "new-123" });
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"create",
|
|
"--data",
|
|
'{"name":"New Incident"}',
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
const opts: Record<string, unknown> =
|
|
mockExecuteApiRequest.mock.calls[0][0];
|
|
expect(opts.operation).toBe("create");
|
|
expect(opts.data).toEqual({ name: "New Incident" });
|
|
});
|
|
|
|
it("should support reading data from a file", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({ _id: "new-123" });
|
|
|
|
const tmpFile: string = path.join(
|
|
os.tmpdir(),
|
|
"cli-test-" + Date.now() + ".json",
|
|
);
|
|
fs.writeFileSync(tmpFile, '{"name":"From File"}');
|
|
|
|
try {
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"create",
|
|
"--file",
|
|
tmpFile,
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].data).toEqual({
|
|
name: "From File",
|
|
});
|
|
} finally {
|
|
fs.unlinkSync(tmpFile);
|
|
}
|
|
});
|
|
|
|
it("should error when neither --data nor --file is provided", async () => {
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "create"]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should error on invalid JSON in --data", async () => {
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"create",
|
|
"--data",
|
|
"not-json",
|
|
]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("update subcommand", () => {
|
|
it("should call API with update operation, id, and data", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({ _id: "abc-123" });
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"update",
|
|
"abc-123",
|
|
"--data",
|
|
'{"name":"Updated"}',
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
const opts: Record<string, unknown> =
|
|
mockExecuteApiRequest.mock.calls[0][0];
|
|
expect(opts.operation).toBe("update");
|
|
expect(opts.id).toBe("abc-123");
|
|
expect(opts.data).toEqual({ name: "Updated" });
|
|
});
|
|
|
|
it("should handle update errors", async () => {
|
|
mockExecuteApiRequest.mockRejectedValue(new Error("API error"));
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"update",
|
|
"abc-123",
|
|
"--data",
|
|
'{"name":"x"}',
|
|
]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("delete subcommand", () => {
|
|
it("should call API with delete operation and id", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({});
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"delete",
|
|
"abc-123",
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
const opts: Record<string, unknown> =
|
|
mockExecuteApiRequest.mock.calls[0][0];
|
|
expect(opts.operation).toBe("delete");
|
|
expect(opts.id).toBe("abc-123");
|
|
});
|
|
|
|
it("should handle API errors", async () => {
|
|
mockExecuteApiRequest.mockRejectedValue(new Error("not found 404"));
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"delete",
|
|
"abc-123",
|
|
]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("count subcommand", () => {
|
|
it("should call API with count operation", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({ count: 42 });
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "count"]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].operation).toBe("count");
|
|
// eslint-disable-next-line no-console
|
|
expect(console.log).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it("should pass query filter", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue({ count: 5 });
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"incident",
|
|
"count",
|
|
"--query",
|
|
'{"status":"active"}',
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].query).toEqual({
|
|
status: "active",
|
|
});
|
|
});
|
|
|
|
it("should handle response without count field", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue(99);
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "count"]);
|
|
|
|
// eslint-disable-next-line no-console
|
|
expect(console.log).toHaveBeenCalledWith(99);
|
|
});
|
|
|
|
it("should handle non-object response in count", async () => {
|
|
mockExecuteApiRequest.mockResolvedValue("some-string");
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "count"]);
|
|
|
|
// eslint-disable-next-line no-console
|
|
expect(console.log).toHaveBeenCalledWith("some-string");
|
|
});
|
|
|
|
it("should handle count errors", async () => {
|
|
mockExecuteApiRequest.mockRejectedValue(new Error("API error"));
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync(["node", "test", "incident", "count"]);
|
|
|
|
expect(process.exit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("credential resolution in commands", () => {
|
|
it("should use global --api-key and --url flags", async () => {
|
|
delete process.env["ONEUPTIME_API_KEY"];
|
|
delete process.env["ONEUPTIME_URL"];
|
|
mockExecuteApiRequest.mockResolvedValue({ data: [] });
|
|
|
|
const program: Command = createProgramWithResources();
|
|
await program.parseAsync([
|
|
"node",
|
|
"test",
|
|
"--api-key",
|
|
"global-key",
|
|
"--url",
|
|
"https://global.com",
|
|
"incident",
|
|
"list",
|
|
]);
|
|
|
|
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].apiKey).toBe(
|
|
"global-key",
|
|
);
|
|
expect(mockExecuteApiRequest.mock.calls[0][0].apiUrl).toBe(
|
|
"https://global.com",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|