From b89ff11db862269a64e82ffabe580a13b64e475f Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Sun, 15 Feb 2026 10:54:50 +0000 Subject: [PATCH] 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. --- CLI/Tests/ApiClient.test.ts | 380 +++++++++++++++---- CLI/Tests/ConfigCommands.test.ts | 252 +++++++++++++ CLI/Tests/ConfigManager.test.ts | 251 ++++++++++++- CLI/Tests/ErrorHandler.test.ts | 100 +++++ CLI/Tests/Index.test.ts | 62 ++++ CLI/Tests/OutputFormatter.test.ts | 274 +++++++++++++- CLI/Tests/ResourceCommands.test.ts | 482 ++++++++++++++++++++++++- CLI/Tests/SelectFieldGenerator.test.ts | 157 ++++++++ CLI/Tests/UtilityCommands.test.ts | 191 ++++++++++ CLI/jest.config.json | 6 +- 10 files changed, 2071 insertions(+), 84 deletions(-) create mode 100644 CLI/Tests/ConfigCommands.test.ts create mode 100644 CLI/Tests/ErrorHandler.test.ts create mode 100644 CLI/Tests/Index.test.ts create mode 100644 CLI/Tests/SelectFieldGenerator.test.ts create mode 100644 CLI/Tests/UtilityCommands.test.ts diff --git a/CLI/Tests/ApiClient.test.ts b/CLI/Tests/ApiClient.test.ts index eca581eb43..175940633f 100644 --- a/CLI/Tests/ApiClient.test.ts +++ b/CLI/Tests/ApiClient.test.ts @@ -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(); }); }); }); diff --git a/CLI/Tests/ConfigCommands.test.ts b/CLI/Tests/ConfigCommands.test.ts new file mode 100644 index 0000000000..eb1982e94f --- /dev/null +++ b/CLI/Tests/ConfigCommands.test.ts @@ -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); + }); + }); +}); diff --git a/CLI/Tests/ConfigManager.test.ts b/CLI/Tests/ConfigManager.test.ts index 26ea0bba28..4820196fea 100644 --- a/CLI/Tests/ConfigManager.test.ts +++ b/CLI/Tests/ConfigManager.test.ts @@ -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"); + }); }); }); diff --git a/CLI/Tests/ErrorHandler.test.ts b/CLI/Tests/ErrorHandler.test.ts new file mode 100644 index 0000000000..e074216b62 --- /dev/null +++ b/CLI/Tests/ErrorHandler.test.ts @@ -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); + }); + }); +}); diff --git a/CLI/Tests/Index.test.ts b/CLI/Tests/Index.test.ts new file mode 100644 index 0000000000..24e7b578e3 --- /dev/null +++ b/CLI/Tests/Index.test.ts @@ -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 ", "API key (overrides config)") + .option("--url ", "OneUptime instance URL (overrides config)") + .option("--context ", "Use a specific context") + .option("-o, --output ", "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 ", "API key") + .option("--url ", "URL") + .option("--context ", "Context") + .option("-o, --output ", "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"); + }); +}); diff --git a/CLI/Tests/OutputFormatter.test.ts b/CLI/Tests/OutputFormatter.test.ts index 9a2548260e..3a1ef43388 100644 --- a/CLI/Tests/OutputFormatter.test.ts +++ b/CLI/Tests/OutputFormatter.test.ts @@ -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"); + }); }); }); diff --git a/CLI/Tests/ResourceCommands.test.ts b/CLI/Tests/ResourceCommands.test.ts index 1da8efdaa6..a86df132b3 100644 --- a/CLI/Tests/ResourceCommands.test.ts +++ b/CLI/Tests/ResourceCommands.test.ts @@ -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 ", "API key") + .option("--url ", "URL") + .option("--context ", "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", + ); + }); + }); + }); }); diff --git a/CLI/Tests/SelectFieldGenerator.test.ts b/CLI/Tests/SelectFieldGenerator.test.ts new file mode 100644 index 0000000000..795809ff6e --- /dev/null +++ b/CLI/Tests/SelectFieldGenerator.test.ts @@ -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; + } + }); + }); + }); +}); diff --git a/CLI/Tests/UtilityCommands.test.ts b/CLI/Tests/UtilityCommands.test.ts new file mode 100644 index 0000000000..97cae6313f --- /dev/null +++ b/CLI/Tests/UtilityCommands.test.ts @@ -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 ", "API key") + .option("--url ", "URL") + .option("--context ", "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(); + }); + }); +}); diff --git a/CLI/jest.config.json b/CLI/jest.config.json index 818d55ad43..e77ee693fe 100644 --- a/CLI/jest.config.json +++ b/CLI/jest.config.json @@ -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" }