diff --git a/package-lock.json b/package-lock.json index 64dc9304e..f27506b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "mongodb-query-parser": "^4.3.0", "mongodb-schema": "^12.5.2", "numeral": "^2.0.6", + "query-string": "^7.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", "ts-log": "^2.2.7", @@ -12729,6 +12730,15 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/decompress": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", @@ -15388,6 +15398,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -22490,6 +22509,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystring-es3": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", @@ -24036,6 +24073,15 @@ "spdx-ranges": "^2.0.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -24259,6 +24305,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", diff --git a/package.json b/package.json index 0ead0532e..bb774bb7e 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "onChatParticipant:mongodb.participant", "onLanguage:json", "onLanguage:javascript", - "onLanguage:plaintext" + "onLanguage:plaintext", + "onUri" ], "contributes": { "chatParticipants": [ @@ -1338,6 +1339,7 @@ "mongodb-query-parser": "^4.3.0", "mongodb-schema": "^12.5.2", "numeral": "^2.0.6", + "query-string": "^7.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", "ts-log": "^2.2.7", diff --git a/src/connectionController.ts b/src/connectionController.ts index 664c8f358..06ef42e09 100644 --- a/src/connectionController.ts +++ b/src/connectionController.ts @@ -31,6 +31,7 @@ import { isAtlasStream } from 'mongodb-build-info'; import type { ConnectionTreeItem } from './explorer'; import { PresetConnectionEditedTelemetryEvent } from './telemetry'; import getBuildInfo from 'mongodb-build-info'; +import type { RequiredBy } from './utils/types'; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJSON = require('../package.json'); @@ -73,6 +74,12 @@ type RecursivePartial = { : T[P]; }; +interface NewConnectionParams { + connectionString?: string; + name?: string; + reuseExisting?: boolean; +} + function isOIDCAuth(connectionString: string): boolean { const authMechanismString = ( new ConnectionString(connectionString).searchParams.get('authMechanism') || @@ -139,7 +146,6 @@ export default class ConnectionController { _connectionStorage: ConnectionStorage; _telemetryService: TelemetryService; - private readonly _serviceName = 'mdb.vscode.savedConnections'; private _currentConnectionId: null | string = null; _connectionAttempt: null | ConnectionAttempt = null; @@ -243,16 +249,18 @@ export default class ConnectionController { })); */ } - async connectWithURI(): Promise { - let connectionString: string | undefined; - + async connectWithURI({ + connectionString, + reuseExisting, + name, + }: NewConnectionParams = {}): Promise { log.info('connectWithURI command called'); const cancellationToken = new vscode.CancellationTokenSource(); this._connectionStringInputCancellationToken = cancellationToken; try { - connectionString = await vscode.window.showInputBox( + connectionString ??= await vscode.window.showInputBox( { value: '', ignoreFocusOut: true, @@ -292,27 +300,48 @@ export default class ConnectionController { return false; } - return this.addNewConnectionStringAndConnect(connectionString); + return this.addNewConnectionStringAndConnect({ + connectionString, + reuseExisting: reuseExisting ?? false, + name, + }); } // Resolves the new connection id when the connection is successfully added. // Resolves false when it is added and not connected. // The connection can fail to connect but be successfully added. - async addNewConnectionStringAndConnect( - connectionString: string - ): Promise { + async addNewConnectionStringAndConnect({ + connectionString, + reuseExisting, + name, + }: RequiredBy): Promise { log.info('Trying to connect to a new connection configuration...'); const connectionStringData = new ConnectionString(connectionString); try { - const connectResult = await this.saveNewConnectionAndConnect({ - connectionId: uuidv4(), - connectionOptions: { - connectionString: connectionStringData.toString(), - }, - connectionType: ConnectionTypes.CONNECTION_STRING, - }); + let existingConnection: LoadedConnection | undefined; + if (reuseExisting) { + existingConnection = + this._findConnectionByConnectionString(connectionString); + + if (existingConnection && existingConnection.name !== name) { + void vscode.window.showInformationMessage( + `Connection with the same connection string already exists, under a different name: '${existingConnection.name}'. Connecting to the existing one...` + ); + } + } + + const connectResult = await (existingConnection + ? this.connectWithConnectionId(existingConnection.id) + : this.saveNewConnectionAndConnect({ + connectionId: uuidv4(), + connectionOptions: { + connectionString: connectionStringData.toString(), + }, + connectionType: ConnectionTypes.CONNECTION_STRING, + name, + })); return connectResult.successfullyConnected; } catch (error) { @@ -339,14 +368,17 @@ export default class ConnectionController { connectionOptions, connectionId, connectionType, + name, }: { connectionOptions: ConnectionOptions; connectionId: string; connectionType: ConnectionTypes; + name?: string; }): Promise { const connection = this._connectionStorage.createNewConnection({ connectionId, connectionOptions, + name, }); await this._connectionStorage.saveConnection(connection); @@ -510,13 +542,7 @@ export default class ConnectionController { log.info('Successfully connected', { connectionId }); - const message = 'MongoDB connection successful.'; - this._statusView.showMessage(message); - setTimeout(() => { - if (this._statusView._statusBarItem.text === message) { - this._statusView.hideMessage(); - } - }, 5000); + this._statusView.showTemporaryMessage('MongoDB connection successful.'); dataService.addReauthenticationHandler( this._reauthenticationHandler.bind(this) @@ -572,6 +598,19 @@ export default class ConnectionController { } } + _findConnectionByConnectionString( + connectionString: string + ): LoadedConnection | undefined { + const searchStrings = [connectionString]; + if (!connectionString.endsWith('/')) { + searchStrings.push(`${connectionString}/`); + } + + return this.getConnectionsFromHistory().find((connection) => + searchStrings.includes(connection.connectionOptions?.connectionString) + ); + } + private async onConnectSuccess({ connectionInfo, dataService, @@ -719,14 +758,7 @@ export default class ConnectionController { this._disconnecting = false; - const message = 'MongoDB disconnected.'; - this._statusView.showMessage(message); - setTimeout(() => { - if (this._statusView._statusBarItem.text === message) { - this._statusView.hideMessage(); - } - }, 5000); - + this._statusView.showTemporaryMessage('MongoDB disconnected.'); return true; } @@ -744,23 +776,32 @@ export default class ConnectionController { } // Prompts the user to remove the connection then removes it on affirmation. - async removeMongoDBConnection(connectionId: string): Promise { - if (!this._connections[connectionId]) { + async _removeMongoDBConnection({ + connectionId, + force = false, + }: { + connectionId: string; + force?: boolean; + }): Promise { + const connection = this._connections[connectionId]; + if (!connection) { // No active connection(s) to remove. void vscode.window.showErrorMessage('Connection does not exist.'); return false; } - const removeConfirmationResponse = - await vscode.window.showInformationMessage( - `Are you sure to want to remove connection ${this._connections[connectionId].name}?`, - { modal: true }, - 'Yes' - ); + if (!force) { + const removeConfirmationResponse = + await vscode.window.showInformationMessage( + `Are you sure to want to remove connection ${connection.name}?`, + { modal: true }, + 'Yes' + ); - if (removeConfirmationResponse !== 'Yes') { - return false; + if (removeConfirmationResponse !== 'Yes') { + return false; + } } if (this._activeDataService && connectionId === this._currentConnectionId) { @@ -774,57 +815,89 @@ export default class ConnectionController { await this.removeSavedConnection(connectionId); - void vscode.window.showInformationMessage('MongoDB connection removed.'); + void vscode.window.showInformationMessage( + `MongoDB connection '${connection.name}' removed.` + ); return true; } - async onRemoveMongoDBConnection(): Promise { + async onRemoveMongoDBConnection( + options: ( + | { connectionString: string } + | { name: string } + | { id: string } + | {} + ) & { + force?: boolean; + } = {} + ): Promise { log.info('mdb.removeConnection command called'); - const connectionIds = Object.entries(this._connections) - .map(([id, connection]) => { - return { id, connection }; - }) - .filter( - ({ connection }) => - connection.source !== 'globalSettings' && - connection.source !== 'workspaceSettings' - ); + let connectionIdToRemove: string; + if ('id' in options) { + connectionIdToRemove = options.id; + } else if ('connectionString' in options) { + const connectionId = this._findConnectionByConnectionString( + options.connectionString + )?.id; + + if (!connectionId) { + // No connection to remove, so just return silently. + return false; + } - if (connectionIds.length === 0) { - // No active connection(s) to remove. - void vscode.window.showErrorMessage('No connections to remove.'); + connectionIdToRemove = connectionId; + } else if ('name' in options) { + const connectionId = this.getConnectionsFromHistory().find( + (connection) => connection.name === options.name + )?.id; + if (!connectionId) { + // No connection to remove, so just return silently. + return false; + } - return false; - } + connectionIdToRemove = connectionId; + } else { + const connectionIds = this.getConnectionsFromHistory(); - if (connectionIds.length === 1) { - return this.removeMongoDBConnection(connectionIds[0].id); - } + if (connectionIds.length === 0) { + // No active connection(s) to remove. + void vscode.window.showErrorMessage('No connections to remove.'); - // There is more than 1 possible connection to remove. - // We attach the index of the connection so that we can infer their pick. - const connectionNameToRemove: string | undefined = - await vscode.window.showQuickPick( - connectionIds.map( - ({ connection }, index) => `${index + 1}: ${connection.name}` - ), - { - placeHolder: 'Choose a connection to remove...', + return false; + } + + if (connectionIds.length === 1) { + connectionIdToRemove = connectionIds[0].id; + } else { + // There is more than 1 possible connection to remove. + // We attach the index of the connection so that we can infer their pick. + const connectionNameToRemove: string | undefined = + await vscode.window.showQuickPick( + connectionIds.map( + (connection, index) => `${index + 1}: ${connection.name}` + ), + { + placeHolder: 'Choose a connection to remove...', + } + ); + + if (!connectionNameToRemove) { + return false; } - ); - if (!connectionNameToRemove) { - return false; + // We attach the index of the connection so that we can infer their pick. + const connectionIndexToRemove = + Number(connectionNameToRemove.split(':', 1)[0]) - 1; + connectionIdToRemove = connectionIds[connectionIndexToRemove].id; + } } - // We attach the index of the connection so that we can infer their pick. - const connectionIndexToRemove = - Number(connectionNameToRemove.split(':', 1)[0]) - 1; - const connectionIdToRemove = connectionIds[connectionIndexToRemove].id; - - return this.removeMongoDBConnection(connectionIdToRemove); + return this._removeMongoDBConnection({ + connectionId: connectionIdToRemove, + force: options.force, + }); } async updateConnection({ @@ -939,6 +1012,15 @@ export default class ConnectionController { return Object.values(this._connections); } + private getConnectionsFromHistory(): LoadedConnection[] { + return this.getSavedConnections().filter((connection) => { + return ( + connection.source !== 'globalSettings' && + connection.source !== 'workspaceSettings' + ); + }); + } + getSavedConnectionName(connectionId: string): string { return this._connections[connectionId] ? this._connections[connectionId].name diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index a671ada2d..2a82a2efb 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -33,7 +33,7 @@ import launchMongoShell from './commands/launchMongoShell'; import type SchemaTreeItem from './explorer/schemaTreeItem'; import { StatusView } from './views'; import { StorageController, StorageVariables } from './storage'; -import { TelemetryService } from './telemetry'; +import { DeepLinkTelemetryEvent, TelemetryService } from './telemetry'; import type PlaygroundsTreeItem from './explorer/playgroundsTreeItem'; import PlaygroundResultProvider from './editors/playgroundResultProvider'; import WebviewController from './views/webviewController'; @@ -56,6 +56,8 @@ import { DocumentEditedTelemetryEvent, } from './telemetry'; +import * as queryString from 'query-string'; + // This class is the top-level controller for our extension. // Commands which the extensions handles are defined in the function `activate`. export default class MDBExtensionController implements vscode.Disposable { @@ -187,6 +189,7 @@ export default class MDBExtensionController implements vscode.Disposable { this.registerCommands(); this.showOverviewPageIfRecentlyInstalled(); this.subscribeToConfigurationChanges(); + this.registerUriHandler(); const copilot = vscode.extensions.getExtension(COPILOT_EXTENSION_ID); void vscode.commands.executeCommand( @@ -210,6 +213,50 @@ export default class MDBExtensionController implements vscode.Disposable { } } + registerUriHandler = (): void => { + vscode.window.registerUriHandler({ + handleUri: this._handleDeepLink, + }); + }; + + _handleDeepLink = async (uri: vscode.Uri): Promise => { + let command = uri.path.replace(/^\//, ''); + if (!command.startsWith('mdb.')) { + command = `mdb.${command}`; + } + + const parameters = queryString.parse(uri.query, { + parseBooleans: true, + parseNumbers: true, + }); + + const source = + 'utm_source' in parameters && typeof parameters.utm_source === 'string' + ? parameters.utm_source + : undefined; + + delete parameters.utm_source; // Don't propagate after tracking. + this._telemetryService.track(new DeepLinkTelemetryEvent(command, source)); + + try { + if ( + !Object.values(EXTENSION_COMMANDS).includes( + command as EXTENSION_COMMANDS + ) + ) { + throw new Error( + `Unable to execute command '${command}' since it is not registered by the MongoDB extension.` + ); + } + + await vscode.commands.executeCommand(command, parameters); + } catch (error) { + await vscode.window.showErrorMessage( + `Failed to handle '${uri}': ${error}` + ); + } + }; + registerCommands = (): void => { // Register our extension's commands. These are the event handlers and // control the functionality of our extension. @@ -222,14 +269,14 @@ export default class MDBExtensionController implements vscode.Disposable { this._webviewController.openWebview(this._context); return Promise.resolve(true); }); - this.registerCommand(EXTENSION_COMMANDS.MDB_CONNECT_WITH_URI, () => - this._connectionController.connectWithURI() - ); + this.registerCommand(EXTENSION_COMMANDS.MDB_CONNECT_WITH_URI, (params) => { + return this._connectionController.connectWithURI(params); + }); this.registerCommand(EXTENSION_COMMANDS.MDB_DISCONNECT, () => this._connectionController.disconnect() ); - this.registerCommand(EXTENSION_COMMANDS.MDB_REMOVE_CONNECTION, () => - this._connectionController.onRemoveMongoDBConnection() + this.registerCommand(EXTENSION_COMMANDS.MDB_REMOVE_CONNECTION, (params) => + this._connectionController.onRemoveMongoDBConnection(params) ); this.registerCommand(EXTENSION_COMMANDS.MDB_CHANGE_ACTIVE_CONNECTION, () => this._connectionController.changeActiveConnection() @@ -521,7 +568,9 @@ export default class MDBExtensionController implements vscode.Disposable { this.registerCommand( EXTENSION_COMMANDS.MDB_REMOVE_CONNECTION_TREE_VIEW, (element: ConnectionTreeItem) => - this._connectionController.removeMongoDBConnection(element.connectionId) + this._connectionController.onRemoveMongoDBConnection({ + id: element.connectionId, + }) ); this.registerCommand( EXTENSION_COMMANDS.MDB_EDIT_CONNECTION, diff --git a/src/storage/connectionStorage.ts b/src/storage/connectionStorage.ts index 0751cc3e0..838df6721 100644 --- a/src/storage/connectionStorage.ts +++ b/src/storage/connectionStorage.ts @@ -58,11 +58,13 @@ export class ConnectionStorage { createNewConnection({ connectionOptions, connectionId, + name, }: { connectionOptions: ConnectionOptions; connectionId: string; + name?: string; }): LoadedConnection { - const name = getConnectionTitle({ + name ??= getConnectionTitle({ connectionOptions, }); diff --git a/src/telemetry/telemetryEvents.ts b/src/telemetry/telemetryEvents.ts index a454f02f8..a97476af2 100644 --- a/src/telemetry/telemetryEvents.ts +++ b/src/telemetry/telemetryEvents.ts @@ -669,6 +669,32 @@ export class TreeItemExpandedTelemetryEvent implements TelemetryEventBase { } } +/** + * Reported when the extension handles a deep link (e.g. vscode://mongodb.mongodb-vscode/command) + */ +export class DeepLinkTelemetryEvent implements TelemetryEventBase { + type = 'Deep Link Handled'; + properties: { + /** + * The command that the deeplink requested - e.g. `mdb.connectWithURI`. This event is reported even + * if the command is not valid and an error eventually shown to the user. + */ + command: string; + + /** + * The source of the deep link - e.g. the Atlas CLI or the docs website. + */ + source?: string; + }; + + constructor(command: string, source?: string) { + this.properties = { + command, + source, + }; + } +} + export type TelemetryEvent = | PlaygroundExecutedTelemetryEvent | LinkClickedTelemetryEvent @@ -693,4 +719,5 @@ export type TelemetryEvent = | ParticipantResponseGeneratedTelemetryEvent | PresetConnectionEditedTelemetryEvent | SidePanelOpenedTelemetryEvent - | TreeItemExpandedTelemetryEvent; + | TreeItemExpandedTelemetryEvent + | DeepLinkTelemetryEvent; diff --git a/src/test/suite/connectionController.test.ts b/src/test/suite/connectionController.test.ts index 758eb63f5..37292e6f2 100644 --- a/src/test/suite/connectionController.test.ts +++ b/src/test/suite/connectionController.test.ts @@ -8,6 +8,7 @@ import { expect } from 'chai'; import ConnectionString from 'mongodb-connection-string-url'; import ConnectionController, { + ConnectionTypes, DataServiceEventTypes, getNotifyDeviceFlowForConnectionAttempt, } from '../../connectionController'; @@ -81,9 +82,9 @@ suite('Connection Controller Test Suite', function () { test('it connects to mongodb', async () => { const successfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const connectionId = testConnectionController.getActiveConnectionId() || ''; const name = testConnectionController._connections[connectionId].name; const dataService = testConnectionController.getActiveDataService(); @@ -104,9 +105,9 @@ suite('Connection Controller Test Suite', function () { test('should append appName with connection and anonymous id', async () => { const successfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const connectionId = testConnectionController.getActiveConnectionId() || ''; const connection = testConnectionController._connections[connectionId]; @@ -134,9 +135,9 @@ suite('Connection Controller Test Suite', function () { test('should override legacy appended appName and persist it', async () => { // Simulate legacy behavior of appending vscode appName by manually creating one const successfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - `${TEST_DATABASE_URI}/?appname=mongodb-vscode+9.9.9` - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: `${TEST_DATABASE_URI}/?appname=mongodb-vscode+9.9.9`, + }); const connectionId = testConnectionController.getActiveConnectionId() || ''; @@ -171,9 +172,9 @@ suite('Connection Controller Test Suite', function () { test('does not override other user-set appName', async () => { const successfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - `${TEST_DATABASE_URI}/?appName=test-123+9.9.9` - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: `${TEST_DATABASE_URI}/?appName=test-123+9.9.9`, + }); const connectionId = testConnectionController.getActiveConnectionId() || ''; let connection = testConnectionController._connections[connectionId]; @@ -204,9 +205,9 @@ suite('Connection Controller Test Suite', function () { }); test('getMongoClientConnectionOptions appends anonymous and connection ID and options properties', async function () { - await testConnectionController.addNewConnectionStringAndConnect( - `${TEST_DATABASE_URI}` - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const mongoClientConnectionOptions = testConnectionController.getMongoClientConnectionOptions(); @@ -242,9 +243,9 @@ suite('Connection Controller Test Suite', function () { test('"disconnect()" disconnects from the active connection', async () => { const successfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); expect(successfullyConnected).to.be.true; expect(testConnectionController.getConnectionStatus()).to.equal( @@ -270,74 +271,194 @@ suite('Connection Controller Test Suite', function () { expect(dataService).to.be.null; }); - test('"removeMongoDBConnection()" returns a reject promise when there is no active connection', async () => { - const expectedMessage = 'No connections to remove.'; - const successfullyRemovedMongoDBConnection = - await testConnectionController.onRemoveMongoDBConnection(); + suite('onRemoveMongoDBConnection', () => { + const addConnection = ( + id: string, + name: string, + connectionString = 'mongodb://localhost:12345', + otherOptions: Partial = {} + ): void => { + testConnectionController._connections[id] = { + connectionOptions: { connectionString }, + storageLocation: StorageLocation.NONE, + secretStorageLocation: SecretStorageLocation.SecretStorage, + name, + id, + ...otherOptions, + }; + }; - expect(showErrorMessageStub.firstCall.args[0]).to.equal(expectedMessage); - expect(successfullyRemovedMongoDBConnection).to.be.false; - }); + test('returns a reject promise when there is no active connection', async () => { + const expectedMessage = 'No connections to remove.'; + const successfullyRemovedMongoDBConnection = + await testConnectionController.onRemoveMongoDBConnection(); - test('"removeMongoDBConnection()" hides preset connections', async () => { - const connectionBase: Omit = { - connectionOptions: { - connectionString: 'localhost:3000', - }, - storageLocation: StorageLocation.NONE, - secretStorageLocation: SecretStorageLocation.SecretStorage, - }; + expect(showErrorMessageStub.firstCall.args[0]).to.equal(expectedMessage); + expect(successfullyRemovedMongoDBConnection).to.be.false; + }); - testConnectionController._connections['1234'] = { - ...connectionBase, - name: 'valid 1', - id: '1234', - }; - testConnectionController._connections['5678'] = { - ...connectionBase, - name: 'valid 2', - id: '5678', - source: 'user', - }; - testConnectionController._connections['3333'] = { - ...connectionBase, - id: '3333', - name: 'invalid 1', - source: 'workspaceSettings', - }; - testConnectionController._connections['3333'] = { - ...connectionBase, - id: '3333', - name: 'invalid 2', - source: 'globalSettings', - }; + test('hides preset connections', async () => { + addConnection('1234', 'valid 1'); + addConnection('5678', 'valid 2', undefined, { source: 'user' }); + addConnection('3333', 'invalid 1', undefined, { + source: 'workspaceSettings', + }); + addConnection('4444', 'invalid 2', undefined, { + source: 'globalSettings', + }); - const showQuickPickStub = sinon - .stub(vscode.window, 'showQuickPick') - .resolves(undefined); - const successfullyRemovedMongoDBConnection = - await testConnectionController.onRemoveMongoDBConnection(); + const showQuickPickStub = sinon + .stub(vscode.window, 'showQuickPick') + .resolves(undefined); + const successfullyRemovedMongoDBConnection = + await testConnectionController.onRemoveMongoDBConnection(); - expect(showErrorMessageStub).not.called; - expect(showQuickPickStub.firstCall.firstArg).deep.equal([ - '1: valid 1', - '2: valid 2', - ]); - expect(successfullyRemovedMongoDBConnection).to.be.false; + expect(showErrorMessageStub).not.called; + expect(showQuickPickStub.firstCall.firstArg).deep.equal([ + '1: valid 1', + '2: valid 2', + ]); + expect(successfullyRemovedMongoDBConnection).to.be.false; + }); + + test('when connection does not exist, shows error', async () => { + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + id: 'abc', + }); + expect(didRemove).to.be.false; + expect(showErrorMessageStub).to.be.calledOnceWith( + 'Connection does not exist.' + ); + }); + + test('when force: false, prompts user for confirmation', async () => { + addConnection('1234', 'foo'); + showInformationMessageStub.resolves('No'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + id: '1234', + }); + + expect(didRemove).to.be.false; + expect(showInformationMessageStub).to.be.calledOnceWith( + 'Are you sure to want to remove connection foo?', + { modal: true }, + 'Yes' + ); + }); + + test('when force: true, does not prompt user for confirmation', async () => { + addConnection('1234', 'foo'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + id: '1234', + force: true, + }); + + expect(didRemove).to.be.true; + expect(testConnectionController._connections['1234']).to.be.undefined; + }); + + test('with connection name, removes connection', async () => { + addConnection('1234', 'bar'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + name: 'bar', + force: true, + }); + + expect(didRemove).to.be.true; + expect(testConnectionController._connections['1234']).to.be.undefined; + }); + + test('with connection name, when not found, silently returns', async () => { + addConnection('1234', 'bar'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + name: 'foo', + force: true, + }); + + expect(didRemove).to.be.false; + expect(showInformationMessageStub).to.not.have.been.called; + expect(testConnectionController._connections['1234']).to.not.be.undefined; + }); + + test('with connection name, when multiple connections match, removes first one', async () => { + addConnection('1234', 'bar'); + addConnection('5678', 'bar'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + name: 'bar', + force: true, + }); + + expect(didRemove).to.be.true; + expect(testConnectionController._connections['1234']).to.be.undefined; + expect(testConnectionController._connections['5678']).to.not.be.undefined; + }); + + test('with connection string, removes connection', async () => { + addConnection('1234', 'bar', 'mongodb://localhost:12345'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + connectionString: 'mongodb://localhost:12345', + force: true, + }); + + expect(didRemove).to.be.true; + expect(testConnectionController._connections['1234']).to.be.undefined; + }); + + test('with connection name, when not found, silently returns', async () => { + addConnection('1234', 'bar', 'mongodb://localhost:12345'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + connectionString: 'mongodb://localhost:27017', + force: true, + }); + + expect(didRemove).to.be.false; + expect(showInformationMessageStub).to.not.have.been.called; + expect(testConnectionController._connections['1234']).to.not.be.undefined; + }); + + test('with connection name, when multiple connections match, removes first one', async () => { + addConnection('1234', 'foo', 'mongodb://localhost:12345'); + addConnection('5678', 'bar', 'mongodb://localhost:12345'); + + const didRemove = + await testConnectionController.onRemoveMongoDBConnection({ + connectionString: 'mongodb://localhost:12345', + force: true, + }); + + expect(didRemove).to.be.true; + expect(testConnectionController._connections['1234']).to.be.undefined; + expect(testConnectionController._connections['5678']).to.not.be.undefined; + }); }); test('when adding a new connection it disconnects from the current connection', async () => { const succesfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); expect(succesfullyConnected).to.be.true; try { - await testConnectionController.addNewConnectionStringAndConnect( - testDatabaseURI2WithTimeout - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: testDatabaseURI2WithTimeout, + }); } catch (error) { const expectedError = 'Failed to connect'; @@ -349,9 +470,9 @@ suite('Connection Controller Test Suite', function () { test('when adding a new connection it sets the connection controller as connecting while it disconnects from the current connection', async () => { const succesfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); expect(succesfullyConnected).to.be.true; @@ -363,9 +484,9 @@ suite('Connection Controller Test Suite', function () { }); const succesfullyConnected2 = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); expect(succesfullyConnected2).to.be.true; expect(wasSetToConnectingWhenDisconnecting).to.be.true; @@ -385,9 +506,9 @@ suite('Connection Controller Test Suite', function () { } ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); testConnectionController.removeEventListener( DataServiceEventTypes.CONNECTIONS_DID_CHANGE, @@ -412,9 +533,9 @@ suite('Connection Controller Test Suite', function () { } ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); await testConnectionController.disconnect(); testConnectionController.removeEventListener( @@ -446,9 +567,9 @@ suite('Connection Controller Test Suite', function () { }; // Should persist as this is a saved connection. - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); await testConnectionController.loadSavedConnections(); @@ -460,24 +581,24 @@ suite('Connection Controller Test Suite', function () { await vscode.workspace .getConfiguration('mdb.connectionSaving') .update('defaultConnectionSavingLocation', DefaultSavingLocations.Global); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); await vscode.workspace .getConfiguration('mdb.connectionSaving') .update( 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); await testConnectionController.disconnect(); testConnectionController.clearAllConnections(); await testConnectionController.loadSavedConnections(); @@ -499,9 +620,9 @@ suite('Connection Controller Test Suite', function () { await vscode.workspace .getConfiguration('mdb.connectionSaving') .update('defaultConnectionSavingLocation', DefaultSavingLocations.Global); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const globalStoreConnections = testStorageController.get( StorageVariables.GLOBAL_SAVED_CONNECTIONS, @@ -531,9 +652,9 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const workspaceStoreConnections = testStorageController.get( StorageVariables.WORKSPACE_SAVED_CONNECTIONS, @@ -582,9 +703,9 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const workspaceStoreConnections = testStorageController.get( StorageVariables.WORKSPACE_SAVED_CONNECTIONS, @@ -618,9 +739,9 @@ suite('Connection Controller Test Suite', function () { const expectedDriverUrl = 'mongodb://localhost:27088/'; await testConnectionController.loadSavedConnections(); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const activeConnectionId = testConnectionController.getActiveConnectionId(); @@ -644,9 +765,9 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations['Session Only'] ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const objectString = JSON.stringify(undefined); const globalStoreConnections = testStorageController.get( @@ -705,9 +826,9 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const workspaceStoreConnections = testStorageController.get( StorageVariables.WORKSPACE_SAVED_CONNECTIONS, @@ -735,9 +856,9 @@ suite('Connection Controller Test Suite', function () { await vscode.workspace .getConfiguration('mdb.connectionSaving') .update('defaultConnectionSavingLocation', DefaultSavingLocations.Global); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const globalStoreConnections = testStorageController.get( StorageVariables.GLOBAL_SAVED_CONNECTIONS, @@ -764,9 +885,9 @@ suite('Connection Controller Test Suite', function () { 'deleteSecret' ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI_USER - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + }); const [connection] = testConnectionController.getSavedConnections(); await testConnectionController.removeSavedConnection(connection.id); @@ -781,9 +902,9 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const workspaceStoreConnections = testStorageController.get( StorageVariables.WORKSPACE_SAVED_CONNECTIONS, @@ -830,9 +951,9 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const workspaceStoreConnections = testStorageController.get( StorageVariables.WORKSPACE_SAVED_CONNECTIONS, @@ -906,12 +1027,12 @@ suite('Connection Controller Test Suite', function () { 'defaultConnectionSavingLocation', DefaultSavingLocations.Workspace ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); await testConnectionController.disconnect(); testConnectionController.clearAllConnections(); @@ -951,12 +1072,12 @@ suite('Connection Controller Test Suite', function () { suite('connecting to a new connection when already connecting', () => { test('connects to the new connection', async () => { await Promise.all([ - testConnectionController.addNewConnectionStringAndConnect( - testDatabaseURI2WithTimeout - ), - testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ), + testConnectionController.addNewConnectionStringAndConnect({ + connectionString: testDatabaseURI2WithTimeout, + }), + testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }), ]); expect(testConnectionController.isConnecting()).to.be.false; @@ -1000,9 +1121,9 @@ suite('Connection Controller Test Suite', function () { suite('when connected', function () { beforeEach(async function () { - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); }); test('two disconnects on one connection at once complete without erroring', (done) => { @@ -1113,46 +1234,10 @@ suite('Connection Controller Test Suite', function () { expect(newSavedConnectionInfoWithSecrets).to.deep.equal(connectionInfo); }); - test('addNewConnectionStringAndConnect saves connection without secrets to the global storage', async () => { - const fakeConnect = sandbox.fake.resolves({ - successfullyConnected: true, - }); - sandbox.replace(testConnectionController, '_connect', fakeConnect); - - await vscode.workspace - .getConfiguration('mdb.connectionSaving') - .update('defaultConnectionSavingLocation', DefaultSavingLocations.Global); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI_USER - ); - - const workspaceStoreConnections = testStorageController.get( - StorageVariables.GLOBAL_SAVED_CONNECTIONS - ); - - expect(workspaceStoreConnections).to.not.be.empty; - const connections = Object.values(workspaceStoreConnections); - - expect(connections).to.have.lengthOf(1); - expect(connections[0].connectionOptions?.connectionString).to.include( - TEST_USER_USERNAME - ); - expect(connections[0].connectionOptions?.connectionString).to.not.include( - TEST_USER_PASSWORD - ); - expect( - testConnectionController._connections[connections[0].id].connectionOptions - ?.connectionString - ).to.include(TEST_USER_PASSWORD); - expect( - testConnectionController._connections[connections[0].id].name - ).to.equal('localhost:27088'); - }); - test('getMongoClientConnectionOptions returns url and options properties', async () => { - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const mongoClientConnectionOptions = testConnectionController.getMongoClientConnectionOptions(); @@ -1213,17 +1298,13 @@ suite('Connection Controller Test Suite', function () { }); suite('when connection secrets are already in SecretStorage', () => { - afterEach(() => { - testSandbox.restore(); - }); - test('should be able to load connection with its secrets', async () => { - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI_USER - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + }); // By default the connection secrets are already stored in SecretStorage const savedConnections = testConnectionController.getSavedConnections(); @@ -1252,9 +1333,9 @@ suite('Connection Controller Test Suite', function () { }); test('should fire a CONNECTIONS_DID_CHANGE event if connections are loaded successfully', async () => { - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI_USER - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + }); await testConnectionController.disconnect(); testConnectionController.clearAllConnections(); @@ -1419,4 +1500,243 @@ suite('Connection Controller Test Suite', function () { ]); }); }); + + suite('connectWithURI', () => { + let showInputBoxStub: sinon.SinonStub; + let addNewConnectionAndConnectStub: sinon.SinonStub; + + beforeEach(() => { + showInputBoxStub = sandbox.stub(vscode.window, 'showInputBox'); + addNewConnectionAndConnectStub = sandbox.stub( + testConnectionController, + 'addNewConnectionStringAndConnect' + ); + }); + + test('without arguments, prompts for input', async () => { + showInputBoxStub.returns(undefined); + + const result = await testConnectionController.connectWithURI(); + expect(result).to.be.false; + expect(showInputBoxStub).to.have.been.calledOnce; + }); + + test('without arguments, uses input provided by user', async () => { + showInputBoxStub.returns(TEST_DATABASE_URI); + addNewConnectionAndConnectStub.returns(true); + + const result = await testConnectionController.connectWithURI(); + expect(result).to.be.true; + expect(showInputBoxStub).to.have.been.calledOnce; + expect(addNewConnectionAndConnectStub).to.have.been.calledOnceWithExactly( + { + connectionString: TEST_DATABASE_URI, + reuseExisting: false, + name: undefined, + } + ); + }); + + test('with arguments, uses provided connection string', async () => { + addNewConnectionAndConnectStub.returns(true); + + const result = await testConnectionController.connectWithURI({ + connectionString: 'mongodb://127.0.0.1:12345', + reuseExisting: true, + name: 'foo', + }); + expect(result).to.be.true; + expect(showInputBoxStub).to.not.have.been.called; + expect(addNewConnectionAndConnectStub).to.have.been.calledOnceWithExactly( + { + connectionString: 'mongodb://127.0.0.1:12345', + reuseExisting: true, + name: 'foo', + } + ); + }); + }); + + suite('addNewConnectionStringAndConnect', () => { + let fakeConnect: sinon.SinonStub; + + beforeEach(() => { + fakeConnect = sandbox + .stub(testConnectionController, '_connect') + .resolves({ successfullyConnected: true, connectionErrorMessage: '' }); + }); + + test('saves connection without secrets to the global storage', async () => { + await vscode.workspace + .getConfiguration('mdb.connectionSaving') + .update( + 'defaultConnectionSavingLocation', + DefaultSavingLocations.Global + ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + }); + + const workspaceStoreConnections = testStorageController.get( + StorageVariables.GLOBAL_SAVED_CONNECTIONS + ); + + expect(workspaceStoreConnections).to.not.be.empty; + const connections = Object.values(workspaceStoreConnections); + + expect(connections).to.have.lengthOf(1); + expect(connections[0].connectionOptions?.connectionString).to.include( + TEST_USER_USERNAME + ); + expect(connections[0].connectionOptions?.connectionString).to.not.include( + TEST_USER_PASSWORD + ); + expect( + testConnectionController._connections[connections[0].id] + .connectionOptions?.connectionString + ).to.include(TEST_USER_PASSWORD); + expect( + testConnectionController._connections[connections[0].id].name + ).to.equal('localhost:27088'); + }); + + suite('with reuseExisting: ', () => { + test('false, adds a new connection', async () => { + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + name: 'foo', + reuseExisting: false, + }); + + expect(testConnectionController.getSavedConnections()).to.have.lengthOf( + 1 + ); + + expect(fakeConnect).to.have.been.calledOnce; + + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + name: 'foo', + reuseExisting: false, + }); + + expect(testConnectionController.getSavedConnections()).to.have.lengthOf( + 2 + ); + expect(fakeConnect).to.have.been.calledTwice; + expect(showInformationMessageStub).to.not.have.been.called; + }); + + test('true, reuses existing connection', async () => { + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + name: 'foo', + reuseExisting: true, + }); + + expect(testConnectionController.getSavedConnections()).to.have.lengthOf( + 1 + ); + + expect(fakeConnect).to.have.been.calledOnce; + expect(showInformationMessageStub).to.not.have.been.called; // First time we're adding this connection + + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + name: 'foo', + reuseExisting: true, + }); + + expect(testConnectionController.getSavedConnections()).to.have.lengthOf( + 1 + ); + expect(fakeConnect).to.have.been.calledTwice; + + // Adding a connection with the same connection string and name should not show a message + expect(showInformationMessageStub).to.not.have.been.called; + }); + + test('true, does not override existing connection name', async () => { + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + name: 'foo', + reuseExisting: true, + }); + + let connections = testConnectionController.getSavedConnections(); + expect(connections).to.have.lengthOf(1); + expect(connections[0].name).to.equal('foo'); + expect(showInformationMessageStub).to.not.have.been.called; // First time we're adding this connection + + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI_USER, + name: 'bar', + reuseExisting: true, + }); + + connections = testConnectionController.getSavedConnections(); + expect(connections).to.have.lengthOf(1); + expect(connections[0].name).to.equal('foo'); // not 'bar' + + // Connecting with a different name should show a message + expect(showInformationMessageStub).to.have.been.calledOnceWith( + "Connection with the same connection string already exists, under a different name: 'foo'. Connecting to the existing one..." + ); + }); + + test('true, matches connection regardless of trailing slash', async () => { + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: 'mongodb://localhost:12345/', + reuseExisting: true, + }); + + let connections = testConnectionController.getSavedConnections(); + expect(connections[0].connectionOptions?.connectionString).to.equal( + 'mongodb://localhost:12345/' + ); + + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: 'mongodb://localhost:12345/', + reuseExisting: true, + }); + expect(fakeConnect).to.have.been.calledWith( + connections[0].id, + ConnectionTypes.CONNECTION_ID + ); + connections = testConnectionController.getSavedConnections(); + expect(connections).to.have.lengthOf(1); + + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: 'mongodb://localhost:12345', // No-slash + reuseExisting: true, + }); + expect(fakeConnect).to.have.been.calledWith(connections[0].id); + connections = testConnectionController.getSavedConnections(); + expect(connections).to.have.lengthOf(1); + }); + }); + + suite('with name: ', () => { + test('supplied, uses provided name', async () => { + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: 'mongodb://localhost:12345/', + name: 'foo', + }); + + const connections = testConnectionController.getSavedConnections(); + expect(connections).to.have.lengthOf(1); + expect(connections[0].name).to.equal('foo'); + }); + + test('not supplied, generates one', async () => { + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: 'mongodb://localhost:12345/', + }); + + const connections = testConnectionController.getSavedConnections(); + expect(connections).to.have.lengthOf(1); + expect(connections[0].name).to.equal('localhost:12345'); + }); + }); + }); }); diff --git a/src/test/suite/editors/playgroundSelectionCodeActionProvider.test.ts b/src/test/suite/editors/playgroundSelectionCodeActionProvider.test.ts index c3e3393cb..a4b8a7c22 100644 --- a/src/test/suite/editors/playgroundSelectionCodeActionProvider.test.ts +++ b/src/test/suite/editors/playgroundSelectionCodeActionProvider.test.ts @@ -44,7 +44,7 @@ suite('Playground Selection Code Action Provider Test Suite', function () { ); await mdbTestExtension.testExtensionController._connectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI + { connectionString: TEST_DATABASE_URI } ); const testExportToLanguageCodeLensProvider = diff --git a/src/test/suite/explorer/explorerController.test.ts b/src/test/suite/explorer/explorerController.test.ts index 0e47cced2..a91e46233 100644 --- a/src/test/suite/explorer/explorerController.test.ts +++ b/src/test/suite/explorer/explorerController.test.ts @@ -98,9 +98,9 @@ suite('Explorer Controller Test Suite', function () { const treeController = testExplorerController.getTreeController(); const succesfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); assert( succesfullyConnected === true, @@ -144,9 +144,9 @@ suite('Explorer Controller Test Suite', function () { const treeController = testExplorerController.getTreeController(); const succesfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); assert( succesfullyConnected === true, @@ -168,9 +168,9 @@ suite('Explorer Controller Test Suite', function () { assert(connectionsItemsFirstConnect[0].isExpanded); try { - await testConnectionController.addNewConnectionStringAndConnect( - testDatabaseURI2WithTimeout - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: testDatabaseURI2WithTimeout, + }); } catch (error) { /* Silent fail (should fail) */ } @@ -202,9 +202,9 @@ suite('Explorer Controller Test Suite', function () { mdbTestExtension.testExtensionController._explorerController; const treeController = testExplorerController.getTreeController(); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const connectionId = testConnectionController.getActiveConnectionId() || ''; @@ -258,9 +258,9 @@ suite('Explorer Controller Test Suite', function () { mdbTestExtension.testExtensionController._explorerController; const treeController = testExplorerController.getTreeController(); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const connectionsItems = await treeController.getChildren(); const databaseItems = await connectionsItems[0].getChildren(); @@ -282,9 +282,9 @@ suite('Explorer Controller Test Suite', function () { mdbTestExtension.testExtensionController._explorerController; const treeController = testExplorerController.getTreeController(); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); const connectionsItems = await treeController.getChildren(); @@ -355,9 +355,9 @@ suite('Explorer Controller Test Suite', function () { sandbox.replace(vscode.window, 'createTreeView', vscodeCreateTreeViewStub); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); await testConnectionController.disconnect(); assert(vscodeCreateTreeViewStub.called); diff --git a/src/test/suite/mdbExtensionController.test.ts b/src/test/suite/mdbExtensionController.test.ts index 518b917bc..02a525ddd 100644 --- a/src/test/suite/mdbExtensionController.test.ts +++ b/src/test/suite/mdbExtensionController.test.ts @@ -28,6 +28,8 @@ import { } from '../../storage/storageController'; import { VIEW_COLLECTION_SCHEME } from '../../editors/collectionDocumentsProvider'; import type { CollectionDetailsType } from '../../explorer/collectionTreeItem'; +import { expect } from 'chai'; +import { DeepLinkTelemetryEvent } from '../../telemetry'; const testDatabaseURI = 'mongodb://localhost:27088'; @@ -304,7 +306,7 @@ suite('MDBExtensionController Test Suite', function () { const fakeRemoveMongoDBConnection = sandbox.fake(); sandbox.replace( mdbTestExtension.testExtensionController._connectionController, - 'removeMongoDBConnection', + '_removeMongoDBConnection', fakeRemoveMongoDBConnection ); await vscode.commands.executeCommand( @@ -312,10 +314,10 @@ suite('MDBExtensionController Test Suite', function () { testTreeItem ); assert.strictEqual(fakeRemoveMongoDBConnection.calledOnce, true); - assert.strictEqual( - fakeRemoveMongoDBConnection.firstCall.args[0], - 'craving_for_pancakes_with_maple_syrup' - ); + assert.deepStrictEqual(fakeRemoveMongoDBConnection.firstCall.args[0], { + connectionId: 'craving_for_pancakes_with_maple_syrup', + force: undefined, + }); }); test('mdb.copyConnectionString command should try to copy the driver url to the vscode env clipboard', async () => { @@ -710,9 +712,9 @@ suite('MDBExtensionController Test Suite', function () { test.skip('mdb.dropCollection fails when a collection does not exist', async () => { const testConnectionController = mdbTestExtension.testExtensionController._connectionController; - await testConnectionController.addNewConnectionStringAndConnect( - testDatabaseURI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: testDatabaseURI, + }); const testCollectionTreeItem = getTestCollectionTreeItem({ collection: { @@ -802,9 +804,9 @@ suite('MDBExtensionController Test Suite', function () { test('mdb.dropDatabase succeeds even when a database doesnt exist (mdb behavior)', async () => { const testConnectionController = mdbTestExtension.testExtensionController._connectionController; - await testConnectionController.addNewConnectionStringAndConnect( - testDatabaseURI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: testDatabaseURI, + }); const testDatabaseTreeItem = getTestDatabaseTreeItem({ databaseName: 'narnia____a', @@ -1766,4 +1768,174 @@ suite('MDBExtensionController Test Suite', function () { preview: true, }); }); + + suite('handleDeepLink', () => { + let fakeExecuteCommand: sinon.SinonStub; + let fakeTrack: sinon.SinonStub; + + let fakeShowErrorMessage: sinon.SinonSpy; + + beforeEach(() => { + fakeExecuteCommand = sandbox.stub(vscode.commands, 'executeCommand'); + fakeShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage'); + fakeTrack = sandbox.stub( + mdbTestExtension.testExtensionController._telemetryService, + 'track' + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + test('errors when command is not registered', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse('vscode://mongodb.mongodb-vscode/invalid-command') + ); + + expect(fakeExecuteCommand).to.not.have.been.called; + expect(fakeShowErrorMessage).to.have.been.calledOnceWith( + "Failed to handle 'vscode://mongodb.mongodb-vscode/invalid-command': Error: Unable to execute command 'mdb.invalid-command' since it is not registered by the MongoDB extension." + ); + }); + + test('handles valid command', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse('vscode://mongodb.mongodb-vscode/mdb.connectWithURI') + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI'); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('handles valid command without mdb. prefix', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse('vscode://mongodb.mongodb-vscode/connectWithURI') + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI'); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('handles valid command with query parameters', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/connectWithURI?foo=bar&baz=qux' + ) + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI', { + foo: 'bar', + baz: 'qux', + }); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('converts query parameters to booleans and numbers', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/connectWithURI?foo=true&bar=987&baz=str' + ) + ); + + expect(fakeExecuteCommand).to.have.been.calledOnceWith( + 'mdb.connectWithURI', + { + foo: true, + bar: 987, + baz: 'str', + } + ); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('decodes query parameters', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.from({ + scheme: 'vscode', + authority: 'mongodb.mongodb-vscode', + path: '/connectWithURI', + query: + 'connectionString=mongodb%3A%2F%2Flocalhost%3A27017%2F%3FappName%3Dblah%26test%3Dtrue&reuseExisting=true', + }) + ); + + expect(fakeExecuteCommand).to.have.been.calledOnceWith( + 'mdb.connectWithURI', + { + connectionString: 'mongodb://localhost:27017/?appName=blah&test=true', + reuseExisting: true, + } + ); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('shows an error message when executeCommand fails', async () => { + fakeExecuteCommand.rejects(new Error('fake error')); + + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse('vscode://mongodb.mongodb-vscode/mdb.connectWithURI') + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI'); + expect(fakeShowErrorMessage).to.have.been.calledOnceWith( + "Failed to handle 'vscode://mongodb.mongodb-vscode/mdb.connectWithURI': Error: fake error" + ); + }); + + test('reports telemetry event', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/connectWithURI?foo=true&bar=987&baz=str' + ) + ); + + expect(fakeTrack).to.have.been.calledOnceWith( + new DeepLinkTelemetryEvent('mdb.connectWithURI') + ); + }); + + test('reports utm_source if present', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/connectWithURI?foo=true&bar=987&baz=str&utm_source=AtlasCLI' + ) + ); + + expect(fakeTrack).to.have.been.calledOnceWith( + new DeepLinkTelemetryEvent('mdb.connectWithURI', 'AtlasCLI') + ); + }); + + test('reports even non-existent commands', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/invalid_command?foo=true&bar=987&baz=str&utm_source=RogueActor' + ) + ); + + expect(fakeTrack).to.have.been.calledOnceWith( + new DeepLinkTelemetryEvent('mdb.invalid_command', 'RogueActor') + ); + }); + + test('removes utm_source from parameters passed to command', async () => { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/mdb.connectWithURI?foo=bar&utm_source=abc' + ) + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI', { + foo: 'bar', + }); + expect(fakeExecuteCommand.firstCall.args[1]).to.not.have.property( + 'utm_source' + ); + expect(fakeTrack).to.have.been.calledOnceWith( + new DeepLinkTelemetryEvent('mdb.connectWithURI', 'abc') + ); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + }); }); diff --git a/src/test/suite/oidc.test.ts b/src/test/suite/oidc.test.ts index 29c853894..8830f4c9d 100644 --- a/src/test/suite/oidc.test.ts +++ b/src/test/suite/oidc.test.ts @@ -188,9 +188,9 @@ suite('OIDC Tests', function () { test('can successfully connect with a connection string', async function () { const succesfullyConnected = - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }); expect(succesfullyConnected).to.be.true; await launchMongoShell(testConnectionController); @@ -230,9 +230,9 @@ suite('OIDC Tests', function () { }; expect( - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ) + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }) ).to.be.true; const connectionId = testConnectionController.getActiveConnectionId(); @@ -262,9 +262,9 @@ suite('OIDC Tests', function () { }; expect( - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ) + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }) ).to.be.true; const connectionId = testConnectionController.getActiveConnectionId(); @@ -300,7 +300,7 @@ suite('OIDC Tests', function () { }; testConnectionController - .addNewConnectionStringAndConnect(connectionString) + .addNewConnectionStringAndConnect({ connectionString }) .catch(() => { // ignored }); @@ -308,9 +308,9 @@ suite('OIDC Tests', function () { await once(emitter, 'authorizeEndpointCalled'); overrideRequestHandler = (): void => {}; const connected = - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }); emitter.emit('secondConnectionEstablished'); expect(connected).to.be.true; }); @@ -346,9 +346,9 @@ suite('OIDC Tests', function () { }; expect( - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ) + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }) ).to.be.true; afterReauth = true; @@ -395,9 +395,9 @@ suite('OIDC Tests', function () { }; const isConnected = - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }); expect(isConnected).to.be.true; @@ -433,9 +433,9 @@ suite('OIDC Tests', function () { }; expect( - await testConnectionController.addNewConnectionStringAndConnect( - connectionString - ) + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString, + }) ).to.be.true; await vscode.commands.executeCommand('mdb.createPlayground'); diff --git a/src/test/suite/views/webviewController.test.ts b/src/test/suite/views/webviewController.test.ts index ed8c33ae1..cec07a15a 100644 --- a/src/test/suite/views/webviewController.test.ts +++ b/src/test/suite/views/webviewController.test.ts @@ -356,9 +356,9 @@ suite('Webview Test Suite', () => { }, }); - void testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + void testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); }); test('web view runs the "connectWithURI" command when open connection string input is received', async () => { @@ -455,7 +455,7 @@ suite('Webview Test Suite', () => { ); void testConnectionController - .addNewConnectionStringAndConnect(TEST_DATABASE_URI) + .addNewConnectionStringAndConnect({ connectionString: TEST_DATABASE_URI }) .then(() => { // Mock a connection status request call. messageReceived({ @@ -492,9 +492,9 @@ suite('Webview Test Suite', () => { mdbTestExtension.extensionContextStub ); - await testConnectionController.addNewConnectionStringAndConnect( - TEST_DATABASE_URI - ); + await testConnectionController.addNewConnectionStringAndConnect({ + connectionString: TEST_DATABASE_URI, + }); // Mock a connection status request call. messageReceived({ diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 000000000..e2e1b2b8c --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1 @@ +export type RequiredBy = T & Required>; diff --git a/src/views/statusView.ts b/src/views/statusView.ts index b367f2143..181f7ca36 100644 --- a/src/views/statusView.ts +++ b/src/views/statusView.ts @@ -17,6 +17,15 @@ export default class StatusView { this._statusBarItem.show(); } + public showTemporaryMessage(message: string): void { + this.showMessage(message); + setTimeout(() => { + if (this._statusBarItem.text === message) { + this.hideMessage(); + } + }, 5000); + } + public hideMessage(): void { this._statusBarItem.hide(); }