mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Add comprehensive tests for CLI commands and error handling
- Implement tests for ResourceCommands, ConfigCommands, UtilityCommands, and ErrorHandler. - Enhance test coverage for command registration and execution, including list, get, create, update, delete, and count operations. - Introduce tests for credential management and context handling in commands. - Add error handling tests to ensure graceful exits on API errors and invalid inputs. - Update jest configuration to exclude test files from coverage and adjust TypeScript settings.
This commit is contained in:
@@ -1,79 +1,335 @@
|
||||
// ApiClient tests - route building and request data construction
|
||||
// Note: actual API calls are not tested here; this tests the logic
|
||||
import { executeApiRequest, ApiRequestOptions } from "../Core/ApiClient";
|
||||
import API from "Common/Utils/API";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
|
||||
// Mock the Common/Utils/API module
|
||||
jest.mock("Common/Utils/API", () => {
|
||||
const mockPost = jest.fn();
|
||||
const mockPut = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
|
||||
function MockAPI(protocol, hostname, _route) {
|
||||
this.protocol = protocol;
|
||||
this.hostname = hostname;
|
||||
}
|
||||
|
||||
MockAPI.post = mockPost;
|
||||
MockAPI.put = mockPut;
|
||||
MockAPI.delete = mockDelete;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockAPI,
|
||||
};
|
||||
});
|
||||
|
||||
function createSuccessResponse(data) {
|
||||
return { data, statusCode: 200 };
|
||||
}
|
||||
|
||||
function createErrorResponse(statusCode, message) {
|
||||
// HTTPErrorResponse computes `message` from `.data` via a getter.
|
||||
// We create a proper prototype chain and set data to contain the message.
|
||||
const resp = Object.create(HTTPErrorResponse.prototype);
|
||||
resp.statusCode = statusCode;
|
||||
// HTTPResponse stores data in _jsonData and exposes it via `data` getter
|
||||
// But since the prototype chain may not have full getters, we define them
|
||||
Object.defineProperty(resp, "data", {
|
||||
get: () => ({ message: message }),
|
||||
configurable: true,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
describe("ApiClient", () => {
|
||||
describe("route building", () => {
|
||||
// We test the route logic conceptually since the function is internal.
|
||||
// The important thing is that executeApiRequest constructs correct routes.
|
||||
let mockPost;
|
||||
let mockPut;
|
||||
let mockDelete;
|
||||
|
||||
it("should build create route as /api{path}", () => {
|
||||
// For create: /api/incident
|
||||
const expected = "/api/incident";
|
||||
expect(expected).toBe("/api/incident");
|
||||
beforeEach(() => {
|
||||
mockPost = API.post;
|
||||
mockPut = API.put;
|
||||
mockDelete = API.delete;
|
||||
(mockPost as any).mockReset();
|
||||
(mockPut as any).mockReset();
|
||||
(mockDelete as any).mockReset();
|
||||
});
|
||||
|
||||
const baseOptions: ApiRequestOptions = {
|
||||
apiUrl: "https://oneuptime.com",
|
||||
apiKey: "test-api-key",
|
||||
apiPath: "/incident",
|
||||
operation: "create",
|
||||
};
|
||||
|
||||
describe("create operation", () => {
|
||||
it("should make a POST request with data wrapped in { data: ... }", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({ _id: "123" }));
|
||||
|
||||
const result = await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "create",
|
||||
data: { name: "Test Incident" },
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.data).toEqual({ data: { name: "Test Incident" } });
|
||||
expect(result).toEqual({ _id: "123" });
|
||||
});
|
||||
|
||||
it("should build list route as /api{path}/get-list", () => {
|
||||
const expected = "/api/incident/get-list";
|
||||
expect(expected).toBe("/api/incident/get-list");
|
||||
});
|
||||
it("should use empty object when no data provided for create", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({ _id: "123" }));
|
||||
|
||||
it("should build read route as /api{path}/{id}/get-item", () => {
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const expected = `/api/incident/${id}/get-item`;
|
||||
expect(expected).toContain(id);
|
||||
expect(expected).toContain("/get-item");
|
||||
});
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "create",
|
||||
});
|
||||
|
||||
it("should build update route as /api{path}/{id}/", () => {
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const expected = `/api/incident/${id}/`;
|
||||
expect(expected).toContain(id);
|
||||
});
|
||||
|
||||
it("should build delete route as /api{path}/{id}/", () => {
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const expected = `/api/incident/${id}/`;
|
||||
expect(expected).toContain(id);
|
||||
});
|
||||
|
||||
it("should build count route as /api{path}/count", () => {
|
||||
const expected = "/api/incident/count";
|
||||
expect(expected).toContain("/count");
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.data).toEqual({ data: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("header construction", () => {
|
||||
it("should include correct headers", () => {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
APIKey: "test-api-key",
|
||||
};
|
||||
describe("read operation", () => {
|
||||
it("should make a POST request with select and id in route", async () => {
|
||||
(mockPost as any).mockResolvedValue(
|
||||
createSuccessResponse({ _id: "abc", name: "Test" }),
|
||||
);
|
||||
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
expect(headers["Accept"]).toBe("application/json");
|
||||
expect(headers["APIKey"]).toBe("test-api-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("request data formatting", () => {
|
||||
it("should wrap create data in { data: ... }", () => {
|
||||
const data = { name: "Test Incident", projectId: "123" };
|
||||
const requestData = { data };
|
||||
expect(requestData).toEqual({ data: { name: "Test Incident", projectId: "123" } });
|
||||
});
|
||||
|
||||
it("should include query, select, skip, limit, sort for list", () => {
|
||||
const requestData = {
|
||||
query: { status: "active" },
|
||||
const result = await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "read",
|
||||
id: "abc-123",
|
||||
select: { _id: true, name: true },
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("abc-123/get-item");
|
||||
expect(callArgs.data).toEqual({ select: { _id: true, name: true } });
|
||||
expect(result).toEqual({ _id: "abc", name: "Test" });
|
||||
});
|
||||
|
||||
it("should use empty select when none provided", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "read",
|
||||
id: "abc-123",
|
||||
});
|
||||
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.data).toEqual({ select: {} });
|
||||
});
|
||||
|
||||
it("should build route without id when no id provided", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "read",
|
||||
});
|
||||
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("/api/incident");
|
||||
expect(callArgs.url.toString()).not.toContain("/get-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("list operation", () => {
|
||||
it("should make a POST request with query, select, skip, limit, sort", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({ data: [] }));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "list",
|
||||
query: { status: "active" },
|
||||
select: { _id: true },
|
||||
skip: 5,
|
||||
limit: 20,
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("/get-list");
|
||||
expect(callArgs.data).toEqual({
|
||||
query: { status: "active" },
|
||||
select: { _id: true },
|
||||
skip: 5,
|
||||
limit: 20,
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should use defaults when no query options provided", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({ data: [] }));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.data).toEqual({
|
||||
query: {},
|
||||
select: {},
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
sort: { createdAt: -1 },
|
||||
};
|
||||
expect(requestData.query).toEqual({ status: "active" });
|
||||
expect(requestData.skip).toBe(0);
|
||||
expect(requestData.limit).toBe(10);
|
||||
sort: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("count operation", () => {
|
||||
it("should make a POST request to /count path", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({ count: 42 }));
|
||||
|
||||
const result = await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "count",
|
||||
query: { status: "active" },
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("/count");
|
||||
expect(result).toEqual({ count: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update operation", () => {
|
||||
it("should make a PUT request with data", async () => {
|
||||
(mockPut as any).mockResolvedValue(createSuccessResponse({ _id: "abc" }));
|
||||
|
||||
const result = await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "update",
|
||||
id: "abc-123",
|
||||
data: { name: "Updated" },
|
||||
});
|
||||
|
||||
expect(mockPut).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (mockPut as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("abc-123");
|
||||
expect(callArgs.data).toEqual({ data: { name: "Updated" } });
|
||||
expect(result).toEqual({ _id: "abc" });
|
||||
});
|
||||
|
||||
it("should use empty object when no data provided for update", async () => {
|
||||
(mockPut as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "update",
|
||||
id: "abc-123",
|
||||
});
|
||||
|
||||
const callArgs = (mockPut as any).mock.calls[0][0];
|
||||
expect(callArgs.data).toEqual({ data: {} });
|
||||
});
|
||||
|
||||
it("should build route without id when no id provided", async () => {
|
||||
(mockPut as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "update",
|
||||
});
|
||||
|
||||
const callArgs = (mockPut as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("/api/incident");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete operation", () => {
|
||||
it("should make a DELETE request", async () => {
|
||||
(mockDelete as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "delete",
|
||||
id: "abc-123",
|
||||
});
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (mockDelete as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("abc-123");
|
||||
expect(callArgs.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should build route without id when no id provided", async () => {
|
||||
(mockDelete as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "delete",
|
||||
});
|
||||
|
||||
const callArgs = (mockDelete as any).mock.calls[0][0];
|
||||
expect(callArgs.url.toString()).toContain("/api/incident");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should throw on HTTPErrorResponse", async () => {
|
||||
(mockPost as any).mockResolvedValue(createErrorResponse(500, "Server Error"));
|
||||
|
||||
await expect(
|
||||
executeApiRequest({ ...baseOptions, operation: "create", data: {} }),
|
||||
).rejects.toThrow("API error");
|
||||
});
|
||||
|
||||
it("should include status code in error message", async () => {
|
||||
(mockPost as any).mockResolvedValue(createErrorResponse(403, "Forbidden"));
|
||||
|
||||
await expect(
|
||||
executeApiRequest({ ...baseOptions, operation: "list" }),
|
||||
).rejects.toThrow("403");
|
||||
});
|
||||
|
||||
it("should handle error response with no message", async () => {
|
||||
(mockPost as any).mockResolvedValue(createErrorResponse(500, ""));
|
||||
|
||||
await expect(
|
||||
executeApiRequest({ ...baseOptions, operation: "list" }),
|
||||
).rejects.toThrow("API error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("headers", () => {
|
||||
it("should include APIKey, Content-Type, and Accept headers", async () => {
|
||||
(mockPost as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "create",
|
||||
data: { name: "Test" },
|
||||
});
|
||||
|
||||
const callArgs = (mockPost as any).mock.calls[0][0];
|
||||
expect(callArgs.headers["APIKey"]).toBe("test-api-key");
|
||||
expect(callArgs.headers["Content-Type"]).toBe("application/json");
|
||||
expect(callArgs.headers["Accept"]).toBe("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default/unknown operation", () => {
|
||||
it("should handle unknown operation in buildRequestData (falls to default)", async () => {
|
||||
// The "delete" case hits the default branch in buildRequestData returning undefined
|
||||
(mockDelete as any).mockResolvedValue(createSuccessResponse({}));
|
||||
|
||||
await executeApiRequest({
|
||||
...baseOptions,
|
||||
operation: "delete",
|
||||
id: "123",
|
||||
});
|
||||
|
||||
// Should not send data for delete
|
||||
const callArgs = (mockDelete as any).mock.calls[0][0];
|
||||
expect(callArgs.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
252
CLI/Tests/ConfigCommands.test.ts
Normal file
252
CLI/Tests/ConfigCommands.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Command } from "commander";
|
||||
import { registerConfigCommands } from "../Commands/ConfigCommands";
|
||||
import * as ConfigManager from "../Core/ConfigManager";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
||||
|
||||
describe("ConfigCommands", () => {
|
||||
let originalConfigContent: string | null = null;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let exitSpy: jest.SpyInstance;
|
||||
|
||||
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);
|
||||
}
|
||||
consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride(); // Prevent commander from calling process.exit
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
registerConfigCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("login command", () => {
|
||||
it("should create a context and set it as current", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"login",
|
||||
"my-api-key",
|
||||
"https://example.com",
|
||||
]);
|
||||
|
||||
const ctx = ConfigManager.getCurrentContext();
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.name).toBe("default");
|
||||
expect(ctx!.apiUrl).toBe("https://example.com");
|
||||
expect(ctx!.apiKey).toBe("my-api-key");
|
||||
});
|
||||
|
||||
it("should use custom context name", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"login",
|
||||
"key123",
|
||||
"https://prod.com",
|
||||
"--context-name",
|
||||
"production",
|
||||
]);
|
||||
|
||||
const ctx = ConfigManager.getCurrentContext();
|
||||
expect(ctx!.name).toBe("production");
|
||||
});
|
||||
|
||||
it("should handle login errors gracefully", async () => {
|
||||
// Mock addContext to throw
|
||||
const addCtxSpy = jest
|
||||
.spyOn(ConfigManager, "addContext")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("Permission denied");
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"login",
|
||||
"key123",
|
||||
"https://example.com",
|
||||
]);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
addCtxSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should strip trailing slashes from URL", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"login",
|
||||
"key123",
|
||||
"https://example.com///",
|
||||
]);
|
||||
|
||||
const ctx = ConfigManager.getCurrentContext();
|
||||
expect(ctx!.apiUrl).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context list command", () => {
|
||||
it("should show message when no contexts exist", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "list"]);
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should list contexts with current marker", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "a",
|
||||
apiUrl: "https://a.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
ConfigManager.addContext({
|
||||
name: "b",
|
||||
apiUrl: "https://b.com",
|
||||
apiKey: "k2",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "list"]);
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context use command", () => {
|
||||
it("should switch to the specified context", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "a",
|
||||
apiUrl: "https://a.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
ConfigManager.addContext({
|
||||
name: "b",
|
||||
apiUrl: "https://b.com",
|
||||
apiKey: "k2",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "use", "b"]);
|
||||
|
||||
const current = ConfigManager.getCurrentContext();
|
||||
expect(current!.name).toBe("b");
|
||||
});
|
||||
|
||||
it("should handle non-existent context", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "use", "nope"]);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("context current command", () => {
|
||||
it("should show current context info", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "myctx",
|
||||
apiUrl: "https://myctx.com",
|
||||
apiKey: "abcdefghijklm",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "current"]);
|
||||
|
||||
// Check that masked key is shown
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("Context: myctx");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("URL: https://myctx.com");
|
||||
// Key should be masked: abcd****jklm
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("****"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show message when no current context", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "current"]);
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mask short API keys", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "short",
|
||||
apiUrl: "https://s.com",
|
||||
apiKey: "abc",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "context", "current"]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("API Key: ****");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context delete command", () => {
|
||||
it("should delete a context", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "todelete",
|
||||
apiUrl: "https://del.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"context",
|
||||
"delete",
|
||||
"todelete",
|
||||
]);
|
||||
|
||||
const contexts = ConfigManager.listContexts();
|
||||
expect(contexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle deletion of non-existent context", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"context",
|
||||
"delete",
|
||||
"nonexistent",
|
||||
]);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as ConfigManager from "../Core/ConfigManager";
|
||||
import { CLIContext, ResolvedCredentials } from "../Types/CLITypes";
|
||||
import { CLIContext, CLIConfig, ResolvedCredentials } from "../Types/CLITypes";
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
||||
@@ -11,14 +11,12 @@ describe("ConfigManager", () => {
|
||||
let originalConfigContent: string | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
// Save existing config if present
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
originalConfigContent = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original config
|
||||
if (originalConfigContent) {
|
||||
fs.writeFileSync(CONFIG_FILE, originalConfigContent, { mode: 0o600 });
|
||||
} else if (fs.existsSync(CONFIG_FILE)) {
|
||||
@@ -27,10 +25,16 @@ describe("ConfigManager", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Start each test with empty config
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
fs.unlinkSync(CONFIG_FILE);
|
||||
}
|
||||
delete process.env["ONEUPTIME_API_KEY"];
|
||||
delete process.env["ONEUPTIME_URL"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["ONEUPTIME_API_KEY"];
|
||||
delete process.env["ONEUPTIME_URL"];
|
||||
});
|
||||
|
||||
describe("load", () => {
|
||||
@@ -43,7 +47,7 @@ describe("ConfigManager", () => {
|
||||
});
|
||||
|
||||
it("should load existing config from file", () => {
|
||||
const testConfig = {
|
||||
const testConfig: CLIConfig = {
|
||||
currentContext: "test",
|
||||
contexts: {
|
||||
test: { name: "test", apiUrl: "https://test.com", apiKey: "key123" },
|
||||
@@ -61,22 +65,105 @@ describe("ConfigManager", () => {
|
||||
expect(config.currentContext).toBe("test");
|
||||
expect(config.contexts["test"]?.apiKey).toBe("key123");
|
||||
});
|
||||
|
||||
it("should return default config when file contains invalid JSON", () => {
|
||||
if (!fs.existsSync(CONFIG_DIR)) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CONFIG_FILE, "not valid json {{{", { mode: 0o600 });
|
||||
|
||||
const config = ConfigManager.load();
|
||||
expect(config.currentContext).toBe("");
|
||||
expect(config.contexts).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addContext and getCurrentContext", () => {
|
||||
describe("save", () => {
|
||||
it("should create config directory if it does not exist", () => {
|
||||
// Remove the dir if it exists (we'll restore after)
|
||||
const tmpDir = path.join(os.tmpdir(), ".oneuptime-test-" + Date.now());
|
||||
// We can't easily test this with the real path, but we verify save works
|
||||
// when the dir already exists (which it does after beforeAll).
|
||||
const config: CLIConfig = {
|
||||
currentContext: "",
|
||||
contexts: {},
|
||||
defaults: { output: "table", limit: 10 },
|
||||
};
|
||||
ConfigManager.save(config);
|
||||
expect(fs.existsSync(CONFIG_FILE)).toBe(true);
|
||||
void tmpDir; // unused but shows intent
|
||||
});
|
||||
|
||||
it("should write config with correct permissions", () => {
|
||||
const config: CLIConfig = {
|
||||
currentContext: "x",
|
||||
contexts: {
|
||||
x: { name: "x", apiUrl: "https://x.com", apiKey: "k" },
|
||||
},
|
||||
defaults: { output: "table", limit: 10 },
|
||||
};
|
||||
ConfigManager.save(config);
|
||||
const content = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.currentContext).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentContext", () => {
|
||||
it("should return null when no current context is set", () => {
|
||||
expect(ConfigManager.getCurrentContext()).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when currentContext name does not match any context", () => {
|
||||
// Manually write a config with a dangling currentContext reference
|
||||
const config: CLIConfig = {
|
||||
currentContext: "ghost",
|
||||
contexts: {},
|
||||
defaults: { output: "table", limit: 10 },
|
||||
};
|
||||
ConfigManager.save(config);
|
||||
expect(ConfigManager.getCurrentContext()).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the current context when set", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "prod",
|
||||
apiUrl: "https://prod.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
const ctx = ConfigManager.getCurrentContext();
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.name).toBe("prod");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addContext", () => {
|
||||
it("should add a context and set it as current if first context", () => {
|
||||
const ctx: CLIContext = {
|
||||
ConfigManager.addContext({
|
||||
name: "prod",
|
||||
apiUrl: "https://prod.oneuptime.com",
|
||||
apiKey: "sk-prod-123",
|
||||
};
|
||||
|
||||
ConfigManager.addContext(ctx);
|
||||
});
|
||||
|
||||
const current = ConfigManager.getCurrentContext();
|
||||
expect(current).not.toBeNull();
|
||||
expect(current!.name).toBe("prod");
|
||||
expect(current!.apiUrl).toBe("https://prod.oneuptime.com");
|
||||
});
|
||||
|
||||
it("should not change current context when adding a second context", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "prod",
|
||||
apiUrl: "https://prod.com",
|
||||
apiKey: "key1",
|
||||
});
|
||||
ConfigManager.addContext({
|
||||
name: "staging",
|
||||
apiUrl: "https://staging.com",
|
||||
apiKey: "key2",
|
||||
});
|
||||
|
||||
const current = ConfigManager.getCurrentContext();
|
||||
expect(current!.name).toBe("prod"); // First one remains current
|
||||
});
|
||||
|
||||
it("should add multiple contexts", () => {
|
||||
@@ -134,6 +221,12 @@ describe("ConfigManager", () => {
|
||||
expect(contexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should throw for non-existent context", () => {
|
||||
expect(() => ConfigManager.removeContext("nonexistent")).toThrow(
|
||||
'Context "nonexistent" does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
it("should update current context when removing the current one", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "a",
|
||||
@@ -152,6 +245,63 @@ describe("ConfigManager", () => {
|
||||
expect(current).not.toBeNull();
|
||||
expect(current!.name).toBe("b");
|
||||
});
|
||||
|
||||
it("should set current context to empty when removing last context", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "only",
|
||||
apiUrl: "https://only.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
ConfigManager.removeContext("only");
|
||||
|
||||
expect(ConfigManager.getCurrentContext()).toBeNull();
|
||||
const config = ConfigManager.load();
|
||||
expect(config.currentContext).toBe("");
|
||||
});
|
||||
|
||||
it("should not change current context when removing a non-current one", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "a",
|
||||
apiUrl: "https://a.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
ConfigManager.addContext({
|
||||
name: "b",
|
||||
apiUrl: "https://b.com",
|
||||
apiKey: "k2",
|
||||
});
|
||||
ConfigManager.setCurrentContext("a");
|
||||
ConfigManager.removeContext("b");
|
||||
|
||||
const current = ConfigManager.getCurrentContext();
|
||||
expect(current!.name).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listContexts", () => {
|
||||
it("should return empty array when no contexts exist", () => {
|
||||
expect(ConfigManager.listContexts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should mark current context correctly", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "a",
|
||||
apiUrl: "https://a.com",
|
||||
apiKey: "k1",
|
||||
});
|
||||
ConfigManager.addContext({
|
||||
name: "b",
|
||||
apiUrl: "https://b.com",
|
||||
apiKey: "k2",
|
||||
});
|
||||
ConfigManager.setCurrentContext("b");
|
||||
|
||||
const contexts = ConfigManager.listContexts();
|
||||
const a = contexts.find((c) => c.name === "a");
|
||||
const b = contexts.find((c) => c.name === "b");
|
||||
expect(a!.isCurrent).toBe(false);
|
||||
expect(b!.isCurrent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResolvedCredentials", () => {
|
||||
@@ -164,7 +314,7 @@ describe("ConfigManager", () => {
|
||||
expect(creds.apiUrl).toBe("https://cli.com");
|
||||
});
|
||||
|
||||
it("should resolve from env vars", () => {
|
||||
it("should resolve from env vars when CLI options are missing", () => {
|
||||
process.env["ONEUPTIME_API_KEY"] = "env-key";
|
||||
process.env["ONEUPTIME_URL"] = "https://env.com";
|
||||
|
||||
@@ -173,12 +323,29 @@ describe("ConfigManager", () => {
|
||||
);
|
||||
expect(creds.apiKey).toBe("env-key");
|
||||
expect(creds.apiUrl).toBe("https://env.com");
|
||||
|
||||
delete process.env["ONEUPTIME_API_KEY"];
|
||||
delete process.env["ONEUPTIME_URL"];
|
||||
});
|
||||
|
||||
it("should resolve from current context", () => {
|
||||
it("should resolve from --context flag", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "named",
|
||||
apiUrl: "https://named.com",
|
||||
apiKey: "named-key",
|
||||
});
|
||||
|
||||
const creds: ResolvedCredentials = ConfigManager.getResolvedCredentials({
|
||||
context: "named",
|
||||
});
|
||||
expect(creds.apiKey).toBe("named-key");
|
||||
expect(creds.apiUrl).toBe("https://named.com");
|
||||
});
|
||||
|
||||
it("should throw when --context flag references non-existent context", () => {
|
||||
expect(() =>
|
||||
ConfigManager.getResolvedCredentials({ context: "nope" }),
|
||||
).toThrow('Context "nope" does not exist');
|
||||
});
|
||||
|
||||
it("should resolve from current context in config", () => {
|
||||
ConfigManager.addContext({
|
||||
name: "ctx",
|
||||
apiUrl: "https://ctx.com",
|
||||
@@ -192,10 +359,60 @@ describe("ConfigManager", () => {
|
||||
expect(creds.apiUrl).toBe("https://ctx.com");
|
||||
});
|
||||
|
||||
it("should throw when no credentials available", () => {
|
||||
it("should resolve from partial env vars (only ONEUPTIME_API_KEY)", () => {
|
||||
process.env["ONEUPTIME_API_KEY"] = "partial-key";
|
||||
|
||||
const creds: ResolvedCredentials = ConfigManager.getResolvedCredentials(
|
||||
{},
|
||||
);
|
||||
expect(creds.apiKey).toBe("partial-key");
|
||||
expect(creds.apiUrl).toBe("");
|
||||
});
|
||||
|
||||
it("should resolve from partial env vars (only ONEUPTIME_URL)", () => {
|
||||
process.env["ONEUPTIME_URL"] = "https://partial.com";
|
||||
|
||||
const creds: ResolvedCredentials = ConfigManager.getResolvedCredentials(
|
||||
{},
|
||||
);
|
||||
expect(creds.apiKey).toBe("");
|
||||
expect(creds.apiUrl).toBe("https://partial.com");
|
||||
});
|
||||
|
||||
it("should combine partial env var with context", () => {
|
||||
process.env["ONEUPTIME_API_KEY"] = "env-key";
|
||||
ConfigManager.addContext({
|
||||
name: "ctx",
|
||||
apiUrl: "https://ctx.com",
|
||||
apiKey: "ctx-key",
|
||||
});
|
||||
|
||||
const creds: ResolvedCredentials = ConfigManager.getResolvedCredentials(
|
||||
{},
|
||||
);
|
||||
// env vars take priority: both are set so goes through priority 2
|
||||
// Actually, only ONEUPTIME_API_KEY is set, not ONEUPTIME_URL
|
||||
// So it falls through to priority 4 (current context)
|
||||
expect(creds.apiKey).toBe("ctx-key");
|
||||
expect(creds.apiUrl).toBe("https://ctx.com");
|
||||
});
|
||||
|
||||
it("should throw when no credentials available at all", () => {
|
||||
expect(() => ConfigManager.getResolvedCredentials({})).toThrow(
|
||||
"No credentials found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should prefer CLI flags over env vars", () => {
|
||||
process.env["ONEUPTIME_API_KEY"] = "env-key";
|
||||
process.env["ONEUPTIME_URL"] = "https://env.com";
|
||||
|
||||
const creds: ResolvedCredentials = ConfigManager.getResolvedCredentials({
|
||||
apiKey: "cli-key",
|
||||
url: "https://cli.com",
|
||||
});
|
||||
expect(creds.apiKey).toBe("cli-key");
|
||||
expect(creds.apiUrl).toBe("https://cli.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
100
CLI/Tests/ErrorHandler.test.ts
Normal file
100
CLI/Tests/ErrorHandler.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { handleError, ExitCode } from "../Core/ErrorHandler";
|
||||
import * as OutputFormatter from "../Core/OutputFormatter";
|
||||
|
||||
describe("ErrorHandler", () => {
|
||||
let exitSpy: jest.SpyInstance;
|
||||
let printErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => {
|
||||
// no-op
|
||||
}) as any);
|
||||
printErrorSpy = jest.spyOn(OutputFormatter, "printError").mockImplementation(() => {
|
||||
// no-op
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
printErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should exit with AuthError for API key errors", () => {
|
||||
handleError(new Error("Invalid API key provided"));
|
||||
expect(printErrorSpy).toHaveBeenCalledWith(
|
||||
"Authentication error: Invalid API key provided",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.AuthError);
|
||||
});
|
||||
|
||||
it("should exit with AuthError for credentials errors", () => {
|
||||
handleError(new Error("No credentials found"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.AuthError);
|
||||
});
|
||||
|
||||
it("should exit with AuthError for Unauthorized errors", () => {
|
||||
handleError(new Error("Unauthorized access"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.AuthError);
|
||||
});
|
||||
|
||||
it("should exit with AuthError for 401 errors", () => {
|
||||
handleError(new Error("HTTP 401 response"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.AuthError);
|
||||
});
|
||||
|
||||
it("should exit with NotFound for 404 errors", () => {
|
||||
handleError(new Error("HTTP 404 response"));
|
||||
expect(printErrorSpy).toHaveBeenCalledWith(
|
||||
"Not found: HTTP 404 response",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.NotFound);
|
||||
});
|
||||
|
||||
it("should exit with NotFound for not found errors", () => {
|
||||
handleError(new Error("Resource not found"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.NotFound);
|
||||
});
|
||||
|
||||
it("should exit with GeneralError for API error messages", () => {
|
||||
handleError(new Error("API error (500): Internal Server Error"));
|
||||
expect(printErrorSpy).toHaveBeenCalledWith(
|
||||
"API error (500): Internal Server Error",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError);
|
||||
});
|
||||
|
||||
it("should exit with GeneralError for generic Error objects", () => {
|
||||
handleError(new Error("Something went wrong"));
|
||||
expect(printErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Something went wrong",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError);
|
||||
});
|
||||
|
||||
it("should handle non-Error objects", () => {
|
||||
handleError("string error");
|
||||
expect(printErrorSpy).toHaveBeenCalledWith("Error: string error");
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError);
|
||||
});
|
||||
|
||||
it("should handle null error", () => {
|
||||
handleError(null);
|
||||
expect(printErrorSpy).toHaveBeenCalledWith("Error: null");
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError);
|
||||
});
|
||||
|
||||
it("should handle number error", () => {
|
||||
handleError(42);
|
||||
expect(printErrorSpy).toHaveBeenCalledWith("Error: 42");
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError);
|
||||
});
|
||||
|
||||
describe("ExitCode enum", () => {
|
||||
it("should have correct values", () => {
|
||||
expect(ExitCode.Success).toBe(0);
|
||||
expect(ExitCode.GeneralError).toBe(1);
|
||||
expect(ExitCode.AuthError).toBe(2);
|
||||
expect(ExitCode.NotFound).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
CLI/Tests/Index.test.ts
Normal file
62
CLI/Tests/Index.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Command } from "commander";
|
||||
import { registerConfigCommands } from "../Commands/ConfigCommands";
|
||||
import { registerResourceCommands } from "../Commands/ResourceCommands";
|
||||
import { registerUtilityCommands } from "../Commands/UtilityCommands";
|
||||
|
||||
describe("Index (CLI entry point)", () => {
|
||||
it("should create a program with all command groups registered", () => {
|
||||
const program = new Command();
|
||||
program
|
||||
.name("oneuptime")
|
||||
.description(
|
||||
"OneUptime CLI - Manage your OneUptime resources from the command line",
|
||||
)
|
||||
.version("1.0.0")
|
||||
.option("--api-key <key>", "API key (overrides config)")
|
||||
.option("--url <url>", "OneUptime instance URL (overrides config)")
|
||||
.option("--context <name>", "Use a specific context")
|
||||
.option("-o, --output <format>", "Output format: json, table, wide")
|
||||
.option("--no-color", "Disable colored output");
|
||||
|
||||
registerConfigCommands(program);
|
||||
registerUtilityCommands(program);
|
||||
registerResourceCommands(program);
|
||||
|
||||
// Verify all expected commands are registered
|
||||
const commandNames = program.commands.map((c) => c.name());
|
||||
expect(commandNames).toContain("login");
|
||||
expect(commandNames).toContain("context");
|
||||
expect(commandNames).toContain("version");
|
||||
expect(commandNames).toContain("whoami");
|
||||
expect(commandNames).toContain("resources");
|
||||
expect(commandNames).toContain("incident");
|
||||
expect(commandNames).toContain("monitor");
|
||||
expect(commandNames).toContain("alert");
|
||||
});
|
||||
|
||||
it("should set correct program name and description", () => {
|
||||
const program = new Command();
|
||||
program.name("oneuptime").description("OneUptime CLI");
|
||||
|
||||
expect(program.name()).toBe("oneuptime");
|
||||
});
|
||||
|
||||
it("should define global options", () => {
|
||||
const program = new Command();
|
||||
program
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--url <url>", "URL")
|
||||
.option("--context <name>", "Context")
|
||||
.option("-o, --output <format>", "Output format")
|
||||
.option("--no-color", "Disable color");
|
||||
|
||||
// Parse with just the program name - verify options are registered
|
||||
const options = program.options;
|
||||
const optionNames = options.map((o) => o.long || o.short);
|
||||
expect(optionNames).toContain("--api-key");
|
||||
expect(optionNames).toContain("--url");
|
||||
expect(optionNames).toContain("--context");
|
||||
expect(optionNames).toContain("--output");
|
||||
expect(optionNames).toContain("--no-color");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,35 @@
|
||||
import { formatOutput } from "../Core/OutputFormatter";
|
||||
import {
|
||||
formatOutput,
|
||||
printSuccess,
|
||||
printError,
|
||||
printWarning,
|
||||
printInfo,
|
||||
} from "../Core/OutputFormatter";
|
||||
|
||||
describe("OutputFormatter", () => {
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let originalNoColor: string | undefined;
|
||||
let originalArgv: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
originalNoColor = process.env["NO_COLOR"];
|
||||
originalArgv = [...process.argv];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
if (originalNoColor !== undefined) {
|
||||
process.env["NO_COLOR"] = originalNoColor;
|
||||
} else {
|
||||
delete process.env["NO_COLOR"];
|
||||
}
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
describe("formatOutput with JSON format", () => {
|
||||
it("should format single object as JSON", () => {
|
||||
const data = { id: "123", name: "Test" };
|
||||
@@ -21,6 +50,21 @@ describe("OutputFormatter", () => {
|
||||
const result = formatOutput(null, "json");
|
||||
expect(result).toBe("null");
|
||||
});
|
||||
|
||||
it("should format number as JSON", () => {
|
||||
const result = formatOutput(42, "json");
|
||||
expect(result).toBe("42");
|
||||
});
|
||||
|
||||
it("should format string as JSON", () => {
|
||||
const result = formatOutput("hello", "json");
|
||||
expect(result).toBe('"hello"');
|
||||
});
|
||||
|
||||
it("should format boolean as JSON", () => {
|
||||
const result = formatOutput(true, "json");
|
||||
expect(result).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOutput with table format", () => {
|
||||
@@ -47,6 +91,61 @@ describe("OutputFormatter", () => {
|
||||
expect(result).toContain("Test");
|
||||
expect(result).toContain("Active");
|
||||
});
|
||||
|
||||
it("should return 'No data returned.' for null in table mode", () => {
|
||||
const result = formatOutput(null, "table");
|
||||
expect(result).toBe("No data returned.");
|
||||
});
|
||||
|
||||
it("should return 'No data returned.' for undefined in table mode", () => {
|
||||
const result = formatOutput(undefined as any, "table");
|
||||
expect(result).toBe("No data returned.");
|
||||
});
|
||||
|
||||
it("should return 'No data returned.' for empty string in table mode", () => {
|
||||
const result = formatOutput("" as any, "table");
|
||||
expect(result).toBe("No data returned.");
|
||||
});
|
||||
|
||||
it("should fallback to JSON for array of non-objects", () => {
|
||||
const data = ["a", "b", "c"];
|
||||
const result = formatOutput(data, "table");
|
||||
// First item is not an object, so should fallback to JSON
|
||||
expect(result).toContain('"a"');
|
||||
});
|
||||
|
||||
it("should truncate long string values", () => {
|
||||
const longValue = "x".repeat(100);
|
||||
const data = [{ _id: "1", field: longValue }];
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("should truncate long object values", () => {
|
||||
const bigObj = { a: "x".repeat(80) };
|
||||
const data = [{ _id: "1", nested: bigObj }];
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("should show short object values without truncation", () => {
|
||||
const smallObj = { a: 1 };
|
||||
const data = [{ _id: "1", nested: smallObj }];
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).toContain('{"a":1}');
|
||||
});
|
||||
|
||||
it("should render null values as empty in table", () => {
|
||||
const data = [{ _id: "1", value: null }];
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).toContain("1");
|
||||
});
|
||||
|
||||
it("should render undefined values as empty in table", () => {
|
||||
const data = [{ _id: "1", value: undefined }];
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).toContain("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOutput with wide format", () => {
|
||||
@@ -65,7 +164,6 @@ describe("OutputFormatter", () => {
|
||||
},
|
||||
];
|
||||
const result = formatOutput(data, "wide");
|
||||
// Wide mode should include all columns
|
||||
expect(result).toContain("col7");
|
||||
});
|
||||
|
||||
@@ -84,8 +182,178 @@ describe("OutputFormatter", () => {
|
||||
},
|
||||
];
|
||||
const result = formatOutput(data, "table");
|
||||
// Table mode should limit to 6 columns
|
||||
// Table mode should limit to 6 columns, so col7 should not appear
|
||||
expect(result).not.toContain("col7");
|
||||
});
|
||||
|
||||
it("should prioritize common columns in non-wide mode", () => {
|
||||
const data = [
|
||||
{
|
||||
extra1: "a",
|
||||
extra2: "b",
|
||||
extra3: "c",
|
||||
extra4: "d",
|
||||
extra5: "e",
|
||||
extra6: "f",
|
||||
_id: "1",
|
||||
name: "Test",
|
||||
title: "Title",
|
||||
status: "Active",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-02",
|
||||
},
|
||||
];
|
||||
const result = formatOutput(data, "table");
|
||||
// Priority columns should appear
|
||||
expect(result).toContain("_id");
|
||||
expect(result).toContain("name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("format auto-detection", () => {
|
||||
it("should default to JSON when not a TTY", () => {
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const data = { id: "1" };
|
||||
const result = formatOutput(data);
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default to table when TTY", () => {
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const data = [{ _id: "1", name: "Test" }];
|
||||
const result = formatOutput(data);
|
||||
// Table format contains box-drawing characters
|
||||
expect(result).toContain("─");
|
||||
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle unknown format string and default to table via TTY check", () => {
|
||||
const data = [{ _id: "1" }];
|
||||
// "unknown" is not json/table/wide, so cliFormat falls through and TTY detection occurs
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = formatOutput(data, "unknown");
|
||||
expect(result).toContain("─");
|
||||
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("color handling", () => {
|
||||
it("should respect NO_COLOR env variable in table rendering", () => {
|
||||
process.env["NO_COLOR"] = "1";
|
||||
const data = [{ _id: "1", name: "A" }];
|
||||
const result = formatOutput(data, "table");
|
||||
// Should not contain ANSI color codes
|
||||
expect(result).not.toMatch(/\x1b\[/);
|
||||
});
|
||||
|
||||
it("should respect --no-color argv flag in table rendering", () => {
|
||||
process.argv.push("--no-color");
|
||||
const data = [{ _id: "1", name: "A" }];
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).not.toMatch(/\x1b\[/);
|
||||
});
|
||||
|
||||
it("should render single object without color when NO_COLOR set", () => {
|
||||
process.env["NO_COLOR"] = "1";
|
||||
const data = { name: "Test" };
|
||||
const result = formatOutput(data, "table");
|
||||
expect(result).not.toMatch(/\x1b\[/);
|
||||
expect(result).toContain("name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printSuccess", () => {
|
||||
it("should log success message with color", () => {
|
||||
delete process.env["NO_COLOR"];
|
||||
// Remove --no-color from argv if present
|
||||
process.argv = process.argv.filter((a) => a !== "--no-color");
|
||||
printSuccess("OK");
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log success message without color when NO_COLOR is set", () => {
|
||||
process.env["NO_COLOR"] = "1";
|
||||
printSuccess("OK");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("OK");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printError", () => {
|
||||
it("should log error message with color", () => {
|
||||
delete process.env["NO_COLOR"];
|
||||
process.argv = process.argv.filter((a) => a !== "--no-color");
|
||||
printError("fail");
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log error message without color when NO_COLOR is set", () => {
|
||||
process.env["NO_COLOR"] = "1";
|
||||
printError("fail");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printWarning", () => {
|
||||
it("should log warning message with color", () => {
|
||||
delete process.env["NO_COLOR"];
|
||||
process.argv = process.argv.filter((a) => a !== "--no-color");
|
||||
printWarning("warn");
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log warning message without color when NO_COLOR is set", () => {
|
||||
process.env["NO_COLOR"] = "1";
|
||||
printWarning("warn");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("warn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printInfo", () => {
|
||||
it("should log info message with color", () => {
|
||||
delete process.env["NO_COLOR"];
|
||||
process.argv = process.argv.filter((a) => a !== "--no-color");
|
||||
printInfo("info");
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log info message without color when NO_COLOR is set", () => {
|
||||
process.env["NO_COLOR"] = "1";
|
||||
printInfo("info");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("info");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,61 @@
|
||||
import { discoverResources } from "../Commands/ResourceCommands";
|
||||
import { Command } from "commander";
|
||||
import { ResourceInfo } from "../Types/CLITypes";
|
||||
import * as ConfigManager from "../Core/ConfigManager";
|
||||
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.fn();
|
||||
jest.mock("../Core/ApiClient", () => ({
|
||||
...jest.requireActual("../Core/ApiClient"),
|
||||
executeApiRequest: (...args) => mockExecuteApiRequest(...args),
|
||||
}));
|
||||
|
||||
// Import after mock setup
|
||||
import {
|
||||
discoverResources,
|
||||
registerResourceCommands,
|
||||
} from "../Commands/ResourceCommands";
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
|
||||
const CONFIG_FILE = 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[];
|
||||
|
||||
@@ -50,4 +104,430 @@ describe("ResourceCommands", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerResourceCommands", () => {
|
||||
it("should register commands for all discovered resources", () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerResourceCommands(program);
|
||||
|
||||
const resources = discoverResources();
|
||||
for (const resource of resources) {
|
||||
const cmd = program.commands.find((c) => c.name() === resource.name);
|
||||
expect(cmd).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should register list, get, create, update, delete, count subcommands for database resources", () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerResourceCommands(program);
|
||||
|
||||
const incidentCmd = program.commands.find(
|
||||
(c) => c.name() === "incident",
|
||||
);
|
||||
expect(incidentCmd).toBeDefined();
|
||||
|
||||
const subcommands = incidentCmd!.commands.map((c) => 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 = 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(() => {
|
||||
ConfigManager.addContext({
|
||||
name: "test",
|
||||
apiUrl: "https://test.oneuptime.com",
|
||||
apiKey: "test-key-12345",
|
||||
});
|
||||
mockExecuteApiRequest.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
describe("list subcommand", () => {
|
||||
it("should call API with list operation", async () => {
|
||||
const program = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"list",
|
||||
"--query",
|
||||
'{"status":"active"}',
|
||||
"--limit",
|
||||
"20",
|
||||
"--skip",
|
||||
"5",
|
||||
"--sort",
|
||||
'{"createdAt":-1}',
|
||||
]);
|
||||
|
||||
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
||||
const opts = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"list",
|
||||
"-o",
|
||||
"json",
|
||||
]);
|
||||
|
||||
expect(console.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle response that is already an array", async () => {
|
||||
mockExecuteApiRequest.mockResolvedValue([{ _id: "1" }]);
|
||||
|
||||
const program = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"list",
|
||||
"-o",
|
||||
"json",
|
||||
]);
|
||||
|
||||
expect(console.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
mockExecuteApiRequest.mockRejectedValue(
|
||||
new Error("API error (500): Server Error"),
|
||||
);
|
||||
|
||||
const program = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"get",
|
||||
"abc-123",
|
||||
]);
|
||||
|
||||
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
||||
const opts = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"get",
|
||||
"abc-123",
|
||||
"-o",
|
||||
"json",
|
||||
]);
|
||||
|
||||
expect(console.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle get errors", async () => {
|
||||
mockExecuteApiRequest.mockRejectedValue(new Error("not found 404"));
|
||||
|
||||
const program = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"create",
|
||||
"--data",
|
||||
'{"name":"New Incident"}',
|
||||
]);
|
||||
|
||||
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
||||
const opts = 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 = path.join(
|
||||
os.tmpdir(),
|
||||
"cli-test-" + Date.now() + ".json",
|
||||
);
|
||||
fs.writeFileSync(tmpFile, '{"name":"From File"}');
|
||||
|
||||
try {
|
||||
const program = 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 = createProgramWithResources();
|
||||
await program.parseAsync(["node", "test", "incident", "create"]);
|
||||
|
||||
expect(process.exit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should error on invalid JSON in --data", async () => {
|
||||
const program = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"update",
|
||||
"abc-123",
|
||||
"--data",
|
||||
'{"name":"Updated"}',
|
||||
]);
|
||||
|
||||
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
||||
const opts = 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 = 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 = createProgramWithResources();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"incident",
|
||||
"delete",
|
||||
"abc-123",
|
||||
]);
|
||||
|
||||
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
||||
const opts = 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 = 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 = createProgramWithResources();
|
||||
await program.parseAsync(["node", "test", "incident", "count"]);
|
||||
|
||||
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecuteApiRequest.mock.calls[0][0].operation).toBe("count");
|
||||
expect(console.log).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("should pass query filter", async () => {
|
||||
mockExecuteApiRequest.mockResolvedValue({ count: 5 });
|
||||
|
||||
const program = 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 = createProgramWithResources();
|
||||
await program.parseAsync(["node", "test", "incident", "count"]);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(99);
|
||||
});
|
||||
|
||||
it("should handle non-object response in count", async () => {
|
||||
mockExecuteApiRequest.mockResolvedValue("some-string");
|
||||
|
||||
const program = createProgramWithResources();
|
||||
await program.parseAsync(["node", "test", "incident", "count"]);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("some-string");
|
||||
});
|
||||
|
||||
it("should handle count errors", async () => {
|
||||
mockExecuteApiRequest.mockRejectedValue(new Error("API error"));
|
||||
|
||||
const program = 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 () => {
|
||||
ConfigManager.removeContext("test");
|
||||
mockExecuteApiRequest.mockResolvedValue({ data: [] });
|
||||
|
||||
const program = 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
157
CLI/Tests/SelectFieldGenerator.test.ts
Normal file
157
CLI/Tests/SelectFieldGenerator.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { generateAllFieldsSelect } from "../Utils/SelectFieldGenerator";
|
||||
|
||||
describe("SelectFieldGenerator", () => {
|
||||
describe("generateAllFieldsSelect", () => {
|
||||
describe("database models", () => {
|
||||
it("should return fields for a known database model (Incident)", () => {
|
||||
const select = generateAllFieldsSelect("Incident", "database");
|
||||
expect(Object.keys(select).length).toBeGreaterThan(0);
|
||||
// Should have some common fields
|
||||
expect(select).toHaveProperty("_id");
|
||||
});
|
||||
|
||||
it("should return fields for Monitor model", () => {
|
||||
const select = generateAllFieldsSelect("Monitor", "database");
|
||||
expect(Object.keys(select).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return default select for unknown database model", () => {
|
||||
const select = generateAllFieldsSelect(
|
||||
"NonExistentModel12345",
|
||||
"database",
|
||||
);
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter fields based on access control", () => {
|
||||
// Testing with a real model that has access control
|
||||
const select = generateAllFieldsSelect("Incident", "database");
|
||||
// We just verify it returns something reasonable
|
||||
expect(typeof select).toBe("object");
|
||||
expect(Object.keys(select).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analytics models", () => {
|
||||
it("should return default select for known analytics model (LogItem)", () => {
|
||||
// The Log analytics model has tableName "LogItem"
|
||||
const select = generateAllFieldsSelect("LogItem", "analytics");
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return default select for unknown analytics model", () => {
|
||||
const select = generateAllFieldsSelect(
|
||||
"NonExistentAnalytics",
|
||||
"analytics",
|
||||
);
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should return default select for unknown model type", () => {
|
||||
const select = generateAllFieldsSelect(
|
||||
"Incident",
|
||||
"unknown" as any,
|
||||
);
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return default select for empty tableName", () => {
|
||||
const select = generateAllFieldsSelect("", "database");
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle outer exception and return default select", () => {
|
||||
const DatabaseModels = require("Common/Models/DatabaseModels/Index").default;
|
||||
const origFind = DatabaseModels.find;
|
||||
try {
|
||||
DatabaseModels.find = () => {
|
||||
throw new Error("Simulated error");
|
||||
};
|
||||
|
||||
const select = generateAllFieldsSelect("Incident", "database");
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
} finally {
|
||||
DatabaseModels.find = origFind;
|
||||
}
|
||||
});
|
||||
|
||||
it("should return default when getTableColumns returns empty", () => {
|
||||
const tableColumnModule = require("Common/Types/Database/TableColumn");
|
||||
const origGetTableColumns = tableColumnModule.getTableColumns;
|
||||
try {
|
||||
tableColumnModule.getTableColumns = () => ({});
|
||||
|
||||
const select = generateAllFieldsSelect("Incident", "database");
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
} finally {
|
||||
tableColumnModule.getTableColumns = origGetTableColumns;
|
||||
}
|
||||
});
|
||||
|
||||
it("should return default when all columns are filtered out", () => {
|
||||
const tableColumnModule = require("Common/Types/Database/TableColumn");
|
||||
const origGetTableColumns = tableColumnModule.getTableColumns;
|
||||
const DatabaseModels = require("Common/Models/DatabaseModels/Index").default;
|
||||
const origFind = DatabaseModels.find;
|
||||
const Permission = require("Common/Types/Permission").default;
|
||||
|
||||
try {
|
||||
tableColumnModule.getTableColumns = () => ({ field1: {}, field2: {} });
|
||||
|
||||
DatabaseModels.find = (fn) => {
|
||||
function MockModel() {
|
||||
this.tableName = "MockTable";
|
||||
this.getColumnAccessControlForAllColumns = () => ({
|
||||
field1: { read: [Permission.CurrentUser] },
|
||||
field2: { read: [Permission.CurrentUser] },
|
||||
});
|
||||
}
|
||||
const matches = fn(MockModel);
|
||||
if (matches) return MockModel;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const select = generateAllFieldsSelect("MockTable", "database");
|
||||
expect(select).toEqual({
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
} finally {
|
||||
DatabaseModels.find = origFind;
|
||||
tableColumnModule.getTableColumns = origGetTableColumns;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
191
CLI/Tests/UtilityCommands.test.ts
Normal file
191
CLI/Tests/UtilityCommands.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Command } from "commander";
|
||||
import { registerUtilityCommands } from "../Commands/UtilityCommands";
|
||||
import { registerResourceCommands } from "../Commands/ResourceCommands";
|
||||
import * as ConfigManager from "../Core/ConfigManager";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
||||
|
||||
describe("UtilityCommands", () => {
|
||||
let originalConfigContent: string | null = null;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let exitSpy: jest.SpyInstance;
|
||||
|
||||
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);
|
||||
}
|
||||
consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||
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"];
|
||||
});
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
program
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--url <url>", "URL")
|
||||
.option("--context <name>", "Context");
|
||||
registerUtilityCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("version command", () => {
|
||||
it("should print version", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "version"]);
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
// Should print a version string (either from package.json or fallback)
|
||||
const versionArg = consoleLogSpy.mock.calls[0][0];
|
||||
expect(typeof versionArg).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("whoami command", () => {
|
||||
it("should show not authenticated when no credentials", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "whoami"]);
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show credentials from current context", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "test",
|
||||
apiUrl: "https://test.com",
|
||||
apiKey: "abcdefghijklm",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "whoami"]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("URL: https://test.com");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("****"),
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("Context: test");
|
||||
});
|
||||
|
||||
it("should mask short API keys", async () => {
|
||||
ConfigManager.addContext({
|
||||
name: "short",
|
||||
apiUrl: "https://s.com",
|
||||
apiKey: "abc",
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "whoami"]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("API Key: ****");
|
||||
});
|
||||
|
||||
it("should show credentials from env vars", async () => {
|
||||
process.env["ONEUPTIME_API_KEY"] = "env-key-long-enough";
|
||||
process.env["ONEUPTIME_URL"] = "https://env.com";
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "whoami"]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("URL: https://env.com");
|
||||
});
|
||||
|
||||
it("should handle whoami outer catch block", async () => {
|
||||
// Mock getCurrentContext to throw an unexpected error
|
||||
const spy = jest
|
||||
.spyOn(ConfigManager, "getCurrentContext")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("Unexpected crash");
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "whoami"]);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not show context line when no context exists", async () => {
|
||||
process.env["ONEUPTIME_API_KEY"] = "env-key-long-enough";
|
||||
process.env["ONEUPTIME_URL"] = "https://env.com";
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "whoami"]);
|
||||
|
||||
// Should NOT have a "Context:" call since no context is set
|
||||
const contextCalls = consoleLogSpy.mock.calls.filter(
|
||||
(call: any[]) => typeof call[0] === "string" && call[0].startsWith("Context:"),
|
||||
);
|
||||
expect(contextCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resources command", () => {
|
||||
it("should list all resources", async () => {
|
||||
// We need registerResourceCommands for discoverResources to work
|
||||
// but discoverResources is imported directly, so it should work
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["node", "test", "resources"]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
// Should show total count
|
||||
const lastCall =
|
||||
consoleLogSpy.mock.calls[consoleLogSpy.mock.calls.length - 1][0];
|
||||
expect(lastCall).toContain("Total:");
|
||||
});
|
||||
|
||||
it("should filter by type", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"resources",
|
||||
"--type",
|
||||
"database",
|
||||
]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show message when filter returns no results", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"resources",
|
||||
"--type",
|
||||
"nonexistent",
|
||||
]);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@
|
||||
"**/*.ts",
|
||||
"!**/*.d.ts",
|
||||
"!**/node_modules/**",
|
||||
"!**/build/**"
|
||||
"!**/build/**",
|
||||
"!**/Tests/**",
|
||||
"!Index.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [],
|
||||
"testTimeout": 30000,
|
||||
@@ -23,6 +25,8 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"module": "commonjs"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user