From 71165da92e23f3d048a8094f79d450abf259e1fc Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Wed, 25 Jun 2025 18:54:16 +0530 Subject: [PATCH 01/98] feat/cext-4859 Refactored BaseCommand and constants --- src/BaseCommand.js | 48 ----------------------- src/DBBaseCommand.js | 11 ++++++ src/StateBaseCommand.js | 67 ++++++++++++++++++++++++++++++++ src/commands/app/db/ping.js | 0 src/commands/app/db/provision.js | 0 src/commands/app/db/status.js | 0 src/commands/app/state/delete.js | 4 +- src/commands/app/state/get.js | 4 +- src/commands/app/state/list.js | 6 +-- src/commands/app/state/put.js | 8 ++-- src/commands/app/state/stats.js | 4 +- src/constants.js | 5 --- src/constants/db.js | 11 ++++++ src/constants/state.js | 16 ++++++++ 14 files changed, 118 insertions(+), 66 deletions(-) create mode 100644 src/DBBaseCommand.js create mode 100644 src/StateBaseCommand.js create mode 100644 src/commands/app/db/ping.js create mode 100644 src/commands/app/db/provision.js create mode 100644 src/commands/app/db/status.js create mode 100644 src/constants/db.js create mode 100644 src/constants/state.js diff --git a/src/BaseCommand.js b/src/BaseCommand.js index cfee6a9..bc686e1 100644 --- a/src/BaseCommand.js +++ b/src/BaseCommand.js @@ -11,12 +11,8 @@ governing permissions and limitations under the License. */ import { Command, Flags } from '@oclif/core' -import config from '@adobe/aio-lib-core-config' import AioLogger from '@adobe/aio-lib-core-logging' - -import { CONFIG_STATE_REGION } from './constants.js' import chalk from 'chalk' -import semver from 'semver' export class BaseCommand extends Command { async init () { @@ -38,50 +34,6 @@ export class BaseCommand extends Command { this.flags = flags this.args = args this.debugLogger.debug(`${command} args=${JSON.stringify(this.args)} flags=${JSON.stringify(this.flags)}`) - - // check application dependencies - let packageJson - try { - const file = await readFile('package.json') - packageJson = JSON.parse(file.toString()) - } catch (e) { - this.debugLogger.debug('package.json not found, skipping dependency check') - } - if (packageJson) { - const aioLibStateVersion = packageJson.dependencies?.['@adobe/aio-lib-state'] - const aioSdkVersion = packageJson.dependencies?.['@adobe/aio-sdk'] - if ((aioLibStateVersion && semver.lt(semver.coerce(aioLibStateVersion), '4.0.0')) || - (aioSdkVersion && semver.lt(semver.coerce(aioSdkVersion), '6.0.0'))) { - this.error('State commands are not available for legacy State, please migrate to the latest "@adobe/aio-lib-state" (or "@adobe/aio-sdk" >= 6.0.0).') - } - } - - // init state client - const owOptions = { - namespace: config.get('runtime.namespace'), - auth: config.get('runtime.auth') - } - if (!(owOptions.namespace && owOptions.auth)) { - this.error( -`This command is expected to be run in the root of a App Builder app project. - Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment variables are configured.` - ) - } - const region = flags.region || config.get(CONFIG_STATE_REGION) || 'amer' - this.debugLogger.info('using state region: %s', region) - - if (config.get('state.endpoint')) { - process.env.AIO_STATE_ENDPOINT = config.get('state.endpoint') - this.debugLogger.info('using custom endpoint: %s', process.env.AIO_STATE_ENDPOINT) - } - // dynamic import to be able to reload the AIO_STATE_ENDPOINT var - // eslint-disable-next-line node/no-unsupported-features/es-syntax - const State = await import('@adobe/aio-lib-state') - - /** @type {import('@adobe/aio-lib-state').AdobeState} */ - this.state = await State.init({ region, ow: owOptions }) - - this.rtNamespace = owOptions.namespace } async catch (error) { diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js new file mode 100644 index 0000000..795f41f --- /dev/null +++ b/src/DBBaseCommand.js @@ -0,0 +1,11 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ diff --git a/src/StateBaseCommand.js b/src/StateBaseCommand.js new file mode 100644 index 0000000..854ee7a --- /dev/null +++ b/src/StateBaseCommand.js @@ -0,0 +1,67 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { BaseCommand } from './BaseCommand.js' +import config from '@adobe/aio-lib-core-config' +import { CONFIG_STATE_REGION } from './constants/state.js' +import chalk from 'chalk' +import semver from 'semver' + +export class StateBaseCommand extends BaseCommand { + async init () { + await super.init() + // check application dependencies + let packageJson + try { + const { readFile } = await import('fs/promises') + const file = await readFile('package.json') + packageJson = JSON.parse(file.toString()) + } catch (e) { + this.debugLogger?.debug?.('package.json not found, skipping dependency check') + } + if (packageJson) { + const aioLibStateVersion = packageJson.dependencies?.['@adobe/aio-lib-state'] + const aioSdkVersion = packageJson.dependencies?.['@adobe/aio-sdk'] + if ((aioLibStateVersion && semver.lt(semver.coerce(aioLibStateVersion), '4.0.0')) || + (aioSdkVersion && semver.lt(semver.coerce(aioSdkVersion), '6.0.0'))) { + this.error('State commands are not available for legacy State, please migrate to the latest "@adobe/aio-lib-state" (or "@adobe/aio-sdk" >= 6.0.0).') + } + } + + // init state client + const owOptions = { + namespace: config.get('runtime.namespace'), + auth: config.get('runtime.auth') + } + if (!(owOptions.namespace && owOptions.auth)) { + this.error( +`This command is expected to be run in the root of a App Builder app project. + Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment variables are configured.` + ) + } + const region = this.flags.region || config.get(CONFIG_STATE_REGION) || 'amer' + this.debugLogger?.info?.('using state region: %s', region) + + if (config.get('state.endpoint')) { + process.env.AIO_STATE_ENDPOINT = config.get('state.endpoint') + this.debugLogger?.info?.('using custom endpoint: %s', process.env.AIO_STATE_ENDPOINT) + } + // dynamic import to be able to reload the AIO_STATE_ENDPOINT var + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const State = await import('@adobe/aio-lib-state') + + /** @type {import('@adobe/aio-lib-state').AdobeState} */ + this.state = await State.init({ region, ow: owOptions }) + + this.rtNamespace = owOptions.namespace + } +} diff --git a/src/commands/app/db/ping.js b/src/commands/app/db/ping.js new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/app/db/provision.js b/src/commands/app/db/provision.js new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/app/state/delete.js b/src/commands/app/state/delete.js index d32d466..30b0c22 100644 --- a/src/commands/app/state/delete.js +++ b/src/commands/app/state/delete.js @@ -10,12 +10,12 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import chalk from 'chalk' -import { BaseCommand } from '../../../BaseCommand.js' +import { StateBaseCommand } from '../../../StateBaseCommand.js' import { Args, Flags } from '@oclif/core' const MAX_ARGV_NO_CONFIRM = 5 -export class Delete extends BaseCommand { +export class Delete extends StateBaseCommand { async run () { const { match, force } = this.flags const { argv: keysToDelete } = await this.parse(Delete) diff --git a/src/commands/app/state/get.js b/src/commands/app/state/get.js index f1da403..d4c59f7 100644 --- a/src/commands/app/state/get.js +++ b/src/commands/app/state/get.js @@ -10,10 +10,10 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import chalk from 'chalk' -import { BaseCommand } from '../../../BaseCommand.js' +import { StateBaseCommand } from '../../../StateBaseCommand.js' import { Args } from '@oclif/core' -export class Get extends BaseCommand { +export class Get extends StateBaseCommand { async run () { const ret = await this.state.get(this.args.key) diff --git a/src/commands/app/state/list.js b/src/commands/app/state/list.js index 0f6e04b..a98712e 100644 --- a/src/commands/app/state/list.js +++ b/src/commands/app/state/list.js @@ -9,14 +9,14 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { BaseCommand } from '../../../BaseCommand.js' +import { StateBaseCommand } from '../../../StateBaseCommand.js' import { Flags } from '@oclif/core' import chalk from 'chalk' const MAX_KEYS = 5000 const COUNT_HINT = 500 // per iteration -export class List extends BaseCommand { +export class List extends StateBaseCommand { async run () { const allKeys = [] @@ -55,7 +55,7 @@ List.examples = [ ] List.flags = { - ...BaseCommand.flags, + ...StateBaseCommand.flags, match: Flags.string({ name: 'match', char: 'm', diff --git a/src/commands/app/state/put.js b/src/commands/app/state/put.js index c0bb460..4fbc2f2 100644 --- a/src/commands/app/state/put.js +++ b/src/commands/app/state/put.js @@ -9,12 +9,12 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { BaseCommand } from '../../../BaseCommand.js' +import { StateBaseCommand } from '../../../StateBaseCommand.js' import { Args, Flags } from '@oclif/core' -import { DEFAULT_TTL_SECONDS } from '../../../constants.js' +import { DEFAULT_TTL_SECONDS } from '../../../constants/state.js' import chalk from 'chalk' -export class Put extends BaseCommand { +export class Put extends StateBaseCommand { async run () { const { key, value } = this.args const { json, ttl } = this.flags @@ -55,7 +55,7 @@ Put.args = { } Put.flags = { - ...BaseCommand.flags, + ...StateBaseCommand.flags, ttl: Flags.integer({ char: 't', description: 'Time to live in seconds. Default is 86400 (24 hours), max is 31536000 (1 year).', diff --git a/src/commands/app/state/stats.js b/src/commands/app/state/stats.js index d968da2..6aa9f9d 100644 --- a/src/commands/app/state/stats.js +++ b/src/commands/app/state/stats.js @@ -9,9 +9,9 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { BaseCommand } from '../../../BaseCommand.js' +import { StateBaseCommand } from '../../../StateBaseCommand.js' -export class Stats extends BaseCommand { +export class Stats extends StateBaseCommand { async run () { const ret = await this.state.stats() this.log( diff --git a/src/constants.js b/src/constants.js index 97fbe09..795f41f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -9,8 +9,3 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - -// state.region is a new configuration (env=AIO_STATE_REGION) -export const CONFIG_STATE_REGION = 'state.region' - -export const DEFAULT_TTL_SECONDS = 60 * 60 * 24 // 24 hours diff --git a/src/constants/db.js b/src/constants/db.js new file mode 100644 index 0000000..795f41f --- /dev/null +++ b/src/constants/db.js @@ -0,0 +1,11 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ diff --git a/src/constants/state.js b/src/constants/state.js new file mode 100644 index 0000000..97fbe09 --- /dev/null +++ b/src/constants/state.js @@ -0,0 +1,16 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// state.region is a new configuration (env=AIO_STATE_REGION) +export const CONFIG_STATE_REGION = 'state.region' + +export const DEFAULT_TTL_SECONDS = 60 * 60 * 24 // 24 hours From 8410a790ee3f9ad2a46d49f7f34c833df62f29ee Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Thu, 26 Jun 2025 19:09:34 +0530 Subject: [PATCH 02/98] feat:cext-4859 added support for db commands --- package.json | 2 + src/BaseCommand.js | 13 +- src/DBBaseCommand.js | 66 ++++++++++ src/StateBaseCommand.js | 8 +- src/commands/app/db/ping.js | 84 +++++++++++++ src/commands/app/db/provision.js | 156 ++++++++++++++++++++++++ src/commands/app/db/status.js | 202 +++++++++++++++++++++++++++++++ src/commands/app/state/delete.js | 2 +- src/constants.js | 11 -- 9 files changed, 528 insertions(+), 16 deletions(-) delete mode 100644 src/constants.js diff --git a/package.json b/package.json index 9691266..d2215eb 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "dependencies": { "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", + "@adobe/aio-lib-db": "file:../aio-lib-db", "@adobe/aio-lib-state": "^5", "@inquirer/prompts": "^5", "@oclif/core": "^4", "@oclif/plugin-help": "^6", "chalk": "^5", + "dotenv": "^16.5.0", "semver": "^7.6.3" }, "devDependencies": { diff --git a/src/BaseCommand.js b/src/BaseCommand.js index bc686e1..bf8bb78 100644 --- a/src/BaseCommand.js +++ b/src/BaseCommand.js @@ -17,13 +17,12 @@ import chalk from 'chalk' export class BaseCommand extends Command { async init () { await super.init() - // eslint-disable-next-line node/no-unsupported-features/es-syntax - const { readFile } = await import('fs/promises') // dynamic import to be able to mock fs, ESM and Jest are not friends // setup debug logger const command = this.constructor.name.toLowerCase() // hacky but convenient + const serviceName = this.getServiceName() // Get service name dynamically this.debugLogger = AioLogger( - `aio:app:state:${command}`, + `aio:app:${serviceName}:${command}`, { provider: 'debug' } ) // override warn to stderr @@ -36,6 +35,14 @@ export class BaseCommand extends Command { this.debugLogger.debug(`${command} args=${JSON.stringify(this.args)} flags=${JSON.stringify(this.flags)}`) } + /** + * Get the service name for logging namespace + * Override in subclasses to provide service-specific namespaces + */ + getServiceName() { + return 'app' // Default fallback + } + async catch (error) { this.debugLogger.error(error) // debug log with stack trace diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js index 795f41f..a28a4e6 100644 --- a/src/DBBaseCommand.js +++ b/src/DBBaseCommand.js @@ -9,3 +9,69 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + +import { BaseCommand } from './BaseCommand.js' +import config from '@adobe/aio-lib-core-config' + +export class DBBaseCommand extends BaseCommand { + async init () { + await super.init() + + // Initialize database client + await this.initializeDBClient() + + this.debugLogger?.info?.('DBBaseCommand initialized with DB client') + } + + /** + * Initialize the database client using aio-lib-db + */ + async initializeDBClient() { + try { + // Dynamic import of aio-lib-db (CommonJS module) + const aioLibDb = await import('@adobe/aio-lib-db') + const { init } = aioLibDb.default || aioLibDb + + // Get database configuration + const dbConfig = { + namespace: config.get('runtime.namespace'), + auth: config.get('runtime.auth'), + region: this.flags?.region || config.get('db.region') || 'amer', + endpoint: config.get('db.endpoint') || process.env.AIO_DB_ENDPOINT + } + + // Validate required configuration + if (!(dbConfig.namespace && dbConfig.auth)) { + this.error( + `Database commands require App Builder project configuration. +Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment variables are configured.` + ) + } + + this.debugLogger?.info?.('Initializing DB client with config:', { + namespace: dbConfig.namespace, + region: dbConfig.region, + hasAuth: !!dbConfig.auth, + endpoint: dbConfig.endpoint || 'default' + }) + + // Initialize the database client + this.db = await init(dbConfig.namespace, dbConfig.auth) + this.dbConfig = dbConfig + this.rtNamespace = dbConfig.namespace + + this.debugLogger?.info?.('DB client initialized successfully') + + } catch (error) { + this.debugLogger?.error?.('Failed to initialize DB client:', error.message) + this.error(`Failed to initialize database client: ${error.message}`) + } + } + + /** + * Get the service name for logging + */ + getServiceName() { + return 'db' + } +} diff --git a/src/StateBaseCommand.js b/src/StateBaseCommand.js index 854ee7a..f978b8c 100644 --- a/src/StateBaseCommand.js +++ b/src/StateBaseCommand.js @@ -13,7 +13,6 @@ governing permissions and limitations under the License. import { BaseCommand } from './BaseCommand.js' import config from '@adobe/aio-lib-core-config' import { CONFIG_STATE_REGION } from './constants/state.js' -import chalk from 'chalk' import semver from 'semver' export class StateBaseCommand extends BaseCommand { @@ -64,4 +63,11 @@ export class StateBaseCommand extends BaseCommand { this.rtNamespace = owOptions.namespace } + + /** + * Get the service name for logging + */ + getServiceName() { + return 'state' + } } diff --git a/src/commands/app/db/ping.js b/src/commands/app/db/ping.js index e69de29..356b6b9 100644 --- a/src/commands/app/db/ping.js +++ b/src/commands/app/db/ping.js @@ -0,0 +1,84 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { DBBaseCommand } from '../../../DBBaseCommand.js' +import chalk from 'chalk' + +export class Ping extends DBBaseCommand { + async run () { + this.debugLogger?.info?.('Starting database ping test') + + try { + this.log(chalk.blue('Testing database connectivity...')) + + const startTime = Date.now() + const pingResult = await this.db.ping() + const endTime = Date.now() + const responseTime = endTime - startTime + + this.debugLogger?.info?.('Ping result:', pingResult) + + this.log(chalk.green('Database connection successful')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Response time: ${responseTime}ms`)) + + if (typeof pingResult === 'string') { + this.log(chalk.dim(` Response: ${pingResult}`)) + } + + const result = { + status: 'success', + namespace: this.rtNamespace, + responseTime: responseTime, + response: pingResult, + timestamp: new Date().toISOString() + } + + if (!this.flags.json) { + this.log(chalk.dim('Database is ready for operations')) + } + + return result + + } catch (error) { + this.debugLogger?.error?.('Ping command error:', error) + + this.log(chalk.red('Database connection failed')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + const result = { + status: 'failed', + namespace: this.rtNamespace, + error: error.message, + timestamp: new Date().toISOString() + } + + + + return result + } + } +} + +Ping.description = 'Test connectivity to your App Builder database' + +Ping.examples = [ + '$ aio app db ping', + '$ aio app db ping --json' +] + +Ping.flags = { + ...DBBaseCommand.flags +} + +Ping.args = {} diff --git a/src/commands/app/db/provision.js b/src/commands/app/db/provision.js index e69de29..20c3a7a 100644 --- a/src/commands/app/db/provision.js +++ b/src/commands/app/db/provision.js @@ -0,0 +1,156 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { DBBaseCommand } from '../../../DBBaseCommand.js' +import { Flags } from '@oclif/core' +import chalk from 'chalk' + +export class Provision extends DBBaseCommand { + async run () { + const { region } = this.flags + + this.debugLogger?.info?.('Starting database provisioning process') + + try { + // First check if database is already provisioned + this.log(chalk.blue('Checking current provisioning status...')) + + let provisionStatusResponse + try { + provisionStatusResponse = await this.db.provisionStatus() + this.debugLogger?.info?.('Provision status:', provisionStatusResponse) + } catch (error) { + this.debugLogger?.info?.('No existing provisioning status found:', error.message) + provisionStatusResponse = null + } + + if (provisionStatusResponse) { + const currentStatus = provisionStatusResponse.status.toUpperCase() + + if (currentStatus === 'PROVISIONED') { + this.log(chalk.green('Database is already provisioned and ready for use')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Region: ${provisionStatusResponse.region || 'amer'}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) + + return { + status: 'already_provisioned', + namespace: this.rtNamespace, + region: provisionStatusResponse.region || 'amer', + details: provisionStatusResponse + } + } else if (currentStatus === 'REQUESTED' || currentStatus === 'PROCESSING') { + this.log(chalk.yellow('Database provisioning is already in progress')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Region: ${provisionStatusResponse.region || 'amer'}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) + this.log(chalk.dim('\nUse "aio app db status --watch" to monitor progress')) + + return { + status: 'in_progress', + namespace: this.rtNamespace, + region: provisionStatusResponse.region || 'amer', + details: provisionStatusResponse + } + } else if (currentStatus === 'FAILED') { + this.log(chalk.red('Previous database provisioning failed')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) + this.log(chalk.yellow('\nAttempting to provision again...')) + } + } + + // Create a new database if not yet provisioned + this.warn('Database provisioning will create new database resources') + + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const { confirm } = await import('@inquirer/prompts') + + const confirmed = await confirm({ + message: `Provision database for namespace '${this.rtNamespace}'?`, + default: false + }) + + if (!confirmed) { + this.log('Database provisioning cancelled') + return { status: 'cancelled' } + } + + // Start provisioning + this.log(chalk.blue('Starting database provisioning...')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Region: ${region || 'amer'}`)) + + const provisionResult = await this.db.provisionRequest({ region }) + this.debugLogger?.info?.('Provision request result:', provisionResult) + + // Handle different provision result statuses + const resultStatus = provisionResult?.status?.toUpperCase() || 'UNKNOWN' + + if (resultStatus === 'PROVISIONED') { + this.log(chalk.green('Database provisioned successfully and ready for use!')) + } else if (resultStatus === 'REQUESTED') { + this.log(chalk.blue('Database provisioning request submitted successfully')) + this.log(chalk.dim('Provisioning is now in progress...')) + } else if (resultStatus === 'PROCESSING') { + this.log(chalk.yellow('Database is being provisioned...')) + } else if (resultStatus === 'FAILED') { + this.error(`Database provisioning failed: ${provisionResult.message || 'Unknown error'}`) + } else { + this.log(chalk.blue('Database provisioning request submitted')) + } + + const result = { + status: resultStatus.toLowerCase(), + namespace: this.rtNamespace, + region: region || 'amer', + timestamp: new Date().toISOString(), + details: provisionResult + } + + if (!this.flags.json && resultStatus !== 'PROVISIONED') { + this.log(chalk.dim('\nNext steps:')) + this.log(chalk.dim(' - Monitor progress: aio app db status --watch')) + this.log(chalk.dim(' - Check status: aio app db status')) + } else if (!this.flags.json && resultStatus === 'PROVISIONED') { + this.log(chalk.dim('\nNext steps:')) + this.log(chalk.dim(' - Test connection: aio app db ping')) + } + + return result + + } catch (error) { + this.debugLogger?.error?.('Provision command error:', error) + this.error(`Database provisioning failed: ${error.message}`) + } + } +} + +Provision.description = 'Provision a new database for your App Builder application' + +Provision.examples = [ + '$ aio app db provision', + '$ aio app db provision --region amer', + '$ aio app db provision --json' +] + +Provision.flags = { + ...DBBaseCommand.flags, + region: Flags.string({ + description: 'Region in which database is to be provisioned', + required: false, + options: ['amer', 'emea', 'apac'], + default: 'amer' + }) +} + +Provision.args = {} diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js index e69de29..326f7f1 100644 --- a/src/commands/app/db/status.js +++ b/src/commands/app/db/status.js @@ -0,0 +1,202 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { DBBaseCommand } from '../../../DBBaseCommand.js' +import { Flags } from '@oclif/core' +import chalk from 'chalk' + +export class Status extends DBBaseCommand { + async run () { + const { watch } = this.flags + + this.debugLogger?.info?.('Checking database provisioning status') + + if (watch) { + return this.watchStatus() + } else { + return this.checkStatus() + } + } + + async checkStatus() { + try { + this.log(chalk.blue('Checking database provisioning status...')) + + const provisionStatusResponse = await this.db.provisionStatus() + this.debugLogger?.info?.('Status result:', provisionStatusResponse) + + this.displayStatus(provisionStatusResponse) + + const result = { + ...provisionStatusResponse, + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + + return result + + } catch (error) { + this.debugLogger?.error?.('Status command error:', error) + + if (error.message.includes('not found') || error.message.includes('404')) { + this.log(chalk.yellow('No database has been provisioned for this workspace')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Status: NOT_PROVISIONED`)) + + return { + status: 'NOT_PROVISIONED', + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + } + + this.log(chalk.red('Failed to check database status')) + this.log(chalk.dim(` Error: ${error.message}`)) + + return { + status: 'error', + namespace: this.rtNamespace, + error: error.message, + timestamp: new Date().toISOString() + } + } + } + + async watchStatus() { + this.log(chalk.blue('Watching database provisioning status (press Ctrl+C to stop)...')) + + let previousStatus = null + const checkInterval = 3000 // 3 seconds + + const watchLoop = async () => { + try { + const provisionStatusResponse = await this.db.provisionStatus() + + // Only display if status changed + if (!previousStatus || previousStatus.status !== provisionStatusResponse.status) { + this.log(chalk.dim(`\n[${new Date().toLocaleTimeString()}]`)) + this.displayStatus(provisionStatusResponse, false) // Don't show timestamp in watch mode + previousStatus = provisionStatusResponse + + // Stop watching if provisioning is complete or failed + const currentStatus = provisionStatusResponse.status.toUpperCase() + if (currentStatus === 'PROVISIONED' || currentStatus === 'FAILED') { + this.log(chalk.dim('\nProvisioning completed. Stopping watch mode.')) + return provisionStatusResponse + } + } + + // Schedule next check + setTimeout(watchLoop, checkInterval) + + } catch (error) { + this.debugLogger?.error?.('Watch status error:', error) + this.log(chalk.red(`\n[${new Date().toLocaleTimeString()}] Error: ${error.message}`)) + + // Continue watching despite errors + setTimeout(watchLoop, checkInterval) + } + } + + // Start watching + return watchLoop() + } + + displayStatus(provisionStatusResponse, showTimestamp = true) { + const currentStatus = provisionStatusResponse.status.toUpperCase() + const statusColor = this.getStatusColor(currentStatus) + + this.log(statusColor(`Database Status: ${currentStatus}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (provisionStatusResponse.region) { + this.log(chalk.dim(` Region: ${provisionStatusResponse.region}`)) + } + + // Display status-specific descriptions + const statusDescription = this.getStatusDescription(currentStatus) + if (statusDescription) { + this.log(chalk.dim(` Description: ${statusDescription}`)) + } + + if (provisionStatusResponse.message) { + this.log(chalk.dim(` Message: ${provisionStatusResponse.message}`)) + } + + if (provisionStatusResponse.created) { + this.log(chalk.dim(` Created: ${new Date(provisionStatusResponse.created).toLocaleString()}`)) + } + + if (provisionStatusResponse.updated) { + this.log(chalk.dim(` Updated: ${new Date(provisionStatusResponse.updated).toLocaleString()}`)) + } + + if (showTimestamp) { + this.log(chalk.dim(` Checked: ${new Date().toLocaleString()}`)) + } + } + + getStatusColor(statusValue) { + const status = statusValue.toUpperCase() + switch (status) { + case 'PROVISIONED': + return chalk.green + case 'REQUESTED': + case 'PROCESSING': + return chalk.yellow + case 'FAILED': + return chalk.red + case 'NOT_PROVISIONED': + return chalk.blue + default: + return chalk.gray + } + } + + getStatusDescription(statusValue) { + const status = statusValue.toUpperCase() + switch (status) { + case 'NOT_PROVISIONED': + return 'No Database has been provisioned for this Workspace' + case 'REQUESTED': + return 'A Database has been requested for this Workspace' + case 'PROCESSING': + return 'A Database is being provisioned for this Workspace' + case 'FAILED': + return 'Failed to provision a Database for this Workspace' + case 'PROVISIONED': + return 'A Database has been provisioned for this Workspace and is ready for use' + default: + return null + } + } + + +} + +Status.description = 'Check the provisioning status of your App Builder database' + +Status.examples = [ + '$ aio app db status', + '$ aio app db status --watch', + '$ aio app db status --json' +] + +Status.flags = { + ...DBBaseCommand.flags, + watch: Flags.boolean({ + description: 'Watch for status changes (press Ctrl+C to stop)', + default: false + }) +} + +Status.args = {} diff --git a/src/commands/app/state/delete.js b/src/commands/app/state/delete.js index 30b0c22..c29a9d7 100644 --- a/src/commands/app/state/delete.js +++ b/src/commands/app/state/delete.js @@ -100,7 +100,7 @@ Delete.args = { } Delete.flags = { - ...BaseCommand.flags, + ...StateBaseCommand.flags, match: Flags.string({ description: '[use with caution!] deletes ALL key-values matching the provided glob-like pattern', required: false diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index 795f41f..0000000 --- a/src/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright 2024 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ From 5223e50e1b3222a281d37d006f7f3f6a93e9172a Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Thu, 26 Jun 2025 19:28:16 +0530 Subject: [PATCH 03/98] feat:cext-4859 added support for db commands --- test/BaseCommand.test.js | 109 +++---------------- test/StateBaseCommand.test.js | 142 +++++++++++++++++++++++++ test/commands/app/state/delete.test.js | 4 +- test/commands/app/state/get.test.js | 4 +- test/commands/app/state/list.test.js | 4 +- test/commands/app/state/put.test.js | 6 +- test/commands/app/state/stats.test.js | 4 +- 7 files changed, 170 insertions(+), 103 deletions(-) create mode 100644 test/StateBaseCommand.test.js diff --git a/test/BaseCommand.test.js b/test/BaseCommand.test.js index a2c56b4..016e146 100644 --- a/test/BaseCommand.test.js +++ b/test/BaseCommand.test.js @@ -11,10 +11,13 @@ governing permissions and limitations under the License. */ import { expect, jest } from '@jest/globals' import { BaseCommand } from '../src/BaseCommand.js' -import { init } from '@adobe/aio-lib-state' +import { Command } from '@oclif/core' import { stderr } from 'stdout-stderr' describe('prototype', () => { + test('extends Command', () => { + expect(BaseCommand.prototype instanceof Command).toBe(true) + }) test('args', () => { expect(Object.keys(BaseCommand.args)).toEqual([]) }) @@ -23,13 +26,12 @@ describe('prototype', () => { expect(BaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) expect(BaseCommand.enableJsonFlag).toEqual(true) }) + test('getServiceName', () => { + const command = new BaseCommand([]) + expect(command.getServiceName()).toBe('app') + }) }) -const mockReadFile = jest.fn() -jest.unstable_mockModule('fs/promises', async () => ({ - readFile: mockReadFile -})) - describe('init', () => { let command beforeEach(async () => { @@ -37,110 +39,33 @@ describe('init', () => { command.config = { runHook: jest.fn().mockResolvedValue({}) } - mockReadFile.mockReset() - mockReadFile.mockResolvedValue(JSON.stringify({ - dependencies: { - '@adobe/aio-lib-state': '^4', - '@adobe/aio-sdk': '^6' - } - })) - }) - - test('dependencies not declared', async () => { - command.argv = [] - mockReadFile.mockResolvedValue(JSON.stringify({ - dependencies: {} - })) - // ignores - await expect(command.init()).resolves.toBeUndefined() - - mockReadFile.mockResolvedValue(JSON.stringify({})) - // ignores - await expect(command.init()).resolves.toBeUndefined() }) - test('package.json not exist', async () => { + test('basic initialization', async () => { command.argv = [] - mockReadFile.mockRejectedValue('some error') - // ignores await expect(command.init()).resolves.toBeUndefined() + expect(command.debugLogger).toBeDefined() + expect(command.flags).toBeDefined() + expect(command.args).toBeDefined() }) - test('aio-lib-state dependency < 4', async () => { - command.argv = [] - mockReadFile.mockResolvedValue(JSON.stringify({ - dependencies: { '@adobe/aio-lib-state': '^3' } - })) - await expect(command.init()).rejects.toThrow('State commands are not available for legacy State, please migrate to the latest "@adobe/aio-lib-state" (or "@adobe/aio-sdk" >= 6.0.0).') - }) - - test('aio-sdk dependency < 6', async () => { - command.argv = [] - mockReadFile.mockResolvedValue(JSON.stringify({ - dependencies: { '@adobe/aio-sdk': '^5' } - })) - await expect(command.init()).rejects.toThrow('State commands are not available for legacy State, please migrate to the latest "@adobe/aio-lib-state" (or "@adobe/aio-sdk" >= 6.0.0).') - }) - - test('missing namespace', async () => { - command.argv = [] - global.fakeConfig['runtime.namespace'] = null - await expect(command.init()).rejects.toThrow('This command is expected to be run in the root of a App Builder app project.\n Please make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.') - }) - - test('missing auth', async () => { - command.argv = [] - global.fakeConfig['runtime.auth'] = null - await expect(command.init()).rejects.toThrow('This command is expected to be run in the root of a App Builder app project.\n Please make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.') - }) - - test('default', async () => { + test('debug logger namespace', async () => { command.argv = [] await command.init() - expect(init).toHaveBeenCalledWith({ - region: 'amer', - ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } - }) - }) - - test('config state.region=emea', async () => { - global.fakeConfig['state.region'] = 'emea' - command.argv = [] - await command.init() - expect(init).toHaveBeenCalledWith({ - region: 'emea', - ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } - }) - }) - - test('--region emea', async () => { - command.argv = ['--region', 'emea'] - await command.init() - expect(init).toHaveBeenCalledWith({ - region: 'emea', - ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } - }) - }) - - test('config state.endpoint=https://fake.endpoint', async () => { - global.fakeConfig['state.endpoint'] = 'https://fake.endpoint' - command.argv = [] - await command.init() - expect(process.env.AIO_STATE_ENDPOINT).toBe('https://fake.endpoint') - expect(init).toHaveBeenCalledWith({ - region: 'amer', - ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } - }) + // BaseCommand should use 'app' service name + expect(command.debugLogger).toBeDefined() }) test('catch error', async () => { await command.init() await expect(command.catch(new Error('fake error'))).rejects.toThrow('fake error') }) + test('catch prompt interrupt', async () => { await command.init() await expect(command.catch(new Error('jfdsl User force closed the prompt fadsdljf'))).rejects.toThrow('EEXIT: 2') }) + test('catch error --json', async () => { command.argv = ['--json'] await command.init() diff --git a/test/StateBaseCommand.test.js b/test/StateBaseCommand.test.js new file mode 100644 index 0000000..dddb1d4 --- /dev/null +++ b/test/StateBaseCommand.test.js @@ -0,0 +1,142 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { expect, jest } from '@jest/globals' +import { StateBaseCommand } from '../src/StateBaseCommand.js' +import { BaseCommand } from '../src/BaseCommand.js' +import { init } from '@adobe/aio-lib-state' + +describe('prototype', () => { + test('extends BaseCommand', () => { + expect(StateBaseCommand.prototype instanceof BaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(StateBaseCommand.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(StateBaseCommand.flags).sort()).toEqual(['region']) + expect(StateBaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(StateBaseCommand.enableJsonFlag).toEqual(true) + }) + test('getServiceName', () => { + const command = new StateBaseCommand([]) + expect(command.getServiceName()).toBe('state') + }) +}) + +const mockReadFile = jest.fn() +jest.unstable_mockModule('fs/promises', async () => ({ + readFile: mockReadFile +})) + +describe('init', () => { + let command + beforeEach(async () => { + command = new StateBaseCommand([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + mockReadFile.mockReset() + mockReadFile.mockResolvedValue(JSON.stringify({ + dependencies: { + '@adobe/aio-lib-state': '^4', + '@adobe/aio-sdk': '^6' + } + })) + }) + + test('dependencies not declared', async () => { + command.argv = [] + mockReadFile.mockResolvedValue(JSON.stringify({ + dependencies: {} + })) + // ignores + await expect(command.init()).resolves.toBeUndefined() + + mockReadFile.mockResolvedValue(JSON.stringify({})) + // ignores + await expect(command.init()).resolves.toBeUndefined() + }) + + test('package.json not exist', async () => { + command.argv = [] + mockReadFile.mockRejectedValue('some error') + // ignores + await expect(command.init()).resolves.toBeUndefined() + }) + + test('aio-lib-state dependency < 4', async () => { + command.argv = [] + mockReadFile.mockResolvedValue(JSON.stringify({ + dependencies: { '@adobe/aio-lib-state': '^3' } + })) + await expect(command.init()).rejects.toThrow('State commands are not available for legacy State, please migrate to the latest "@adobe/aio-lib-state" (or "@adobe/aio-sdk" >= 6.0.0).') + }) + + test('aio-sdk dependency < 6', async () => { + command.argv = [] + mockReadFile.mockResolvedValue(JSON.stringify({ + dependencies: { '@adobe/aio-sdk': '^5' } + })) + await expect(command.init()).rejects.toThrow('State commands are not available for legacy State, please migrate to the latest "@adobe/aio-lib-state" (or "@adobe/aio-sdk" >= 6.0.0).') + }) + + test('missing namespace', async () => { + command.argv = [] + global.fakeConfig['runtime.namespace'] = null + await expect(command.init()).rejects.toThrow('This command is expected to be run in the root of a App Builder app project.\n Please make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.') + }) + + test('missing auth', async () => { + command.argv = [] + global.fakeConfig['runtime.auth'] = null + await expect(command.init()).rejects.toThrow('This command is expected to be run in the root of a App Builder app project.\n Please make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.') + }) + + test('default', async () => { + command.argv = [] + await command.init() + expect(init).toHaveBeenCalledWith({ + region: 'amer', + ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } + }) + }) + + test('config state.region=emea', async () => { + global.fakeConfig['state.region'] = 'emea' + command.argv = [] + await command.init() + expect(init).toHaveBeenCalledWith({ + region: 'emea', + ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } + }) + }) + + test('--region emea', async () => { + command.argv = ['--region', 'emea'] + await command.init() + expect(init).toHaveBeenCalledWith({ + region: 'emea', + ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } + }) + }) + + test('config state.endpoint=https://fake.endpoint', async () => { + global.fakeConfig['state.endpoint'] = 'https://fake.endpoint' + command.argv = [] + await command.init() + expect(process.env.AIO_STATE_ENDPOINT).toBe('https://fake.endpoint') + expect(init).toHaveBeenCalledWith({ + region: 'amer', + ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } + }) + }) +}) diff --git a/test/commands/app/state/delete.test.js b/test/commands/app/state/delete.test.js index ffb3221..10e7381 100644 --- a/test/commands/app/state/delete.test.js +++ b/test/commands/app/state/delete.test.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { Delete } from '../../../../src/commands/app/state/delete.js' import { expect, jest } from '@jest/globals' import { stdout, stderr } from 'stdout-stderr' -import { BaseCommand } from '../../../../src/BaseCommand.js' +import { StateBaseCommand } from '../../../../src/StateBaseCommand.js' // mock state const mockStateInstance = global.getStateInstanceMock() @@ -28,7 +28,7 @@ const mockPromptInput = global.getPromptInstanceMock().input describe('prototype', () => { test('extends', () => { - expect(Delete.prototype instanceof BaseCommand).toBe(true) + expect(Delete.prototype instanceof StateBaseCommand).toBe(true) }) test('args', () => { expect(Object.keys(Delete.args)).toEqual(['keys']) diff --git a/test/commands/app/state/get.test.js b/test/commands/app/state/get.test.js index 27e9bbc..0cfcd66 100644 --- a/test/commands/app/state/get.test.js +++ b/test/commands/app/state/get.test.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { Get } from '../../../../src/commands/app/state/get.js' import { expect, jest } from '@jest/globals' import { stdout, stderr } from 'stdout-stderr' -import { BaseCommand } from '../../../../src/BaseCommand.js' +import { StateBaseCommand } from '../../../../src/StateBaseCommand.js' // mock state /** @type {import('@jest/globals').jest.Mock} */ @@ -20,7 +20,7 @@ const mockStateGet = global.getStateInstanceMock().get describe('prototype', () => { test('extends', () => { - expect(Get.prototype instanceof BaseCommand).toBe(true) + expect(Get.prototype instanceof StateBaseCommand).toBe(true) }) test('args', () => { expect(Object.keys(Get.args)).toEqual(['key']) diff --git a/test/commands/app/state/list.test.js b/test/commands/app/state/list.test.js index 37960d5..4d6fef2 100644 --- a/test/commands/app/state/list.test.js +++ b/test/commands/app/state/list.test.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { List } from '../../../../src/commands/app/state/list.js' import { expect, jest, test } from '@jest/globals' import { stdout, stderr } from 'stdout-stderr' -import { BaseCommand } from '../../../../src/BaseCommand.js' +import { StateBaseCommand } from '../../../../src/StateBaseCommand.js' // mock state /** @type {import('@jest/globals').jest.Mock} */ @@ -39,7 +39,7 @@ const mockStateListKeys = (keysIter = []) => { describe('prototype', () => { test('extends', () => { - expect(List.prototype instanceof BaseCommand).toBe(true) + expect(List.prototype instanceof StateBaseCommand).toBe(true) }) test('args', () => { expect(Object.keys(List.args)).toEqual([]) diff --git a/test/commands/app/state/put.test.js b/test/commands/app/state/put.test.js index 69cbc3f..52f592d 100644 --- a/test/commands/app/state/put.test.js +++ b/test/commands/app/state/put.test.js @@ -12,8 +12,8 @@ governing permissions and limitations under the License. import { Put } from '../../../../src/commands/app/state/put.js' import { expect, jest } from '@jest/globals' import { stdout, stderr } from 'stdout-stderr' -import { DEFAULT_TTL_SECONDS } from '../../../../src/constants.js' -import { BaseCommand } from '../../../../src/BaseCommand.js' +import { DEFAULT_TTL_SECONDS } from '../../../../src/constants/state.js' +import { StateBaseCommand } from '../../../../src/StateBaseCommand.js' /** @type {import('@jest/globals').jest.Mock} */ const mockStatePut = global.getStateInstanceMock().put @@ -22,7 +22,7 @@ const getDateString = (ttl) => (new Date(ttl * 1000 + Date.now())).toISOString() describe('prototype', () => { test('extends', () => { - expect(Put.prototype instanceof BaseCommand).toBe(true) + expect(Put.prototype instanceof StateBaseCommand).toBe(true) }) test('args', () => { expect(Object.keys(Put.args)).toEqual(['key', 'value']) diff --git a/test/commands/app/state/stats.test.js b/test/commands/app/state/stats.test.js index 3db1a13..3dc267a 100644 --- a/test/commands/app/state/stats.test.js +++ b/test/commands/app/state/stats.test.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { Stats } from '../../../../src/commands/app/state/stats.js' import { expect, jest } from '@jest/globals' import { stdout } from 'stdout-stderr' -import { BaseCommand } from '../../../../src/BaseCommand.js' +import { StateBaseCommand } from '../../../../src/StateBaseCommand.js' // mock state /** @type {import('@jest/globals').jest.Mock} */ @@ -20,7 +20,7 @@ const mockStateStats = global.getStateInstanceMock().stats describe('prototype', () => { test('extends', () => { - expect(Stats.prototype instanceof BaseCommand).toBe(true) + expect(Stats.prototype instanceof StateBaseCommand).toBe(true) }) test('args', () => { expect(Object.keys(Stats.args)).toEqual([]) From 337ac68e9083b066562eef441ae2c743e44c3313 Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Thu, 26 Jun 2025 19:29:50 +0530 Subject: [PATCH 04/98] feat:cext-4859 added support for db commands --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index d2215eb..348f3e8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "@oclif/core": "^4", "@oclif/plugin-help": "^6", "chalk": "^5", - "dotenv": "^16.5.0", "semver": "^7.6.3" }, "devDependencies": { From a6188134de7d42e8945b8d84bf8bdd0aa412efe1 Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Fri, 27 Jun 2025 19:59:23 +0530 Subject: [PATCH 05/98] feat:cext-4859 added support for db provisioning commands --- package.json | 4 +- src/BaseCommand.js | 6 +- src/DBBaseCommand.js | 7 +- src/StateBaseCommand.js | 8 +- src/commands/app/db/ping.js | 5 +- src/commands/app/db/provision.js | 1 - src/commands/app/db/status.js | 16 +- test/DBBaseCommand.test.js | 234 +++++++++++++++++++ test/commands/app/db/ping.test.js | 299 ++++++++++++++++++++++++ test/commands/app/db/provision.test.js | 280 +++++++++++++++++++++++ test/commands/app/db/status.test.js | 301 +++++++++++++++++++++++++ test/commands/app/state/delete.test.js | 2 +- test/jest.env.js | 14 ++ test/jest.setup.js | 19 +- 14 files changed, 1169 insertions(+), 27 deletions(-) create mode 100644 test/DBBaseCommand.test.js create mode 100644 test/commands/app/db/ping.test.js create mode 100644 test/commands/app/db/provision.test.js create mode 100644 test/commands/app/db/status.test.js create mode 100644 test/jest.env.js diff --git a/package.json b/package.json index 348f3e8..be621a7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@oclif/core": "^4", "@oclif/plugin-help": "^6", "chalk": "^5", + "dotenv": "^16.5.0", "semver": "^7.6.3" }, "devDependencies": { @@ -72,6 +73,7 @@ "jest": { "collectCoverage": true, "testEnvironment": "node", - "transform": {} + "transform": {}, + "setupFiles": ["/test/jest.env.js"] } } diff --git a/src/BaseCommand.js b/src/BaseCommand.js index bf8bb78..5eec5c2 100644 --- a/src/BaseCommand.js +++ b/src/BaseCommand.js @@ -36,10 +36,10 @@ export class BaseCommand extends Command { } /** - * Get the service name for logging namespace - * Override in subclasses to provide service-specific namespaces + * Get the service name for logging + * @returns {string} The service name */ - getServiceName() { + getServiceName () { return 'app' // Default fallback } diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js index a28a4e6..ba1df61 100644 --- a/src/DBBaseCommand.js +++ b/src/DBBaseCommand.js @@ -26,9 +26,10 @@ export class DBBaseCommand extends BaseCommand { /** * Initialize the database client using aio-lib-db */ - async initializeDBClient() { + async initializeDBClient () { try { // Dynamic import of aio-lib-db (CommonJS module) + // eslint-disable-next-line node/no-unsupported-features/es-syntax const aioLibDb = await import('@adobe/aio-lib-db') const { init } = aioLibDb.default || aioLibDb @@ -61,7 +62,6 @@ Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment this.rtNamespace = dbConfig.namespace this.debugLogger?.info?.('DB client initialized successfully') - } catch (error) { this.debugLogger?.error?.('Failed to initialize DB client:', error.message) this.error(`Failed to initialize database client: ${error.message}`) @@ -70,8 +70,9 @@ Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment /** * Get the service name for logging + * @returns {string} The service name */ - getServiceName() { + getServiceName () { return 'db' } } diff --git a/src/StateBaseCommand.js b/src/StateBaseCommand.js index f978b8c..62c6243 100644 --- a/src/StateBaseCommand.js +++ b/src/StateBaseCommand.js @@ -21,6 +21,7 @@ export class StateBaseCommand extends BaseCommand { // check application dependencies let packageJson try { + // eslint-disable-next-line node/no-unsupported-features/es-syntax const { readFile } = await import('fs/promises') const file = await readFile('package.json') packageJson = JSON.parse(file.toString()) @@ -56,18 +57,19 @@ export class StateBaseCommand extends BaseCommand { } // dynamic import to be able to reload the AIO_STATE_ENDPOINT var // eslint-disable-next-line node/no-unsupported-features/es-syntax - const State = await import('@adobe/aio-lib-state') + const aioLibState = await import('@adobe/aio-lib-state') /** @type {import('@adobe/aio-lib-state').AdobeState} */ - this.state = await State.init({ region, ow: owOptions }) + this.state = await aioLibState.init({ region, ow: owOptions }) this.rtNamespace = owOptions.namespace } /** * Get the service name for logging + * @returns {string} The service name */ - getServiceName() { + getServiceName () { return 'state' } } diff --git a/src/commands/app/db/ping.js b/src/commands/app/db/ping.js index 356b6b9..732bfa6 100644 --- a/src/commands/app/db/ping.js +++ b/src/commands/app/db/ping.js @@ -38,7 +38,7 @@ export class Ping extends DBBaseCommand { const result = { status: 'success', namespace: this.rtNamespace, - responseTime: responseTime, + responseTime, response: pingResult, timestamp: new Date().toISOString() } @@ -48,7 +48,6 @@ export class Ping extends DBBaseCommand { } return result - } catch (error) { this.debugLogger?.error?.('Ping command error:', error) @@ -63,8 +62,6 @@ export class Ping extends DBBaseCommand { timestamp: new Date().toISOString() } - - return result } } diff --git a/src/commands/app/db/provision.js b/src/commands/app/db/provision.js index 20c3a7a..11b83f4 100644 --- a/src/commands/app/db/provision.js +++ b/src/commands/app/db/provision.js @@ -127,7 +127,6 @@ export class Provision extends DBBaseCommand { } return result - } catch (error) { this.debugLogger?.error?.('Provision command error:', error) this.error(`Database provisioning failed: ${error.message}`) diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js index 326f7f1..e85ec98 100644 --- a/src/commands/app/db/status.js +++ b/src/commands/app/db/status.js @@ -27,7 +27,7 @@ export class Status extends DBBaseCommand { } } - async checkStatus() { + async checkStatus () { try { this.log(chalk.blue('Checking database provisioning status...')) @@ -43,14 +43,13 @@ export class Status extends DBBaseCommand { } return result - } catch (error) { this.debugLogger?.error?.('Status command error:', error) if (error.message.includes('not found') || error.message.includes('404')) { this.log(chalk.yellow('No database has been provisioned for this workspace')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) - this.log(chalk.dim(` Status: NOT_PROVISIONED`)) + this.log(chalk.dim(' Status: NOT_PROVISIONED')) return { status: 'NOT_PROVISIONED', @@ -71,7 +70,7 @@ export class Status extends DBBaseCommand { } } - async watchStatus() { + async watchStatus () { this.log(chalk.blue('Watching database provisioning status (press Ctrl+C to stop)...')) let previousStatus = null @@ -97,7 +96,6 @@ export class Status extends DBBaseCommand { // Schedule next check setTimeout(watchLoop, checkInterval) - } catch (error) { this.debugLogger?.error?.('Watch status error:', error) this.log(chalk.red(`\n[${new Date().toLocaleTimeString()}] Error: ${error.message}`)) @@ -111,7 +109,7 @@ export class Status extends DBBaseCommand { return watchLoop() } - displayStatus(provisionStatusResponse, showTimestamp = true) { + displayStatus (provisionStatusResponse, showTimestamp = true) { const currentStatus = provisionStatusResponse.status.toUpperCase() const statusColor = this.getStatusColor(currentStatus) @@ -145,7 +143,7 @@ export class Status extends DBBaseCommand { } } - getStatusColor(statusValue) { + getStatusColor (statusValue) { const status = statusValue.toUpperCase() switch (status) { case 'PROVISIONED': @@ -162,7 +160,7 @@ export class Status extends DBBaseCommand { } } - getStatusDescription(statusValue) { + getStatusDescription (statusValue) { const status = statusValue.toUpperCase() switch (status) { case 'NOT_PROVISIONED': @@ -179,8 +177,6 @@ export class Status extends DBBaseCommand { return null } } - - } Status.description = 'Check the provisioning status of your App Builder database' diff --git a/test/DBBaseCommand.test.js b/test/DBBaseCommand.test.js new file mode 100644 index 0000000..2f669a5 --- /dev/null +++ b/test/DBBaseCommand.test.js @@ -0,0 +1,234 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { expect, jest } from '@jest/globals' +import { DBBaseCommand } from '../src/DBBaseCommand.js' +import { BaseCommand } from '../src/BaseCommand.js' + +// Mock aio-lib-db +const mockInit = jest.fn() +const mockDbInstance = { + ping: jest.fn(), + provisionStatus: jest.fn(), + provisionRequest: jest.fn() +} + +jest.unstable_mockModule('@adobe/aio-lib-db', () => ({ + default: { + init: mockInit + }, + init: mockInit +})) + +describe('prototype', () => { + test('extends BaseCommand', () => { + expect(DBBaseCommand.prototype instanceof BaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(DBBaseCommand.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(DBBaseCommand.flags).sort()).toEqual(['region']) + expect(DBBaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(DBBaseCommand.enableJsonFlag).toEqual(true) + }) + test('getServiceName', () => { + const command = new DBBaseCommand([]) + expect(command.getServiceName()).toBe('db') + }) +}) + +describe('init', () => { + let command + beforeEach(async () => { + command = new DBBaseCommand([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + mockInit.mockReset() + mockInit.mockResolvedValue(mockDbInstance) + }) + + test('successful initialization', async () => { + command.argv = [] + await command.init() + + expect(mockInit).toHaveBeenCalledWith( + global.fakeConfig['runtime.namespace'], + global.fakeConfig['runtime.auth'] + ) + expect(command.db).toBe(mockDbInstance) + expect(command.dbConfig).toBeDefined() + expect(command.rtNamespace).toBe(global.fakeConfig['runtime.namespace']) + }) + + test('initialization with custom region', async () => { + command.argv = ['--region', 'emea'] + await command.init() + + expect(command.dbConfig.region).toBe('emea') + }) + + test('initialization with default region', async () => { + command.argv = [] + await command.init() + + expect(command.dbConfig.region).toBe('amer') + }) + + test('initialization with config region', async () => { + global.fakeConfig['db.region'] = 'apac' + command.argv = [] + await command.init() + + expect(command.dbConfig.region).toBe('apac') + }) + + test('initialization with custom endpoint', async () => { + global.fakeConfig['db.endpoint'] = 'https://custom.endpoint.com' + command.argv = [] + await command.init() + + expect(command.dbConfig.endpoint).toBe('https://custom.endpoint.com') + }) + + test('initialization with environment endpoint', async () => { + process.env.AIO_DB_ENDPOINT = 'https://env.endpoint.com' + command.argv = [] + await command.init() + + expect(command.dbConfig.endpoint).toBe('https://env.endpoint.com') + + // Cleanup + delete process.env.AIO_DB_ENDPOINT + }) + + test('missing namespace', async () => { + command.argv = [] + global.fakeConfig['runtime.namespace'] = null + + await expect(command.init()).rejects.toThrow( + 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.' + ) + }) + + test('missing auth', async () => { + command.argv = [] + global.fakeConfig['runtime.auth'] = null + + await expect(command.init()).rejects.toThrow( + 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.' + ) + }) + + test('aio-lib-db initialization failure', async () => { + command.argv = [] + mockInit.mockRejectedValue(new Error('Failed to initialize DB client')) + + await expect(command.init()).rejects.toThrow('Failed to initialize database client: Failed to initialize DB client') + }) + + test('debug logging during initialization', async () => { + command.argv = [] + await command.init() + + // Verify that debug logger is available and used + expect(command.debugLogger).toBeDefined() + }) +}) + +describe('initializeDBClient', () => { + let command + beforeEach(async () => { + command = new DBBaseCommand([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.debugLogger = { + info: jest.fn(), + error: jest.fn() + } + mockInit.mockReset() + }) + + test('successful DB client initialization', async () => { + mockInit.mockResolvedValue(mockDbInstance) + + await command.initializeDBClient() + + expect(command.db).toBe(mockDbInstance) + expect(command.debugLogger.info).toHaveBeenCalledWith('DB client initialized successfully') + }) + + test('DB client initialization with debug logging', async () => { + mockInit.mockResolvedValue(mockDbInstance) + + await command.initializeDBClient() + + expect(command.debugLogger.info).toHaveBeenCalledWith( + 'Initializing DB client with config:', + expect.objectContaining({ + namespace: global.fakeConfig['runtime.namespace'], + region: 'amer', + hasAuth: true, + endpoint: 'default' + }) + ) + }) + + test('DB client initialization failure with debug logging', async () => { + const error = new Error('Connection failed') + mockInit.mockRejectedValue(error) + + await expect(command.initializeDBClient()).rejects.toThrow('Failed to initialize database client: Connection failed') + + expect(command.debugLogger.error).toHaveBeenCalledWith('Failed to initialize DB client:', 'Connection failed') + }) +}) + +describe('configuration', () => { + let command + beforeEach(() => { + command = new DBBaseCommand([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + mockInit.mockReset() + mockInit.mockResolvedValue(mockDbInstance) + }) + + test('dbConfig contains expected properties', async () => { + command.argv = [] + await command.init() + + expect(command.dbConfig).toEqual({ + namespace: global.fakeConfig['runtime.namespace'], + auth: global.fakeConfig['runtime.auth'], + region: 'amer', + endpoint: undefined + }) + }) + + test('dbConfig with all custom values', async () => { + global.fakeConfig['db.region'] = 'emea' + global.fakeConfig['db.endpoint'] = 'https://custom.db.com' + + command.argv = ['--region', 'apac'] // Flag should override config + await command.init() + + expect(command.dbConfig).toEqual({ + namespace: global.fakeConfig['runtime.namespace'], + auth: global.fakeConfig['runtime.auth'], + region: 'apac', // Flag takes precedence + endpoint: 'https://custom.db.com' + }) + }) +}) diff --git a/test/commands/app/db/ping.test.js b/test/commands/app/db/ping.test.js new file mode 100644 index 0000000..93b1f39 --- /dev/null +++ b/test/commands/app/db/ping.test.js @@ -0,0 +1,299 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { Ping } from '../../../../src/commands/app/db/ping.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockPing = global.mockDBInstance.ping + +// Mock Date.now for response time testing +const originalDateNow = Date.now +const mockDateNow = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Ping.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Ping.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(Ping.flags).sort()).toEqual(['region']) + expect(Ping.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new Ping([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockPing.mockReset() + mockDateNow.mockReset() + + // Mock Date.now for consistent response time testing + Date.now = mockDateNow + }) + + afterEach(() => { + Date.now = originalDateNow + }) + + describe('successful ping', () => { + test('ping returns string response', async () => { + command.argv = [] + await command.init() + + const startTime = 1000 + const endTime = 1150 + mockDateNow + .mockReturnValueOnce(startTime) // Start time + .mockReturnValueOnce(endTime) // End time + + mockPing.mockResolvedValue('pong') + + const result = await command.run() + + expect(mockPing).toHaveBeenCalled() + expect(result).toEqual({ + status: 'success', + namespace: 'test-namespace', + responseTime: 150, + response: 'pong', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain('Database connection successful') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Response time: 150ms') + expect(stdout.output).toContain('Response: pong') + expect(stdout.output).toContain('Database is ready for operations') + }) + + test('ping returns object response', async () => { + command.argv = [] + await command.init() + + const startTime = 2000 + const endTime = 2050 + mockDateNow + .mockReturnValueOnce(startTime) + .mockReturnValueOnce(endTime) + + const pingResponse = { status: 'ok', version: '1.0.0' } + mockPing.mockResolvedValue(pingResponse) + + const result = await command.run() + + expect(result).toEqual({ + status: 'success', + namespace: 'test-namespace', + responseTime: 50, + response: pingResponse, + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain('Database connection successful') + expect(stdout.output).toContain('Response time: 50ms') + expect(stdout.output).not.toContain('Response:') // Object responses don't show in console + }) + + test('fast response time', async () => { + command.argv = [] + await command.init() + + const startTime = 5000 + const endTime = 5001 + mockDateNow + .mockReturnValueOnce(startTime) + .mockReturnValueOnce(endTime) + + mockPing.mockResolvedValue('fast pong') + + const result = await command.run() + + expect(result.responseTime).toBe(1) + expect(stdout.output).toContain('Response time: 1ms') + }) + }) + + describe('failed ping', () => { + test('ping throws error', async () => { + command.argv = [] + await command.init() + + const startTime = 3000 + const endTime = 3500 + mockDateNow + .mockReturnValueOnce(startTime) + .mockReturnValueOnce(endTime) + + const error = new Error('Connection timeout') + mockPing.mockRejectedValue(error) + + const result = await command.run() + + expect(result).toEqual({ + status: 'failed', + namespace: 'test-namespace', + error: 'Connection timeout', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain('Database connection failed') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection timeout') + }) + + test('network error', async () => { + command.argv = [] + await command.init() + + mockDateNow.mockReturnValue(4000) + mockPing.mockRejectedValue(new Error('ECONNREFUSED')) + + const result = await command.run() + + expect(result.status).toBe('failed') + expect(result.error).toBe('ECONNREFUSED') + expect(stdout.output).toContain('Database connection failed') + }) + + test('authentication error', async () => { + command.argv = [] + await command.init() + + mockDateNow.mockReturnValue(5000) + mockPing.mockRejectedValue(new Error('401 Unauthorized')) + + const result = await command.run() + + expect(result.status).toBe('failed') + expect(result.error).toBe('401 Unauthorized') + }) + }) + + describe('json output', () => { + test('json flag suppresses console output on success', async () => { + command.argv = ['--json'] + await command.init() + + mockDateNow + .mockReturnValueOnce(6000) + .mockReturnValueOnce(6100) + + mockPing.mockResolvedValue('pong') + + const result = await command.run() + + expect(result.status).toBe('success') + expect(result.responseTime).toBe(100) + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Database connection successful') + expect(stdout.output).not.toContain('Database is ready for operations') + }) + + test('json flag suppresses console output on failure', async () => { + command.argv = ['--json'] + await command.init() + + mockDateNow.mockReturnValue(7000) + mockPing.mockRejectedValue(new Error('Test error')) + + const result = await command.run() + + expect(result.status).toBe('failed') + expect(result.error).toBe('Test error') + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Database connection failed') + }) + }) + + describe('response time measurement', () => { + test('calculates response time correctly', async () => { + command.argv = [] + await command.init() + + const scenarios = [ + { start: 1000, end: 1001, expected: 1 }, + { start: 2000, end: 2500, expected: 500 }, + { start: 3000, end: 4000, expected: 1000 }, + { start: 5000, end: 5999, expected: 999 } + ] + + for (const scenario of scenarios) { + mockDateNow + .mockReturnValueOnce(scenario.start) + .mockReturnValueOnce(scenario.end) + + mockPing.mockResolvedValue('test') + + const result = await command.run() + expect(result.responseTime).toBe(scenario.expected) + + // Reset for next iteration + mockPing.mockReset() + stdout.start() + } + }) + }) + + describe('namespace display', () => { + test('shows correct namespace', async () => { + command.argv = [] + await command.init() + + command.rtNamespace = 'custom-namespace-123' + + mockDateNow + .mockReturnValueOnce(8000) + .mockReturnValueOnce(8050) + + mockPing.mockResolvedValue('pong') + + const result = await command.run() + + expect(result.namespace).toBe('custom-namespace-123') + expect(stdout.output).toContain('Namespace: custom-namespace-123') + }) + }) + + describe('timestamp', () => { + test('includes ISO timestamp in result', async () => { + command.argv = [] + await command.init() + + mockDateNow + .mockReturnValueOnce(9000) + .mockReturnValueOnce(9100) + + mockPing.mockResolvedValue('pong') + + const result = await command.run() + + expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + + // Verify it's a recent timestamp + const timestamp = new Date(result.timestamp) + const now = new Date() + expect(Math.abs(now.getTime() - timestamp.getTime())).toBeLessThan(5000) // Within 5 seconds + }) + }) +}) diff --git a/test/commands/app/db/provision.test.js b/test/commands/app/db/provision.test.js new file mode 100644 index 0000000..62a9ca7 --- /dev/null +++ b/test/commands/app/db/provision.test.js @@ -0,0 +1,280 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { Provision } from '../../../../src/commands/app/db/provision.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockProvisionStatus = global.mockDBInstance.provisionStatus +const mockProvisionRequest = global.mockDBInstance.provisionRequest + +// Mock inquirer prompts +const mockConfirm = jest.fn() +jest.unstable_mockModule('@inquirer/prompts', () => ({ + confirm: mockConfirm +})) + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Provision.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Provision.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(Provision.flags).sort()).toEqual(['region']) + expect(Provision.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(Provision.flags.region.default).toBe('amer') + expect(Provision.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new Provision([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockProvisionStatus.mockReset() + mockProvisionRequest.mockReset() + mockConfirm.mockReset() + }) + + describe('already provisioned', () => { + test('database already provisioned', async () => { + command.argv = [] + await command.init() + + const existingStatus = { + status: 'PROVISIONED', + region: 'amer', + created: '2024-01-01T00:00:00Z' + } + mockProvisionStatus.mockResolvedValue(existingStatus) + + const result = await command.run() + + expect(result).toEqual({ + status: 'already_provisioned', + namespace: 'test-namespace', + region: 'amer', + details: existingStatus + }) + expect(mockProvisionRequest).not.toHaveBeenCalled() + }) + + test('database in progress', async () => { + command.argv = [] + await command.init() + + const inProgressStatus = { + status: 'PROCESSING', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(inProgressStatus) + + const result = await command.run() + + expect(result).toEqual({ + status: 'in_progress', + namespace: 'test-namespace', + region: 'amer', + details: inProgressStatus + }) + expect(mockProvisionRequest).not.toHaveBeenCalled() + }) + + test('previous provision failed, continues with new attempt', async () => { + command.argv = [] + await command.init() + + const failedStatus = { + status: 'FAILED', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(failedStatus) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: 'REQUESTED', + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('requested') + expect(mockProvisionRequest).toHaveBeenCalledWith({ region: 'amer' }) + }) + }) + + describe('new provision', () => { + test('no existing database, user confirms', async () => { + command.argv = [] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: 'REQUESTED', + region: 'amer' + }) + + const result = await command.run() + + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Provision database for namespace 'test-namespace'?", + default: false + }) + expect(mockProvisionRequest).toHaveBeenCalledWith({ region: 'amer' }) + expect(result).toEqual({ + status: 'requested', + namespace: 'test-namespace', + region: 'amer', + timestamp: expect.any(String), + details: { + status: 'REQUESTED', + region: 'amer' + } + }) + }) + + test('user cancels provision', async () => { + command.argv = [] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(false) + + const result = await command.run() + + expect(result).toEqual({ status: 'cancelled' }) + expect(mockProvisionRequest).not.toHaveBeenCalled() + }) + + test('provision with custom region', async () => { + command.argv = ['--region', 'emea'] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: 'PROVISIONED', + region: 'emea' + }) + + const result = await command.run() + + expect(mockProvisionRequest).toHaveBeenCalledWith({ region: 'emea' }) + expect(result.region).toBe('emea') + }) + }) + + describe('provision results', () => { + beforeEach(() => { + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(true) + }) + + test('instant provisioning success', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ + status: 'PROVISIONED', + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('provisioned') + expect(stdout.output).toContain('Database provisioned successfully and ready for use!') + }) + + test('provision request submitted', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ + status: 'REQUESTED', + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('requested') + expect(stdout.output).toContain('Database provisioning request submitted successfully') + }) + + test('provision processing', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ + status: 'PROCESSING', + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('processing') + expect(stdout.output).toContain('Database is being provisioned...') + }) + + test('provision failed', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ + status: 'FAILED', + message: 'Provisioning failed due to quota limits' + }) + + await expect(command.run()).rejects.toThrow('Database provisioning failed: Provisioning failed due to quota limits') + }) + }) + + describe('error handling', () => { + test('db client throws error', async () => { + command.argv = [] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockRejectedValue(new Error('Network error')) + + await expect(command.run()).rejects.toThrow('Database provisioning failed: Network error') + }) + }) + + describe('json output', () => { + test('json flag suppresses console output', async () => { + command.argv = ['--json'] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: 'PROVISIONED', + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('provisioned') + // Next steps should not be shown with --json + expect(stdout.output).not.toContain('Next steps:') + }) + }) +}) diff --git a/test/commands/app/db/status.test.js b/test/commands/app/db/status.test.js new file mode 100644 index 0000000..da13517 --- /dev/null +++ b/test/commands/app/db/status.test.js @@ -0,0 +1,301 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { Status } from '../../../../src/commands/app/db/status.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockProvisionStatus = global.mockDBInstance.provisionStatus + +// Mock setTimeout for watch mode testing +const originalSetTimeout = global.setTimeout +const mockSetTimeout = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Status.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Status.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(Status.flags).sort()).toEqual(['region', 'watch']) + expect(Status.flags.watch.type).toBe('boolean') + expect(Status.flags.watch.default).toBe(false) + expect(Status.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new Status([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockProvisionStatus.mockReset() + mockSetTimeout.mockReset() + }) + + describe('checkStatus', () => { + test('database provisioned', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: 'PROVISIONED', + region: 'amer', + created: '2024-01-01T00:00:00Z', + updated: '2024-01-01T00:00:00Z' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result).toEqual({ + ...statusResponse, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + expect(stdout.output).toContain('Database Status: PROVISIONED') + expect(stdout.output).toContain('A Database has been provisioned for this Workspace and is ready for use') + }) + + test('database processing', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: 'PROCESSING', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('PROCESSING') + expect(stdout.output).toContain('Database Status: PROCESSING') + expect(stdout.output).toContain('A Database is being provisioned for this Workspace') + }) + + test('database requested', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: 'REQUESTED', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('REQUESTED') + expect(stdout.output).toContain('Database Status: REQUESTED') + expect(stdout.output).toContain('A Database has been requested for this Workspace') + }) + + test('database failed', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: 'FAILED', + region: 'amer', + message: 'Quota exceeded' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('FAILED') + expect(stdout.output).toContain('Database Status: FAILED') + expect(stdout.output).toContain('Failed to provision a Database for this Workspace') + expect(stdout.output).toContain('Message: Quota exceeded') + }) + + test('database not found (404)', async () => { + command.argv = [] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('404 not found')) + + const result = await command.run() + + expect(result).toEqual({ + status: 'NOT_PROVISIONED', + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + expect(stdout.output).toContain('No database has been provisioned for this workspace') + }) + + test('other error', async () => { + command.argv = [] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('Network error')) + + const result = await command.run() + + expect(result).toEqual({ + status: 'error', + namespace: 'test-namespace', + error: 'Network error', + timestamp: expect.any(String) + }) + expect(stdout.output).toContain('Failed to check database status') + }) + }) + + describe('watch mode', () => { + beforeEach(() => { + global.setTimeout = mockSetTimeout + }) + + afterEach(() => { + global.setTimeout = originalSetTimeout + }) + + test('watch flag enables watch mode', async () => { + command.argv = ['--watch'] + await command.init() + + const statusResponse = { + status: 'PROVISIONED', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + // Mock setTimeout to immediately call the callback + mockSetTimeout.mockImplementation((callback) => { + // Don't actually call callback to avoid infinite loop in test + return 'mock-timeout-id' + }) + + const result = await command.run() + + expect(result.status).toBe('PROVISIONED') + expect(stdout.output).toContain('Watching database provisioning status') + }) + + test('watch stops on provisioned status', async () => { + command.argv = ['--watch'] + await command.init() + + const statusResponse = { + status: 'PROVISIONED', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('PROVISIONED') + expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') + }) + + test('watch stops on failed status', async () => { + command.argv = ['--watch'] + await command.init() + + const statusResponse = { + status: 'FAILED', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('FAILED') + expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') + }) + }) + + describe('displayStatus', () => { + test('shows all status information', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: 'PROVISIONED', + region: 'emea', + message: 'Database ready', + created: '2024-01-01T00:00:00Z', + updated: '2024-01-02T00:00:00Z' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + await command.run() + + expect(stdout.output).toContain('Database Status: PROVISIONED') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Region: emea') + expect(stdout.output).toContain('Description: A Database has been provisioned for this Workspace and is ready for use') + expect(stdout.output).toContain('Message: Database ready') + expect(stdout.output).toContain('Created:') + expect(stdout.output).toContain('Updated:') + expect(stdout.output).toContain('Checked:') + }) + + test('hides timestamp in watch mode', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: 'PROCESSING', + region: 'amer' + } + + // Test displayStatus directly with showTimestamp=false + command.displayStatus(statusResponse, false) + + expect(stdout.output).toContain('Database Status: PROCESSING') + expect(stdout.output).not.toContain('Checked:') + }) + }) + + describe('getStatusDescription', () => { + test('returns correct descriptions for each status', () => { + const command = new Status([]) + + expect(command.getStatusDescription('NOT_PROVISIONED')).toBe('No Database has been provisioned for this Workspace') + expect(command.getStatusDescription('REQUESTED')).toBe('A Database has been requested for this Workspace') + expect(command.getStatusDescription('PROCESSING')).toBe('A Database is being provisioned for this Workspace') + expect(command.getStatusDescription('FAILED')).toBe('Failed to provision a Database for this Workspace') + expect(command.getStatusDescription('PROVISIONED')).toBe('A Database has been provisioned for this Workspace and is ready for use') + expect(command.getStatusDescription('UNKNOWN')).toBe(null) + }) + }) + + describe('json output', () => { + test('json flag works correctly', async () => { + command.argv = ['--json'] + await command.init() + + const statusResponse = { + status: 'PROVISIONED', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('PROVISIONED') + // Should still return the data for JSON output + expect(result.namespace).toBe('test-namespace') + }) + }) +}) diff --git a/test/commands/app/state/delete.test.js b/test/commands/app/state/delete.test.js index 10e7381..17940e6 100644 --- a/test/commands/app/state/delete.test.js +++ b/test/commands/app/state/delete.test.js @@ -75,7 +75,7 @@ describe('run', () => { command.argv = ['key'] await command.init() mockStateAny.mockResolvedValue(false) - await expect(command.run()).rejects.toThrow('there are no keys stored in \'11111-ns\'!') + await expect(command.run()).rejects.toThrow('there are no keys stored in \'test-namespace\'!') }) test('--match and args', async () => { diff --git a/test/jest.env.js b/test/jest.env.js new file mode 100644 index 0000000..92fe3fa --- /dev/null +++ b/test/jest.env.js @@ -0,0 +1,14 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// Set NODE_ENV to test for all Jest tests +process.env.NODE_ENV = 'test' diff --git a/test/jest.setup.js b/test/jest.setup.js index f73e961..b8c47a4 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -49,6 +49,19 @@ jest.unstable_mockModule('@adobe/aio-lib-state', () => ({ })) global.getStateInstanceMock = () => mockInstance +// mock db +const mockDBInit = jest.fn() +const mockDBInstance = { + ping: jest.fn(), + provisionStatus: jest.fn(), + provisionRequest: jest.fn() +} +jest.unstable_mockModule('@adobe/aio-lib-db', () => ({ + init: mockDBInit +})) +global.getDBInstanceMock = () => mockDBInstance +global.mockDBInstance = mockDBInstance + // mock prompt const mockPrompt = { input: jest.fn() @@ -66,7 +79,7 @@ beforeEach(() => { // config fakes global.fakeConfig = { 'state.region': null, - 'runtime.namespace': '11111-ns', + 'runtime.namespace': 'test-namespace', 'runtime.auth': 'auth', 'state.endpoint': null } @@ -76,6 +89,10 @@ beforeEach(() => { mockInit.mockResolvedValue(mockInstance) Object.values(mockInstance).forEach(mock => mock.mockReset()) + mockDBInit.mockReset() + mockDBInit.mockResolvedValue(mockDBInstance) + Object.values(mockDBInstance).forEach(mock => mock.mockReset()) + Object.values(mockPrompt).forEach(mock => mock.mockReset()) }) afterEach(() => { stdout.stop(); stderr.stop() }) From 10bcbd9fc73442f6c4788e740981aaaa625799ed Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Mon, 30 Jun 2025 14:02:28 +0530 Subject: [PATCH 06/98] feat:cext-4859 Added support for provisioning commands --- src/BaseCommand.js | 3 +- src/DBBaseCommand.js | 3 +- src/StateBaseCommand.js | 3 +- src/commands/app/db/provision.js | 72 +++++++++++++++------- src/commands/app/db/status.js | 30 ++++++---- src/constants/db.js | 14 +++++ test/BaseCommand.test.js | 3 +- test/DBBaseCommand.test.js | 5 +- test/StateBaseCommand.test.js | 5 +- test/commands/app/db/provision.test.js | 82 +++++++++++++++++++++----- test/commands/app/db/status.test.js | 69 +++++++++++++++++----- 11 files changed, 215 insertions(+), 74 deletions(-) diff --git a/src/BaseCommand.js b/src/BaseCommand.js index 5eec5c2..e4cb1f8 100644 --- a/src/BaseCommand.js +++ b/src/BaseCommand.js @@ -13,6 +13,7 @@ governing permissions and limitations under the License. import { Command, Flags } from '@oclif/core' import AioLogger from '@adobe/aio-lib-core-logging' import chalk from 'chalk' +import { AVAILABLE_REGIONS } from './constants/db.js' export class BaseCommand extends Command { async init () { @@ -68,7 +69,7 @@ BaseCommand.flags = { region: Flags.string({ description: 'State region. Defaults to \'AIO_STATE_REGION\' env or \'amer\' if neither is set.', required: false, - options: ['amer', 'emea', 'apac'] + options: AVAILABLE_REGIONS }) } diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js index ba1df61..484aa29 100644 --- a/src/DBBaseCommand.js +++ b/src/DBBaseCommand.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. import { BaseCommand } from './BaseCommand.js' import config from '@adobe/aio-lib-core-config' +import { DEFAULT_REGION } from './constants/db.js' export class DBBaseCommand extends BaseCommand { async init () { @@ -37,7 +38,7 @@ export class DBBaseCommand extends BaseCommand { const dbConfig = { namespace: config.get('runtime.namespace'), auth: config.get('runtime.auth'), - region: this.flags?.region || config.get('db.region') || 'amer', + region: this.flags?.region || config.get('db.region') || DEFAULT_REGION, endpoint: config.get('db.endpoint') || process.env.AIO_DB_ENDPOINT } diff --git a/src/StateBaseCommand.js b/src/StateBaseCommand.js index 62c6243..8e5e6a4 100644 --- a/src/StateBaseCommand.js +++ b/src/StateBaseCommand.js @@ -13,6 +13,7 @@ governing permissions and limitations under the License. import { BaseCommand } from './BaseCommand.js' import config from '@adobe/aio-lib-core-config' import { CONFIG_STATE_REGION } from './constants/state.js' +import { DEFAULT_REGION } from './constants/db.js' import semver from 'semver' export class StateBaseCommand extends BaseCommand { @@ -48,7 +49,7 @@ export class StateBaseCommand extends BaseCommand { Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment variables are configured.` ) } - const region = this.flags.region || config.get(CONFIG_STATE_REGION) || 'amer' + const region = this.flags.region || config.get(CONFIG_STATE_REGION) || DEFAULT_REGION this.debugLogger?.info?.('using state region: %s', region) if (config.get('state.endpoint')) { diff --git a/src/commands/app/db/provision.js b/src/commands/app/db/provision.js index 11b83f4..e9ac54d 100644 --- a/src/commands/app/db/provision.js +++ b/src/commands/app/db/provision.js @@ -13,6 +13,7 @@ governing permissions and limitations under the License. import { DBBaseCommand } from '../../../DBBaseCommand.js' import { Flags } from '@oclif/core' import chalk from 'chalk' +import { DB_STATUS, DEFAULT_REGION, AVAILABLE_REGIONS } from '../../../constants/db.js' export class Provision extends DBBaseCommand { async run () { @@ -36,36 +37,60 @@ export class Provision extends DBBaseCommand { if (provisionStatusResponse) { const currentStatus = provisionStatusResponse.status.toUpperCase() - if (currentStatus === 'PROVISIONED') { + if (currentStatus === DB_STATUS.PROVISIONED) { this.log(chalk.green('Database is already provisioned and ready for use')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) - this.log(chalk.dim(` Region: ${provisionStatusResponse.region || 'amer'}`)) + this.log(chalk.dim(` Region: ${provisionStatusResponse.region || DEFAULT_REGION}`)) this.log(chalk.dim(` Status: ${currentStatus}`)) return { status: 'already_provisioned', namespace: this.rtNamespace, - region: provisionStatusResponse.region || 'amer', + region: provisionStatusResponse.region || DEFAULT_REGION, details: provisionStatusResponse } - } else if (currentStatus === 'REQUESTED' || currentStatus === 'PROCESSING') { - this.log(chalk.yellow('Database provisioning is already in progress')) + } else if (currentStatus === DB_STATUS.REQUESTED) { + this.log(chalk.yellow('Database provisioning request has been submitted and is pending')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) - this.log(chalk.dim(` Region: ${provisionStatusResponse.region || 'amer'}`)) + this.log(chalk.dim(` Region: ${provisionStatusResponse.region || DEFAULT_REGION}`)) this.log(chalk.dim(` Status: ${currentStatus}`)) this.log(chalk.dim('\nUse "aio app db status --watch" to monitor progress')) return { status: 'in_progress', namespace: this.rtNamespace, - region: provisionStatusResponse.region || 'amer', + region: provisionStatusResponse.region || DEFAULT_REGION, details: provisionStatusResponse } - } else if (currentStatus === 'FAILED') { + } else if (currentStatus === DB_STATUS.PROCESSING) { + this.log(chalk.yellow('Database is currently being provisioned')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Region: ${provisionStatusResponse.region || DEFAULT_REGION}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) + this.log(chalk.dim('\nUse "aio app db status --watch" to monitor progress')) + + return { + status: 'in_progress', + namespace: this.rtNamespace, + region: provisionStatusResponse.region || DEFAULT_REGION, + details: provisionStatusResponse + } + } else if (currentStatus === DB_STATUS.FAILED) { this.log(chalk.red('Previous database provisioning failed')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) this.log(chalk.dim(` Status: ${currentStatus}`)) this.log(chalk.yellow('\nAttempting to provision again...')) + this.log(chalk.red('If the problem persists, please contact the App Builder team')) + } else if (currentStatus === DB_STATUS.REJECTED) { + this.log(chalk.red('Previous database provisioning request was rejected')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) + this.log(chalk.yellow('\nAttempting to provision again...')) + this.log(chalk.red('If the problem persists, please contact the App Builder team')) + } else if (currentStatus !== DB_STATUS.NOT_PROVISIONED) { + this.log(chalk.yellow(`Database status is '${currentStatus}' - attempting to provision...`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) } } @@ -86,42 +111,45 @@ export class Provision extends DBBaseCommand { } // Start provisioning - this.log(chalk.blue('Starting database provisioning...')) + this.log(chalk.blue(`Submitting a request for a database to be provisioned for the '${this.rtNamespace}' namespace...`)) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) - this.log(chalk.dim(` Region: ${region || 'amer'}`)) + this.log(chalk.dim(` Region: ${region || DEFAULT_REGION}`)) const provisionResult = await this.db.provisionRequest({ region }) this.debugLogger?.info?.('Provision request result:', provisionResult) // Handle different provision result statuses - const resultStatus = provisionResult?.status?.toUpperCase() || 'UNKNOWN' + const resultStatus = provisionResult?.status?.toUpperCase() || DB_STATUS.UNKNOWN - if (resultStatus === 'PROVISIONED') { + if (resultStatus === DB_STATUS.PROVISIONED) { this.log(chalk.green('Database provisioned successfully and ready for use!')) - } else if (resultStatus === 'REQUESTED') { + } else if (resultStatus === DB_STATUS.REQUESTED) { this.log(chalk.blue('Database provisioning request submitted successfully')) - this.log(chalk.dim('Provisioning is now in progress...')) - } else if (resultStatus === 'PROCESSING') { + this.log(chalk.dim('Provisioning is now pending...')) + } else if (resultStatus === DB_STATUS.PROCESSING) { this.log(chalk.yellow('Database is being provisioned...')) - } else if (resultStatus === 'FAILED') { + } else if (resultStatus === DB_STATUS.FAILED) { this.error(`Database provisioning failed: ${provisionResult.message || 'Unknown error'}`) + } else if (resultStatus === DB_STATUS.REJECTED) { + this.error(`Database provisioning request was rejected: ${provisionResult.message || 'Unknown reason'}`) } else { - this.log(chalk.blue('Database provisioning request submitted')) + this.warn(`Database provisioning request returned unrecognized status '${resultStatus}', an update to the aio cli tool may be necessary.`) + this.warn('If the issue persists, please contact the App Builder team.') } const result = { status: resultStatus.toLowerCase(), namespace: this.rtNamespace, - region: region || 'amer', + region: region || DEFAULT_REGION, timestamp: new Date().toISOString(), details: provisionResult } - if (!this.flags.json && resultStatus !== 'PROVISIONED') { + if (!this.flags.json && resultStatus !== DB_STATUS.PROVISIONED) { this.log(chalk.dim('\nNext steps:')) this.log(chalk.dim(' - Monitor progress: aio app db status --watch')) this.log(chalk.dim(' - Check status: aio app db status')) - } else if (!this.flags.json && resultStatus === 'PROVISIONED') { + } else if (!this.flags.json && resultStatus === DB_STATUS.PROVISIONED) { this.log(chalk.dim('\nNext steps:')) this.log(chalk.dim(' - Test connection: aio app db ping')) } @@ -147,8 +175,8 @@ Provision.flags = { region: Flags.string({ description: 'Region in which database is to be provisioned', required: false, - options: ['amer', 'emea', 'apac'], - default: 'amer' + options: AVAILABLE_REGIONS, + default: DEFAULT_REGION }) } diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js index e85ec98..a5bcd30 100644 --- a/src/commands/app/db/status.js +++ b/src/commands/app/db/status.js @@ -13,6 +13,7 @@ governing permissions and limitations under the License. import { DBBaseCommand } from '../../../DBBaseCommand.js' import { Flags } from '@oclif/core' import chalk from 'chalk' +import { DB_STATUS } from '../../../constants/db.js' export class Status extends DBBaseCommand { async run () { @@ -49,10 +50,10 @@ export class Status extends DBBaseCommand { if (error.message.includes('not found') || error.message.includes('404')) { this.log(chalk.yellow('No database has been provisioned for this workspace')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) - this.log(chalk.dim(' Status: NOT_PROVISIONED')) + this.log(chalk.dim(` Status: ${DB_STATUS.NOT_PROVISIONED}`)) return { - status: 'NOT_PROVISIONED', + status: DB_STATUS.NOT_PROVISIONED, namespace: this.rtNamespace, timestamp: new Date().toISOString() } @@ -88,7 +89,7 @@ export class Status extends DBBaseCommand { // Stop watching if provisioning is complete or failed const currentStatus = provisionStatusResponse.status.toUpperCase() - if (currentStatus === 'PROVISIONED' || currentStatus === 'FAILED') { + if (currentStatus === DB_STATUS.PROVISIONED || currentStatus === DB_STATUS.FAILED || currentStatus === DB_STATUS.REJECTED) { this.log(chalk.dim('\nProvisioning completed. Stopping watch mode.')) return provisionStatusResponse } @@ -146,14 +147,15 @@ export class Status extends DBBaseCommand { getStatusColor (statusValue) { const status = statusValue.toUpperCase() switch (status) { - case 'PROVISIONED': + case DB_STATUS.PROVISIONED: return chalk.green - case 'REQUESTED': - case 'PROCESSING': + case DB_STATUS.REQUESTED: + case DB_STATUS.PROCESSING: return chalk.yellow - case 'FAILED': + case DB_STATUS.FAILED: + case DB_STATUS.REJECTED: return chalk.red - case 'NOT_PROVISIONED': + case DB_STATUS.NOT_PROVISIONED: return chalk.blue default: return chalk.gray @@ -163,15 +165,17 @@ export class Status extends DBBaseCommand { getStatusDescription (statusValue) { const status = statusValue.toUpperCase() switch (status) { - case 'NOT_PROVISIONED': + case DB_STATUS.NOT_PROVISIONED: return 'No Database has been provisioned for this Workspace' - case 'REQUESTED': + case DB_STATUS.REQUESTED: return 'A Database has been requested for this Workspace' - case 'PROCESSING': + case DB_STATUS.PROCESSING: return 'A Database is being provisioned for this Workspace' - case 'FAILED': + case DB_STATUS.FAILED: return 'Failed to provision a Database for this Workspace' - case 'PROVISIONED': + case DB_STATUS.REJECTED: + return 'Database provisioning request was rejected for this Workspace' + case DB_STATUS.PROVISIONED: return 'A Database has been provisioned for this Workspace and is ready for use' default: return null diff --git a/src/constants/db.js b/src/constants/db.js index 795f41f..39226b6 100644 --- a/src/constants/db.js +++ b/src/constants/db.js @@ -9,3 +9,17 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + +export const DB_STATUS = { + PROVISIONED: 'PROVISIONED', + REQUESTED: 'REQUESTED', + PROCESSING: 'PROCESSING', + FAILED: 'FAILED', + REJECTED: 'REJECTED', + NOT_PROVISIONED: 'NOT_PROVISIONED', + UNKNOWN: 'UNKNOWN' +} + +export const DEFAULT_REGION = 'amer' + +export const AVAILABLE_REGIONS = ['amer', 'emea', 'apac'] diff --git a/test/BaseCommand.test.js b/test/BaseCommand.test.js index 016e146..3ec4319 100644 --- a/test/BaseCommand.test.js +++ b/test/BaseCommand.test.js @@ -13,6 +13,7 @@ import { expect, jest } from '@jest/globals' import { BaseCommand } from '../src/BaseCommand.js' import { Command } from '@oclif/core' import { stderr } from 'stdout-stderr' +import { AVAILABLE_REGIONS } from '../src/constants/db.js' describe('prototype', () => { test('extends Command', () => { @@ -23,7 +24,7 @@ describe('prototype', () => { }) test('flags', () => { expect(Object.keys(BaseCommand.flags).sort()).toEqual(['region']) - expect(BaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(BaseCommand.flags.region.options).toEqual(AVAILABLE_REGIONS) expect(BaseCommand.enableJsonFlag).toEqual(true) }) test('getServiceName', () => { diff --git a/test/DBBaseCommand.test.js b/test/DBBaseCommand.test.js index 2f669a5..6cff592 100644 --- a/test/DBBaseCommand.test.js +++ b/test/DBBaseCommand.test.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. import { expect, jest } from '@jest/globals' import { DBBaseCommand } from '../src/DBBaseCommand.js' import { BaseCommand } from '../src/BaseCommand.js' +import { AVAILABLE_REGIONS, DEFAULT_REGION } from '../src/constants/db.js' // Mock aio-lib-db const mockInit = jest.fn() @@ -37,7 +38,7 @@ describe('prototype', () => { }) test('flags', () => { expect(Object.keys(DBBaseCommand.flags).sort()).toEqual(['region']) - expect(DBBaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(DBBaseCommand.flags.region.options).toEqual(AVAILABLE_REGIONS) expect(DBBaseCommand.enableJsonFlag).toEqual(true) }) test('getServiceName', () => { @@ -81,7 +82,7 @@ describe('init', () => { command.argv = [] await command.init() - expect(command.dbConfig.region).toBe('amer') + expect(command.dbConfig.region).toBe(DEFAULT_REGION) }) test('initialization with config region', async () => { diff --git a/test/StateBaseCommand.test.js b/test/StateBaseCommand.test.js index dddb1d4..eaecf96 100644 --- a/test/StateBaseCommand.test.js +++ b/test/StateBaseCommand.test.js @@ -13,6 +13,7 @@ import { expect, jest } from '@jest/globals' import { StateBaseCommand } from '../src/StateBaseCommand.js' import { BaseCommand } from '../src/BaseCommand.js' import { init } from '@adobe/aio-lib-state' +import { AVAILABLE_REGIONS, DEFAULT_REGION } from '../src/constants/db.js' describe('prototype', () => { test('extends BaseCommand', () => { @@ -23,7 +24,7 @@ describe('prototype', () => { }) test('flags', () => { expect(Object.keys(StateBaseCommand.flags).sort()).toEqual(['region']) - expect(StateBaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(StateBaseCommand.flags.region.options).toEqual(AVAILABLE_REGIONS) expect(StateBaseCommand.enableJsonFlag).toEqual(true) }) test('getServiceName', () => { @@ -105,7 +106,7 @@ describe('init', () => { command.argv = [] await command.init() expect(init).toHaveBeenCalledWith({ - region: 'amer', + region: DEFAULT_REGION, ow: { namespace: global.fakeConfig['runtime.namespace'], auth: global.fakeConfig['runtime.auth'] } }) }) diff --git a/test/commands/app/db/provision.test.js b/test/commands/app/db/provision.test.js index 62a9ca7..e269ad6 100644 --- a/test/commands/app/db/provision.test.js +++ b/test/commands/app/db/provision.test.js @@ -11,8 +11,9 @@ governing permissions and limitations under the License. */ import { Provision } from '../../../../src/commands/app/db/provision.js' import { expect, jest } from '@jest/globals' -import { stdout } from 'stdout-stderr' +import { stdout, stderr } from 'stdout-stderr' import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' +import { DB_STATUS, DEFAULT_REGION, AVAILABLE_REGIONS } from '../../../../src/constants/db.js' // Use the global DB mock const mockProvisionStatus = global.mockDBInstance.provisionStatus @@ -33,8 +34,8 @@ describe('prototype', () => { }) test('flags', () => { expect(Object.keys(Provision.flags).sort()).toEqual(['region']) - expect(Provision.flags.region.options).toEqual(['amer', 'emea', 'apac']) - expect(Provision.flags.region.default).toBe('amer') + expect(Provision.flags.region.options).toEqual(AVAILABLE_REGIONS) + expect(Provision.flags.region.default).toBe(DEFAULT_REGION) expect(Provision.enableJsonFlag).toEqual(true) }) }) @@ -59,7 +60,7 @@ describe('run', () => { await command.init() const existingStatus = { - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer', created: '2024-01-01T00:00:00Z' } @@ -81,7 +82,7 @@ describe('run', () => { await command.init() const inProgressStatus = { - status: 'PROCESSING', + status: DB_STATUS.PROCESSING, region: 'amer' } mockProvisionStatus.mockResolvedValue(inProgressStatus) @@ -102,13 +103,13 @@ describe('run', () => { await command.init() const failedStatus = { - status: 'FAILED', + status: DB_STATUS.FAILED, region: 'amer' } mockProvisionStatus.mockResolvedValue(failedStatus) mockConfirm.mockResolvedValue(true) mockProvisionRequest.mockResolvedValue({ - status: 'REQUESTED', + status: DB_STATUS.REQUESTED, region: 'amer' }) @@ -117,6 +118,29 @@ describe('run', () => { expect(result.status).toBe('requested') expect(mockProvisionRequest).toHaveBeenCalledWith({ region: 'amer' }) }) + + test('previous provision rejected, continues with new attempt', async () => { + command.argv = [] + await command.init() + + const rejectedStatus = { + status: DB_STATUS.REJECTED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(rejectedStatus) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.REQUESTED, + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('requested') + expect(mockProvisionRequest).toHaveBeenCalledWith({ region: 'amer' }) + expect(stdout.output).toContain('Previous database provisioning request was rejected') + expect(stdout.output).toContain('If the problem persists, please contact the App Builder team') + }) }) describe('new provision', () => { @@ -127,7 +151,7 @@ describe('run', () => { mockProvisionStatus.mockRejectedValue(new Error('not found')) mockConfirm.mockResolvedValue(true) mockProvisionRequest.mockResolvedValue({ - status: 'REQUESTED', + status: DB_STATUS.REQUESTED, region: 'amer' }) @@ -144,7 +168,7 @@ describe('run', () => { region: 'amer', timestamp: expect.any(String), details: { - status: 'REQUESTED', + status: DB_STATUS.REQUESTED, region: 'amer' } }) @@ -170,7 +194,7 @@ describe('run', () => { mockProvisionStatus.mockRejectedValue(new Error('not found')) mockConfirm.mockResolvedValue(true) mockProvisionRequest.mockResolvedValue({ - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'emea' }) @@ -192,7 +216,7 @@ describe('run', () => { await command.init() mockProvisionRequest.mockResolvedValue({ - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer' }) @@ -207,7 +231,7 @@ describe('run', () => { await command.init() mockProvisionRequest.mockResolvedValue({ - status: 'REQUESTED', + status: DB_STATUS.REQUESTED, region: 'amer' }) @@ -222,7 +246,7 @@ describe('run', () => { await command.init() mockProvisionRequest.mockResolvedValue({ - status: 'PROCESSING', + status: DB_STATUS.PROCESSING, region: 'amer' }) @@ -237,12 +261,40 @@ describe('run', () => { await command.init() mockProvisionRequest.mockResolvedValue({ - status: 'FAILED', + status: DB_STATUS.FAILED, message: 'Provisioning failed due to quota limits' }) await expect(command.run()).rejects.toThrow('Database provisioning failed: Provisioning failed due to quota limits') }) + + test('provision rejected', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.REJECTED, + message: 'Request rejected due to policy violation' + }) + + await expect(command.run()).rejects.toThrow('Database provisioning request was rejected: Request rejected due to policy violation') + }) + + test('provision unknown status', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ + status: 'NEW_UNKNOWN_STATUS', + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('new_unknown_status') + expect(stderr.output).toContain('Database provisioning request returned unrecognized status \'NEW_UNKNOWN_STATUS\'') + expect(stderr.output).toContain('If the issue persists, please contact the App Builder team') + }) }) describe('error handling', () => { @@ -266,7 +318,7 @@ describe('run', () => { mockProvisionStatus.mockRejectedValue(new Error('not found')) mockConfirm.mockResolvedValue(true) mockProvisionRequest.mockResolvedValue({ - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer' }) diff --git a/test/commands/app/db/status.test.js b/test/commands/app/db/status.test.js index da13517..5a586c7 100644 --- a/test/commands/app/db/status.test.js +++ b/test/commands/app/db/status.test.js @@ -13,6 +13,7 @@ import { Status } from '../../../../src/commands/app/db/status.js' import { expect, jest } from '@jest/globals' import { stdout } from 'stdout-stderr' import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' +import { DB_STATUS } from '../../../../src/constants/db.js' // Use the global DB mock const mockProvisionStatus = global.mockDBInstance.provisionStatus @@ -55,7 +56,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer', created: '2024-01-01T00:00:00Z', updated: '2024-01-01T00:00:00Z' @@ -78,7 +79,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROCESSING', + status: DB_STATUS.PROCESSING, region: 'amer' } mockProvisionStatus.mockResolvedValue(statusResponse) @@ -95,7 +96,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'REQUESTED', + status: DB_STATUS.REQUESTED, region: 'amer' } mockProvisionStatus.mockResolvedValue(statusResponse) @@ -112,7 +113,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'FAILED', + status: DB_STATUS.FAILED, region: 'amer', message: 'Quota exceeded' } @@ -126,6 +127,25 @@ describe('run', () => { expect(stdout.output).toContain('Message: Quota exceeded') }) + test('database rejected', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: DB_STATUS.REJECTED, + region: 'amer', + message: 'Policy violation' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('REJECTED') + expect(stdout.output).toContain('Database Status: REJECTED') + expect(stdout.output).toContain('Database provisioning request was rejected for this Workspace') + expect(stdout.output).toContain('Message: Policy violation') + }) + test('database not found (404)', async () => { command.argv = [] await command.init() @@ -135,7 +155,7 @@ describe('run', () => { const result = await command.run() expect(result).toEqual({ - status: 'NOT_PROVISIONED', + status: DB_STATUS.NOT_PROVISIONED, namespace: 'test-namespace', timestamp: expect.any(String) }) @@ -174,7 +194,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer' } mockProvisionStatus.mockResolvedValue(statusResponse) @@ -196,7 +216,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer' } mockProvisionStatus.mockResolvedValue(statusResponse) @@ -212,7 +232,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'FAILED', + status: DB_STATUS.FAILED, region: 'amer' } mockProvisionStatus.mockResolvedValue(statusResponse) @@ -222,6 +242,22 @@ describe('run', () => { expect(result.status).toBe('FAILED') expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') }) + + test('watch stops on rejected status', async () => { + command.argv = ['--watch'] + await command.init() + + const statusResponse = { + status: DB_STATUS.REJECTED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('REJECTED') + expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') + }) }) describe('displayStatus', () => { @@ -230,7 +266,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'emea', message: 'Database ready', created: '2024-01-01T00:00:00Z', @@ -255,7 +291,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROCESSING', + status: DB_STATUS.PROCESSING, region: 'amer' } @@ -271,11 +307,12 @@ describe('run', () => { test('returns correct descriptions for each status', () => { const command = new Status([]) - expect(command.getStatusDescription('NOT_PROVISIONED')).toBe('No Database has been provisioned for this Workspace') - expect(command.getStatusDescription('REQUESTED')).toBe('A Database has been requested for this Workspace') - expect(command.getStatusDescription('PROCESSING')).toBe('A Database is being provisioned for this Workspace') - expect(command.getStatusDescription('FAILED')).toBe('Failed to provision a Database for this Workspace') - expect(command.getStatusDescription('PROVISIONED')).toBe('A Database has been provisioned for this Workspace and is ready for use') + expect(command.getStatusDescription(DB_STATUS.NOT_PROVISIONED)).toBe('No Database has been provisioned for this Workspace') + expect(command.getStatusDescription(DB_STATUS.REQUESTED)).toBe('A Database has been requested for this Workspace') + expect(command.getStatusDescription(DB_STATUS.PROCESSING)).toBe('A Database is being provisioned for this Workspace') + expect(command.getStatusDescription(DB_STATUS.FAILED)).toBe('Failed to provision a Database for this Workspace') + expect(command.getStatusDescription(DB_STATUS.REJECTED)).toBe('Database provisioning request was rejected for this Workspace') + expect(command.getStatusDescription(DB_STATUS.PROVISIONED)).toBe('A Database has been provisioned for this Workspace and is ready for use') expect(command.getStatusDescription('UNKNOWN')).toBe(null) }) }) @@ -286,7 +323,7 @@ describe('run', () => { await command.init() const statusResponse = { - status: 'PROVISIONED', + status: DB_STATUS.PROVISIONED, region: 'amer' } mockProvisionStatus.mockResolvedValue(statusResponse) From 0ebc33a247576f6c8c37d5e78763d9aead51d7c3 Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Mon, 30 Jun 2025 14:57:27 +0530 Subject: [PATCH 07/98] feat:cext-4859 Added support for provisioning commands --- src/commands/app/db/provision.js | 11 ++++-- src/commands/app/db/status.js | 52 ++++---------------------- test/commands/app/db/ping.test.js | 18 --------- test/commands/app/db/provision.test.js | 2 +- test/commands/app/db/status.test.js | 42 +++------------------ 5 files changed, 22 insertions(+), 103 deletions(-) diff --git a/src/commands/app/db/provision.js b/src/commands/app/db/provision.js index e9ac54d..a2f4023 100644 --- a/src/commands/app/db/provision.js +++ b/src/commands/app/db/provision.js @@ -68,6 +68,7 @@ export class Provision extends DBBaseCommand { this.log(chalk.dim(` Region: ${provisionStatusResponse.region || DEFAULT_REGION}`)) this.log(chalk.dim(` Status: ${currentStatus}`)) this.log(chalk.dim('\nUse "aio app db status --watch" to monitor progress')) + this.log(chalk.red('If provisioning takes unusually long, please contact the App Builder team')) return { status: 'in_progress', @@ -85,12 +86,13 @@ export class Provision extends DBBaseCommand { this.log(chalk.red('Previous database provisioning request was rejected')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) this.log(chalk.dim(` Status: ${currentStatus}`)) - this.log(chalk.yellow('\nAttempting to provision again...')) - this.log(chalk.red('If the problem persists, please contact the App Builder team')) + this.log(chalk.yellow('\nYou can attempt to provision again, but the request may be rejected again')) + this.log(chalk.red('If the problem persists, please contact the App Builder team for assistance')) } else if (currentStatus !== DB_STATUS.NOT_PROVISIONED) { this.log(chalk.yellow(`Database status is '${currentStatus}' - attempting to provision...`)) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) this.log(chalk.dim(` Status: ${currentStatus}`)) + this.log(chalk.red('If you encounter issues, please contact the App Builder team')) } } @@ -132,8 +134,11 @@ export class Provision extends DBBaseCommand { this.error(`Database provisioning failed: ${provisionResult.message || 'Unknown error'}`) } else if (resultStatus === DB_STATUS.REJECTED) { this.error(`Database provisioning request was rejected: ${provisionResult.message || 'Unknown reason'}`) + } else if (resultStatus === DB_STATUS.UNKNOWN) { + this.warn(`Database provisioning request returned unrecognized status '${provisionResult.status || 'undefined'}', an update to the aio cli tool may be necessary.`) + this.warn('If the issue persists, please contact the App Builder team.') } else { - this.warn(`Database provisioning request returned unrecognized status '${resultStatus}', an update to the aio cli tool may be necessary.`) + this.warn(`Database provisioning request returned unexpected status '${resultStatus}', an update to the aio cli tool may be necessary.`) this.warn('If the issue persists, please contact the App Builder team.') } diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js index a5bcd30..5233def 100644 --- a/src/commands/app/db/status.js +++ b/src/commands/app/db/status.js @@ -47,7 +47,7 @@ export class Status extends DBBaseCommand { } catch (error) { this.debugLogger?.error?.('Status command error:', error) - if (error.message.includes('not found') || error.message.includes('404')) { + if (error.message?.toLowerCase().includes('not found') || error.message?.includes('404')) { this.log(chalk.yellow('No database has been provisioned for this workspace')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) this.log(chalk.dim(` Status: ${DB_STATUS.NOT_PROVISIONED}`)) @@ -59,15 +59,7 @@ export class Status extends DBBaseCommand { } } - this.log(chalk.red('Failed to check database status')) - this.log(chalk.dim(` Error: ${error.message}`)) - - return { - status: 'error', - namespace: this.rtNamespace, - error: error.message, - timestamp: new Date().toISOString() - } + this.error(`Failed to check database status: ${error.message}`) } } @@ -82,15 +74,15 @@ export class Status extends DBBaseCommand { const provisionStatusResponse = await this.db.provisionStatus() // Only display if status changed - if (!previousStatus || previousStatus.status !== provisionStatusResponse.status) { + if (previousStatus?.status !== provisionStatusResponse?.status) { this.log(chalk.dim(`\n[${new Date().toLocaleTimeString()}]`)) this.displayStatus(provisionStatusResponse, false) // Don't show timestamp in watch mode previousStatus = provisionStatusResponse // Stop watching if provisioning is complete or failed const currentStatus = provisionStatusResponse.status.toUpperCase() - if (currentStatus === DB_STATUS.PROVISIONED || currentStatus === DB_STATUS.FAILED || currentStatus === DB_STATUS.REJECTED) { - this.log(chalk.dim('\nProvisioning completed. Stopping watch mode.')) + if (currentStatus !== DB_STATUS.REQUESTED && currentStatus !== DB_STATUS.PROCESSING) { + this.log(chalk.dim('\nStopping watch mode.')) return provisionStatusResponse } } @@ -117,22 +109,12 @@ export class Status extends DBBaseCommand { this.log(statusColor(`Database Status: ${currentStatus}`)) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) - if (provisionStatusResponse.region) { - this.log(chalk.dim(` Region: ${provisionStatusResponse.region}`)) - } - - // Display status-specific descriptions - const statusDescription = this.getStatusDescription(currentStatus) - if (statusDescription) { - this.log(chalk.dim(` Description: ${statusDescription}`)) - } - if (provisionStatusResponse.message) { this.log(chalk.dim(` Message: ${provisionStatusResponse.message}`)) } - if (provisionStatusResponse.created) { - this.log(chalk.dim(` Created: ${new Date(provisionStatusResponse.created).toLocaleString()}`)) + if (provisionStatusResponse.submitted) { + this.log(chalk.dim(` Submitted: ${new Date(provisionStatusResponse.submitted).toLocaleString()}`)) } if (provisionStatusResponse.updated) { @@ -161,26 +143,6 @@ export class Status extends DBBaseCommand { return chalk.gray } } - - getStatusDescription (statusValue) { - const status = statusValue.toUpperCase() - switch (status) { - case DB_STATUS.NOT_PROVISIONED: - return 'No Database has been provisioned for this Workspace' - case DB_STATUS.REQUESTED: - return 'A Database has been requested for this Workspace' - case DB_STATUS.PROCESSING: - return 'A Database is being provisioned for this Workspace' - case DB_STATUS.FAILED: - return 'Failed to provision a Database for this Workspace' - case DB_STATUS.REJECTED: - return 'Database provisioning request was rejected for this Workspace' - case DB_STATUS.PROVISIONED: - return 'A Database has been provisioned for this Workspace and is ready for use' - default: - return null - } - } } Status.description = 'Check the provisioning status of your App Builder database' diff --git a/test/commands/app/db/ping.test.js b/test/commands/app/db/ping.test.js index 93b1f39..4664b2e 100644 --- a/test/commands/app/db/ping.test.js +++ b/test/commands/app/db/ping.test.js @@ -112,24 +112,6 @@ describe('run', () => { expect(stdout.output).toContain('Response time: 50ms') expect(stdout.output).not.toContain('Response:') // Object responses don't show in console }) - - test('fast response time', async () => { - command.argv = [] - await command.init() - - const startTime = 5000 - const endTime = 5001 - mockDateNow - .mockReturnValueOnce(startTime) - .mockReturnValueOnce(endTime) - - mockPing.mockResolvedValue('fast pong') - - const result = await command.run() - - expect(result.responseTime).toBe(1) - expect(stdout.output).toContain('Response time: 1ms') - }) }) describe('failed ping', () => { diff --git a/test/commands/app/db/provision.test.js b/test/commands/app/db/provision.test.js index e269ad6..f4b6c8c 100644 --- a/test/commands/app/db/provision.test.js +++ b/test/commands/app/db/provision.test.js @@ -292,7 +292,7 @@ describe('run', () => { const result = await command.run() expect(result.status).toBe('new_unknown_status') - expect(stderr.output).toContain('Database provisioning request returned unrecognized status \'NEW_UNKNOWN_STATUS\'') + expect(stderr.output).toContain('Database provisioning request returned unexpected status \'NEW_UNKNOWN_STATUS\'') expect(stderr.output).toContain('If the issue persists, please contact the App Builder team') }) }) diff --git a/test/commands/app/db/status.test.js b/test/commands/app/db/status.test.js index 5a586c7..8f0aa26 100644 --- a/test/commands/app/db/status.test.js +++ b/test/commands/app/db/status.test.js @@ -71,7 +71,6 @@ describe('run', () => { timestamp: expect.any(String) }) expect(stdout.output).toContain('Database Status: PROVISIONED') - expect(stdout.output).toContain('A Database has been provisioned for this Workspace and is ready for use') }) test('database processing', async () => { @@ -88,7 +87,6 @@ describe('run', () => { expect(result.status).toBe('PROCESSING') expect(stdout.output).toContain('Database Status: PROCESSING') - expect(stdout.output).toContain('A Database is being provisioned for this Workspace') }) test('database requested', async () => { @@ -105,7 +103,6 @@ describe('run', () => { expect(result.status).toBe('REQUESTED') expect(stdout.output).toContain('Database Status: REQUESTED') - expect(stdout.output).toContain('A Database has been requested for this Workspace') }) test('database failed', async () => { @@ -123,7 +120,6 @@ describe('run', () => { expect(result.status).toBe('FAILED') expect(stdout.output).toContain('Database Status: FAILED') - expect(stdout.output).toContain('Failed to provision a Database for this Workspace') expect(stdout.output).toContain('Message: Quota exceeded') }) @@ -142,7 +138,6 @@ describe('run', () => { expect(result.status).toBe('REJECTED') expect(stdout.output).toContain('Database Status: REJECTED') - expect(stdout.output).toContain('Database provisioning request was rejected for this Workspace') expect(stdout.output).toContain('Message: Policy violation') }) @@ -168,15 +163,7 @@ describe('run', () => { mockProvisionStatus.mockRejectedValue(new Error('Network error')) - const result = await command.run() - - expect(result).toEqual({ - status: 'error', - namespace: 'test-namespace', - error: 'Network error', - timestamp: expect.any(String) - }) - expect(stdout.output).toContain('Failed to check database status') + await expect(command.run()).rejects.toThrow('Failed to check database status: Network error') }) }) @@ -224,7 +211,7 @@ describe('run', () => { const result = await command.run() expect(result.status).toBe('PROVISIONED') - expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') + expect(stdout.output).toContain('Stopping watch mode.') }) test('watch stops on failed status', async () => { @@ -240,7 +227,7 @@ describe('run', () => { const result = await command.run() expect(result.status).toBe('FAILED') - expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') + expect(stdout.output).toContain('Stopping watch mode.') }) test('watch stops on rejected status', async () => { @@ -256,7 +243,7 @@ describe('run', () => { const result = await command.run() expect(result.status).toBe('REJECTED') - expect(stdout.output).toContain('Provisioning completed. Stopping watch mode.') + expect(stdout.output).toContain('Stopping watch mode.') }) }) @@ -267,9 +254,8 @@ describe('run', () => { const statusResponse = { status: DB_STATUS.PROVISIONED, - region: 'emea', message: 'Database ready', - created: '2024-01-01T00:00:00Z', + submitted: '2024-01-01T00:00:00Z', updated: '2024-01-02T00:00:00Z' } mockProvisionStatus.mockResolvedValue(statusResponse) @@ -278,10 +264,8 @@ describe('run', () => { expect(stdout.output).toContain('Database Status: PROVISIONED') expect(stdout.output).toContain('Namespace: test-namespace') - expect(stdout.output).toContain('Region: emea') - expect(stdout.output).toContain('Description: A Database has been provisioned for this Workspace and is ready for use') expect(stdout.output).toContain('Message: Database ready') - expect(stdout.output).toContain('Created:') + expect(stdout.output).toContain('Submitted:') expect(stdout.output).toContain('Updated:') expect(stdout.output).toContain('Checked:') }) @@ -303,20 +287,6 @@ describe('run', () => { }) }) - describe('getStatusDescription', () => { - test('returns correct descriptions for each status', () => { - const command = new Status([]) - - expect(command.getStatusDescription(DB_STATUS.NOT_PROVISIONED)).toBe('No Database has been provisioned for this Workspace') - expect(command.getStatusDescription(DB_STATUS.REQUESTED)).toBe('A Database has been requested for this Workspace') - expect(command.getStatusDescription(DB_STATUS.PROCESSING)).toBe('A Database is being provisioned for this Workspace') - expect(command.getStatusDescription(DB_STATUS.FAILED)).toBe('Failed to provision a Database for this Workspace') - expect(command.getStatusDescription(DB_STATUS.REJECTED)).toBe('Database provisioning request was rejected for this Workspace') - expect(command.getStatusDescription(DB_STATUS.PROVISIONED)).toBe('A Database has been provisioned for this Workspace and is ready for use') - expect(command.getStatusDescription('UNKNOWN')).toBe(null) - }) - }) - describe('json output', () => { test('json flag works correctly', async () => { command.argv = ['--json'] From 3365d4a9d7af0fe4ba0606f67a77d73f7d7ef499 Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Mon, 30 Jun 2025 15:30:30 +0530 Subject: [PATCH 08/98] feat:cext-4859 removed updated status from db/status.js --- src/commands/app/db/status.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js index 5233def..640940f 100644 --- a/src/commands/app/db/status.js +++ b/src/commands/app/db/status.js @@ -117,9 +117,9 @@ export class Status extends DBBaseCommand { this.log(chalk.dim(` Submitted: ${new Date(provisionStatusResponse.submitted).toLocaleString()}`)) } - if (provisionStatusResponse.updated) { - this.log(chalk.dim(` Updated: ${new Date(provisionStatusResponse.updated).toLocaleString()}`)) - } + // if (provisionStatusResponse.updated) { + // this.log(chalk.dim(` Updated: ${new Date(provisionStatusResponse.updated).toLocaleString()}`)) + // } if (showTimestamp) { this.log(chalk.dim(` Checked: ${new Date().toLocaleString()}`)) From 4296aca12b2b4c63a4813375d8da87653616a02f Mon Sep 17 00:00:00 2001 From: Simran Gulati Date: Mon, 30 Jun 2025 15:36:14 +0530 Subject: [PATCH 09/98] feat:cext-4859 updated tests --- README.md | 80 ++++++++++++++++++++++++++++- test/commands/app/db/status.test.js | 1 - 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 80597cd..afeceba 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # aio-cli-plugin-app-storage -The CLI Plugin to manage your App Builder State storage. +The CLI Plugin to manage your App Builder State storage and Database services. If you need to access State programmatically, check the [@adobe/aio-lib-state](https://github.com/adobe/aio-lib-state) library. +If you need to access Database programmatically, check the +[@adobe/aio-lib-db](https://github.com/adobe/aio-lib-db) library. + --- * [aio-cli-plugin-app-storage](#aio-cli-plugin-app-storage) @@ -19,10 +22,14 @@ $ aio plugins:install @adobe/aio-cli-plugin-app-storage $ # OR $ aio discover -i $ aio app state --help +$ aio app db --help ``` # Commands +* [`aio app db ping`](#aio-app-db-ping) +* [`aio app db provision`](#aio-app-db-provision) +* [`aio app db status`](#aio-app-db-status) * [`aio app state delete [KEYS]`](#aio-app-state-delete-keys) * [`aio app state get KEY`](#aio-app-state-get-key) * [`aio app state list`](#aio-app-state-list) @@ -190,6 +197,77 @@ EXAMPLES $ aio app state stats --json ``` +## `aio app db ping` + +Test connectivity to your App Builder database + +``` +USAGE + $ aio app db ping [--json] + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Test connectivity to your App Builder database + +EXAMPLES + $ aio app db ping + + $ aio app db ping --json +``` + +## `aio app db provision` + +Provision a new database for your App Builder application + +``` +USAGE + $ aio app db provision [--json] [--region amer|emea|apac] + +FLAGS + --region=