diff --git a/jest.config.js b/jest.config.js index dc92011d2..2ee10c270 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,7 @@ module.exports = { }, moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], setupFilesAfterEnv: ["/src/test/setup.ts"], + reporters: ["default", ["summary", { summaryThreshold: 1 }]], collectCoverageFrom: [ "src/**/*.{ts,tsx}", "!src/test/**", diff --git a/src/test/mock/vscode.ts b/src/test/mock/vscode.ts index 7341981fc..eab728de9 100644 --- a/src/test/mock/vscode.ts +++ b/src/test/mock/vscode.ts @@ -1,5 +1,4 @@ import { jest } from "@jest/globals"; -import { Uri } from "vscode"; // Export VSCode types that were previously defined export const ExtensionKind = { @@ -7,7 +6,10 @@ export const ExtensionKind = { Workspace: 2, }; -export { Uri }; +export const Uri = { + file: jest.fn((f: string) => ({ fsPath: f })), + parse: jest.fn(), +}; export class Position { constructor( @@ -110,6 +112,9 @@ export const window = { hide: jest.fn(), dispose: jest.fn(), }), + withProgress: jest + .fn() + .mockImplementation((_options: any, task: any) => task()), }; export const workspace = { @@ -119,6 +124,12 @@ export const workspace = { update: jest.fn(), }), workspaceFolders: [], + getWorkspaceFolder: jest.fn((uri: typeof Uri) => { + if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + return workspace.workspaceFolders[0]; + } + return undefined; + }), onDidChangeConfiguration: jest.fn().mockReturnValue({ dispose: jest.fn() }), onDidChangeWorkspaceFolders: jest .fn() @@ -129,11 +140,12 @@ export const workspace = { onDidDelete: jest.fn().mockReturnValue({ dispose: jest.fn() }), dispose: jest.fn(), }), -}; +} as any; export const languages = { createDiagnosticCollection: jest.fn().mockReturnValue({ set: jest.fn(), + get: jest.fn(), delete: jest.fn(), clear: jest.fn(), dispose: jest.fn(), @@ -152,6 +164,21 @@ export const languages = { registerCodeLensProvider: jest.fn().mockReturnValue({ dispose: jest.fn() }), }; +export const EventEmitter = jest.fn().mockImplementation(() => ({ + event: jest.fn().mockReturnValue({ dispose: jest.fn() }), + fire: jest.fn(), + dispose: jest.fn(), +})); + +export const ProgressLocation = { + Notification: 15, +}; + +export const RelativePattern = jest.fn(); +export const ViewColumn = {}; +export const Disposable = jest.fn(); +export const Event = jest.fn(); + export const resetMocks = () => { jest.clearAllMocks(); }; diff --git a/src/test/setup.ts b/src/test/setup.ts index fe39a7abc..fded23ac1 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,114 +1 @@ import "reflect-metadata"; - -// Set up the container before tests -import "../inversify.config"; -import { MockEventEmitter } from "./common"; - -// Mock VS Code APIs before any imports -jest.mock("vscode", () => ({ - EventEmitter: jest.fn().mockImplementation(() => new MockEventEmitter()), - workspace: { - getConfiguration: jest.fn().mockReturnValue({ - get: jest.fn(), - update: jest.fn(), - }), - workspaceFolders: [], - onDidChangeConfiguration: jest.fn(), - onDidChangeWorkspaceFolders: jest.fn().mockImplementation((callback) => ({ - dispose: jest.fn(), - })), - createFileSystemWatcher: jest.fn().mockReturnValue({ - onDidChange: jest.fn(), - onDidCreate: jest.fn(), - onDidDelete: jest.fn(), - dispose: jest.fn(), - }), - }, - commands: { - getCommands: jest.fn().mockResolvedValue([]), - registerCommand: jest.fn(), - executeCommand: jest.fn(), - }, - window: { - showInformationMessage: jest.fn(), - showErrorMessage: jest.fn(), - createTerminal: jest.fn().mockReturnValue({ - dispose: jest.fn(), - hide: jest.fn(), - show: jest.fn(), - sendText: jest.fn(), - }), - createOutputChannel: jest.fn().mockReturnValue({ - appendLine: jest.fn(), - show: jest.fn(), - clear: jest.fn(), - dispose: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }), - }, - languages: { - createDiagnosticCollection: jest.fn().mockReturnValue({ - set: jest.fn(), - delete: jest.fn(), - clear: jest.fn(), - dispose: jest.fn(), - }), - }, - Uri: { - file: jest.fn((f: string) => ({ fsPath: f })), - parse: jest.fn(), - }, - DiagnosticSeverity: { - Error: 0, - Warning: 1, - Information: 2, - Hint: 3, - }, - Disposable: { - from: jest.fn(), - }, - ExtensionKind: { - UI: 1, - Workspace: 2, - }, - Diagnostic: jest.fn().mockImplementation((range, message, severity) => ({ - range, - message, - severity, - })), - Range: jest - .fn() - .mockImplementation((startLine, startChar, endLine, endChar) => ({ - start: { line: startLine, character: startChar }, - end: { line: endLine, character: endChar }, - })), - Position: jest.fn().mockImplementation((line, character) => ({ - line, - character, - })), - TreeItemCollapsibleState: { - None: 0, - Collapsed: 1, - Expanded: 2, - }, - TreeItem: jest.fn().mockImplementation((label, collapsibleState) => ({ - label, - collapsibleState, - })), - CancellationTokenSource: jest.fn().mockImplementation(() => ({ - token: { - onCancellationRequested: jest.fn(), - isCancellationRequested: false, - }, - cancel: jest.fn(), - dispose: jest.fn(), - })), - CancellationToken: { - None: { - onCancellationRequested: jest.fn(), - isCancellationRequested: false, - }, - }, -})); diff --git a/src/test/suite/dbtProject.test.ts b/src/test/suite/dbtProject.test.ts index 35253e164..b96a6f285 100644 --- a/src/test/suite/dbtProject.test.ts +++ b/src/test/suite/dbtProject.test.ts @@ -1,4 +1,14 @@ -import { DBTTerminal, NoCredentialsError } from "@altimateai/dbt-integration"; +import { + Catalog, + DBTCommandExecutionInfrastructure, + DBTCommandFactory, + DBTDiagnosticData, + DBTProjectIntegrationAdapterEvents, + DBTTerminal, + NoCredentialsError, + ParsedManifest, + RunResultsEventData, +} from "@altimateai/dbt-integration"; import { afterEach, beforeEach, @@ -7,17 +17,137 @@ import { it, jest, } from "@jest/globals"; +import { EventEmitter } from "events"; +import * as vscode from "vscode"; import { AltimateRequest } from "../../altimate"; +import { DBTProject } from "../../dbt_client/dbtProject"; +import { DBTProjectLog } from "../../dbt_client/dbtProjectLog"; +import { ManifestCacheChangedEvent } from "../../dbt_client/event/manifestCacheChangedEvent"; +import { PythonEnvironment } from "../../dbt_client/pythonEnvironment"; +import { AltimateAuthService } from "../../services/altimateAuthService"; +import { SharedStateService } from "../../services/sharedStateService"; import { TelemetryService } from "../../telemetry"; import { ValidationProvider } from "../../validation_provider"; -describe("DbtProject Test Suite", () => { +// Mock the @altimateai/dbt-integration module +jest.mock("@altimateai/dbt-integration", () => { + // Get the actual module to inherit constants + const actualModule = jest.requireActual("@altimateai/dbt-integration") as any; + + return { + // First, include all the constants that should be inherited + DBT_PROJECT_FILE: actualModule.DBT_PROJECT_FILE || "dbt_project.yml", + MANIFEST_FILE: actualModule.MANIFEST_FILE || "manifest.json", + RUN_RESULTS_FILE: actualModule.RUN_RESULTS_FILE || "run_results.json", + CATALOG_FILE: actualModule.CATALOG_FILE || "catalog.json", + RESOURCE_TYPE_MODEL: actualModule.RESOURCE_TYPE_MODEL || "model", + RESOURCE_TYPE_MACRO: actualModule.RESOURCE_TYPE_MACRO || "macro", + RESOURCE_TYPE_ANALYSIS: actualModule.RESOURCE_TYPE_ANALYSIS || "analysis", + RESOURCE_TYPE_SOURCE: actualModule.RESOURCE_TYPE_SOURCE || "source", + RESOURCE_TYPE_EXPOSURE: actualModule.RESOURCE_TYPE_EXPOSURE || "exposure", + RESOURCE_TYPE_SEED: actualModule.RESOURCE_TYPE_SEED || "seed", + RESOURCE_TYPE_SNAPSHOT: actualModule.RESOURCE_TYPE_SNAPSHOT || "snapshot", + RESOURCE_TYPE_TEST: actualModule.RESOURCE_TYPE_TEST || "test", + RESOURCE_TYPE_METRIC: actualModule.RESOURCE_TYPE_METRIC || "semantic_model", + + // Include any other constants from the actual module + DEFAULT_CONFIGURATION_VALUES: + actualModule.DEFAULT_CONFIGURATION_VALUES || {}, + + // Mock functions + validateSQLUsingSqlGlot: jest.fn(), + + // Keep the actual event constants but ensure they're defined + DBTProjectIntegrationAdapterEvents: + actualModule.DBTProjectIntegrationAdapterEvents || { + SOURCE_FILE_CHANGED: "sourceFileChanged", + MANIFEST_PARSED: "manifestParsed", + RUN_RESULTS_PARSED: "runResultsParsed", + DIAGNOSTICS_CHANGED: "diagnosticsChanged", + PROJECT_CONFIG_CHANGED: "projectConfigChanged", + REBUILD_MANIFEST_STATUS_CHANGE: "rebuildManifestStatusChange", + }, + + // Mock the error class but keep it extending Error + NoCredentialsError: class NoCredentialsError extends Error { + constructor(message?: string) { + super(message); + this.name = "NoCredentialsError"; + } + }, + }; +}); + +// Mock python-bridge +jest.mock("python-bridge", () => ({ + PythonException: class PythonException extends Error { + exception: any; + constructor(message: string) { + super(message); + this.exception = { message }; + } + }, +})); + +// Mock vscode module +jest.mock("vscode", () => { + const mock = jest.requireActual("../mock/vscode"); + return mock; +}); + +// Mock getProjectRelativePath to avoid workspace issues +jest.mock("../../utils", () => { + return { + getProjectRelativePath: jest.fn(() => "test-project"), + extendErrorWithSupportLinks: jest.fn((error: any) => error), + getColumnNameByCase: jest.fn((name: string) => name), + }; +}); + +describe("DBTProject Test Suite", () => { let mockTerminal: jest.Mocked; let mockTelemetry: jest.Mocked; let mockAltimate: jest.Mocked; let mockValidationProvider: jest.Mocked; + let mockPythonEnvironment: jest.Mocked; + let mockSharedStateService: jest.Mocked; + let mockAltimateAuthService: jest.Mocked; + let mockExecutionInfrastructure: jest.Mocked; + let mockCommandFactory: jest.Mocked; + let mockProjectIntegration: any; + let mockDbtProjectLog: jest.Mocked; + let mockManifestChangedEmitter: jest.Mocked< + vscode.EventEmitter + >; + let dbtProject: DBTProject; + let dbtProjectLogFactory: jest.Mock; beforeEach(() => { + // Setup workspace configuration mock + (vscode.workspace as any).workspaceFolders = [ + { + uri: vscode.Uri.file("/test/workspace"), + name: "Test Workspace", + index: 0, + }, + ]; + (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ + get: jest.fn((key: string) => { + if (key === "dbtIntegration") { + return "core"; + } + if (key === "queryLimit") { + return 500; + } + if (key === "deferConfigPerProject") { + return {}; + } + return undefined; + }), + has: jest.fn(), + update: jest.fn(), + }); + // Mock DBTTerminal mockTerminal = { show: jest.fn(), log: jest.fn(), @@ -33,6 +163,7 @@ describe("DbtProject Test Suite", () => { warn: jest.fn(), } as unknown as jest.Mocked; + // Mock TelemetryService mockTelemetry = { sendTelemetryEvent: jest.fn(), sendTelemetryError: jest.fn(), @@ -42,38 +173,924 @@ describe("DbtProject Test Suite", () => { dispose: jest.fn(), } as unknown as jest.Mocked; + // Mock AltimateRequest mockAltimate = { handlePreviewFeatures: jest.fn().mockReturnValue(true), - enabled: jest.fn(), - isAuthenticated: jest.fn(), + enabled: jest.fn().mockReturnValue(true), + isAuthenticated: jest.fn().mockReturnValue(true), validateCredentials: jest.fn(), + getAIKey: jest.fn().mockReturnValue("test-ai-key"), + getInstanceName: jest.fn().mockReturnValue("test-instance"), + getAltimateUrl: jest.fn().mockReturnValue("https://test.altimate.ai"), dispose: jest.fn(), } as unknown as jest.Mocked; + // Mock ValidationProvider mockValidationProvider = { validateCredentialsSilently: jest.fn(), } as unknown as jest.Mocked; + + // Mock PythonEnvironment + mockPythonEnvironment = { + onPythonEnvironmentChanged: jest.fn().mockReturnValue({ + dispose: jest.fn(), + }), + } as unknown as jest.Mocked; + + // Mock SharedStateService + mockSharedStateService = {} as unknown as jest.Mocked; + + // Mock AltimateAuthService + mockAltimateAuthService = {} as unknown as jest.Mocked; + + // Mock DBTCommandExecutionInfrastructure + mockExecutionInfrastructure = { + createPythonBridge: jest.fn().mockImplementation(() => ({ + ex: jest.fn(), + lock: jest.fn(), + pid: 1234, + end: jest.fn(), + disconnect: jest.fn(), + kill: jest.fn(), + ex_json: jest.fn(), + exNB: jest.fn(), + exAsync: jest.fn(), + runJupyterKernel: jest.fn(), + stdin: null, + stdout: null, + stderr: null, + connected: true, + })), + closePythonBridge: jest.fn(), + } as unknown as jest.Mocked; + + // Mock DBTCommandFactory + mockCommandFactory = { + createDocsGenerateCommand: jest.fn().mockReturnValue({ + focus: false, + logToTerminal: false, + showProgress: false, + }), + } as unknown as jest.Mocked; + + // Mock DBTProjectIntegrationAdapter with EventEmitter functionality + const integrationEventEmitter = new EventEmitter(); + mockProjectIntegration = { + on: jest.fn().mockImplementation((event: any, listener: any) => { + integrationEventEmitter.on(event, listener); + }), + emit: jest.fn().mockImplementation((event: any, ...args: any) => { + integrationEventEmitter.emit(event, ...args); + }), + getCurrentProjectIntegration: jest.fn(() => mockProjectIntegration), + cleanupConnections: jest.fn(), + getProjectName: jest.fn().mockReturnValue("test-project"), + getSelectedTarget: jest.fn().mockReturnValue("dev"), + getTargetNames: jest.fn().mockReturnValue(["dev", "prod"]), + getColumnsOfModel: jest.fn(() => Promise.resolve([])), + getColumnsOfSource: jest.fn(() => Promise.resolve([])), + getCatalog: jest.fn(() => Promise.resolve({})), + unsafeCompileNode: jest.fn(), + unsafeCompileQuery: jest.fn(), + runQuery: jest.fn(), + getColumnValues: jest.fn(), + unsafeGenerateDocsImmediately: jest.fn(), + setSelectedTarget: jest.fn(), + getTargetPath: jest.fn().mockReturnValue("/project/target"), + getPackageInstallPath: jest.fn().mockReturnValue("/project/dbt_packages"), + getModelPaths: jest.fn().mockReturnValue(["/project/models"]), + getSeedPaths: jest.fn().mockReturnValue(["/project/seeds"]), + getMacroPaths: jest.fn().mockReturnValue(["/project/macros"]), + getPythonBridgeStatus: jest.fn().mockReturnValue("ready"), + getDiagnostics: jest.fn().mockReturnValue({ + pythonBridgeDiagnostics: [], + rebuildManifestDiagnostics: [], + projectConfigDiagnostics: [], + }), + initialize: jest.fn(), + parseManifest: jest.fn(), + rebuildManifest: jest.fn(), + createDbtCommand: jest.fn(), + runDbtCommand: jest.fn(), + dispose: jest.fn(), + }; + + // Mock DBTProjectLog + mockDbtProjectLog = { + dispose: jest.fn(), + } as unknown as jest.Mocked; + + // Create factory that returns the same mock instance + dbtProjectLogFactory = jest.fn().mockReturnValue(mockDbtProjectLog); + + // Mock EventEmitter for manifest changes + mockManifestChangedEmitter = { + event: jest.fn().mockReturnValue({ dispose: jest.fn() }), + fire: jest.fn(), + dispose: jest.fn(), + } as unknown as jest.Mocked>; }); afterEach(() => { jest.clearAllMocks(); + if (dbtProject) { + dbtProject.dispose(); + } + }); + + describe("Constructor and Initialization", () => { + it("should have access to constants from @altimateai/dbt-integration", () => { + // Import the mocked module to verify constants are available + const { + DBT_PROJECT_FILE, + MANIFEST_FILE, + RESOURCE_TYPE_MODEL, + DBTProjectIntegrationAdapterEvents, + } = require("@altimateai/dbt-integration"); + + // Verify constants are defined + expect(DBT_PROJECT_FILE).toBe("dbt_project.yml"); + expect(MANIFEST_FILE).toBe("manifest.json"); + expect(RESOURCE_TYPE_MODEL).toBe("model"); + expect(DBTProjectIntegrationAdapterEvents.SOURCE_FILE_CHANGED).toBe( + "sourceFileChanged", + ); + }); + + it("should create DBTProject instance with correct configuration", () => { + const projectUri = vscode.Uri.file("/test/project"); + const projectConfig = {}; + + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + projectConfig, + mockManifestChangedEmitter, + ); + + expect(dbtProject.projectRoot).toBe(projectUri); + expect( + mockValidationProvider.validateCredentialsSilently, + ).toHaveBeenCalled(); + expect(mockTerminal.debug).toHaveBeenCalledWith( + "DbtProject", + expect.stringContaining("Created core dbt project"), + ); + }); + + it("should handle validation errors gracefully", () => { + mockValidationProvider.validateCredentialsSilently.mockImplementation( + () => { + throw new NoCredentialsError(); + }, + ); + + const projectUri = vscode.Uri.file("/test/project"); + const projectConfig = {}; + + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + projectConfig, + mockManifestChangedEmitter, + ); + + expect(mockTerminal.error).toHaveBeenCalledWith( + "validateCredentialsSilently", + "Credential validation failed", + expect.any(NoCredentialsError), + false, + ); + }); + + it("should initialize project integration on initialize()", async () => { + const projectUri = vscode.Uri.file("/test/project"); + + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + + await dbtProject.initialize(); + + expect(mockProjectIntegration.initialize).toHaveBeenCalled(); + }); }); - it("should handle telemetry events correctly", () => { - const eventName = "test_event"; - mockTelemetry.sendTelemetryEvent(eventName); - expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith(eventName); + describe("Project Configuration Methods", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should get project name", () => { + expect(dbtProject.getProjectName()).toBe("test-project"); + expect(mockProjectIntegration.getProjectName).toHaveBeenCalled(); + }); + + it("should get project root", () => { + expect(dbtProject.getProjectRoot()).toBe("/test/project"); + }); + + it("should get selected target", () => { + expect(dbtProject.getSelectedTarget()).toBe("dev"); + expect(mockProjectIntegration.getSelectedTarget).toHaveBeenCalled(); + }); + + it("should get target names", () => { + expect(dbtProject.getTargetNames()).toEqual(["dev", "prod"]); + expect(mockProjectIntegration.getTargetNames).toHaveBeenCalled(); + }); + + it("should set selected target with progress", async () => { + await dbtProject.setSelectedTarget("prod"); + + expect(vscode.window.withProgress).toHaveBeenCalledWith( + { + location: vscode.ProgressLocation.Notification, + title: "Changing target...", + cancellable: false, + }, + expect.any(Function), + ); + expect(mockProjectIntegration.setSelectedTarget).toHaveBeenCalledWith( + "prod", + ); + }); + + it("should get DBT project file path", () => { + const { DBT_PROJECT_FILE } = require("@altimateai/dbt-integration"); + expect(dbtProject.getDBTProjectFilePath()).toBe( + `/test/project/${DBT_PROJECT_FILE}`, + ); + }); + + it("should get manifest path", () => { + expect(dbtProject.getManifestPath()).toBe( + "/project/target/manifest.json", + ); + }); + + it("should get catalog path", () => { + expect(dbtProject.getCatalogPath()).toBe("/project/target/catalog.json"); + }); + + it("should return undefined for paths when target path is not available", () => { + mockProjectIntegration.getTargetPath.mockReturnValue(undefined); + expect(dbtProject.getManifestPath()).toBeUndefined(); + expect(dbtProject.getCatalogPath()).toBeUndefined(); + }); }); - it("should handle validation provider calls", () => { - mockValidationProvider.validateCredentialsSilently.mockImplementation( - () => { - throw new NoCredentialsError(); - }, - ); + describe("Event Handling", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should handle source file changed events", () => { + const sourceFileChangedHandler = jest.fn(); + dbtProject.onSourceFileChanged(sourceFileChangedHandler); + + // Trigger the event from the integration + const onCall = mockProjectIntegration.on.mock.calls.find( + (call: any) => + call[0] === DBTProjectIntegrationAdapterEvents.SOURCE_FILE_CHANGED, + ); + onCall![1](); // Call the handler + + expect(mockTerminal.debug).toHaveBeenCalledWith( + "DBTProject", + "Received sourceFileChanged event from Node.js file watchers", + ); + }); + + it("should handle manifest parsed events", () => { + const parsedManifest: ParsedManifest = { + nodeMetaMap: { + lookupByBaseName: (() => undefined) as any, + lookupByUniqueId: (() => undefined) as any, + nodes: (() => []) as any, + }, + macroMetaMap: new Map(), + metricMetaMap: new Map(), + sourceMetaMap: new Map(), + graphMetaMap: { + parents: new Map(), + children: new Map(), + tests: new Map(), + metrics: new Map(), + }, + testMetaMap: new Map(), + docMetaMap: new Map(), + exposureMetaMap: new Map(), + modelDepthMap: new Map(), + }; + + // Trigger the event from the integration + const onCall = mockProjectIntegration.on.mock.calls.find( + (call: any) => + call[0] === DBTProjectIntegrationAdapterEvents.MANIFEST_PARSED, + ); + onCall![1](parsedManifest); + + expect(mockManifestChangedEmitter.fire).toHaveBeenCalledWith({ + added: [ + expect.objectContaining({ + project: dbtProject, + ...parsedManifest, + }), + ], + }); + }); + + it("should handle run results parsed events", () => { + const runResultsData: RunResultsEventData = { + results: [ + { unique_id: "model.test.model1" }, + { unique_id: "model.test.model2" }, + ], + }; + + const runResultsHandler = jest.fn(); + dbtProject.onRunResults(runResultsHandler); + + // Trigger the event from the integration + const onCall = mockProjectIntegration.on.mock.calls.find( + (call: any) => + call[0] === DBTProjectIntegrationAdapterEvents.RUN_RESULTS_PARSED, + ); + onCall![1](runResultsData); + + expect(mockTerminal.debug).toHaveBeenCalledWith( + "DBTProject", + "Received runResultsParsed event from dbtIntegrationAdapter", + ); + }); + }); + + describe("Diagnostics", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should get all diagnostics", () => { + const mockDiagnosticData: DBTDiagnosticData = { + message: "Test diagnostic", + severity: "error", + filePath: "/test/file.sql", + source: "dbt", + category: "error", + range: { + startLine: 1, + startColumn: 0, + endLine: 1, + endColumn: 10, + }, + }; + + (mockProjectIntegration.getDiagnostics as jest.Mock).mockReturnValue({ + pythonBridgeDiagnostics: [mockDiagnosticData], + rebuildManifestDiagnostics: [], + projectConfigDiagnostics: [], + }); + + const diagnostics = dbtProject.getAllDiagnostic(); + + expect(diagnostics).toHaveLength(1); + // Check the diagnostic properties instead of checking if constructor was called + expect(diagnostics[0]).toMatchObject({ + message: mockDiagnosticData.message, + severity: vscode.DiagnosticSeverity.Error, + }); + }); + + it("should update diagnostics in problems panel", () => { + const mockDiagnosticData: DBTDiagnosticData = { + message: "Test diagnostic", + severity: "warning", + filePath: "/test/file.sql", + source: "dbt", + category: "warning", + }; + + (mockProjectIntegration.getDiagnostics as jest.Mock).mockReturnValue({ + pythonBridgeDiagnostics: [mockDiagnosticData], + rebuildManifestDiagnostics: [mockDiagnosticData], + projectConfigDiagnostics: [mockDiagnosticData], + }); + + dbtProject.updateDiagnosticsInProblemsPanel(); + + expect(dbtProject.pythonBridgeDiagnostics.set).toHaveBeenCalled(); + expect(dbtProject.rebuildManifestDiagnostics.set).toHaveBeenCalled(); + expect(dbtProject.projectConfigDiagnostics.set).toHaveBeenCalled(); + }); + }); + + describe("Model Operations", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should compile node", async () => { + mockProjectIntegration.unsafeCompileNode.mockResolvedValue( + "-- compiled SQL", + ); + + const result = await dbtProject.compileNode("model.test.my_model"); + + expect(result).toBe("-- compiled SQL"); + expect(mockProjectIntegration.unsafeCompileNode).toHaveBeenCalledWith( + "model.test.my_model", + ); + }); + + it("should handle compile node errors", async () => { + mockProjectIntegration.unsafeCompileNode.mockRejectedValue( + new Error("Compile failed"), + ); + + const result = await dbtProject.compileNode("model.test.my_model"); + + // When an error occurs, it returns a string with error details + expect(result).toContain("Detailed error information:"); + // Check that error message was shown to user + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + }); + + it("should validate SQL", async () => { + const mockValidationResult = { + isValid: true, + errors: [], + }; + + // Import and mock the validateSQLUsingSqlGlot function + const { + validateSQLUsingSqlGlot, + } = require("@altimateai/dbt-integration"); + validateSQLUsingSqlGlot.mockResolvedValue(mockValidationResult); + + const request = { + sql: "SELECT * FROM table", + dialect: "postgres", + models: [], + }; + + const result = await dbtProject.validateSql(request); + + expect(result).toEqual(mockValidationResult); + expect(validateSQLUsingSqlGlot).toHaveBeenCalledWith( + expect.anything(), // pythonBridge + request.sql, + request.dialect, + request.models, + ); + expect(mockExecutionInfrastructure.closePythonBridge).toHaveBeenCalled(); + }); + }); + + describe("Query Execution", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should compile query", async () => { + const mockCompiledSQL = "SELECT col1 FROM table"; + + mockProjectIntegration.unsafeCompileQuery.mockResolvedValue( + mockCompiledSQL, + ); + + const result = await dbtProject.compileQuery( + "SELECT col1 FROM {{ ref('table') }}", + "test_model", + ); + + expect(result).toEqual(mockCompiledSQL); + expect(mockProjectIntegration.unsafeCompileQuery).toHaveBeenCalledWith( + "SELECT col1 FROM {{ ref('table') }}", + "test_model", + ); + }); + + it("should get column values", async () => { + const mockColumnValues = ["value1", "value2"]; + + mockProjectIntegration.getColumnValues.mockReturnValue(mockColumnValues); + + const result = await dbtProject.getColumnValues("model", "col"); + + expect(result).toEqual(mockColumnValues); + expect(mockProjectIntegration.getColumnValues).toHaveBeenCalledWith( + "model", + "col", + ); + expect(mockProjectIntegration.cleanupConnections).toHaveBeenCalled(); + }); + }); + + describe("Healthcheck", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should perform datapilot healthcheck", async () => { + const mockHealthcheckResult = { + model_insights: { + test_model: [ + { + original_file_path: "models/test.sql", + path: "models/test.sql", + }, + ], + }, + }; + + // Create a fresh mock for this test + const pythonBridge = { + ex: jest.fn(), + lock: jest + .fn() + .mockImplementation(() => Promise.resolve(mockHealthcheckResult)), + pid: 1234, + end: jest.fn(), + disconnect: jest.fn(), + kill: jest.fn(), + ex_json: jest.fn(), + exNB: jest.fn(), + exAsync: jest.fn(), + runJupyterKernel: jest.fn(), + stdin: null, + stdout: null, + stderr: null, + connected: true, + }; + + mockExecutionInfrastructure.createPythonBridge.mockReturnValueOnce( + pythonBridge as any, + ); + + const result = await dbtProject.performDatapilotHealthcheck({ + projectRoot: "/test/project", + configType: "Manual", + configPath: "/config/path", + } as any); + + expect(result).toHaveProperty("model_insights"); + expect(result.model_insights).toEqual( + mockHealthcheckResult.model_insights, + ); + expect( + mockExecutionInfrastructure.closePythonBridge, + ).toHaveBeenCalledWith(pythonBridge); + }); + + it("should handle healthcheck with catalog generation", async () => { + const mockHealthcheckResult = { + model_insights: {}, + }; + + // Create a fresh mock for this test + const pythonBridge = { + ex: jest.fn(), + lock: jest + .fn() + .mockImplementation(() => Promise.resolve(mockHealthcheckResult)), + pid: 1234, + end: jest.fn(), + disconnect: jest.fn(), + kill: jest.fn(), + ex_json: jest.fn(), + exNB: jest.fn(), + exAsync: jest.fn(), + runJupyterKernel: jest.fn(), + stdin: null, + stdout: null, + stderr: null, + connected: true, + }; + + mockExecutionInfrastructure.createPythonBridge.mockReturnValueOnce( + pythonBridge as any, + ); + + mockProjectIntegration.unsafeGenerateDocsImmediately.mockResolvedValue( + {}, + ); + + await dbtProject.performDatapilotHealthcheck({ + projectRoot: "/test/project", + configType: "All", + config_schema: [ + { + files_required: ["Catalog"], + }, + ], + } as any); + + expect(mockCommandFactory.createDocsGenerateCommand).toHaveBeenCalled(); + expect( + mockProjectIntegration.unsafeGenerateDocsImmediately, + ).toHaveBeenCalled(); + }); + }); + + describe("Catalog Operations", () => { + beforeEach(() => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + }); + + it("should get catalog", async () => { + const mockCatalog: Catalog = { + nodes: { + "model.test.my_model": { + unique_id: "model.test.my_model", + columns: { + col1: { name: "col1", type: "varchar" }, + }, + }, + }, + } as any; + + // Mock getCatalog method to return the catalog + mockProjectIntegration.getCatalog.mockImplementation(() => + Promise.resolve(mockCatalog), + ); + + const result = await dbtProject.getCatalog(); + + expect(result).toEqual(mockCatalog); + expect(mockProjectIntegration.getCatalog).toHaveBeenCalled(); + expect(mockProjectIntegration.cleanupConnections).toHaveBeenCalled(); + }); + + it("should get columns of model", async () => { + const mockColumns = [ + { name: "col1", type: "varchar" }, + { name: "col2", type: "integer" }, + ]; + + // Mock getColumnsOfModel method returns the columns directly + mockProjectIntegration.getColumnsOfModel.mockImplementation(() => + Promise.resolve(mockColumns), + ); + + const result = await dbtProject.getColumnsOfModel("model.test.my_model"); + + expect(result).toEqual(mockColumns); + expect(mockProjectIntegration.getColumnsOfModel).toHaveBeenCalledWith( + "model.test.my_model", + ); + expect(mockProjectIntegration.cleanupConnections).toHaveBeenCalled(); + }); + + it("should get columns of source", async () => { + const mockColumns = [{ name: "col1", type: "varchar" }]; + + // Mock getColumnsOfSource method + mockProjectIntegration.getColumnsOfSource.mockImplementation(() => + Promise.resolve(mockColumns), + ); + + const result = await dbtProject.getColumnsOfSource( + "my_source", + "my_table", + ); + + expect(result).toEqual(mockColumns); + expect(mockProjectIntegration.getColumnsOfSource).toHaveBeenCalledWith( + "my_source", + "my_table", + ); + expect(mockProjectIntegration.cleanupConnections).toHaveBeenCalled(); + }); + }); + + describe("Disposal", () => { + it("should dispose all resources properly", async () => { + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + + // Initialize to ensure dbtProjectLog is added to disposables + await dbtProject.initialize(); + + const projectHealthDispose = jest.spyOn( + dbtProject.projectHealth, + "dispose", + ); + const pythonBridgeDiagnosticsDispose = jest.spyOn( + dbtProject.pythonBridgeDiagnostics, + "dispose", + ); + const rebuildManifestDiagnosticsDispose = jest.spyOn( + dbtProject.rebuildManifestDiagnostics, + "dispose", + ); + const projectConfigDiagnosticsDispose = jest.spyOn( + dbtProject.projectConfigDiagnostics, + "dispose", + ); + + await dbtProject.dispose(); + + expect(projectHealthDispose).toHaveBeenCalled(); + expect(pythonBridgeDiagnosticsDispose).toHaveBeenCalled(); + expect(rebuildManifestDiagnosticsDispose).toHaveBeenCalled(); + expect(projectConfigDiagnosticsDispose).toHaveBeenCalled(); + expect(mockProjectIntegration.dispose).toHaveBeenCalled(); + // dbtProjectLog is created in constructor and added to disposables in initialize + expect(mockDbtProjectLog.dispose).toHaveBeenCalled(); + }); + }); + + describe("dbt Loom Integration", () => { + it("should check if dbt loom is installed", async () => { + const pythonBridge = mockExecutionInfrastructure.createPythonBridge(); + (pythonBridge.ex as any).mockResolvedValue(undefined); + + const projectUri = vscode.Uri.file("/test/project"); + dbtProject = new DBTProject( + mockPythonEnvironment, + dbtProjectLogFactory as any, + mockCommandFactory, + mockTerminal, + mockSharedStateService, + mockTelemetry, + mockExecutionInfrastructure, + jest.fn().mockReturnValue(mockProjectIntegration) as any, + mockAltimate, + mockValidationProvider, + mockAltimateAuthService, + projectUri, + {}, + mockManifestChangedEmitter, + ); + + // Wait for the async dbt loom check to complete + await new Promise((resolve) => setTimeout(resolve, 100)); - expect(() => mockValidationProvider.validateCredentialsSilently()).toThrow( - NoCredentialsError, - ); + expect(mockTelemetry.setTelemetryCustomAttribute).toHaveBeenCalledWith( + "dbtLoomInstalled", + "true", + ); + }); }); }); diff --git a/src/test/suite/dbtProjectContainer.test.ts b/src/test/suite/dbtProjectContainer.test.ts index 5cde6c8bc..95a847a18 100644 --- a/src/test/suite/dbtProjectContainer.test.ts +++ b/src/test/suite/dbtProjectContainer.test.ts @@ -1,4 +1,7 @@ -import { DBTTerminal, EnvironmentVariables } from "@altimateai/dbt-integration"; +import { + DataPilotHealtCheckParams, + RunModelType, +} from "@altimateai/dbt-integration"; import { afterEach, beforeEach, @@ -7,41 +10,78 @@ import { it, jest, } from "@jest/globals"; -import { EventEmitter, WorkspaceFolder } from "vscode"; -import { AltimateRequest } from "../../altimate"; -import { DBTClient } from "../../dbt_client"; -import { AltimateDatapilot } from "../../dbt_client/datapilot"; -import { - DBTProjectContainer, - ProjectRegisteredUnregisteredEvent, -} from "../../dbt_client/dbtProjectContainer"; -import { DBTWorkspaceFolder } from "../../dbt_client/dbtWorkspaceFolder"; -import { ManifestCacheChangedEvent } from "../../dbt_client/event/manifestCacheChangedEvent"; +import { EventEmitter } from "events"; +import { commands, ExtensionContext, Uri, window, workspace } from "vscode"; +import { DBTProjectContainer } from "../../dbt_client/dbtProjectContainer"; + +// Mock vscode module +jest.mock("vscode", () => { + const mock = jest.requireActual("../mock/vscode"); + return mock; +}); describe("DBTProjectContainer Tests", () => { let container: DBTProjectContainer; - let mockDbtClient: jest.Mocked; - let mockDbtTerminal: jest.Mocked; - let mockAltimateDatapilot: jest.Mocked; - let mockAltimateRequest: jest.Mocked; - let mockDbtWorkspaceFolder: jest.Mocked; - - const createMockDbtWorkspaceFolder = ( - workspaceFolder: WorkspaceFolder, - onManifestChanged: EventEmitter, - onProjectRegisteredUnregistered: EventEmitter, - pythonPath?: string, - envVars?: EnvironmentVariables, - ): DBTWorkspaceFolder => { - return mockDbtWorkspaceFolder; - }; + let mockDbtClient: any; + let mockDbtTerminal: any; + let mockAltimateDatapilot: any; + let mockAltimateRequest: any; + let mockDbtWorkspaceFolder: any; + let mockDbtProject: any; + let mockDbtWorkspaceFolderFactory: any; beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Mock DBT project + mockDbtProject = { + projectRoot: Uri.file("/path/to/project"), + findPackageName: jest.fn(() => "test_package"), + initialize: jest.fn(), + executeSQLOnQueryPanel: jest.fn(), + runModel: jest.fn(), + buildModel: jest.fn(), + buildProject: jest.fn(), + runTest: jest.fn(), + runModelTest: jest.fn(), + compileModel: jest.fn(), + generateDocs: jest.fn(), + compileQuery: jest.fn(() => Promise.resolve("compiled query")), + showRunSQL: jest.fn(), + showCompiledSql: jest.fn(), + generateSchemaYML: jest.fn(), + performDatapilotHealthcheck: jest.fn(), + dispose: jest.fn(), + }; + + // Mock DBT workspace folder + mockDbtWorkspaceFolder = { + dispose: jest.fn(), + contains: jest.fn(() => true), + findDBTProject: jest.fn(() => mockDbtProject), + getProjects: jest.fn(() => [mockDbtProject]), + getAdapters: jest.fn(() => ["postgres"]), + discoverProjects: jest.fn(() => Promise.resolve()), + onRebuildManifestStatusChange: (handler: any) => { + // Mock event registration + return { dispose: jest.fn() }; + }, + }; + + // Mock DBT client mockDbtClient = { - onDBTInstallationVerification: new EventEmitter().event, + onDBTInstallationVerification: new EventEmitter().on, dispose: jest.fn(), - } as unknown as jest.Mocked; + showErrorIfDbtOrPythonNotInstalled: jest.fn(), + showErrorIfDbtIsNotInstalled: jest.fn(), + detectDBT: jest.fn(() => Promise.resolve()), + getPythonEnvironment: jest.fn(() => ({ + pythonPath: "/path/to/python", + })), + }; + // Mock DBT terminal mockDbtTerminal = { show: jest.fn(), log: jest.fn(), @@ -55,48 +95,764 @@ describe("DBTProjectContainer Tests", () => { logHorizontalRule: jest.fn(), logBlock: jest.fn(), warn: jest.fn(), - } as unknown as jest.Mocked; + }; + // Mock Altimate datapilot mockAltimateDatapilot = { - checkIfAltimateDatapilotInstalled: jest.fn(), - installAltimateDatapilot: jest.fn(), - } as unknown as jest.Mocked; + checkIfAltimateDatapilotInstalled: jest.fn(() => + Promise.resolve("1.0.0"), + ), + installAltimateDatapilot: jest.fn(() => Promise.resolve()), + }; + // Mock Altimate request mockAltimateRequest = { dispose: jest.fn(), enabled: jest.fn(), isAuthenticated: jest.fn(), validateCredentials: jest.fn(), - } as unknown as jest.Mocked; + getDatapilotVersion: jest.fn(() => + Promise.resolve({ altimate_datapilot_version: "1.0.0" }), + ), + }; - mockDbtWorkspaceFolder = { - dispose: jest.fn(), - } as unknown as jest.Mocked; + // Mock workspace folder factory + mockDbtWorkspaceFolderFactory = jest.fn(() => mockDbtWorkspaceFolder); + // Create container container = new DBTProjectContainer( mockDbtClient, - createMockDbtWorkspaceFolder, + mockDbtWorkspaceFolderFactory, mockDbtTerminal, mockAltimateDatapilot, mockAltimateRequest, ); + + // Set up workspace folders for testing + container.dbtWorkspaceFolders = [mockDbtWorkspaceFolder]; }); afterEach(() => { jest.clearAllMocks(); }); - it("should initialize with correct dependencies", () => { - expect(container).toBeDefined(); - expect(container.onDBTInstallationVerification).toBe( - mockDbtClient.onDBTInstallationVerification, - ); + describe("Initialization and Lifecycle", () => { + it("should initialize with correct dependencies", () => { + expect(container).toBeDefined(); + expect(container.onDBTInstallationVerification).toBe( + mockDbtClient.onDBTInstallationVerification, + ); + }); + + it("should dispose all dependencies", () => { + container.dispose(); + + expect(mockDbtWorkspaceFolder.dispose).toHaveBeenCalled(); + expect(mockDbtClient.dispose).toHaveBeenCalled(); + expect(mockDbtTerminal.dispose).toHaveBeenCalled(); + }); + + it("should set context properly", () => { + const mockContext = { + extensionUri: Uri.file("/path/to/extension"), + extension: { id: "test-extension", packageJSON: { version: "1.0.0" } }, + } as ExtensionContext; + + container.setContext(mockContext); + expect(container.extensionUri).toEqual(mockContext.extensionUri); + expect(container.extensionVersion).toBe("1.0.0"); + expect(container.extensionId).toBe("test-extension"); + }); + + it("should detect DBT installation", async () => { + await container.detectDBT(); + expect(mockDbtClient.detectDBT).toHaveBeenCalled(); + }); + + it("should initialize all projects", async () => { + await container.initialize(); + expect(mockDbtProject.initialize).toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { + it("should show error if dbt or Python not installed", () => { + container.showErrorIfDbtOrPythonNotInstalled(); + expect( + mockDbtClient.showErrorIfDbtOrPythonNotInstalled, + ).toHaveBeenCalled(); + }); + + it("should show error if dbt is not installed", () => { + container.showErrorIfDbtIsNotInstalled(); + expect(mockDbtClient.showErrorIfDbtIsNotInstalled).toHaveBeenCalled(); + }); + }); + + describe("Project Management", () => { + it("should find DBT project for given URI", () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + const result = container.findDBTProject(uri); + + expect(mockDbtWorkspaceFolder.findDBTProject).toHaveBeenCalledWith(uri); + expect(result).toBe(mockDbtProject); + }); + + it("should return undefined when no DBT project found", () => { + container.dbtWorkspaceFolders = []; + const uri = Uri.file("/path/to/non-dbt/file.txt"); + const result = container.findDBTProject(uri); + + expect(result).toBeUndefined(); + }); + + it("should get all projects", () => { + const projects = container.getProjects(); + expect(projects).toEqual([mockDbtProject]); + }); + + it("should get unique adapters", () => { + const adapters = container.getAdapters(); + expect(adapters).toEqual(["postgres"]); + }); + + it("should get Python environment", () => { + const pythonEnv = container.getPythonEnvironment(); + expect(pythonEnv?.pythonPath).toBe("/path/to/python"); + }); + + it("should get package name from URI", () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + const packageName = container.getPackageName(uri); + + expect(packageName).toBe("test_package"); + }); + + it("should get project root path from URI", () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + const rootPath = container.getProjectRootpath(uri); + + expect(rootPath).toEqual(mockDbtProject.projectRoot); + }); + }); + + describe("SQL Operations", () => { + it("should execute SQL query", () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + const query = "SELECT * FROM table"; + const modelName = "test_model"; + + container.executeSQL(uri, query, modelName); + + expect(mockDbtProject.executeSQLOnQueryPanel).toHaveBeenCalledWith( + query, + modelName, + ); + }); + + it("should compile query", async () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + const query = "SELECT * FROM {{ ref('model') }}"; + + const result = await container.compileQuery(uri, query); + + expect(mockDbtProject.compileQuery).toHaveBeenCalledWith(query); + expect(result).toBe("compiled query"); + }); + + it("should show run SQL", () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + + container.showRunSQL(uri); + + expect(mockDbtProject.showRunSQL).toHaveBeenCalledWith(uri); + }); + + it("should show compiled SQL", () => { + const uri = Uri.file("/path/to/project/models/test.sql"); + + container.showCompiledSQL(uri); + + expect(mockDbtProject.showCompiledSql).toHaveBeenCalledWith(uri); + }); + }); + + describe("Model Operations", () => { + it("should run model", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + container.runModel(modelPath); + + expect(mockDbtProject.runModel).toHaveBeenCalledWith({ + plusOperatorLeft: "", + modelName: "test", + plusOperatorRight: "", + }); + }); + + it("should run model with parents", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + container.runModel(modelPath, RunModelType.RUN_PARENTS); + + expect(mockDbtProject.runModel).toHaveBeenCalledWith({ + plusOperatorLeft: "+", + modelName: "test", + plusOperatorRight: "", + }); + }); + + it("should run model with children", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + container.runModel(modelPath, RunModelType.RUN_CHILDREN); + + expect(mockDbtProject.runModel).toHaveBeenCalledWith({ + plusOperatorLeft: "", + modelName: "test", + plusOperatorRight: "+", + }); + }); + + it("should build model", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + container.buildModel(modelPath); + + expect(mockDbtProject.buildModel).toHaveBeenCalledWith({ + plusOperatorLeft: "", + modelName: "test", + plusOperatorRight: "", + }); + }); + + it("should build project", () => { + const modelPath = Uri.file("/path/to/project"); + container.buildProject(modelPath); + + expect(mockDbtProject.buildProject).toHaveBeenCalled(); + }); + + it("should compile model", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + container.compileModel(modelPath); + + expect(mockDbtProject.compileModel).toHaveBeenCalledWith({ + plusOperatorLeft: "", + modelName: "test", + plusOperatorRight: "", + }); + }); + + it("should generate docs", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + container.generateDocs(modelPath); + + expect(mockDbtProject.generateDocs).toHaveBeenCalled(); + }); + + it("should generate schema YML", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + const modelName = "test_model"; + + container.generateSchemaYML(modelPath, modelName); + + expect(mockDbtProject.generateSchemaYML).toHaveBeenCalledWith( + modelPath, + modelName, + ); + }); + }); + + describe("Test Operations", () => { + it("should run test", () => { + const modelPath = Uri.file("/path/to/project/tests/test_model.sql"); + const testName = "test_model"; + + container.runTest(modelPath, testName); + + expect(mockDbtProject.runTest).toHaveBeenCalledWith(testName); + }); + + it("should run model test", () => { + const modelPath = Uri.file("/path/to/project/models/test.sql"); + const modelName = "test_model"; + + container.runModelTest(modelPath, modelName); + + expect(mockDbtProject.runModelTest).toHaveBeenCalledWith(modelName); + }); + }); + + describe("State Management", () => { + it("should set and get workspace state", () => { + const mockContext = { + workspaceState: { + get: jest.fn(), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + } as unknown as ExtensionContext; + + container.setContext(mockContext); + + const key = "testKey"; + const value = { test: "value" }; + + container.setToWorkspaceState(key, value); + expect(mockContext.workspaceState.update).toHaveBeenCalledWith( + key, + value, + ); + + mockContext.workspaceState.get = jest.fn(() => value); + const result = container.getFromWorkspaceState(key); + expect(result).toEqual(value); + }); + + it("should set and get global state", () => { + const mockContext = { + workspaceState: { + get: jest.fn(), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + } as unknown as ExtensionContext; + + container.setContext(mockContext); + + const key = "globalKey"; + const value = "globalValue"; + + container.setToGlobalState(key, value); + expect(mockContext.globalState.update).toHaveBeenCalledWith(key, value); + + mockContext.globalState.get = jest.fn(() => value); + const result = container.getFromGlobalState(key); + expect(result).toBe(value); + }); + }); + + describe("Altimate Datapilot Integration", () => { + beforeEach(() => { + const mockContext = { + extension: { id: "test-extension", packageJSON: { version: "1.0.0" } }, + } as ExtensionContext; + container.setContext(mockContext); + }); + + it("should check if Altimate Datapilot is installed", async () => { + const result = await container.checkIfAltimateDatapilotInstalled(); + + expect( + mockAltimateDatapilot.checkIfAltimateDatapilotInstalled, + ).toHaveBeenCalled(); + expect(mockAltimateRequest.getDatapilotVersion).toHaveBeenCalledWith( + "1.0.0", + ); + expect(result).toBe(true); + }); + + it("should install Altimate Datapilot", async () => { + await container.installAltimateDatapilot(); + + expect(mockAltimateRequest.getDatapilotVersion).toHaveBeenCalledWith( + "1.0.0", + ); + expect( + mockAltimateDatapilot.installAltimateDatapilot, + ).toHaveBeenCalledWith("1.0.0"); + }); + + it("should execute Altimate Datapilot healthcheck", async () => { + const args = { + projectRoot: "/path/to/project", + configType: "All" as const, + } as DataPilotHealtCheckParams; + + await container.executeAltimateDatapilotHealthcheck(args); + + expect(mockDbtProject.performDatapilotHealthcheck).toHaveBeenCalledWith( + args, + ); + }); + + it("should throw error when project not found for healthcheck", () => { + // Mock getProjects to return empty array + container.getProjects = jest.fn(() => []); + + const args = { + projectRoot: "/non/existent/project", + configType: "All" as const, + } as DataPilotHealtCheckParams; + + expect(() => container.executeAltimateDatapilotHealthcheck(args)).toThrow( + "Unable to find project /non/existent/project", + ); + }); + }); + + describe("Workspace Folder Management", () => { + it("should initialize DBT projects", async () => { + const mockWorkspaceFolder = { + uri: Uri.file("/path/to/workspace"), + name: "test-workspace", + index: 0, + }; + + // Mock workspace folders + (workspace as any).workspaceFolders = [mockWorkspaceFolder]; + container.dbtWorkspaceFolders = []; + + await container.initializeDBTProjects(); + + expect(mockDbtWorkspaceFolderFactory).toHaveBeenCalledWith( + mockWorkspaceFolder, + expect.anything(), + expect.anything(), + ); + }); + + it("should handle undefined workspace folders", async () => { + // Mock workspace folders as undefined + (workspace as any).workspaceFolders = undefined; + + // Should not throw + await container.initializeDBTProjects(); + + // Should not call factory + expect(mockDbtWorkspaceFolderFactory).not.toHaveBeenCalled(); + }); + + it("should handle workspace folder changes", async () => { + // Setup workspace change handler + let changeHandler: any; + (workspace.onDidChangeWorkspaceFolders as any).mockImplementation( + (handler: any) => { + changeHandler = handler; + return { dispose: jest.fn() }; + }, + ); + + // Create a new container to register the handler + new DBTProjectContainer( + mockDbtClient, + mockDbtWorkspaceFolderFactory, + mockDbtTerminal, + mockAltimateDatapilot, + mockAltimateRequest, + ); + + // Simulate workspace folder change + const addedFolder = { + uri: Uri.file("/path/to/added"), + name: "added", + index: 1, + }; + const removedFolder = { + uri: Uri.file("/path/to/workspace"), + name: "test-workspace", + index: 0, + }; + + await changeHandler({ + added: [addedFolder], + removed: [removedFolder], + }); + + expect(mockDbtWorkspaceFolderFactory).toHaveBeenCalledWith( + addedFolder, + expect.anything(), + expect.anything(), + ); + }); + }); + + describe("Edge Cases", () => { + it("should handle operations when no project is found", () => { + mockDbtWorkspaceFolder.findDBTProject = jest.fn(() => undefined); + + const uri = Uri.file("/path/to/non-dbt/file.sql"); + + // These should not throw errors + container.runModel(uri); + container.buildModel(uri); + container.compileModel(uri); + container.generateDocs(uri); + + expect(mockDbtProject.runModel).not.toHaveBeenCalled(); + expect(mockDbtProject.buildModel).not.toHaveBeenCalled(); + expect(mockDbtProject.compileModel).not.toHaveBeenCalled(); + expect(mockDbtProject.generateDocs).not.toHaveBeenCalled(); + }); + + it("should handle empty workspace folders array", () => { + container.dbtWorkspaceFolders = []; + + expect(container.getProjects()).toEqual([]); + expect(container.getAdapters()).toEqual([]); + }); + + it("should deduplicate adapters", () => { + const mockDbtWorkspaceFolder2 = { + ...mockDbtWorkspaceFolder, + getAdapters: jest.fn(() => ["postgres", "snowflake"]), + }; + + container.dbtWorkspaceFolders = [ + mockDbtWorkspaceFolder, + mockDbtWorkspaceFolder2, + ]; + + const adapters = container.getAdapters(); + expect(adapters).toEqual(["postgres", "snowflake"]); + }); + + it("should handle undefined Python environment", () => { + mockDbtClient.getPythonEnvironment = jest.fn(() => undefined); + + const pythonEnv = container.getPythonEnvironment(); + expect(pythonEnv).toBeUndefined(); + }); + + it("should handle context not set", () => { + const newContainer = new DBTProjectContainer( + mockDbtClient, + mockDbtWorkspaceFolderFactory, + mockDbtTerminal, + mockAltimateDatapilot, + mockAltimateRequest, + ); + + // These should not throw even without context + expect(() => newContainer.extensionId).not.toThrow(); + expect(newContainer.extensionId).toBe(""); + }); + }); + + describe("Walkthrough and Initialization", () => { + beforeEach(() => { + const mockContext = { + workspaceState: { + get: jest.fn(), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + } as unknown as ExtensionContext; + container.setContext(mockContext); + }); + + it("should show walkthrough when user accepts", async () => { + (window.showInformationMessage as any) = jest.fn(() => + Promise.resolve("Yes"), + ); + + await container.showWalkthrough(); + + expect(commands.executeCommand).toHaveBeenCalledWith( + "setContext", + "dbtPowerUser.showSetupWalkthrough", + false, + ); + expect(commands.executeCommand).toHaveBeenCalledWith( + "dbtPowerUser.openSetupWalkthrough", + ); + }); + + it("should not show walkthrough when user ignores", async () => { + (window.showInformationMessage as any) = jest.fn(() => + Promise.resolve("Ignore"), + ); + + await container.showWalkthrough(); + + expect(commands.executeCommand).not.toHaveBeenCalledWith( + "dbtPowerUser.openSetupWalkthrough", + ); + }); + + it("should initialize walkthrough with file associations", async () => { + const mockConfig = { + get: jest.fn((key: string, defaultValue?: any) => { + if (key === "hideWalkthrough") { + return false; + } + if (key === "associations") { + return {}; + } + return defaultValue; + }), + }; + (workspace.getConfiguration as any) = jest.fn(() => mockConfig); + + const mockContext = { + workspaceState: { + get: jest.fn(), + update: jest.fn(), + }, + globalState: { + get: jest.fn(() => true), + update: jest.fn(), + }, + } as unknown as ExtensionContext; + container.setContext(mockContext); + + await container.initializeWalkthrough(); + + expect(commands.executeCommand).toHaveBeenCalledWith( + "setContext", + "dbtPowerUser.projectCount", + 1, + ); + expect(commands.executeCommand).toHaveBeenCalledWith( + "setContext", + "dbtPowerUser.showFileAssociationStep", + true, + ); + }); }); - it("should dispose client and terminal dependencies", () => { - container.dispose(); + describe("Project Registration Events", () => { + it("should set up project registration event handler", () => { + // The project registration event handler is set up in the constructor + // and is tested indirectly through workspace folder operations. + // This test verifies that the container properly initializes with the event system. + + const newContainer = new DBTProjectContainer( + mockDbtClient, + mockDbtWorkspaceFolderFactory, + mockDbtTerminal, + mockAltimateDatapilot, + mockAltimateRequest, + ); + + // Verify the container has the expected event emitters + expect( + (newContainer as any)._onProjectRegisteredUnregistered, + ).toBeDefined(); + expect((newContainer as any)._onManifestChanged).toBeDefined(); + expect( + (newContainer as any)._onRebuildManifestStatusChange, + ).toBeDefined(); + + // Verify public event accessors + expect(newContainer.onManifestChanged).toBeDefined(); + expect(newContainer.onRebuildManifestStatusChange).toBeDefined(); + }); + }); + + describe("SQL Operations with Untitled Files", () => { + it("should handle executeSQL with untitled URI and selected project", () => { + const mockContext = { + workspaceState: { + get: jest.fn((key: string) => { + if (key === "dbtPowerUser.projectSelected") { + return { + label: "test_project", + description: "/path/to/project", + uri: Uri.file("/path/to/project"), + }; + } + return undefined; + }), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + } as unknown as ExtensionContext; + container.setContext(mockContext); + + const untitledUri = { scheme: "untitled", fsPath: "Untitled-1" } as Uri; + const query = "SELECT * FROM table"; + const modelName = "test_model"; + + container.executeSQL(untitledUri, query, modelName); + + expect(mockDbtProject.executeSQLOnQueryPanel).toHaveBeenCalledWith( + query, + modelName, + ); + }); + + it("should handle executeSQL with untitled URI and no selected project", () => { + const mockContext = { + workspaceState: { + get: jest.fn(() => undefined), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + } as unknown as ExtensionContext; + container.setContext(mockContext); + + mockDbtWorkspaceFolder.findDBTProject = jest.fn(() => undefined); + + const untitledUri = { scheme: "untitled", fsPath: "Untitled-1" } as Uri; + const query = "SELECT * FROM table"; + const modelName = "test_model"; + + // Should not throw error + container.executeSQL(untitledUri, query, modelName); + + expect(mockDbtProject.executeSQLOnQueryPanel).not.toHaveBeenCalled(); + }); + }); + + describe("Rebuild Manifest Status", () => { + it("should handle rebuild manifest status changes", () => { + // Setup the event handler + let statusChangeHandler: any; + const mockWorkspaceFolderWithEvent = { + ...mockDbtWorkspaceFolder, + onRebuildManifestStatusChange: (handler: any) => { + statusChangeHandler = handler; + return { dispose: jest.fn() }; + }, + }; + + const factoryWithEvent = jest.fn(() => mockWorkspaceFolderWithEvent); + + // Create container with event support + const newContainer = new DBTProjectContainer( + mockDbtClient, + factoryWithEvent, + mockDbtTerminal, + mockAltimateDatapilot, + mockAltimateRequest, + ); + + // Register workspace folder + const workspaceFolder = { + uri: Uri.file("/path/to/workspace"), + name: "test-workspace", + index: 0, + }; + + // Mock workspace folders + (workspace as any).workspaceFolders = [workspaceFolder]; + newContainer.initializeDBTProjects(); + + // Simulate rebuild manifest status change + if (statusChangeHandler) { + statusChangeHandler({ + project: mockDbtProject, + inProgress: true, + }); - expect(mockDbtClient.dispose).toHaveBeenCalled(); - expect(mockDbtTerminal.dispose).toHaveBeenCalled(); + // Check that the event was properly handled + const rebuildEvent = (newContainer as any) + ._onRebuildManifestStatusChange; + expect(rebuildEvent).toBeDefined(); + } + }); }); });