From 8f98a7168b64628f769ec8e0015fc0f6c36ac528 Mon Sep 17 00:00:00 2001 From: anandgupta42 <93243293+anandgupta42@users.noreply.github.com> Date: Mon, 19 May 2025 22:51:58 -0700 Subject: [PATCH] Add tests to improve coverage --- jest.config.js | 1 + src/test/suite/altimate.test.ts | 146 ++++++++++ .../suite/commandProcessExecution.test.ts | 20 ++ src/test/suite/dbtTerminal.test.ts | 35 ++- src/test/suite/utils.test.ts | 250 ++++++++++++++++++ 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 src/test/suite/utils.test.ts diff --git a/jest.config.js b/jest.config.js index 6b9713756..bddb0e615 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,5 +17,6 @@ module.exports = { moduleNameMapper: { "^vscode$": "/src/test/mock/vscode.ts", "^@lib$": "/src/test/mock/lib.ts", + "^@extension$": "/src/modules.ts", }, }; diff --git a/src/test/suite/altimate.test.ts b/src/test/suite/altimate.test.ts index e83243950..fa8116c2c 100644 --- a/src/test/suite/altimate.test.ts +++ b/src/test/suite/altimate.test.ts @@ -10,7 +10,12 @@ import { TelemetryService } from "../../telemetry"; import { DBTTerminal } from "../../dbt_client/dbtTerminal"; import { PythonEnvironment } from "../../manifest/pythonEnvironment"; import { window, workspace, ConfigurationTarget } from "vscode"; +import { Readable } from "stream"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; import { AltimateRequest } from "../../altimate"; +import { RateLimitException } from "../../exceptions/rateLimitException"; type FetchFn = ( input: string | URL | Request, @@ -170,4 +175,145 @@ describe("AltimateRequest Tests", () => { expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith(expect.stringContaining("success")); }); + + it("getCredentialsMessage varies by config", () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("") + .mockReturnValueOnce(""); + expect(request.getCredentialsMessage()).toContain( + "API Key and an instance name", + ); + + mockPythonEnv.getResolvedConfigValue.mockImplementation((key: string) => + key === "altimateAiKey" ? "k" : "", + ); + expect(request.getCredentialsMessage()).toContain("instance name"); + + mockPythonEnv.getResolvedConfigValue.mockImplementation((key: string) => + key === "altimateInstanceName" ? "i" : "", + ); + expect(request.getCredentialsMessage()).toContain("API key"); + + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + expect(request.getCredentialsMessage()).toBeUndefined(); + }); + + it("handlePreviewFeatures shows message when missing", () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("") + .mockReturnValueOnce(""); + const spy = jest + .spyOn(request as any, "showAPIKeyMessage") + .mockResolvedValue(undefined); + expect(request.handlePreviewFeatures()).toBe(false); + expect(spy).toHaveBeenCalled(); + }); + + it("handlePreviewFeatures returns true when credentials exist", () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("k") + .mockReturnValueOnce("i"); + const spy = jest.spyOn(request as any, "showAPIKeyMessage"); + expect(request.handlePreviewFeatures()).toBe(true); + expect(spy).not.toHaveBeenCalled(); + }); + + it("readStreamToBlob collects stream data", async () => { + const stream = Readable.from(["a", "b"]); + const blob: any = await (request as any).readStreamToBlob(stream as any); + const text = await blob.text(); + expect(text).toBe("ab"); + }); + + it("uploadToS3 uploads file", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + const file = path.join(os.tmpdir(), "up.txt"); + fs.writeFileSync(file, "x"); + const mockRes = new Response("ok", { status: 200, statusText: "OK" }); + fetchMock.mockResolvedValue(mockRes); + const res = await request.uploadToS3("http://s3", {}, file); + expect(res.status).toBe(200); + fs.rmSync(file); + }); + + it("uploadToS3 throws on failure", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + const file = path.join(os.tmpdir(), "up.txt"); + fs.writeFileSync(file, "x"); + const mockRes = new Response("bad", { status: 500, statusText: "ERR" }); + fetchMock.mockResolvedValue(mockRes); + await expect(request.uploadToS3("http://s3", {}, file)).rejects.toThrow( + "Failed to upload data", + ); + fs.rmSync(file); + }); + + it("fetchAsStream throws NotFoundError", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + fetchMock.mockResolvedValue( + new Response("", { status: 404, statusText: "NotFound" }), + ); + await expect(request.fetchAsStream("foo", {}, jest.fn())).rejects.toThrow( + "Resource Not found", + ); + }); + + it("fetchAsStream throws RateLimitException", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + fetchMock.mockResolvedValue( + new Response("wait", { + status: 429, + statusText: "Too Many", + headers: { "Retry-After": "1" }, + }), + ); + await expect(request.fetchAsStream("foo", {}, jest.fn())).rejects.toThrow( + RateLimitException, + ); + }); + + it("fetchAsStream throws ForbiddenError", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + fetchMock.mockResolvedValue( + new Response("", { status: 403, statusText: "Forbidden" }), + ); + await expect(request.fetchAsStream("foo", {}, jest.fn())).rejects.toThrow( + "Invalid credentials", + ); + }); + + it("fetchAsStream returns null on empty body", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + const cb = jest.fn(); + const res = await request.fetchAsStream("foo", {}, cb); + expect(res).toBeNull(); + expect(cb).not.toHaveBeenCalled(); + }); + + it("fetchAsStream throws ExecutionsExhaustedException", async () => { + mockPythonEnv.getResolvedConfigValue + .mockReturnValueOnce("key") + .mockReturnValueOnce("instance"); + fetchMock.mockResolvedValue( + new Response('{"detail":"stop"}', { status: 402, statusText: "Limit" }), + ); + await expect(request.fetchAsStream("foo", {}, jest.fn())).rejects.toThrow( + "stop", + ); + }); }); diff --git a/src/test/suite/commandProcessExecution.test.ts b/src/test/suite/commandProcessExecution.test.ts index cba00c1ff..08e883f1a 100644 --- a/src/test/suite/commandProcessExecution.test.ts +++ b/src/test/suite/commandProcessExecution.test.ts @@ -119,4 +119,24 @@ describe("CommandProcessExecution Tests", () => { const result = await execution.complete(); expect(result.stderr.trim()).toBe("error"); }); + + it("should stream output to terminal", async () => { + const execution = factory.createCommandProcessExecution({ + command: process.platform === "win32" ? "cmd" : "echo", + args: process.platform === "win32" ? ["/c", "echo stream"] : ["stream"], + }); + when(mockTerminal.log(anything())).thenReturn(); + const result = await execution.completeWithTerminalOutput(); + expect(result.stdout.trim()).toBe("stream"); + verify(mockTerminal.log(anything())).atLeast(1); + }); + + it("should format text by replacing newlines", () => { + const execution = new CommandProcessExecution( + instance(mockTerminal), + "", + [], + ); + expect(execution.formatText("a\n\nb")).toBe("a\r\n\rb"); + }); }); diff --git a/src/test/suite/dbtTerminal.test.ts b/src/test/suite/dbtTerminal.test.ts index 4462f527c..2ac4750dc 100644 --- a/src/test/suite/dbtTerminal.test.ts +++ b/src/test/suite/dbtTerminal.test.ts @@ -27,13 +27,19 @@ describe("DBTTerminal Test Suite", () => { sendTelemetryError: jest.fn(), }; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "debug").mockImplementation(() => {}); + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + terminal = new DBTTerminal(mockTelemetry); // @ts-ignore - Manually set the output channel terminal.outputChannel = mockOutputChannel; }); afterEach(() => { - jest.clearAllMocks(); + jest.restoreAllMocks(); }); it("should log messages with proper formatting", () => { @@ -42,6 +48,28 @@ describe("DBTTerminal Test Suite", () => { expect(mockOutputChannel.info).toHaveBeenCalledWith(message, []); }); + it("logLine writes message and newline", () => { + const spy = jest.spyOn(terminal, "log"); + terminal.logLine("abc"); + expect(spy).toHaveBeenCalledWith("abc"); + expect(spy).toHaveBeenCalledWith("\r\n"); + }); + + it("logNewLine logs CRLF", () => { + const spy = jest.spyOn(terminal, "log"); + terminal.logNewLine(); + expect(spy).toHaveBeenCalledWith("\r\n"); + }); + + it("should write to terminal when active", () => { + const message = "hello"; + // @ts-ignore - access private + terminal.terminal = { show: jest.fn(), dispose: jest.fn() } as any; + const fireSpy = jest.spyOn((terminal as any).writeEmitter, "fire"); + terminal.log(message); + expect(fireSpy).toHaveBeenCalledWith(message); + }); + it("should send telemetry on info messages", () => { const name = "test_event"; const message = "Test info message"; @@ -105,6 +133,11 @@ describe("DBTTerminal Test Suite", () => { ); }); + it("should handle string errors", () => { + terminal.error("name", "msg", "oops"); + expect(mockOutputChannel.error).toHaveBeenCalledWith("name:msg:oops", []); + }); + it("should format and log blocks with horizontal rules", () => { const block = ["Line 1", "Line 2", "Line 3"]; terminal.logBlock(block); diff --git a/src/test/suite/utils.test.ts b/src/test/suite/utils.test.ts new file mode 100644 index 000000000..46ba45e41 --- /dev/null +++ b/src/test/suite/utils.test.ts @@ -0,0 +1,250 @@ +import { + expect, + describe, + it, + beforeEach, + afterEach, + jest, +} from "@jest/globals"; +import { Readable } from "stream"; +import { Uri, workspace, window, Position, Range } from "vscode"; +import { + processStreamResponse, + deepEqual, + getStringSizeInMb, + isQuotedIdentifier, + getColumnNameByCase, + isColumnNameEqual, + getFirstWorkspacePath, + getExternalProjectNamesFromDbtLoomConfig, + getCurrentlySelectedModelNameInYamlConfig, + isRelationship, + isAcceptedValues, + getColumnTestConfigFromYml, + setupWatcherHandler, + getProjectRelativePath, + notEmpty, + arrayEquals, + debounce, + extendErrorWithSupportLinks, + stripANSI, + getFormattedDateTime, + isEnclosedWithinCodeBlock, +} from "../../utils"; +import { RateLimitException } from "../../exceptions/rateLimitException"; +import { NoCredentialsError } from "../../exceptions/noCredentialsError"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +describe("utils tests", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("processStreamResponse handles Node streams", async () => { + const stream = Readable.from(["foo", "bar"]); + const cb = jest.fn(); + const result = await processStreamResponse(stream, cb); + expect(result).toBe("foobar"); + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenCalledWith("foo"); + expect(cb).toHaveBeenCalledWith("bar"); + }); + + it("processStreamResponse handles Web streams", async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("baz")); + controller.close(); + }, + }); + const cb = jest.fn(); + const result = await processStreamResponse(stream, cb); + expect(result).toBe("baz"); + expect(cb).toHaveBeenCalledWith("baz"); + }); + + it("deepEqual compares nested objects", () => { + const obj1 = { a: 1, b: { c: [1, 2] } }; + const obj2 = { b: { c: [1, 2] }, a: 1 }; + const obj3 = { a: 1, b: { c: [2, 1] } }; + expect(deepEqual(obj1, obj2)).toBe(true); + expect(deepEqual(obj1, obj3)).toBe(false); + }); + + it("getStringSizeInMb handles multibyte characters", () => { + const asciiSize = getStringSizeInMb("abc"); + const multiSize = getStringSizeInMb("πππ"); + expect(asciiSize).toBeCloseTo(3 / (1024 * 1024)); + expect(multiSize).toBeCloseTo(6 / (1024 * 1024)); + }); + + it("isQuotedIdentifier respects custom regex", () => { + jest.spyOn(workspace, "getConfiguration").mockReturnValue({ + get: (key: string) => + key === "unquotedCaseInsensitiveIdentifierRegex" + ? "^[a-z]+$" + : undefined, + } as any); + expect(isQuotedIdentifier("abc", "any")).toBe(false); + expect(isQuotedIdentifier("ABC", "any")).toBe(true); + }); + + it("getColumnNameByCase and isColumnNameEqual use config", () => { + jest.spyOn(workspace, "getConfiguration").mockReturnValue({ + get: (key: string) => (key === "showColumnNamesInLowercase" ? true : ""), + } as any); + expect(getColumnNameByCase("TEST", "snowflake")).toBe("test"); + expect(isColumnNameEqual("CoL", "col")).toBe(true); + }); + + it("getFirstWorkspacePath falls back when no workspace", () => { + (workspace.workspaceFolders as any) = undefined; + jest.spyOn(Uri, "file").mockReturnValue({ fsPath: "./" } as any); + expect(getFirstWorkspacePath()).toBe("./"); + }); + + it("getCurrentlySelectedModelNameInYamlConfig returns model", () => { + const yaml = `models:\n - name: model_a\n - name: model_b`; + const lines = yaml.split("\n"); + const document = { + languageId: "yaml", + getText: () => yaml, + offsetAt: ({ line, character }: any) => + lines.slice(0, line).join("\n").length + (line > 0 ? 1 : 0) + character, + } as any; + (window as any).activeTextEditor = { + document, + selection: { active: { line: 2, character: 5 } }, + }; + expect(getCurrentlySelectedModelNameInYamlConfig()).toBe("model_b"); + }); + + it("type guard helpers identify metadata", () => { + const rel = { field: "id", to: "ref" }; + const acc = { values: ["a", "b"] }; + expect(isRelationship(rel)).toBe(true); + expect(isAcceptedValues(rel)).toBe(false); + expect(isAcceptedValues(acc)).toBe(true); + expect(isRelationship(acc)).toBe(false); + }); + + it("getColumnTestConfigFromYml extracts config", () => { + const tests = [ + { relationships: { field: "id", to: "ref" } }, + { accepted_values: { values: ["a", "b"] } }, + { not_null: { severity: "warn" } }, + ]; + expect( + getColumnTestConfigFromYml( + tests, + { field: "id", to: "ref" }, + "relationships", + ), + ).toEqual({ field: "id", to: "ref" }); + expect( + getColumnTestConfigFromYml( + tests, + { values: ["b", "a"] }, + "accepted_values", + ), + ).toEqual({ values: ["a", "b"] }); + expect( + getColumnTestConfigFromYml(tests, { severity: "warn" }, "not_null"), + ).toEqual({ not_null: { severity: "warn" } }); + }); + + it("getExternalProjectNamesFromDbtLoomConfig reads file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "loom-")); + const file = path.join(dir, "dbt_loom.config.yml"); + fs.writeFileSync(file, "manifests:\n - name: proj1\n - name: proj2\n"); + const result = getExternalProjectNamesFromDbtLoomConfig(dir); + expect(result).toEqual(["proj1", "proj2"]); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("getExternalProjectNamesFromDbtLoomConfig handles missing file", () => { + const result = getExternalProjectNamesFromDbtLoomConfig("/no/such/dir"); + expect(result).toBeNull(); + }); + + it("setupWatcherHandler wires events", () => { + const watcher = { + onDidChange: jest.fn((cb: any) => (cb(), { dispose: jest.fn() })), + onDidCreate: jest.fn((cb: any) => (cb(), { dispose: jest.fn() })), + onDidDelete: jest.fn((cb: any) => (cb(), { dispose: jest.fn() })), + } as any; + const handler = jest.fn(); + const disposables = setupWatcherHandler(watcher, handler); + expect(handler).toHaveBeenCalledTimes(3); + expect(disposables).toHaveLength(3); + }); + + it("custom exceptions expose properties", () => { + const rl = new RateLimitException("msg", 42); + expect(rl.retryAfter).toBe(42); + const nc = new NoCredentialsError("missing"); + expect(nc.name).toBe("NoCredentialsError"); + }); + + it("notEmpty filters nullish values", () => { + const arr = [1, null, 2, undefined].filter(notEmpty); + expect(arr).toEqual([1, 2]); + }); + + it("arrayEquals compares arrays regardless of order", () => { + expect(arrayEquals([1, 2], [2, 1])).toBe(true); + expect(arrayEquals([1, 2], [1, 2, 3])).toBe(false); + }); + + it("debounce delays execution", () => { + jest.useFakeTimers(); + const fn = jest.fn(); + const debounced = debounce(fn, 50); + debounced(); + jest.advanceTimersByTime(49); + expect(fn).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(fn).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it("extendErrorWithSupportLinks appends link", () => { + const msg = extendErrorWithSupportLinks("problem"); + expect(msg).toContain("contact us"); + }); + + it("stripANSI removes escape codes", () => { + const cleaned = stripANSI("\u001b[31mred\u001b[0m"); + expect(cleaned).toBe("red"); + }); + + it("getFormattedDateTime formats date", () => { + const realDate = Date; + global.Date = class extends Date { + constructor() { + super("2020-01-02T03:04:05Z"); + } + } as DateConstructor; + const formatted = getFormattedDateTime(); + expect(formatted).toBe("02-01-2020-03-04-05"); + global.Date = realDate; + }); + + it("isEnclosedWithinCodeBlock detects braces", () => { + const lines = ["{{", "ref('model')", "}}", "other"]; + const document = { + lineCount: lines.length, + lineAt: (i: number) => ({ text: lines[i] }), + } as any; + const range = { start: new Position(1, 2), end: new Position(1, 5) } as any; + expect(isEnclosedWithinCodeBlock(document, range)).toBe(true); + const outside = { + start: new Position(3, 1), + end: new Position(3, 2), + } as any; + expect(isEnclosedWithinCodeBlock(document, outside)).toBe(false); + }); +});