diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 0ef4052..ca261da 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -14,8 +14,41 @@ on: dependencies-to-update: description: "csv of dependencies to update with the dist-tag" required: false - default: "@adobe/aio-lib-core-config,@adobe/aio-lib-core-logging,@adobe/aio-lib-core-networking,@adobe/aio-lib-env,@adobe/aio-lib-state" + default: "@adobe/aio-lib-core-config,@adobe/aio-lib-core-logging,@adobe/aio-lib-core-networking,@adobe/aio-lib-env,@adobe/aio-lib-state,@adobe/aio-lib-db" + skip-dependencies-to-update: + description: "Skip update of dependency version tags" + required: false + type: boolean + default: false jobs: checkout: - uses: adobe/aio-reusable-workflows/.github/workflows/prerelease.yml@main + name: checkout + # Temporary port of the reusable workflow until secret imports are worked outputs: + # uses: adobe/aio-reusable-workflows/.github/workflows/prerelease.yml@main + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + git config user.name github-actions + git config user.email github-actions@github.com + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: | + npm install + npm test + - name: Update your package.json with an npm pre-release version + id: pre-release-version + uses: adobe/update-prerelease-npm-version@v1.2.0 + with: + pre-release-tag: ${{ github.event.inputs.pre-release-tag }} + dependencies-to-update: ${{ github.event.inputs.dependencies-to-update }} + skip-dependencies-to-update: ${{ github.event.inputs.skip-dependencies-to-update }} + dependencies-to-update-version-tag: ${{ github.event.inputs.dist-tag }} + - run: echo pre-release-version - ${{ steps.pre-release-version.outputs.pre-release-version }} + - uses: JS-DevTools/npm-publish@v3 + with: + token: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} + tag: ${{ github.event.inputs.dist-tag }} + access: 'public' diff --git a/README.md b/README.md index 80597cd..f91263c 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,17 +22,52 @@ $ aio plugins:install @adobe/aio-cli-plugin-app-storage $ # OR $ aio discover -i $ aio app state --help +$ aio app db --help ``` # Commands +## State Storage Commands * [`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) * [`aio app state put KEY VALUE`](#aio-app-state-put-key-value) * [`aio app state stats`](#aio-app-state-stats) + +## Database Commands +### Collection Management +* [`aio app db collection list`](#aio-app-db-collection-list) +* [`aio app db collection create COLLECTION`](#aio-app-db-collection-create-collection) +* [`aio app db collection drop COLLECTION`](#aio-app-db-collection-drop-collection) +* [`aio app db collection rename CURRENTNAME NEWNAME`](#aio-app-db-collection-rename-currentname-newname) +* [`aio app db collection stats COLLECTION`](#aio-app-db-collection-stats-collection) + +### Document Operations +* [`aio app db document insert COLLECTION DOCUMENTS`](#aio-app-db-document-insert-collection-documents) +* [`aio app db document delete COLLECTION FILTER`](#aio-app-db-document-delete-collection-filter) +* [`aio app db document find COLLECTION FILTER`](#aio-app-db-document-find-collection-filter) +* [`aio app db document update COLLECTION FILTER UPDATE`](#aio-app-db-document-update-collection-filter-update) +* [`aio app db document replace COLLECTION FILTER REPLACEMENT`](#aio-app-db-document-replace-collection-filter-replacement) +* [`aio app db document count COLLECTION`](#aio-app-db-document-count-collection) + +### Index Management +* [`aio app db index create COLLECTION`](#aio-app-db-index-create-collection) +* [`aio app db index drop COLLECTION INDEXNAME`](#aio-app-db-index-drop-collection-indexname) +* [`aio app db index list COLLECTION`](#aio-app-db-index-list-collection) + +### Database Management +* [`aio app db ping`](#aio-app-db-ping) +* [`aio app db provision`](#aio-app-db-provision) +* [`aio app db delete`](#aio-app-db-delete) +* [`aio app db status`](#aio-app-db-status) +* [`aio app db stats`](#aio-app-db-stats) +* `aio app db show collections` - Alias for [`aio app db collection list`](#aio-app-db-collection-list) + +## Other Commands * [`aio help [COMMAND]`](#aio-help-command) +# State Storage Commands + ## `aio app state delete [KEYS]` Delete key-values @@ -190,6 +228,637 @@ EXAMPLES $ aio app state stats --json ``` +# Database Commands + +## A Note on Regions + +> Note: All workspace databases have to be provisioned in a specific region, and all database commands must be executed in that same region. If set in the `app.config.yaml` application manifest, that region will be used. If not, it will first fall back to what is set in the --region flag, followed by the AIO_DB_REGION environment variable, and finally to the default `amer` region. See [Getting Started with Database Storage](https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/storage/database) for more details. + +## Collection Management + +> Note: The commands under `aio app db collection ` are also available as `aio app db col ` shorthand aliases. + +### `aio app db collection list` + +Get the list of collections in your App Builder database + +``` +USAGE + $ aio app db collection list [--json] [--region ] [-i] + +FLAGS + -i, --info Show detailed collection information instead of just names + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + + +DESCRIPTION + Get the list of collections in your App Builder database + +ALIASES + $ aio app db col list + $ aio app db show collections + +EXAMPLES + $ aio app db collection list + + $ aio app db collection list --info + + $ aio app db collection list --json + + $ aio app db col list --info --json +``` + +### `aio app db collection create COLLECTION` + +Create a new collection in the database + +``` +USAGE + $ aio app db collection create COLLECTION [--json] [--region ] [-v ] + +ARGUMENTS + COLLECTION The name of the collection to create + +FLAGS + -v, --validator= JSON schema validator for document validation (JSON string) + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Create a new collection in the database + +ALIASES + $ aio app db col create + +EXAMPLES + $ aio app db collection create users + + $ aio app db collection create inventory --validator '{"type": "object", "required": ["id", "quantity"]}' --json + + $ aio app db col create products --json + + $ aio app db col create products --validator '{"type": "object", "properties": {"name": {"type": "string"}, "price": {"type": "number", "minimum": 0}}, "required": ["name", "price"]}' +``` + +### `aio app db collection drop COLLECTION` + +Drop a collection from the database + +``` +USAGE + $ aio app db collection drop COLLECTION [--json] [--region ] + +ARGUMENTS + COLLECTION The name of the collection to drop + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Drop a collection from the database + +ALIASES + $ aio app db col drop + +EXAMPLES + $ aio app db collection drop users + + $ aio app db collection drop products --json + + $ aio app db col drop inventory +``` + +### `aio app db collection rename CURRENTNAME NEWNAME` + +Rename a collection in the database + +``` +USAGE + $ aio app db collection rename CURRENTNAME NEWNAME [--json] [--region ] + +ARGUMENTS + CURRENTNAME The current name of the collection to rename + NEWNAME The new name for the collection + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Rename a collection in the database + +ALIASES + $ aio app db col rename + +EXAMPLES + $ aio app db collection rename users customers + + $ aio app db collection rename old_products new_products --json + + $ aio app db col rename inventory stock +``` + +### `aio app db collection stats COLLECTION` + +Get statistics for a collection in the database + +``` +USAGE + $ aio app db collection stats COLLECTION [--json] [--region ] + +ARGUMENTS + COLLECTION The name of the collection to get stats for + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Get statistics for a collection in the database + +ALIASES + $ aio app db col stats + +EXAMPLES + $ aio app db collection stats users + + $ aio app db collection stats products --json + + $ aio app db col stats inventory +``` + +## Document Operations + +> Note: The commands under `aio app db document ` are also available as `aio app db doc ` shorthand aliases. + +### `aio app db document insert COLLECTION DOCUMENTS` + +Insert one or more documents into a collection + +``` +USAGE + $ aio app db document insert COLLECTION DOCUMENTS [--json] [--region ] [-b] + +ARGUMENTS + COLLECTION The name of the collection to insert documents into + DOCUMENTS JSON object or array of documents to insert + +FLAGS + -b, --bypassDocumentValidation Bypass schema validation if present + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Insert one or more documents into a collection + +ALIASES + $ aio app db doc insert + +EXAMPLES + $ aio app db document insert users '{"name": "John", "age": 30}' + + $ aio app db document insert products '[{"id": 1, "name": "Product A"}, {"id": 2, "name": "Product B"}]' --json + + $ aio app db document insert temp '{"data": "test"}' --bypassDocumentValidation + + $ aio app db doc insert bulk '[{"field": "foo"}, {"field": "bar"}]' --bypassDocumentValidation --json +``` + +### `aio app db document delete COLLECTION FILTER` + +Delete a single document from a collection + +``` +USAGE + $ aio app db document delete COLLECTION FILTER [--json] [--region ] + +ARGUMENTS + COLLECTION The name of the collection + FILTER The filter document (JSON string) + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Delete a single document from a collection + +ALIASES + $ aio app db doc delete + +EXAMPLES + $ aio app db document delete users '{"name": "John"}' + + $ aio app db document delete products '{"id": "123"}' --json + + $ aio app db doc delete posts '{"status": "draft"}' +``` + +### `aio app db document find COLLECTION FILTER` + +Find documents in a collection based on filter criteria. + +``` +USAGE + $ aio app db document find COLLECTION FILTER [--json] [--region ] [-l ] [-s ] [-o ] [-p ] + +ARGUMENTS + COLLECTION The name of the collection + FILTER Filter criteria for the documents to find (JSON string, e.g. '{"status": "active"}') + +FLAGS + -l, --limit= [default: 20] Limit the number of documents returned, max: 100 + -o, --sort= Sort specification as a JSON object (e.g. '{"field": 1}') + -p, --projection= Projection specification as a JSON object (e.g. '{"field1": 1, "field2": 0}') + -s, --skip= Skip the first N documents + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Find documents in a collection based on filter criteria. + +ALIASES + $ aio app db doc find + +EXAMPLES + $ aio app db document find users '{}' + + $ aio app db document find products '{"category": "Computer Accessories"}' --json + + $ aio app db document find products '{"name": {"$regex": "Speakers$"}}' --sort '{"price": -1}' --limit 10 --skip 5 --projection '{"name": 1, "price": 1}' + + $ aio app db doc find orders '{"status": "pending"}' --sort '{"orderDate": -1}' +``` + +### `aio app db document update COLLECTION FILTER UPDATE` + +Update document(s) in a collection + +``` +USAGE + $ aio app db document update COLLECTION FILTER UPDATE [--json] [--region ] [-u] [-m] + +ARGUMENTS + COLLECTION The name of the collection + FILTER The filter document (JSON string) + UPDATE The update document (JSON string) + +FLAGS + -m, --many Update all documents matching the filter. Without this option, only the first matching document is updated. + -u, --upsert If no document is found, create a new one + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Update document(s) in a collection + +ALIASES + $ aio app db doc update + +EXAMPLES + $ aio app db document update users '{"name": "John"}' '{"$set": {"age": 31}}' + + $ aio app db document update products '{"id": "123"}' '{"$inc": {"stock": -1}}' --json + + $ aio app db document update posts '{"slug": "hello-world"}' '{"$set": {"status": "published"}}' --many + + $ aio app db doc update users '{"email": "john@example.com"}' '{"$set": {"lastLogin": "2024-01-01"}}' --upsert +``` + +### `aio app db document replace COLLECTION FILTER REPLACEMENT` + +Replace a single document in a collection + +``` +USAGE + $ aio app db document replace COLLECTION FILTER REPLACEMENT [--json] [--region ] [-u] + +ARGUMENTS + COLLECTION The name of the collection + FILTER The filter document (JSON string) + REPLACEMENT The replacement document (JSON string) + +FLAGS + -u, --upsert If no document is found, create a new one + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Replace a single document in a collection + +ALIASES + $ aio app db doc replace + +EXAMPLES + $ aio app db document replace users '{"name": "John"}' '{"name": "John Doe", "age": 30, "status": "active"}' + + $ aio app db document replace products '{"id": "123"}' '{"id": "123", "name": "New Product", "price": 99.99}' --json + + $ aio app db document replace posts '{"slug": "hello-world"}' '{"title": "Hello World", "content": "Updated content", "status": "published"}' --upsert + + $ aio app db doc replace users '{"email": "john@example.com"}' '{"email": "john@example.com", "name": "John", "verified": true}' --upsert --json +``` + +### `aio app db document count COLLECTION` + +Count documents in a collection + +``` +USAGE + $ aio app db document count COLLECTION [QUERY] [--json] [--region ] + +ARGUMENTS + COLLECTION The name of the collection + QUERY The query filter document (JSON string). If not provided, counts all documents. + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Count documents in a collection + +ALIASES + $ aio app db doc count + +EXAMPLES + $ aio app db document countDocuments users + + $ aio app db document countDocuments users '{"age": {"$gte": 21}}' + + $ aio app db document countDocuments products '{"category": "electronics"}' --json + + $ aio app db doc count orders '{"status": "shipped"}' +``` + +## Index Management + +> Note: The commands under `aio app db index ` are also available as `aio app db idx ` shorthand aliases. + +### `aio app db index create COLLECTION` + +Create a new index on a collection in the database + +``` +USAGE + $ aio app db index create COLLECTION [--json] [--region ] [-s ] [-k ] [-n ] [-u] + +ARGUMENTS + COLLECTION The name of the collection to create the index on + +FLAGS + -n, --name= A name that uniquely identifies the index + -u, --unique Creates a unique index so that the collection will not accept insertion or update of documents where the index key value matches an + existing value in the index + +REQUIRES AT LEAST ONE OF THE INDEX DEFINITION FLAGS + -k, --key=... Index key to use with default specification + -s, --spec=... Index specification as a JSON object (e.g., '{"name":1, "age":-1}') + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Create a new index on a collection in the database + +ALIASES + $ aio app db idx create + +EXAMPLES + $ aio app db index create users --spec '{"name":1, "age":-1}' + + $ aio app db index create users -s '{"name":1, "age":-1}' --name "name_age_index" + + $ aio app db index create students -s '{"name":1}' --key grade --unique + + $ aio app db index create reviews -k sku -k rating + + $ aio app db index create products -s '{"name":"text", "category":"text"}' --json + + $ aio app db index create books -s '{"author":1}' -k year + + $ aio app db idx create orders --spec '{"customerId":1}' --spec '{"orderDate":-1}' --name "customer_order_index" --unique +``` + +### `aio app db index drop COLLECTION INDEXNAME` + +Drop an index from a collection in the database + +``` +USAGE + $ aio app db index drop COLLECTION INDEXNAME [--json] [--region ] + +ARGUMENTS + COLLECTION The name of the collection to drop the index from + INDEXNAME The name of the index to drop + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Drop an index from a collection in the database + +ALIASES + $ aio app db idx drop + +EXAMPLES + $ aio app db index drop users name_age_index + + $ aio app db index drop products category_1 --json + + $ aio app db idx drop orders orderDate_index +``` + +### `aio app db index list COLLECTION` + +Get the list of indexes from a collection in the database + +``` +USAGE + $ aio app db index list COLLECTION [--json] [--region ] + +ARGUMENTS + COLLECTION The name of the collection to retrieve indexes from + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Get the list of indexes from a collection in the database + +ALIASES + $ aio app db idx list + +EXAMPLES + $ aio app db index list users + + $ aio app db index list products --json + + $ aio app db idx list orders +``` + +## Database Management + +### `aio app db ping` + +Test connectivity to your App Builder database + +``` +USAGE + $ aio app db ping [--json] [--region ] + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +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 ] [-f] + +FLAGS + -y, --yes Skip confirmation prompt and provision automatically + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Provision a new database for your App Builder application + +EXAMPLES + $ aio app db provision + + $ aio app db provision --region amer + + $ aio app db provision --json + + $ aio app db provision --yes +``` + +### `aio app db delete` + +Delete the database for your App Builder application (non-production only) + +``` +USAGE + $ aio app db delete [--json] [--region ] [--force] + +FLAGS + --force [use with caution!] force delete, skips confirmation safety prompt + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Delete the database for your App Builder application (non-production only) + +EXAMPLES + $ aio app db delete + + $ aio app db delete --force + + $ aio app db delete --json +``` + +### `aio app db status` + +Check the provisioning status of your App Builder database + +``` +USAGE + $ aio app db status [--json] [--region ] [--watch] + +FLAGS + --watch Watch for status changes (press Ctrl+C to stop) + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Check the provisioning status of your App Builder database + +EXAMPLES + $ aio app db status + + $ aio app db status --watch + + $ aio app db status --json +``` + +### `aio app db stats` + +Get statistics about your App Builder database + +``` +USAGE + $ aio app db stats [--json] [--region ] + +GLOBAL FLAGS + --json Format output as json. + --region= Database region. Defaults to 'AIO_DB_REGION' environment variable or `amer` if neither is set. + Any database region set in 'app.config.yaml' takes precedence over all of these. + +DESCRIPTION + Get statistics about your App Builder database + +EXAMPLES + $ aio app db stats + + $ aio app db stats --json +``` + +# Other Commands + ## `aio help [COMMAND]` Display help for aio. diff --git a/package.json b/package.json index 9691266..13d8eed 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { "name": "@adobe/aio-cli-plugin-app-storage", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", + "@adobe/aio-lib-db": "^0.1.0-beta.2", + "@adobe/aio-lib-env": "^3.0.1", "@adobe/aio-lib-state": "^5", "@inquirer/prompts": "^5", "@oclif/core": "^4", "@oclif/plugin-help": "^6", + "@oclif/table": "^0.5.0", "chalk": "^5", + "dotenv": "^16.5.0", "semver": "^7.6.3" }, "devDependencies": { @@ -46,6 +50,18 @@ "topics": { "app:state": { "description": "Manage your App Builder State storage" + }, + "app:db": { + "description": "Manage your App Builder Database storage" + }, + "app:db:collection": { + "description": "Manage database collections, also available under 'aio app db col '" + }, + "app:db:document": { + "description": "Manage documents in a collection, also available under 'aio app db doc '" + }, + "app:db:index": { + "description": "Manage indexes on a collection, also available under 'aio app db idx '" } }, "bin": "aio", @@ -71,6 +87,9 @@ "jest": { "collectCoverage": true, "testEnvironment": "node", - "transform": {} + "transform": {}, + "setupFiles": [ + "/test/jest.env.js" + ] } } diff --git a/src/BaseCommand.js b/src/BaseCommand.js index cfee6a9..780cfb4 100644 --- a/src/BaseCommand.js +++ b/src/BaseCommand.js @@ -10,24 +10,19 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { Command, Flags } from '@oclif/core' -import config from '@adobe/aio-lib-core-config' +import { Command } from '@oclif/core' 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 () { 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 @@ -38,50 +33,14 @@ 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 + /** + * Get the service name for logging + * @returns {string} The service name + */ + getServiceName () { + return 'app' // Default fallback } async catch (error) { @@ -105,12 +64,6 @@ export class BaseCommand extends Command { // display the JSON returned by the command's run method. BaseCommand.enableJsonFlag = true -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'] - }) -} +BaseCommand.flags = {} BaseCommand.args = {} diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js new file mode 100644 index 0000000..97d921c --- /dev/null +++ b/src/DBBaseCommand.js @@ -0,0 +1,112 @@ +/* +Copyright 2025 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_RUNTIME_AUTH, CONFIG_RUNTIME_NAMESPACE } from './constants/global.js' +import { AVAILABLE_REGIONS, CONFIG_DB_ENDPOINT, CONFIG_DB_REGION, DEFAULT_REGION } from './constants/db.js' +import { Flags } from '@oclif/core' +import { getCliEnv } from '@adobe/aio-lib-env' + +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 { + const region = this.flags?.region || config.get(CONFIG_DB_REGION) || DEFAULT_REGION + // Get database configuration + const dbConfig = { + ow: { + namespace: config.get(CONFIG_RUNTIME_NAMESPACE), + auth: config.get(CONFIG_RUNTIME_AUTH) + }, + region + } + + // Validate region based on environment + const allowedRegions = AVAILABLE_REGIONS[getCliEnv()] + if (!allowedRegions.includes(region)) { + this.error(`Invalid region '${region}' for the ${getCliEnv()} environment, must be one of: ${allowedRegions.join(', ')}`) + } + + // Validate required configuration + if (!(dbConfig.ow.namespace && dbConfig.ow.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.` + ) + } + + const endpointOverride = config.get(CONFIG_DB_ENDPOINT) + if (endpointOverride) { + process.env.AIO_DB_ENDPOINT = endpointOverride + this.debugLogger?.info?.('Using custom endpoint: %s', process.env.AIO_DB_ENDPOINT) + } + + // Dynamic import to be able to reload the AIO_DB_ENDPOINT var + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const aioLibDb = await import('@adobe/aio-lib-db') + const { init } = aioLibDb.default || aioLibDb + + this.debugLogger?.info?.('Initializing DB client with config:', { + namespace: dbConfig.ow.namespace, + region: dbConfig.region, + hasAuth: !!dbConfig.ow.auth + }) + + // Initialize the database client + this.db = await init(dbConfig) + this.dbConfig = dbConfig + this.rtNamespace = dbConfig.ow.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 + * @returns {string} The service name + */ + getServiceName () { + return 'db' + } +} + +// Add json and region flags to GLOBAL FLAGS section in --help output +DBBaseCommand.flags = { + ...BaseCommand.flags, + json: { + description: 'Format output as json.', + default: false, + required: false, + helpGroup: 'GLOBAL' + }, + region: Flags.string({ + description: `Database region. Defaults to 'AIO_DB_REGION' environment variable or '${DEFAULT_REGION}' if neither is set. Any database region set in 'app.config.yaml' takes precedence over all of these.\n`, + required: false, + helpGroup: 'GLOBAL' + // Don't set default here to let it load from the environment var if not passed as a flag + }) +} diff --git a/src/StateBaseCommand.js b/src/StateBaseCommand.js new file mode 100644 index 0000000..9e85ea6 --- /dev/null +++ b/src/StateBaseCommand.js @@ -0,0 +1,87 @@ +/* +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 { AVAILABLE_REGIONS, CONFIG_STATE_REGION, DEFAULT_REGION } from './constants/state.js' +import { CONFIG_RUNTIME_NAMESPACE, CONFIG_RUNTIME_AUTH } from './constants/global.js' + +import semver from 'semver' +import { Flags } from '@oclif/core' + +export class StateBaseCommand extends BaseCommand { + async init () { + await super.init() + // 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()) + } 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(CONFIG_RUNTIME_NAMESPACE), + auth: config.get(CONFIG_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) || DEFAULT_REGION + 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 aioLibState = await import('@adobe/aio-lib-state') + + /** @type {import('@adobe/aio-lib-state').AdobeState} */ + this.state = await aioLibState.init({ region, ow: owOptions }) + + this.rtNamespace = owOptions.namespace + } + + /** + * Get the service name for logging + * @returns {string} The service name + */ + getServiceName () { + return 'state' + } +} + +StateBaseCommand.flags = { + ...BaseCommand.flags, + region: Flags.string({ + description: 'State region. Defaults to \'AIO_STATE_REGION\' env or \'amer\' if neither is set.', + required: false, + options: AVAILABLE_REGIONS + }) +} diff --git a/src/commands/app/add/db.js b/src/commands/app/add/db.js new file mode 100644 index 0000000..9f5e22f --- /dev/null +++ b/src/commands/app/add/db.js @@ -0,0 +1,20 @@ +/* +Copyright 2025 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 '../db/provision.js' + +// 'aio app add db' is an alias for 'aio app db provision', but to have it show up in +// help correctly it needs its own class instead of using Provision.aliases +export class AddDb extends Provision {} + +// Update the examples for the help display +AddDb.examples = Provision.examples.map(example => example.replace('$ aio app db provision', '$ aio app add db')) diff --git a/src/commands/app/db/collection/create.js b/src/commands/app/db/collection/create.js new file mode 100644 index 0000000..293d93e --- /dev/null +++ b/src/commands/app/db/collection/create.js @@ -0,0 +1,119 @@ +/* +Copyright 2025 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 { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' + +export class CreateCollection extends DBBaseCommand { + async run () { + const { collection } = this.args + const { validator } = this.flags + + try { + this.log(chalk.blue(`Creating collection '${collection}'...`)) + + // Log flag values if set + if (validator) { + this.log(chalk.dim(` Using validator: ${validator}`)) + } + + const client = await this.db.connect() + + // Check if collection already exists + const existingCollections = await client.listCollections() + const collectionExists = existingCollections && existingCollections.some(col => col.name === collection) + + if (collectionExists) { + const errorMessage = `Collection '${collection}' already exists` + + this.log(chalk.red(errorMessage)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + this.error(errorMessage) + } + + // Build collection options + const options = {} + if (validator) { + options.validator = { $jsonSchema: validator } + } + + // Create the collection + const result = await client.createCollection(collection, options) + + this.debugLogger?.info?.('Collection created successfully:', result) + + const response = { + collection, + status: 'created', + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + options + } + + this.log(chalk.green(`Collection '${collection}' created successfully`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (validator) { + // Display the final validator object (after parsing) in compact JSON format + const validatorDisplay = JSON.stringify(options.validator.$jsonSchema) + this.log(chalk.dim(` Validator: ${validatorDisplay}`)) + } + + this.log(chalk.dim(` Created: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error creating collection:', error) + + const errorMessage = `Failed to create collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to create collection')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(errorMessage) + } + } +} + +CreateCollection.description = 'Create a new collection in the database' + +CreateCollection.examples = [ + '$ aio app db collection create users', + '$ aio app db collection create inventory --validator \'{"type": "object", "required": ["id", "quantity"]}\' --json', + '$ aio app db col create products --json', + '$ aio app db col create products --validator \'{"type": "object", "properties": {"name": {"type": "string"}, "price": {"type": "number", "minimum": 0}}, "required": ["name", "price"]}\'' +] + +CreateCollection.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to create', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }) +} + +CreateCollection.flags = { + ...DBBaseCommand.flags, + validator: Flags.string({ + char: 'v', + description: 'JSON schema validator for document validation (JSON string)', + parse: input => asObject(input, 'Validator') + }) +} + +CreateCollection.aliases = ['app:db:col:create'] diff --git a/src/commands/app/db/collection/drop.js b/src/commands/app/db/collection/drop.js new file mode 100644 index 0000000..4b19b59 --- /dev/null +++ b/src/commands/app/db/collection/drop.js @@ -0,0 +1,90 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +export class DropCollection extends DBBaseCommand { + async run () { + const { collection } = this.args + + try { + this.log(chalk.blue(`Dropping collection '${collection}'...`)) + + const client = await this.db.connect() + + // Get the collection object + const coll = client.collection(collection) + + // Drop collection + const result = await coll.drop() + + this.debugLogger?.info?.('Collection dropped successfully:', result) + + const response = { + collection, + status: 'dropped', + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + + this.log(chalk.green(`Collection '${collection}' dropped successfully`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (result && typeof result === 'object' && Object.keys(result).length > 0) { + this.log(chalk.dim(` Details:\n${prettyJson(result)}`)) + } + + this.log(chalk.dim(` Dropped: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error dropping collection:', error) + + const errorMessage = `Failed to drop collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to drop collection')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(errorMessage) + } + } +} + +DropCollection.description = 'Drop a collection from the database' + +DropCollection.examples = [ + '$ aio app db collection drop users', + '$ aio app db collection drop products --json', + '$ aio app db col drop inventory' +] + +DropCollection.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to drop', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }) +} + +DropCollection.flags = { + ...DBBaseCommand.flags +} + +DropCollection.aliases = ['app:db:col:drop'] diff --git a/src/commands/app/db/collection/list.js b/src/commands/app/db/collection/list.js new file mode 100644 index 0000000..d77af10 --- /dev/null +++ b/src/commands/app/db/collection/list.js @@ -0,0 +1,100 @@ +/* +Copyright 2025 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' +import { Flags } from '@oclif/core' +import { prettyJson } from '../../../../utils/output.js' +import { makeTable } from '@oclif/table' + +export class List extends DBBaseCommand { + async run () { + const { info } = this.flags + try { + this.log(chalk.blue('Fetching collections...')) + + const client = await this.db.connect() + const collectionInfo = await client.listCollections() + + this.debugLogger?.info?.(`Retrieved ${collectionInfo.length} collections:`, collectionInfo) + + this.log(chalk.green('Collection Information:')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (collectionInfo && collectionInfo.length > 0) { + this.log(chalk.dim(` Total Collections: ${collectionInfo.length}`)) + + const formattedInfo = collectionInfo.map(col => { + const colInfo = { + name: col.name + } + if (info) { + colInfo.idIndex = `key: ${JSON.stringify(col.idIndex.key)}\nname: ${col.idIndex.name}` + colInfo.info = Object.entries(col.info).map(([key, value]) => { + return `${key}: ${JSON.stringify(value)}` + }).join('\n') + + if (col.options?.validator) { + colInfo.validator = prettyJson(col.options.validator.$jsonSchema, 0) + } + if (col.options?.validationLevel) { + colInfo.validationLevel = prettyJson(col.options.validationLevel, 0) + } + if (col.options?.validationAction) { + colInfo.validationAction = prettyJson(col.options.validationAction, 0) + } + } + + return colInfo + }) + const table = makeTable({ data: formattedInfo, overflow: 'wrap', trimWhitespace: false }) + this.log(' ' + table.replaceAll('\n', '\n ').trimEnd()) + } else { + this.log(chalk.dim(' No collections found')) + } + + this.log(chalk.dim(`\n Retrieved: ${new Date().toLocaleString()}`)) + + return info ? collectionInfo : collectionInfo.map(col => col.name) + } catch (error) { + this.debugLogger?.error?.('Error fetching collections', error) + + this.log(chalk.red('Error fetching collections')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(`Failed to fetch collections: ${error.message}`) + } + } +} + +List.description = 'Get the list of collections in your App Builder database' + +List.examples = [ + '$ aio app db collection list', + '$ aio app db collection list --info', + '$ aio app db collection list --json', + '$ aio app db col list --info --json' +] + +List.flags = { + ...DBBaseCommand.flags, + info: Flags.boolean({ + char: 'i', + description: 'Show detailed collection information instead of just names', + default: false + }) +} + +List.args = {} + +List.aliases = ['app:db:col:list', 'app:db:show:collections'] diff --git a/src/commands/app/db/collection/rename.js b/src/commands/app/db/collection/rename.js new file mode 100644 index 0000000..a149143 --- /dev/null +++ b/src/commands/app/db/collection/rename.js @@ -0,0 +1,98 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +export class RenameCollection extends DBBaseCommand { + async run () { + const { currentName, newName } = this.args + + try { + this.log(chalk.blue(`Renaming collection '${currentName}' to '${newName}'...`)) + + const client = await this.db.connect() + + // Get the collection object + const collection = client.collection(currentName) + + // Rename the collection + const result = await collection.renameCollection(newName) + + this.debugLogger?.info?.('Collection renamed successfully:', result) + + const response = { + currentName, + newName, + status: 'renamed', + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + + this.log(chalk.green(`Collection '${currentName}' renamed to '${newName}' successfully`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (result && typeof result === 'object' && Object.keys(result).length > 0) { + this.log(chalk.dim(` Details:\n${prettyJson(result)}`)) + } + + this.log(chalk.dim(` Renamed: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error renaming collection:', error) + + const errorMessage = `Failed to rename collection '${currentName}': ${error.message}` + + this.log(chalk.red('Failed to rename collection')) + this.log(chalk.dim(` Current: ${currentName}`)) + this.log(chalk.dim(` New: ${newName}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(errorMessage) + } + } +} + +RenameCollection.description = 'Rename a collection in the database' + +RenameCollection.examples = [ + '$ aio app db collection rename users customers', + '$ aio app db collection rename old_products new_products --json', + '$ aio app db col rename inventory stock' +] + +RenameCollection.args = { + currentName: Args.string({ + name: 'currentName', + description: 'The current name of the collection to rename', + required: true, + parse: input => isNonEmptyString(input, 'Current collection name') + }), + newName: Args.string({ + name: 'newName', + description: 'The new name for the collection', + required: true, + parse: input => isNonEmptyString(input, 'New collection name') + }) +} + +RenameCollection.flags = { + ...DBBaseCommand.flags +} + +RenameCollection.aliases = ['app:db:col:rename'] diff --git a/src/commands/app/db/collection/stats.js b/src/commands/app/db/collection/stats.js new file mode 100644 index 0000000..22ea7b5 --- /dev/null +++ b/src/commands/app/db/collection/stats.js @@ -0,0 +1,94 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +export class StatsCollection extends DBBaseCommand { + async run () { + const { collection } = this.args + + try { + this.log(chalk.blue(`Getting stats for collection '${collection}'...`)) + + const client = await this.db.connect() + + // Get the collection object + const coll = client.collection(collection) + + // Get collection-level statistics + const stats = await coll.stats() + + this.debugLogger?.info?.('Collection stats retrieved successfully:', stats) + + const response = { + collection, + stats, + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + + this.log(chalk.green(`Stats for collection '${collection}':`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + // Display stats in a formatted way + Object.entries(stats).forEach(([key, value]) => { + if (typeof value === 'object' && value !== null) { + this.log(chalk.dim(` ${key}:\n${prettyJson(value)}`)) + } else { + this.log(chalk.dim(` ${key}: ${value}`)) + } + }) + + this.log(chalk.dim(` Retrieved: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error getting collection stats:', error) + + const errorMessage = `Failed to get stats for collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to get collection stats')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(errorMessage) + } + } +} + +StatsCollection.description = 'Get statistics for a collection in the database' + +StatsCollection.examples = [ + '$ aio app db collection stats users', + '$ aio app db collection stats products --json', + '$ aio app db col stats inventory' +] + +StatsCollection.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to get stats for', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }) +} + +StatsCollection.flags = { + ...DBBaseCommand.flags +} + +StatsCollection.aliases = ['app:db:col:stats'] diff --git a/src/commands/app/db/delete.js b/src/commands/app/db/delete.js new file mode 100644 index 0000000..e8e7a8c --- /dev/null +++ b/src/commands/app/db/delete.js @@ -0,0 +1,89 @@ +/* +Copyright 2025 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' +import { Flags } from '@oclif/core' +import { DB_STATUS } from '../../../constants/db.js' +import { isProductionNamespace } from '../../../utils/inputValidation.js' + +export class DeleteDb extends DBBaseCommand { + async run () { + const namespace = this.rtNamespace + + try { + // Check if the namespace is a production namespace + if (isProductionNamespace(namespace)) { + this.error('A production database may not be deleted directly. Please contact the App Builder team to have this database deleted.') + } + + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const { input } = await import('@inquirer/prompts') + + if (!this.flags.force) { + process.stderr.write(chalk.red('❌ CAUTION, This action cannot be reverted and all stored data will be lost.') + '\n') + + const res = await input({ + message: chalk.yellow(`confirm deletion by typing: '${namespace}'`) + }) + if (res !== namespace) { + return this.error('confirmation did not match, aborted') + } + } + + this.log(chalk.blue(`Proceeding to delete the database for the namespace: '${namespace}'...`)) + + const deleteResult = await this.db.deleteDatabase() + this.debugLogger?.info?.('Delete request result:', deleteResult) + + const deleteStatus = deleteResult?.status?.toUpperCase() || DB_STATUS.UNKNOWN + + if (deleteStatus === DB_STATUS.DELETED) { + this.log(chalk.green('Database deleted successfully')) + this.log(chalk.dim('Check database status: aio app db status')) + } else { + this.warn(`Delete request returned status '${deleteStatus}'`) + this.warn('If the issue persists, please contact the App Builder team.') + } + + const result = { + status: deleteStatus, + namespace, + timestamp: new Date().toISOString(), + details: deleteResult + } + + return result + } catch (error) { + this.debugLogger?.error?.('Delete command error:', error) + this.error(`Database deletion failed: ${error.message}`) + } + } +} + +DeleteDb.description = 'Delete the database for your App Builder application (non-production only)' + +DeleteDb.examples = [ + '$ aio app db delete', + '$ aio app db delete --force', + '$ aio app db delete --json' +] + +DeleteDb.flags = { + ...DBBaseCommand.flags, + force: Flags.boolean({ + description: '[use with caution!] force delete, skips confirmation safety prompt', + default: false + }) +} + +DeleteDb.args = {} diff --git a/src/commands/app/db/document/count.js b/src/commands/app/db/document/count.js new file mode 100644 index 0000000..54c1e8f --- /dev/null +++ b/src/commands/app/db/document/count.js @@ -0,0 +1,96 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' + +export class Count extends DBBaseCommand { + async run () { + const { collection, query } = this.args + + try { + this.log(chalk.blue(`Counting documents in collection '${collection}'...`)) + + if (query) { + this.log(chalk.dim(` Using query filter: ${JSON.stringify(query)}`)) + } + + const client = await this.db.connect() + const coll = client.collection(collection) + + // Count documents + const count = await coll.countDocuments(query || {}, {}) + + this.debugLogger?.info?.('Document count:', count) + + const response = { + collection, + query: query || {}, + count, + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + + this.log(chalk.green(`Found ${count} document(s) in collection '${collection}'`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (query && Object.keys(query).length > 0) { + this.log(chalk.dim(' Query filter applied: Yes')) + } + + this.log(chalk.dim(` Counted: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error counting documents:', error) + + const errorMessage = `Failed to count documents in collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to count documents')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.error(errorMessage) + } + } +} + +Count.description = 'Count documents in a collection' + +Count.examples = [ + '$ aio app db document countDocuments users', + '$ aio app db document countDocuments users \'{"age": {"$gte": 21}}\'', + '$ aio app db document countDocuments products \'{"category": "electronics"}\' --json', + '$ aio app db doc count orders \'{"status": "shipped"}\'' +] + +Count.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + query: Args.string({ + name: 'query', + description: 'The query filter document (JSON string). If not provided, counts all documents.', + required: false, + parse: input => asObject(input, 'Query') + }) +} + +Count.flags = { + ...DBBaseCommand.flags +} + +Count.aliases = ['app:db:doc:count'] diff --git a/src/commands/app/db/document/delete.js b/src/commands/app/db/document/delete.js new file mode 100644 index 0000000..07c4e5c --- /dev/null +++ b/src/commands/app/db/document/delete.js @@ -0,0 +1,95 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' + +export class Delete extends DBBaseCommand { + async run () { + const { collection, filter } = this.args + + try { + this.log(chalk.blue(`Deleting document from collection '${collection}'...`)) + + const client = await this.db.connect() + const coll = client.collection(collection) + + // Delete the document + const result = await coll.deleteOne(filter) + + this.debugLogger?.info?.('Document deleted successfully:', result) + + const response = { + collection, + filter, + deletedCount: result.deletedCount, + acknowledged: result.acknowledged, + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + + if (result.deletedCount > 0) { + this.log(chalk.green(`Document deleted successfully from collection '${collection}'`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + } else { + this.log(chalk.yellow(`No document found in collection '${collection}' matching the filter`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + } + + this.log(chalk.dim(` Deleted: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error deleting document:', error) + + const errorMessage = `Failed to delete document from collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to delete document')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + this.error(errorMessage) + } + } +} + +Delete.description = 'Delete a single document from a collection' + +Delete.examples = [ + '$ aio app db document delete users \'{"name": "John"}\'', + '$ aio app db document delete products \'{"id": "123"}\' --json', + '$ aio app db doc delete posts \'{"status": "draft"}\'' +] + +Delete.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + filter: Args.string({ + name: 'filter', + description: 'The filter document (JSON string)', + required: true, + parse: input => asObject(input, 'Filter') + }) +} + +Delete.flags = { + ...DBBaseCommand.flags +} + +Delete.aliases = ['app:db:doc:delete'] diff --git a/src/commands/app/db/document/find.js b/src/commands/app/db/document/find.js new file mode 100644 index 0000000..fd44e45 --- /dev/null +++ b/src/commands/app/db/document/find.js @@ -0,0 +1,133 @@ +/* +Copyright 2025 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 { Args, Flags } from '@oclif/core' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' +import chalk from 'chalk' +import { prettyJson } from '../../../../utils/output.js' + +export class Find extends DBBaseCommand { + async run () { + const { collection, filter } = this.args + const { limit, skip, sort, projection } = this.flags + + try { + this.log(chalk.blue(`Finding documents in collection '${collection}'...`)) + this.log(chalk.dim(` Filter:\n${prettyJson(filter)}`)) + + // Prepare options for find + const options = { limit } + this.log(chalk.dim(` Limit: ${limit}`)) + if (skip !== undefined) { + this.log(chalk.dim(` Skip: ${skip}`)) + options.skip = skip + } + if (sort !== undefined) { + this.log(chalk.dim(` Sort:\n${prettyJson(sort)}`)) + options.sort = sort + } + if (projection !== undefined) { + this.log(chalk.dim(` Projection:\n${prettyJson(projection)}`)) + options.projection = projection + } + + this.log(chalk.dim(` Namespace: ${this.rtNamespace}\n`)) + + const client = await this.db.connect() + const coll = client.collection(collection) + const results = await coll.findArray(filter, options) + const timestamp = new Date().toISOString() + const response = { + collection, + filter, + options, + results, + namespace: this.rtNamespace, + timestamp + } + + this.debugLogger?.info?.('Find results:', results) + if (results?.length > 0) { + this.log(chalk.green(`Retrieved ${results.length} document(s) from collection '${collection}'`)) + this.log(chalk.dim(` Searched: ${timestamp}`)) + this.log(chalk.dim(` Results:\n${prettyJson(results)}`)) + } else { + this.log(chalk.green(`No documents matching the filter criteria found in collection '${collection}'.`)) + this.log(chalk.dim(` Searched: ${timestamp}`)) + } + + return response + } catch (error) { + this.debugLogger?.error?.('Error finding documents:', error) + + const errorMessage = `Failed to find documents in collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to find documents')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.error(errorMessage) + } + } +} + +Find.description = 'Find documents in a collection based on filter criteria.' + +Find.examples = [ + '$ aio app db document find users \'{}\'', + '$ aio app db document find products \'{"category": "Computer Accessories"}\' --json', + '$ aio app db document find products \'{"name": {"$regex": "Speakers$"}}\' --sort \'{"price": -1}\' --limit 10 --skip 5 --projection \'{"name": 1, "price": 1}\'', + '$ aio app db doc find orders \'{"status": "pending"}\' --sort \'{"orderDate": -1}\'' +] + +Find.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + filter: Args.string({ + name: 'filter', + description: 'Filter criteria for the documents to find (JSON string, e.g. \'{"status": "active"}\')', + required: true, + parse: input => asObject(input, 'Filter') + }) +} + +Find.flags = { + ...DBBaseCommand.flags, + limit: Flags.integer({ + char: 'l', + description: 'Limit the number of documents returned, max: 100', + default: 20, + max: 100, + min: 0 + }), + skip: Flags.integer({ + char: 's', + description: 'Skip the first N documents', + min: 0 + }), + sort: Flags.string({ + char: 'o', + description: 'Sort specification as a JSON object (e.g. \'{"field": 1}\')', + parse: input => asObject(input, 'Sort') + }), + projection: Flags.string({ + char: 'p', + description: 'Projection specification as a JSON object (e.g. \'{"field1": 1, "field2": 0}\')', + parse: input => asObject(input, 'Projection') + }) +} + +Find.aliases = ['app:db:doc:find'] diff --git a/src/commands/app/db/document/insert.js b/src/commands/app/db/document/insert.js new file mode 100644 index 0000000..eebe011 --- /dev/null +++ b/src/commands/app/db/document/insert.js @@ -0,0 +1,147 @@ +/* +Copyright 2025 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 { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +export class Insert extends DBBaseCommand { + async run () { + const { collection, documents } = this.args + const { bypassDocumentValidation } = this.flags + + try { + this.log(chalk.blue(`Inserting ${documents.length} documents into collection '${collection}'...`)) + + // Build options object + const insertOptions = {} + if (bypassDocumentValidation) { + insertOptions.bypassDocumentValidation = true + this.log(chalk.dim(' Bypassing document validation')) + } + + const client = await this.db.connect() + const coll = await client.collection(collection) + + // Perform the insert operation + const result = await coll.insertMany(documents, insertOptions) + + this.debugLogger?.info?.('Documents inserted successfully:', result) + + const response = { + collection, + status: 'inserted', + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + + if (bypassDocumentValidation) { + response.options = insertOptions + } + + this.log(chalk.green(`Successfully inserted ${result.insertedCount} documents into collection '${collection}'`)) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (result.insertedIds && Object.keys(result.insertedIds).length > 0) { + this.log(chalk.dim(` Inserted IDs: ${JSON.stringify(result.insertedIds)}`)) + } + + this.log(chalk.dim(` Details:\n${prettyJson(result)}`)) + + this.log(chalk.dim(` Inserted: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error inserting documents:', error) + + const errorMessage = `Failed to insert documents into collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to insert documents')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(errorMessage) + } + } +} + +Insert.description = 'Insert one or more documents into a collection' + +Insert.examples = [ + '$ aio app db document insert users \'{"name": "John", "age": 30}\'', + '$ aio app db document insert products \'[{"id": 1, "name": "Product A"}, {"id": 2, "name": "Product B"}]\' --json', + '$ aio app db document insert temp \'{"data": "test"}\' --bypassDocumentValidation', + '$ aio app db doc insert bulk \'[{"field": "foo"}, {"field": "bar"}]\' --bypassDocumentValidation --json' +] + +Insert.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to insert documents into', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + documents: Args.string({ + name: 'documents', + description: 'JSON object or array of documents to insert', + required: true, + parse: input => { + if (typeof input !== 'string' || input.trim().length === 0) { + throw new Error('Documents: Must be a JSON string representing an object or non-empty array') + } + + let result + try { + result = JSON.parse(input) + } catch (e) { + throw new Error(`Documents: JSON parse error: ${e.message}`) + } + + const isSingleDoc = !Array.isArray(result) + if (isSingleDoc) { + result = [result] + } + + if (result.length === 0) { + throw new Error('Documents: Cannot be empty') + } + + // Validate each document is an object + for (let i = 0; i < result.length; i++) { + if (typeof result[i] !== 'object' || result[i] === null || Array.isArray(result[i])) { + if (isSingleDoc) { + throw new Error('Documents: Must be a JSON string representing an object or non-empty array') + } + throw new Error(`Documents: Element at index ${i} must be an object`) + } + } + + return result + } + }) +} + +Insert.flags = { + ...DBBaseCommand.flags, + bypassDocumentValidation: Flags.boolean({ + char: 'b', + description: 'Bypass schema validation if present', + default: false + }) +} + +Insert.aliases = ['app:db:doc:insert'] diff --git a/src/commands/app/db/document/replace.js b/src/commands/app/db/document/replace.js new file mode 100644 index 0000000..e71aaeb --- /dev/null +++ b/src/commands/app/db/document/replace.js @@ -0,0 +1,122 @@ +/* +Copyright 2025 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 { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' + +export class Replace extends DBBaseCommand { + async run () { + const { collection, filter, replacement } = this.args + const { upsert } = this.flags + + try { + this.log(chalk.blue(`Replacing document in collection '${collection}'...`)) + + if (upsert) { + this.log(chalk.dim(' Upsert enabled: Will create document if not found')) + } + + const client = await this.db.connect() + const coll = client.collection(collection) + + // Build options + const options = {} + if (upsert) { + options.upsert = true + } + + // Replace the document + const result = await coll.replaceOne(filter, replacement, options) + + this.debugLogger?.info?.('Document replaced successfully:', result) + + const response = { + collection, + filter, + replacement, + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + + if (result.matchedCount > 0) { + this.log(chalk.green(`Document replaced successfully in collection '${collection}'`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + } else if (upsert && result.upsertedId) { + this.log(chalk.green(`Document created (upserted) in collection '${collection}'`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Upserted ID: ${result.upsertedId}`)) + this.log(chalk.dim(` Upserted count: ${result.upsertedCount}`)) + } else { + this.log(chalk.yellow(`No document found in collection '${collection}' matching the filter`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + } + + this.log(chalk.dim(` Replaced: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error replacing document:', error) + + const errorMessage = `Failed to replace document in collection '${collection}': ${error.message}` + + this.log(chalk.red('Failed to replace document')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + this.error(errorMessage) + } + } +} + +Replace.description = 'Replace a single document in a collection' + +Replace.examples = [ + '$ aio app db document replace users \'{"name": "John"}\' \'{"name": "John Doe", "age": 30, "status": "active"}\'', + '$ aio app db document replace products \'{"id": "123"}\' \'{"id": "123", "name": "New Product", "price": 99.99}\' --json', + '$ aio app db document replace posts \'{"slug": "hello-world"}\' \'{"title": "Hello World", "content": "Updated content", "status": "published"}\' --upsert', + '$ aio app db doc replace users \'{"email": "john@example.com"}\' \'{"email": "john@example.com", "name": "John", "verified": true}\' --upsert --json' +] + +Replace.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + filter: Args.string({ + name: 'filter', + description: 'The filter document (JSON string)', + required: true, + parse: input => asObject(input, 'Filter') + }), + replacement: Args.string({ + name: 'replacement', + description: 'The replacement document (JSON string)', + required: true, + parse: input => asObject(input, 'Replacement') + }) +} + +Replace.flags = { + ...DBBaseCommand.flags, + upsert: Flags.boolean({ + char: 'u', + description: 'If no document is found, create a new one', + default: false + }) +} + +Replace.aliases = ['app:db:doc:replace'] diff --git a/src/commands/app/db/document/update.js b/src/commands/app/db/document/update.js new file mode 100644 index 0000000..807afc8 --- /dev/null +++ b/src/commands/app/db/document/update.js @@ -0,0 +1,144 @@ +/* +Copyright 2025 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 { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' + +export class Update extends DBBaseCommand { + async run () { + const { collection, filter, update } = this.args + const { many, upsert } = this.flags + const docPlural = many ? '(s)' : '' + + try { + this.log(chalk.blue(`Updating document${docPlural} in collection '${collection}'...`)) + this.log(chalk.dim(` Filter: ${JSON.stringify(filter)}`)) + this.log(chalk.dim(` Update: ${JSON.stringify(update)}`)) + + if (upsert) { + this.log(chalk.dim(' Upsert enabled: Will create document if not found')) + } + + const client = await this.db.connect() + const coll = client.collection(collection) + + // Build options + const options = {} + if (upsert) { + options.upsert = true + } + + // Update the document + const result = await (many ? coll.updateMany(filter, update, options) : coll.updateOne(filter, update, options)) + + this.debugLogger?.info?.(`Document${docPlural} updated successfully:`, result) + + const response = { + collection, + filter, + update, + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + const optionOutput = { ...options } + if (many) { + optionOutput.many = true + } + if (Object.keys(optionOutput).length > 0) { + response.options = optionOutput + } + + if (result.matchedCount > 0) { + if (result.modifiedCount > 0) { + this.log(chalk.green(`Document${docPlural} updated successfully in collection '${collection}'`)) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Matched Count: ${result.matchedCount}`)) + this.log(chalk.dim(` Modified Count: ${result.modifiedCount}`)) + } else { + this.log(chalk.green(`${result.matchedCount} matching document${docPlural} found in collection '${collection}', but no update was necessary`)) + } + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + } else if (result.upsertedId) { + this.log(chalk.green(`Document created (upserted) in collection '${collection}'`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Upserted ID: ${result.upsertedId}`)) + this.log(chalk.dim(` Upserted count: ${result.upsertedCount}`)) + } else { + this.log(chalk.yellow(`No document found in collection '${collection}' matching the filter`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + } + + this.log(chalk.dim(` Updated: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.(`Error updating document${docPlural}:`, error) + + const errorMessage = `Failed to update document${docPlural} in collection '${collection}': ${error.message}` + + this.log(chalk.red(`Failed to update document${docPlural}`)) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + this.error(errorMessage) + } + } +} + +Update.description = 'Update document(s) in a collection' + +Update.examples = [ + '$ aio app db document update users \'{"name": "John"}\' \'{"$set": {"age": 31}}\'', + '$ aio app db document update products \'{"id": "123"}\' \'{"$inc": {"stock": -1}}\' --json', + '$ aio app db document update posts \'{"slug": "hello-world"}\' \'{"$set": {"status": "published"}}\' --many', + '$ aio app db doc update users \'{"email": "john@example.com"}\' \'{"$set": {"lastLogin": "2024-01-01"}}\' --upsert' +] + +Update.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + filter: Args.string({ + name: 'filter', + description: 'The filter document (JSON string)', + required: true, + parse: input => asObject(input, 'Filter') + }), + update: Args.string({ + name: 'update', + description: 'The update document (JSON string)', + required: true, + parse: input => asObject(input, 'Update') + }) +} + +Update.flags = { + ...DBBaseCommand.flags, + upsert: Flags.boolean({ + char: 'u', + description: 'If no document is found, create a new one', + default: false + }), + many: Flags.boolean({ + char: 'm', + description: 'Update all documents matching the filter. Without this option, only the first matching document is updated.', + default: false + }) +} + +Update.aliases = ['app:db:doc:update'] diff --git a/src/commands/app/db/index/create.js b/src/commands/app/db/index/create.js new file mode 100644 index 0000000..072a407 --- /dev/null +++ b/src/commands/app/db/index/create.js @@ -0,0 +1,170 @@ +/* +Copyright 2025 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 { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { asObject, isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +// Regular expression to match the -s/--spec or -k/--key flags in = format +// if match.groups.spec is defined, it means the flag was -s or --spec +// if match.groups.key is defined, it means the flag was -k or --key +const specFlagMatch = /^((?-s|--spec)|(?-k|--key))=(?.+)/ + +export class Create extends DBBaseCommand { + getOrderedSpecs () { + // Key/spec order matters when creating an index and both key and spec can be specified, + // so we need to parse argv to obtain the proper order since using this.flags loses the order between the two + const args = this.argv.slice(1) // Ignore the first element (collection name) + const fullSpec = [] + let specOption = false + args.forEach((arg) => { + if (specOption === 'key') { + // Previous arg was -k or --key + fullSpec.push(arg) + specOption = false + } else if (specOption === 'spec') { + // Previous arg was -s or --spec + fullSpec.push(asObject(arg)) + specOption = false + } else if (arg === '-k' || arg === '--key') { + // Next arg is a key + specOption = 'key' + } else if (arg === '-s' || arg === '--spec') { + // Next arg is a spec + specOption = 'spec' + } else { + // Check if the arg is in the form of -s=/--spec= or -k=/--key= + const specMatch = arg.match(specFlagMatch) + if (specMatch?.groups?.spec) { + // Spec is a JSON object + fullSpec.push(asObject(specMatch.groups.val)) + } else if (specMatch?.groups?.key) { + // Key is a string + fullSpec.push(specMatch.groups.val) + } + specOption = false + } + }) + + return fullSpec + } + + async run () { + const { collection } = this.args + const { name, unique } = this.flags + + try { + const fullSpec = this.getOrderedSpecs() + const prettySpec = prettyJson(fullSpec) + + this.log(chalk.blue(`Creating index ${name ? `'${name}' ` : ''}on collection '${collection}'...`)) + this.log(chalk.dim(` Specification:\n${prettySpec}`)) + if (unique) this.log(chalk.dim(` Unique index: ${unique}`)) + + const client = await this.db.connect() + const coll = await client.collection(collection) + + // Build options + const options = {} + if (name) options.name = name + if (unique) options.unique = unique + + const result = await coll.createIndex(fullSpec, options) + + this.debugLogger?.info?.('Index created successfully:', result) + + const response = { + collection, + indexName: result, + specification: fullSpec, + status: 'created', + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + + if (Object.keys(options).length > 0) response.options = options + + this.log(chalk.green(`Index '${result}' created successfully in the '${collection}' collection`)) + this.log(chalk.dim(` Specification:\n${prettySpec}`)) + if (unique) this.log(chalk.dim(` Unique: ${unique}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Created: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error creating index:', error) + + this.log(chalk.red('Failed to create index')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + this.error(`Failed to create index on collection '${collection}': ${error.message}`) + } + } +} + +Create.description = 'Create a new index on a collection in the database' + +Create.examples = [ + '$ aio app db index create users --spec \'{"name":1, "age":-1}\'', + '$ aio app db index create users -s \'{"name":1, "age":-1}\' --name "name_age_index"', + '$ aio app db index create students -s \'{"name":1}\' --key grade --unique', + '$ aio app db index create reviews -k sku -k rating', + '$ aio app db index create products -s \'{"name":"text", "category":"text"}\' --json', + '$ aio app db index create books -s \'{"author":1}\' -k year', + '$ aio app db idx create orders --spec \'{"customerId":1}\' --spec \'{"orderDate":-1}\' --name "customer_order_index" --unique' +] + +Create.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to create the index on', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }) +} + +Create.flags = { + ...DBBaseCommand.flags, + spec: Flags.string({ + char: 's', + helpGroup: 'Requires at least one of the index definition', + description: 'Index specification as a JSON object (e.g., \'{"name":1, "age":-1}\')', + multiple: true, + atLeastOne: ['key', 'spec'], + parse: input => asObject(input, 'Index specification') + }), + key: Flags.string({ + char: 'k', + helpGroup: 'Requires at least one of the index definition', + description: 'Index key to use with default specification', + multiple: true, + atLeastOne: ['key', 'spec'], + // Note: untrimmed whitespace input is allowed for index keys + parse: input => isNonEmptyString(input, 'Index key') + }), + name: Flags.string({ + char: 'n', + description: 'A name that uniquely identifies the index', + // Note: untrimmed whitespace input is allowed for index names + parse: input => isNonEmptyString(input, 'Index name') + }), + unique: Flags.boolean({ + char: 'u', + description: 'Creates a unique index so that the collection will not accept insertion or update of documents where the index key value matches an existing value in the index', + default: false + }) +} + +Create.aliases = ['app:db:idx:create'] diff --git a/src/commands/app/db/index/drop.js b/src/commands/app/db/index/drop.js new file mode 100644 index 0000000..1035c51 --- /dev/null +++ b/src/commands/app/db/index/drop.js @@ -0,0 +1,87 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +export class Drop extends DBBaseCommand { + async run () { + const { collection, indexName } = this.args + + try { + this.log(chalk.blue(`Dropping index '${indexName}' from collection '${collection}'...`)) + + const client = await this.db.connect() + const coll = await client.collection(collection) + + const result = await coll.dropIndex(indexName) + + this.debugLogger?.info?.('Index dropped successfully:', result) + + const response = { + collection, + indexName, + status: 'dropped', + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + result + } + + this.log(chalk.green(`Index '${indexName}' dropped successfully`)) + this.log(chalk.dim(` Details:\n${prettyJson(result)}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Dropped: ${new Date().toLocaleString()}`)) + + return response + } catch (error) { + this.debugLogger?.error?.('Error dropping index:', error) + + this.log(chalk.red('Failed to drop index')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Index: ${indexName}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(`Failed to drop index '${indexName}' from collection '${collection}': ${error.message}`) + } + } +} + +Drop.description = 'Drop an index from a collection in the database' + +Drop.examples = [ + '$ aio app db index drop users name_age_index', + '$ aio app db index drop products category_1 --json', + '$ aio app db idx drop orders orderDate_index' +] + +Drop.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to drop the index from', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }), + indexName: Args.string({ + name: 'indexName', + description: 'The name of the index to drop', + required: true, + parse: input => isNonEmptyString(input, 'Index name') + }) +} + +Drop.flags = DBBaseCommand.flags + +Drop.aliases = ['app:db:idx:drop'] diff --git a/src/commands/app/db/index/list.js b/src/commands/app/db/index/list.js new file mode 100644 index 0000000..c97aebe --- /dev/null +++ b/src/commands/app/db/index/list.js @@ -0,0 +1,82 @@ +/* +Copyright 2025 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 { Args } from '@oclif/core' +import chalk from 'chalk' +import { isNonEmptyString } from '../../../../utils/inputValidation.js' +import { prettyJson } from '../../../../utils/output.js' + +export class List extends DBBaseCommand { + async run () { + const { collection } = this.args + + try { + this.log(chalk.blue(`Getting indexes from collection '${collection}'...`)) + + const client = await this.db.connect() + const coll = await client.collection(collection) + + const result = await coll.getIndexes() + + this.debugLogger?.info?.('Indexes retrieved successfully:', result) + + const response = { + collection, + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + indexes: result + } + + this.log(chalk.green('Indexes retrieved successfully')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Retrieved: ${new Date().toLocaleString()}`)) + if (result && Array.isArray(result) && result.length > 0) { + this.log(chalk.dim(` Indexes:\n${prettyJson(result)}`)) + } else { + this.log(chalk.dim(' No indexes found for this collection')) + } + + return response + } catch (error) { + this.debugLogger?.error?.('Error getting indexes:', error) + + this.log(chalk.red('Failed to retrieve indexes')) + this.log(chalk.dim(` Collection: ${collection}`)) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(`Failed to retrieve indexes from collection '${collection}': ${error.message}`) + } + } +} + +List.description = 'Get the list of indexes from a collection in the database' + +List.examples = [ + '$ aio app db index list users', + '$ aio app db index list products --json', + '$ aio app db idx list orders' +] + +List.args = { + collection: Args.string({ + name: 'collection', + description: 'The name of the collection to retrieve indexes from', + required: true, + parse: input => isNonEmptyString(input, 'Collection name') + }) +} + +List.flags = DBBaseCommand.flags + +List.aliases = ['app:db:idx:list'] diff --git a/src/commands/app/db/ping.js b/src/commands/app/db/ping.js new file mode 100644 index 0000000..436e69e --- /dev/null +++ b/src/commands/app/db/ping.js @@ -0,0 +1,77 @@ +/* +Copyright 2025 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 () { + 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?.(`Database ping completed in ${responseTime}ms:`, 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, + response: pingResult, + timestamp: new Date().toISOString() + } + + 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 new file mode 100644 index 0000000..0dc35e8 --- /dev/null +++ b/src/commands/app/db/provision.js @@ -0,0 +1,190 @@ +/* +Copyright 2025 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' +import { DB_STATUS } from '../../../constants/db.js' +import { Flags } from '@oclif/core' + +export class Provision extends DBBaseCommand { + async run () { + const region = this.db.region + + 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() + const statusRegion = provisionStatusResponse.region + + 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: ${statusRegion}`)) + this.log(chalk.dim(` Status: ${currentStatus}`)) + + return { + status: 'already_provisioned', + namespace: this.rtNamespace, + details: provisionStatusResponse + } + } 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: ${statusRegion}`)) + 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, + details: provisionStatusResponse + } + } 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: ${statusRegion}`)) + 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', + namespace: this.rtNamespace, + 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 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')) + } + } + + // Create a new database if not yet provisioned + this.warn('Database provisioning will create new database resources') + + // Skip confirmation prompt if --yes flag is used + if (!this.flags.yes) { + // 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(`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}`)) + + const provisionResult = await this.db.provisionRequest() + this.debugLogger?.info?.('Provision request result:', provisionResult) + + // Handle different provision result statuses + const resultStatus = provisionResult?.status?.toUpperCase() || DB_STATUS.UNKNOWN + + if (resultStatus === DB_STATUS.PROVISIONED) { + this.log(chalk.green('Database provisioned successfully and ready for use!')) + } else if (resultStatus === DB_STATUS.REQUESTED) { + this.log(chalk.blue('Database provisioning request submitted successfully')) + 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 === 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 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 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.') + } + + const result = { + status: resultStatus.toLowerCase(), + namespace: this.rtNamespace, + timestamp: new Date().toISOString(), + details: provisionResult + } + + // If region was specified as a CLI flag, include it in the output for next steps + const { region: regionFlag } = this.flags + const regionFlagString = regionFlag ? ` --region ${regionFlag}` : '' + + if (resultStatus !== DB_STATUS.PROVISIONED) { + this.log(chalk.dim('\nNext steps:')) + this.log(chalk.dim(` - Monitor progress: aio app db status${regionFlagString} --watch`)) + this.log(chalk.dim(` - Check status: aio app db status${regionFlagString}`)) + } else { + this.log(chalk.dim('\nNext steps:')) + this.log(chalk.dim(` - Test connection: aio app db ping${regionFlagString}`)) + } + + 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', + '$ aio app db provision --yes' +] + +Provision.flags = { + ...DBBaseCommand.flags, + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt and provision automatically', + default: false + }) +} + +Provision.args = {} diff --git a/src/commands/app/db/stats.js b/src/commands/app/db/stats.js new file mode 100644 index 0000000..0bc8463 --- /dev/null +++ b/src/commands/app/db/stats.js @@ -0,0 +1,87 @@ +/* +Copyright 2025 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' +import { prettyJson } from '../../../utils/output.js' + +export class Stats extends DBBaseCommand { + async run () { + try { + this.log(chalk.blue('Fetching database statistics...')) + + const client = await this.db.connect() + const stats = await client.dbStats() + + this.debugLogger?.info?.('Database statistics retrieved:', stats) + + const result = { + ...stats, + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + + this.displayStats(stats) + + return result + } catch (error) { + this.debugLogger?.error?.('Stats command error:', error) + + this.log(chalk.red('Failed to retrieve database statistics')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(`Failed to fetch database statistics: ${error.message}`) + } + } + + displayStats (stats) { + this.log(chalk.green('Database Statistics:')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + + if (stats && typeof stats === 'object') { + // Format and display stats in a readable way + Object.entries(stats).forEach(([key, value]) => { + this.log(chalk.dim(` ${key}: ${this.formatValue(value)}`)) + }) + } else { + this.log(chalk.dim(` Raw Stats: ${this.formatValue(stats)}`)) + } + + this.log('') + this.log(chalk.dim(` Retrieved: ${new Date().toLocaleString()}`)) + } + + formatValue (value) { + if (typeof value === 'number') { + // Format large numbers with commas + return value.toLocaleString() + } + if (typeof value === 'object' && value !== null) { + return `\n${prettyJson(value)}` + } + return String(value) + } +} + +Stats.description = 'Get statistics about your App Builder database' + +Stats.examples = [ + '$ aio app db stats', + '$ aio app db stats --json' +] + +Stats.flags = { + ...DBBaseCommand.flags +} + +Stats.args = {} diff --git a/src/commands/app/db/status.js b/src/commands/app/db/status.js new file mode 100644 index 0000000..09dab87 --- /dev/null +++ b/src/commands/app/db/status.js @@ -0,0 +1,159 @@ +/* +Copyright 2025 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' +import { DB_STATUS } from '../../../constants/db.js' + +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) + + return { + ...provisionStatusResponse, + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + } catch (error) { + this.debugLogger?.error?.('Status command error:', error) + + if (error.httpStatusCode === 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}`)) + + return { + status: DB_STATUS.NOT_PROVISIONED, + namespace: this.rtNamespace, + timestamp: new Date().toISOString() + } + } + + this.error(`Failed to check database status: ${error.message}`) + } + } + + 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?.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.REQUESTED && currentStatus !== DB_STATUS.PROCESSING) { + this.log(chalk.dim('\nStopping 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.message) { + this.log(chalk.dim(` Message: ${provisionStatusResponse.message}`)) + } + + if (provisionStatusResponse.submitted) { + this.log(chalk.dim(` Submitted: ${new Date(provisionStatusResponse.submitted).toLocaleString()}`)) + } + + if (showTimestamp) { + this.log(chalk.dim(` Checked: ${new Date().toLocaleString()}`)) + } + } + + /* istanbul ignore next */ + getStatusColor (statusValue) { + const status = statusValue.toUpperCase() + switch (status) { + case DB_STATUS.PROVISIONED: + return chalk.green + case DB_STATUS.REQUESTED: + case DB_STATUS.PROCESSING: + return chalk.yellow + case DB_STATUS.FAILED: + case DB_STATUS.REJECTED: + return chalk.red + case DB_STATUS.NOT_PROVISIONED: + return chalk.blue + default: + return chalk.gray + } + } +} + +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 d32d466..c29a9d7 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) @@ -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/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/db.js b/src/constants/db.js new file mode 100644 index 0000000..a44aa53 --- /dev/null +++ b/src/constants/db.js @@ -0,0 +1,32 @@ +/* +Copyright 2025 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. +*/ + +export const DB_STATUS = { + PROVISIONED: 'PROVISIONED', + REQUESTED: 'REQUESTED', + PROCESSING: 'PROCESSING', + FAILED: 'FAILED', + REJECTED: 'REJECTED', + NOT_PROVISIONED: 'NOT_PROVISIONED', + DELETED: 'DELETED', + UNKNOWN: 'UNKNOWN' +} + +// Region constants for db are separate from state in case they diverge in the future +export const CONFIG_DB_REGION = 'db.region' +export const DEFAULT_REGION = 'amer' +export const AVAILABLE_REGIONS = { + prod: ['amer', 'emea', 'apac'], + stage: ['amer', 'amer2'] +} + +export const CONFIG_DB_ENDPOINT = 'db.endpoint' diff --git a/src/constants/global.js b/src/constants/global.js new file mode 100644 index 0000000..b9714f1 --- /dev/null +++ b/src/constants/global.js @@ -0,0 +1,14 @@ +/* +Copyright 2025 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. +*/ + +export const CONFIG_RUNTIME_NAMESPACE = 'runtime.namespace' +export const CONFIG_RUNTIME_AUTH = 'runtime.auth' diff --git a/src/constants.js b/src/constants/state.js similarity index 80% rename from src/constants.js rename to src/constants/state.js index 97fbe09..5880c13 100644 --- a/src/constants.js +++ b/src/constants/state.js @@ -11,6 +11,9 @@ governing permissions and limitations under the License. */ // state.region is a new configuration (env=AIO_STATE_REGION) +// Region constants for state are separate from db in case they diverge in the future export const CONFIG_STATE_REGION = 'state.region' +export const DEFAULT_REGION = 'amer' +export const AVAILABLE_REGIONS = ['amer', 'emea', 'apac'] export const DEFAULT_TTL_SECONDS = 60 * 60 * 24 // 24 hours diff --git a/src/utils/inputValidation.js b/src/utils/inputValidation.js new file mode 100644 index 0000000..aed3048 --- /dev/null +++ b/src/utils/inputValidation.js @@ -0,0 +1,74 @@ +/* +Copyright 2025 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. +*/ + +/** + * Helper to verify the argument/option input is a javascript or json object + * Returns the object if valid, or throws an error if invalid + * + * @param {object|string} input - The input to validate + * @param {string=} label - Optional label for an error message, such as the argument/option name + * @returns {object} - The validated object + */ +export function asObject (input, label = undefined) { + label = label ? `${label}: ` : '' + if (typeof input === 'object' && input !== null && !Array.isArray(input)) { + return input + } + if (typeof input !== 'string' || input.trim().length === 0) { + throw new Error(`${label}Value '${input}' is not a JSON object`) + } + + let result + try { + result = JSON.parse(input) + } catch (e) { + e.message = `${label}JSON parse error: ${e.message}` + throw e + } + if (typeof result !== 'object' || result === null || Array.isArray(result)) { + throw new Error(`${label}Value '${input}' is not a JSON object`) + } + + return result +} + +/** + * Helper to verify the argument/option input is a non-empty string + * Throws an error if the input is not a string with length > 0 + * Does not check for whitespace-only strings, call `trim()` when passing the input if needed + * + * @param {string} input - The input to validate + * @param {string=} label - Optional label for an error message, such as the argument/option name + * @returns {string} - The validated non-empty string + */ +export function isNonEmptyString (input, label = undefined) { + label = label ? `${label}: ` : '' + if (typeof input !== 'string' || input.length === 0) { + throw new Error(`${label}Must be a non-empty string`) + } + return input +} + +/** + * Determine if a runtime namespace corresponds to a production workspace. + * production if optional prefix + orgId + projectName with no trailing workspace suffix. + * + * @param {string} namespace - The runtime namespace to check + * @returns {boolean} - True if the namespace is a production workspace, false otherwise + */ +export function isProductionNamespace (namespace) { + if (typeof namespace !== 'string' || !namespace.trim()) { + throw new Error('Invalid runtime namespace') + } + const PROD_NS_REGEX = /^(?:development-)?\d+-[a-z0-9]+$/i + return PROD_NS_REGEX.test(namespace) +} diff --git a/src/utils/output.js b/src/utils/output.js new file mode 100644 index 0000000..31e52c5 --- /dev/null +++ b/src/utils/output.js @@ -0,0 +1,35 @@ +/* +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. +*/ + +/** + * Prettifies json for readable log output + * + * @param {*} val - The value to pretty print as JSON. + * @param {number} indent - The number of spaces to indent the entire output. + * @returns {string} - The pretty printed JSON string. + */ +export function prettyJson (val, indent = 5) { + let out + if (typeof val === 'string') { + try { + out = JSON.stringify(JSON.parse(val), null, 2) + } catch (e) { + out = val // If parsing fails, return the original string + } + } else { + out = JSON.stringify(val, null, 2) + } + if (indent) { + out = out.replace(/^/gm, ' '.repeat(indent)) + } + return out +} diff --git a/test/BaseCommand.test.js b/test/BaseCommand.test.js index a2c56b4..89b83fe 100644 --- a/test/BaseCommand.test.js +++ b/test/BaseCommand.test.js @@ -11,25 +11,26 @@ 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([]) }) test('flags', () => { - expect(Object.keys(BaseCommand.flags).sort()).toEqual(['region']) - expect(BaseCommand.flags.region.options).toEqual(['amer', 'emea', 'apac']) + expect(Object.keys(BaseCommand.flags).sort()).toEqual([]) 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 +38,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/DBBaseCommand.test.js b/test/DBBaseCommand.test.js new file mode 100644 index 0000000..c118af7 --- /dev/null +++ b/test/DBBaseCommand.test.js @@ -0,0 +1,244 @@ +/* +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' +import { AVAILABLE_REGIONS, DEFAULT_REGION } from '../src/constants/db.js' + +const mockInit = global.mockDBInit +const mockDbInstance = global.mockDBInstance + +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(['json', 'region']) + 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({}) + } + }) + + test('successful initialization', async () => { + command.argv = [] + await command.init() + + expect(mockInit).toHaveBeenCalledWith({ + ow: { + namespace: global.fakeConfig['runtime.namespace'], + auth: global.fakeConfig['runtime.auth'] + }, + region: DEFAULT_REGION + }) + expect(command.db).toBe(mockDbInstance) + expect(command.dbConfig).toBeDefined() + expect(command.rtNamespace).toBe(global.fakeConfig['runtime.namespace']) + }) + + test('initialization with custom region from config', async () => { + global.fakeConfig['db.region'] = 'emea' + command.argv = [] + 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(DEFAULT_REGION) + }) + + test('initialization with environment-specific region', async () => { + global.getCliEnvMock().mockReturnValue('stage') + command.argv = ['--region', 'amer2'] + await expect(command.init()).resolves.not.toThrow() + expect(command.dbConfig.region).toBe('amer2') + + global.getCliEnvMock().mockReturnValue('prod') + command.argv = ['--region', 'emea'] + await expect(command.init()).resolves.not.toThrow() + expect(command.dbConfig.region).toBe('emea') + }) + + test('initialization with environment-specific region fails in other environment', async () => { + global.getCliEnvMock().mockReturnValue('stage') + command.argv = ['--region', 'emea'] + await expect(command.init()).rejects.toThrow(`Invalid region 'emea' for the stage environment, must be one of: ${AVAILABLE_REGIONS.stage.join(', ')}`) + + global.getCliEnvMock().mockReturnValue('prod') + command.argv = ['--region', 'amer2'] + await expect(command.init()).rejects.toThrow(`Invalid region 'amer2' for the prod environment, must be one of: ${AVAILABLE_REGIONS.prod.join(', ')}`) + }) + + test('initialization with custom endpoint', async () => { + global.fakeConfig['db.endpoint'] = 'https://custom.endpoint.com' + command.argv = [] + await command.init() + + expect(process.env.AIO_DB_ENDPOINT).toBe('https://custom.endpoint.com') + }) + + 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() + } + }) + + test('successful DB client initialization', async () => { + 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 () => { + await command.initializeDBClient() + + expect(command.debugLogger.info).toHaveBeenCalledWith( + 'Initializing DB client with config:', + expect.objectContaining({ + namespace: global.fakeConfig['runtime.namespace'], + region: 'amer', + hasAuth: true + }) + ) + }) + + 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({}) + } + }) + + test('dbConfig contains expected properties', async () => { + const expectedConfig = { + ow: { + namespace: global.fakeConfig['runtime.namespace'], + auth: global.fakeConfig['runtime.auth'] + }, + region: 'amer' + } + + command.argv = [] + await command.init() + + expect(command.dbConfig).toEqual(expectedConfig) + expect(mockInit).toHaveBeenCalledWith(expectedConfig) + expect(process.env.AIO_DB_ENDPOINT).toBeUndefined() + }) + + test('dbConfig with all custom values', async () => { + global.fakeConfig['db.region'] = 'emea' + global.fakeConfig['db.endpoint'] = 'https://custom.db.com' + const expectedConfig = { + ow: { + namespace: global.fakeConfig['runtime.namespace'], + auth: global.fakeConfig['runtime.auth'] + }, + region: global.fakeConfig['db.region'] + } + + command.argv = [] + await command.init() + + expect(command.dbConfig).toEqual(expectedConfig) + expect(mockInit).toHaveBeenCalledWith(expectedConfig) + expect(process.env.AIO_DB_ENDPOINT).toBe('https://custom.db.com') + }) + + test('dbConfig uses region flag', async () => { + const expectedConfig = { + ow: { + namespace: global.fakeConfig['runtime.namespace'], + auth: global.fakeConfig['runtime.auth'] + }, + region: 'emea' + } + + command.argv = ['--region', 'emea'] + await command.init() + + expect(command.dbConfig).toEqual(expectedConfig) + expect(mockInit).toHaveBeenCalledWith(expectedConfig) + }) +}) diff --git a/test/StateBaseCommand.test.js b/test/StateBaseCommand.test.js new file mode 100644 index 0000000..e834103 --- /dev/null +++ b/test/StateBaseCommand.test.js @@ -0,0 +1,143 @@ +/* +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' +import { AVAILABLE_REGIONS, DEFAULT_REGION } from '../src/constants/state.js' + +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(AVAILABLE_REGIONS) + 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: DEFAULT_REGION, + 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/add/db.test.js b/test/commands/app/add/db.test.js new file mode 100644 index 0000000..37b0c8e --- /dev/null +++ b/test/commands/app/add/db.test.js @@ -0,0 +1,26 @@ +/* +Copyright 2025 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 { AddDb } from '../../../../src/commands/app/add/db.js' +import { Provision } from '../../../../src/commands/app/db/provision.js' +import { expect } from '@jest/globals' + +describe('prototype', () => { + test('extends Provision class', () => { + expect(AddDb.prototype instanceof Provision).toBe(true) + }) + + test('examples have updated syntax', () => { + const expected = Provision.examples.map(example => example.replace('$ aio app db provision', '$ aio app add db')) + expect(AddDb.examples).toEqual(expected) + }) +}) diff --git a/test/commands/app/db/collection/create.test.js b/test/commands/app/db/collection/create.test.js new file mode 100644 index 0000000..3846c17 --- /dev/null +++ b/test/commands/app/db/collection/create.test.js @@ -0,0 +1,342 @@ +/* +Copyright 2025 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 { CreateCollection } from '../../../../../src/commands/app/db/collection/create.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockListCollections = jest.fn() +const mockCreateCollection = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(CreateCollection.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(CreateCollection.args)).toEqual(['collection']) + expect(CreateCollection.args.collection.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['validator']).sort() + expect(Object.keys(CreateCollection.flags).sort()).toEqual(expectedFlags) + expect(CreateCollection.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new CreateCollection(['users']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockListCollections.mockReset() + mockCreateCollection.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + listCollections: mockListCollections, + createCollection: mockCreateCollection + }) + }) + + describe('successful collection creation', () => { + test('creates collection without --json flag', async () => { + command.argv = ['users'] + await command.init() + + // Mock no existing collections + mockListCollections.mockResolvedValue([]) + mockCreateCollection.mockResolvedValue({ ok: 1, info: 'Collection created' }) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockListCollections).toHaveBeenCalled() + expect(mockCreateCollection).toHaveBeenCalledWith('users', {}) + + expect(result).toEqual({ + collection: 'users', + status: 'created', + namespace: 'test-namespace', + timestamp: expect.any(String), + options: {} + }) + + expect(stdout.output).toContain("Creating collection 'users'...") + expect(stdout.output).toContain("Collection 'users' created successfully") + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Created:') + }) + + test('creates collection with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + // Mock no existing collections + mockListCollections.mockResolvedValue([]) + mockCreateCollection.mockResolvedValue({ ok: 1, info: 'Collection created' }) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'users', + status: 'created', + namespace: 'test-namespace', + timestamp: expect.any(String), + options: {} + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain("Creating collection 'users'...") + expect(stdout.output).not.toContain("Collection 'users' created successfully") + expect(stdout.output).not.toContain('Namespace:') + }) + + test('creates collection with minimal result', async () => { + command.argv = ['products'] + await command.init() + + // Mock no existing collections and minimal result + mockListCollections.mockResolvedValue([]) + mockCreateCollection.mockResolvedValue(null) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'products', + status: 'created', + namespace: 'test-namespace', + timestamp: expect.any(String), + options: {} + }) + + expect(stdout.output).toContain("Collection 'products' created successfully") + expect(stdout.output).not.toContain('Details:') + }) + + test('creates collection with validator flag', async () => { + command.argv = ['products', '--validator', '{"type": "object", "required": ["name"]}'] + await command.init() + + // Mock no existing collections + mockListCollections.mockResolvedValue([]) + mockCreateCollection.mockResolvedValue({ ok: 1, info: 'Collection created' }) + + const result = await command.run() + + expect(mockCreateCollection).toHaveBeenCalledWith('products', { + validator: { $jsonSchema: { type: 'object', required: ['name'] } } + }) + + expect(result).toEqual({ + collection: 'products', + status: 'created', + namespace: 'test-namespace', + timestamp: expect.any(String), + options: { validator: { $jsonSchema: { type: 'object', required: ['name'] } } } + }) + + expect(stdout.output).toContain("Collection 'products' created successfully") + expect(stdout.output).toContain('Validator: {"type":"object","required":["name"]}') + }) + }) + + describe('collection already exists', () => { + test('fails when collection exists without --json flag', async () => { + command.argv = ['users'] + await command.init() + + // Mock existing collection + mockListCollections.mockResolvedValue([ + { name: 'users', documentCount: 10 }, + { name: 'products', documentCount: 5 } + ]) + + await expect(command.run()).rejects.toThrow("Collection 'users' already exists") + + expect(mockCreateCollection).not.toHaveBeenCalled() + expect(stdout.output).toContain("Collection 'users' already exists") + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('fails when collection exists with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + // Mock existing collection + mockListCollections.mockResolvedValue([ + { name: 'users', documentCount: 10 } + ]) + + await expect(command.run()).rejects.toThrow("Collection 'users' already exists") + + expect(mockCreateCollection).not.toHaveBeenCalled() + // Should not show console messages with --json + expect(stdout.output).not.toContain("Collection 'users' already exists") + expect(stdout.output).not.toContain('Namespace:') + }) + }) + + describe('missing collection name', () => { + test('fails when collection name is missing', async () => { + command = new CreateCollection([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [] + + // oclif will throw validation error during init() for missing required args + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockListCollections).not.toHaveBeenCalled() + expect(mockCreateCollection).not.toHaveBeenCalled() + }) + + test('fails when collection name is empty string', async () => { + command = new CreateCollection(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockListCollections).not.toHaveBeenCalled() + expect(mockCreateCollection).not.toHaveBeenCalled() + }) + }) + + describe('flag validation', () => { + test('fails with invalid validator JSON', async () => { + command.argv = ['users', '--validator', 'invalid-json'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Validator: JSON parse error:') + + expect(mockListCollections).not.toHaveBeenCalled() + expect(mockCreateCollection).not.toHaveBeenCalled() + }) + + test('fails with empty validator JSON', async () => { + command.argv = ['users', '--validator', ''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('is not a JSON object') + + expect(mockListCollections).not.toHaveBeenCalled() + expect(mockCreateCollection).not.toHaveBeenCalled() + }) + + test('succeeds with valid JSON validator', async () => { + command.argv = ['users', '--validator', '{"type": "object"}'] + await command.init() + + // Mock no existing collections + mockListCollections.mockResolvedValue([]) + mockCreateCollection.mockResolvedValue({ ok: 1 }) + + const result = await command.run() + + expect(mockCreateCollection).toHaveBeenCalledWith('users', { + validator: { $jsonSchema: { type: 'object' } } + }) + expect(result.options.validator).toEqual({ $jsonSchema: { type: 'object' } }) + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to create collection 'users': Connection failed") + + expect(stdout.output).toContain('Failed to create collection') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('connection error with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to create collection 'users': Connection failed") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to create collection') + expect(stdout.output).not.toContain('Collection: users') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('listCollections error', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockResolvedValue({ + listCollections: mockListCollections, + createCollection: mockCreateCollection + }) + mockListCollections.mockRejectedValue(new Error('Query failed')) + + await expect(command.run()).rejects.toThrow("Failed to create collection 'users': Query failed") + + expect(stdout.output).toContain('Failed to create collection') + expect(stdout.output).toContain('Error: Query failed') + }) + + test('createCollection error', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockResolvedValue({ + listCollections: mockListCollections, + createCollection: mockCreateCollection + }) + mockListCollections.mockResolvedValue([]) + mockCreateCollection.mockRejectedValue(new Error('Creation failed')) + + await expect(command.run()).rejects.toThrow("Failed to create collection 'users': Creation failed") + + expect(stdout.output).toContain('Failed to create collection') + expect(stdout.output).toContain('Error: Creation failed') + }) + + test('authentication error', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('401 Unauthorized')) + + await expect(command.run()).rejects.toThrow("Failed to create collection 'users': 401 Unauthorized") + + expect(stdout.output).toContain('Failed to create collection') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/collection/drop.test.js b/test/commands/app/db/collection/drop.test.js new file mode 100644 index 0000000..249caf5 --- /dev/null +++ b/test/commands/app/db/collection/drop.test.js @@ -0,0 +1,248 @@ +/* +Copyright 2025 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 { DropCollection } from '../../../../../src/commands/app/db/collection/drop.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockDrop = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(DropCollection.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(DropCollection.args)).toEqual(['collection']) + expect(DropCollection.args.collection.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(DropCollection.flags).sort()).toEqual(expectedFlags) + expect(DropCollection.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new DropCollection(['users']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockDrop.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + // Mock the collection.drop() method + mockCollection.mockReturnValue({ + drop: mockDrop + }) + }) + + describe('successful collection drop', () => { + test('drops collection without --json flag', async () => { + command.argv = ['users'] + await command.init() + + // Mock collection drop response + mockDrop.mockResolvedValue({ ok: 1, info: 'Collection dropped' }) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith('users') + expect(mockDrop).toHaveBeenCalled() + + expect(result).toEqual({ + collection: 'users', + status: 'dropped', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: { ok: 1, info: 'Collection dropped' } + }) + + expect(stdout.output).toContain("Dropping collection 'users'...") + expect(stdout.output).toContain("Collection 'users' dropped successfully") + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Dropped:') + }) + + test('drops collection with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + // Mock collection drop response + mockDrop.mockResolvedValue({ ok: 1, info: 'Collection dropped' }) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'users', + status: 'dropped', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: { ok: 1, info: 'Collection dropped' } + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain("Dropping collection 'users'...") + expect(stdout.output).not.toContain("Collection 'users' dropped successfully") + expect(stdout.output).not.toContain('Namespace:') + }) + + test('drops collection with minimal result', async () => { + command.argv = ['products'] + await command.init() + + // Mock collection drop response with minimal result + mockDrop.mockResolvedValue(null) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'products', + status: 'dropped', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: null + }) + + expect(stdout.output).toContain("Collection 'products' dropped successfully") + expect(stdout.output).not.toContain('Details:') + }) + }) + + describe('missing collection name', () => { + test('fails when collection name is missing', async () => { + command = new DropCollection([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [] + + // oclif will throw validation error during init() for missing required args + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockDrop).not.toHaveBeenCalled() + }) + + test('fails when collection name is empty string', async () => { + command = new DropCollection(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockDrop).not.toHaveBeenCalled() + }) + }) + + describe('collection does not exist', () => { + test('fails when collection does not exist without --json flag', async () => { + command.argv = ['users'] + await command.init() + + // Mock drop method to throw an error for non-existent collection + mockDrop.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to drop collection 'users': Collection not found") + + expect(stdout.output).toContain('Failed to drop collection') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Collection not found') + }) + + test('fails when collection does not exist with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + // Mock drop method to throw an error for non-existent collection + mockDrop.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to drop collection 'users': Collection not found") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to drop collection') + expect(stdout.output).not.toContain('Collection: users') + expect(stdout.output).not.toContain('Namespace:') + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to drop collection 'users': Connection failed") + + expect(stdout.output).toContain('Failed to drop collection') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('connection error with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to drop collection 'users': Connection failed") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to drop collection') + expect(stdout.output).not.toContain('Collection: users') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('drop method error', async () => { + command.argv = ['users'] + await command.init() + + mockDrop.mockRejectedValue(new Error('Drop failed')) + + await expect(command.run()).rejects.toThrow("Failed to drop collection 'users': Drop failed") + + expect(stdout.output).toContain('Failed to drop collection') + expect(stdout.output).toContain('Error: Drop failed') + }) + + test('authentication error', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('401 Unauthorized')) + + await expect(command.run()).rejects.toThrow("Failed to drop collection 'users': 401 Unauthorized") + + expect(stdout.output).toContain('Failed to drop collection') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/collection/list.test.js b/test/commands/app/db/collection/list.test.js new file mode 100644 index 0000000..45dc92a --- /dev/null +++ b/test/commands/app/db/collection/list.test.js @@ -0,0 +1,215 @@ +/* +Copyright 2025 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 { List } from '../../../../../src/commands/app/db/collection/list.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockListCollections = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(List.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(List.args)).toEqual([]) + }) + test('flags', () => { + const expectedFlags = [...Object.keys(DBBaseCommand.flags), 'info'].sort() + expect(Object.keys(List.flags).sort()).toEqual(expectedFlags) + expect(List.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new List([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockListCollections.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + listCollections: mockListCollections + }) + }) + + describe('successful collection names retrieval', () => { + test('returns array of collection names', async () => { + command.argv = [] + await command.init() + + const collectionInfo = [ + { name: 'users', documentCount: 100 }, + { name: 'products', documentCount: 50 }, + { name: 'orders', documentCount: 200 } + ] + mockListCollections.mockResolvedValue(collectionInfo) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockListCollections).toHaveBeenCalled() + expect(result).toEqual(['users', 'products', 'orders']) + + expect(stdout.output).toContain('Fetching collections...') + expect(stdout.output).toContain('Collection Information:') + expect(stdout.output).toContain('Total Collections: 3') + expect(stdout.output).toContain('users') + expect(stdout.output).toContain('products') + expect(stdout.output).toContain('orders') + }) + + test('handles empty collection list', async () => { + command.argv = [] + await command.init() + + mockListCollections.mockResolvedValue([]) + + const result = await command.run() + + expect(result).toEqual([]) + expect(stdout.output).toContain('Collection Information:') + expect(stdout.output).toContain('No collections found') + }) + + test('json flag returns raw array', async () => { + command.argv = ['--json'] + await command.init() + + const collectionInfo = [ + { name: 'test1', documentCount: 10 }, + { name: 'test2', documentCount: 20 } + ] + mockListCollections.mockResolvedValue(collectionInfo) + + const result = await command.run() + + expect(result).toEqual(['test1', 'test2']) + // Should not show console messages with --json + expect(stdout.output).not.toContain('Fetching collection names...') + expect(stdout.output).not.toContain('Collection names:') + }) + }) + + describe('successful collection info retrieval with --info flag', () => { + test('returns full collection information without --json flag', async () => { + command.argv = ['--info'] + await command.init() + + const collectionInfo = [ + { name: 'users', idIndex: { key: { _id: 1 }, name: '_id_' }, info: { readOnly: false } }, + { name: 'products', idIndex: { key: { _sku: 1 }, name: '_sku_' }, info: { readOnly: false } }, + { name: 'orders', idIndex: { key: { _orderId: -1 }, name: '_orderId_' }, info: { readOnly: false } } + ] + mockListCollections.mockResolvedValue(collectionInfo) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockListCollections).toHaveBeenCalled() + expect(result).toEqual(collectionInfo) + + expect(stdout.output).toContain('Fetching collections...') + expect(stdout.output).toContain('Collection Information:') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Total Collections: 3') + expect(stdout.output).toContain('users') + expect(stdout.output).toContain('products') + expect(stdout.output).toContain('orders') + }) + + test('returns collection information with --json flag', async () => { + command.argv = ['--info', '--json'] + await command.init() + + const collectionInfo = [ + { name: 'users', idIndex: { key: { _id: 1 }, name: '_id_' }, info: { readOnly: false } }, + { name: 'products', idIndex: { key: { _sku: 1 }, name: '_sku_' }, info: { readOnly: false } } + ] + mockListCollections.mockResolvedValue(collectionInfo) + + const result = await command.run() + + expect(result).toEqual(collectionInfo) + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Fetching collection info...') + expect(stdout.output).not.toContain('Collection Information:') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('displays validator information when present', async () => { + command.argv = ['--info'] + await command.init() + + const collectionInfo = [ + { + name: 'users', + idIndex: { key: { _id: 1 }, name: '_id_' }, + info: { readOnly: false }, + options: { + validator: { $jsonSchema: { type: 'object' } }, + validationLevel: 'strict', + validationAction: 'error' + } + } + ] + mockListCollections.mockResolvedValue(collectionInfo) + + const result = await command.run() + + expect(result).toEqual(collectionInfo) + + expect(stdout.output).toContain('validator') + expect(stdout.output).toContain('validationLevel') + expect(stdout.output).toContain('validationAction') + }) + }) + + describe('error handling', () => { + test('connection error', async () => { + command.argv = [] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow('Failed to fetch collections: Connection failed') + + expect(stdout.output).toContain('Error fetching collections') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('listCollections error', async () => { + command.argv = [] + await command.init() + + global.mockDBInstance.connect.mockResolvedValue({ + listCollections: mockListCollections + }) + mockListCollections.mockRejectedValue(new Error('Query failed')) + + await expect(command.run()).rejects.toThrow('Failed to fetch collections: Query failed') + + expect(stdout.output).toContain('Error fetching collections') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Query failed') + }) + }) +}) diff --git a/test/commands/app/db/collection/rename.test.js b/test/commands/app/db/collection/rename.test.js new file mode 100644 index 0000000..545e377 --- /dev/null +++ b/test/commands/app/db/collection/rename.test.js @@ -0,0 +1,300 @@ +/* +Copyright 2025 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 { RenameCollection } from '../../../../../src/commands/app/db/collection/rename.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockRenameCollection = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(RenameCollection.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(RenameCollection.args)).toEqual(['currentName', 'newName']) + expect(RenameCollection.args.currentName.required).toBe(true) + expect(RenameCollection.args.newName.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(RenameCollection.flags).sort()).toEqual(expectedFlags) + expect(RenameCollection.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new RenameCollection(['users', 'customers']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockRenameCollection.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + // Mock the collection.renameCollection() method + mockCollection.mockReturnValue({ + renameCollection: mockRenameCollection + }) + }) + + describe('successful collection rename', () => { + test('renames collection without --json flag', async () => { + command.argv = ['users', 'customers'] + await command.init() + + // Mock collection rename response + mockRenameCollection.mockResolvedValue({ ok: 1, info: 'Collection renamed' }) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith('users') + expect(mockRenameCollection).toHaveBeenCalledWith('customers') + + expect(result).toEqual({ + currentName: 'users', + newName: 'customers', + status: 'renamed', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: { ok: 1, info: 'Collection renamed' } + }) + + expect(stdout.output).toContain("Renaming collection 'users' to 'customers'...") + expect(stdout.output).toContain("Collection 'users' renamed to 'customers' successfully") + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Renamed:') + }) + + test('renames collection with --json flag', async () => { + command.argv = ['users', 'customers', '--json'] + await command.init() + + // Mock collection rename response + mockRenameCollection.mockResolvedValue({ ok: 1, info: 'Collection renamed' }) + + const result = await command.run() + + expect(result).toEqual({ + currentName: 'users', + newName: 'customers', + status: 'renamed', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: { ok: 1, info: 'Collection renamed' } + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain("Renaming collection 'users' to 'customers'...") + expect(stdout.output).not.toContain("Collection 'users' renamed to 'customers' successfully") + expect(stdout.output).not.toContain('Namespace:') + }) + + test('renames collection with minimal result', async () => { + command.argv = ['products', 'items'] + await command.init() + + // Mock collection rename response with minimal result + mockRenameCollection.mockResolvedValue(null) + + const result = await command.run() + + expect(result).toEqual({ + currentName: 'products', + newName: 'items', + status: 'renamed', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: null + }) + + expect(stdout.output).toContain("Collection 'products' renamed to 'items' successfully") + expect(stdout.output).not.toContain('Details:') + }) + }) + + describe('required argument validation', () => { + test('fails when one argument is missing', async () => { + command = new RenameCollection([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['collectionName'] + + // oclif will throw validation error during init() for missing required args + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockRenameCollection).not.toHaveBeenCalled() + }) + + test('fails when source collection name is empty string', async () => { + command = new RenameCollection(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['', 'newCollectionName'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Current collection name: Must be a non-empty string') + + expect(mockRenameCollection).not.toHaveBeenCalled() + }) + + test('fails when destination collection name is empty string', async () => { + command = new RenameCollection(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['sourceCollectionName', ''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('New collection name: Must be a non-empty string') + + expect(mockRenameCollection).not.toHaveBeenCalled() + }) + }) + + describe('rename errors', () => { + test('fails when current collection does not exist without --json flag', async () => { + command.argv = ['users', 'customers'] + await command.init() + + // Mock renameCollection method to throw an error for non-existent collection + mockRenameCollection.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Collection not found") + + expect(stdout.output).toContain('Failed to rename collection') + expect(stdout.output).toContain('Current: users') + expect(stdout.output).toContain('New: customers') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Collection not found') + }) + + test('fails when current collection does not exist with --json flag', async () => { + command.argv = ['users', 'customers', '--json'] + await command.init() + + // Mock renameCollection method to throw an error for non-existent collection + mockRenameCollection.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Collection not found") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to rename collection') + expect(stdout.output).not.toContain('Current: users') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('fails when new collection name already exists without --json flag', async () => { + command.argv = ['users', 'products'] + await command.init() + + // Mock renameCollection method to throw an error for existing target collection + mockRenameCollection.mockRejectedValue(new Error('Target collection already exists')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Target collection already exists") + + expect(stdout.output).toContain('Failed to rename collection') + expect(stdout.output).toContain('Current: users') + expect(stdout.output).toContain('New: products') + expect(stdout.output).toContain('Error: Target collection already exists') + }) + + test('fails when new collection name already exists with --json flag', async () => { + command.argv = ['users', 'products', '--json'] + await command.init() + + // Mock renameCollection method to throw an error for existing target collection + mockRenameCollection.mockRejectedValue(new Error('Target collection already exists')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Target collection already exists") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to rename collection') + expect(stdout.output).not.toContain('Current: users') + expect(stdout.output).not.toContain('Namespace:') + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = ['users', 'customers'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Connection failed") + + expect(stdout.output).toContain('Failed to rename collection') + expect(stdout.output).toContain('Current: users') + expect(stdout.output).toContain('New: customers') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('connection error with --json flag', async () => { + command.argv = ['users', 'customers', '--json'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Connection failed") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to rename collection') + expect(stdout.output).not.toContain('Current: users') + expect(stdout.output).not.toContain('New: customers') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('renameCollection method error', async () => { + command.argv = ['users', 'customers'] + await command.init() + + mockRenameCollection.mockRejectedValue(new Error('Rename failed')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': Rename failed") + + expect(stdout.output).toContain('Failed to rename collection') + expect(stdout.output).toContain('Error: Rename failed') + }) + + test('authentication error', async () => { + command.argv = ['users', 'customers'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('401 Unauthorized')) + + await expect(command.run()).rejects.toThrow("Failed to rename collection 'users': 401 Unauthorized") + + expect(stdout.output).toContain('Failed to rename collection') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/collection/stats.test.js b/test/commands/app/db/collection/stats.test.js new file mode 100644 index 0000000..2bad37c --- /dev/null +++ b/test/commands/app/db/collection/stats.test.js @@ -0,0 +1,329 @@ +/* +Copyright 2025 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 { StatsCollection } from '../../../../../src/commands/app/db/collection/stats.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockStats = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(StatsCollection.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(StatsCollection.args)).toEqual(['collection']) + expect(StatsCollection.args.collection.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(StatsCollection.flags).sort()).toEqual(expectedFlags) + expect(StatsCollection.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new StatsCollection(['users']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockStats.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + // Mock the collection.stats() method + mockCollection.mockReturnValue({ + stats: mockStats + }) + }) + + describe('successful stats retrieval', () => { + test('gets collection stats without --json flag', async () => { + command.argv = ['users'] + await command.init() + + // Mock collection stats response + mockStats.mockResolvedValue({ + documentCount: 10, + documents: 10, + size: 1024, + indexes: 2, + indexSizes: { + _id_: 512, + otherIndex: 512 + }, + avgDocumentSize: 102.4 + }) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith('users') + expect(mockStats).toHaveBeenCalled() + + expect(result).toEqual({ + collection: 'users', + stats: { + documentCount: 10, + documents: 10, + size: 1024, + indexes: 2, + indexSizes: { + _id_: 512, + otherIndex: 512 + }, + avgDocumentSize: 102.4 + }, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain("Getting stats for collection 'users'...") + expect(stdout.output).toContain("Stats for collection 'users':") + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('documents: 10') + expect(stdout.output).toContain('size: 1024') + expect(stdout.output).toContain('indexes: 2') + expect(stdout.output).toContain('"_id_": 512') + expect(stdout.output).toContain('"otherIndex": 512') + expect(stdout.output).toContain('avgDocumentSize: 102.4') + expect(stdout.output).toContain('Retrieved:') + }) + + test('gets collection stats with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + // Mock collection stats response + mockStats.mockResolvedValue({ + documentCount: 10, + documents: 10, + size: 1024, + indexes: 2 + }) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'users', + stats: { + documentCount: 10, + documents: 10, + size: 1024, + indexes: 2 + }, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain("Getting stats for collection 'users'...") + expect(stdout.output).not.toContain("Stats for collection 'users':") + expect(stdout.output).not.toContain('Namespace:') + }) + + test('gets collection stats with minimal stats', async () => { + command.argv = ['products'] + await command.init() + + // Mock collection stats response with minimal stats + mockStats.mockResolvedValue({ + documentCount: 5, + documents: 5 + }) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'products', + stats: { + documentCount: 5, + documents: 5 + }, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain("Stats for collection 'products':") + expect(stdout.output).toContain('documents: 5') + }) + + test('gets collection stats with empty stats', async () => { + command.argv = ['empty'] + await command.init() + + // Mock collection stats response with empty stats + mockStats.mockResolvedValue({ + documentCount: 0 + }) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'empty', + stats: { + documentCount: 0 + }, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain("Stats for collection 'empty':") + // Should not show individual stats entries for empty object + expect(stdout.output).not.toContain('documents:') + }) + }) + + describe('missing collection name', () => { + test('fails when collection name is missing', async () => { + command = new StatsCollection([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [] + + // oclif will throw validation error during init() for missing required args + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockStats).not.toHaveBeenCalled() + }) + + test('fails when collection name is empty string', async () => { + command = new StatsCollection(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockStats).not.toHaveBeenCalled() + }) + }) + + describe('collection does not exist', () => { + test('fails when collection does not exist without --json flag', async () => { + command.argv = ['users'] + await command.init() + + // Mock stats method to throw an error for non-existent collection + mockStats.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to get stats for collection 'users': Collection not found") + + expect(stdout.output).toContain('Failed to get collection stats') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Collection not found') + }) + + test('fails when collection does not exist with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + // Mock stats method to throw an error for non-existent collection + mockStats.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to get stats for collection 'users': Collection not found") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to get collection stats') + expect(stdout.output).not.toContain('Collection: users') + expect(stdout.output).not.toContain('Namespace:') + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to get stats for collection 'users': Connection failed") + + expect(stdout.output).toContain('Failed to get collection stats') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('connection error with --json flag', async () => { + command.argv = ['users', '--json'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to get stats for collection 'users': Connection failed") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to get collection stats') + expect(stdout.output).not.toContain('Collection: users') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('stats method error', async () => { + command.argv = ['users'] + await command.init() + + mockStats.mockRejectedValue(new Error('Query failed')) + + await expect(command.run()).rejects.toThrow("Failed to get stats for collection 'users': Query failed") + + expect(stdout.output).toContain('Failed to get collection stats') + expect(stdout.output).toContain('Error: Query failed') + }) + + test('collection data processing error', async () => { + command.argv = ['users'] + await command.init() + + // Mock stats method to return valid data + mockStats.mockResolvedValue({ + documentCount: 10 + }) + + // This should work fine now since we just extract the collection data + const result = await command.run() + + expect(result.stats).toEqual({ + documentCount: 10 + }) + }) + + test('authentication error', async () => { + command.argv = ['users'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('401 Unauthorized')) + + await expect(command.run()).rejects.toThrow("Failed to get stats for collection 'users': 401 Unauthorized") + + expect(stdout.output).toContain('Failed to get collection stats') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/delete.test.js b/test/commands/app/db/delete.test.js new file mode 100644 index 0000000..9301949 --- /dev/null +++ b/test/commands/app/db/delete.test.js @@ -0,0 +1,148 @@ +/* +Copyright 2025 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, test } from '@jest/globals' +import { stdout, stderr } from 'stdout-stderr' +import { DeleteDb } from '../../../../src/commands/app/db/delete.js' +import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' +import { DB_STATUS } from '../../../../src/constants/db.js' + +// Use the global DB mock +const mockDB = global.mockDBInstance + +// Mock inquirer confirm and input +const mockConfirm = jest.fn() +const mockInput = jest.fn() +jest.unstable_mockModule('@inquirer/prompts', () => ({ + confirm: mockConfirm, + input: mockInput +})) + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(DeleteDb.prototype instanceof DBBaseCommand).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['force']).sort() + expect(Object.keys(DeleteDb.flags).sort()).toEqual(expectedFlags) + expect(DeleteDb.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new DeleteDb([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockConfirm.mockReset() + }) + + test('should delete provisioned database with confirmation', async () => { + command.argv = [] + await command.init() + + mockInput.mockResolvedValue('test-namespace') + mockDB.deleteDatabase.mockResolvedValue({ status: DB_STATUS.DELETED }) + + const result = await command.run() + expect(mockInput).toHaveBeenCalled() + expect(mockDB.deleteDatabase).toHaveBeenCalled() + expect(result.status).toBe('DELETED') + expect(stdout.output).toContain('Database deleted successfully') + }) + + test.each([ + { apiStatus: DB_STATUS.NOT_PROVISIONED, expectedStatus: 'not_provisioned' }, + { apiStatus: DB_STATUS.REQUESTED, expectedStatus: 'requested' } + ])('should warn and return if api returns status other than DELETED', async ({ apiStatus, expectedStatus }) => { + command.argv = [] + await command.init() + + mockInput.mockResolvedValue('test-namespace') + mockDB.deleteDatabase.mockResolvedValue({ status: apiStatus }) + + const result = await command.run() + expect(result.status).toBe(expectedStatus.toUpperCase()) + expect(stderr.output).toContain(`Delete request returned status '${apiStatus}'`) + }) + + test('should cancel deletion when user does not confirm', async () => { + command.argv = [] + await command.init() + + mockInput.mockResolvedValue('wrong-namespace') + + await expect(command.run()).rejects.toThrow('confirmation did not match, aborted') + expect(mockDB.deleteDatabase).not.toHaveBeenCalled() + }) + + test('should skip confirmation when --force is provided', async () => { + command.argv = ['--force'] + await command.init() + + mockInput.mockReset() + mockDB.deleteDatabase.mockResolvedValue({ status: DB_STATUS.DELETED }) + + const result = await command.run() + expect(mockInput).not.toHaveBeenCalled() + expect(mockDB.deleteDatabase).toHaveBeenCalled() + expect(result.status).toBe('DELETED') + }) + + test('should abort when typed confirmation does not match', async () => { + command.argv = [] + await command.init() + + mockConfirm.mockResolvedValue(true) + mockInput.mockResolvedValue('wrong-namespace') + + await expect(command.run()).rejects.toThrow('confirmation did not match, aborted') + expect(mockDB.deleteDatabase).not.toHaveBeenCalled() + }) + + test('should block production namespace', async () => { + command.argv = [] + // set production-like namespace + global.fakeConfig['runtime.namespace'] = '12345-prodapp' + await command.init() + + await expect(command.run()).rejects.toThrow('A production database may not be deleted directly') + }) + + test('should surface error from catch', async () => { + command.argv = [] + await command.init() + + mockConfirm.mockResolvedValue(true) + mockInput.mockResolvedValue('test-namespace') + mockDB.deleteDatabase.mockRejectedValue(new Error('Network error')) + + await expect(command.run()).rejects.toThrow('Database deletion failed: Network error') + }) + + test('should return unknown status when deleteDatabase returns undefined', async () => { + command.argv = [] + await command.init() + + mockConfirm.mockResolvedValue(true) + mockInput.mockResolvedValue('test-namespace') + // deleteDatabase returns undefined + mockDB.deleteDatabase.mockResolvedValue(undefined) + + const result = await command.run() + expect(result.status).toBe('UNKNOWN') + expect(stderr.output).toContain("Delete request returned status 'UNKNOWN'") + }) +}) diff --git a/test/commands/app/db/document/count.test.js b/test/commands/app/db/document/count.test.js new file mode 100644 index 0000000..4003225 --- /dev/null +++ b/test/commands/app/db/document/count.test.js @@ -0,0 +1,219 @@ +/* +Copyright 2025 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 { Count } from '../../../../../src/commands/app/db/document/count.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +describe('CountDocuments', () => { + let command + let mockConnect + let mockCollection + let mockClient + let mockCountDocuments + + beforeEach(() => { + command = new Count(['users']) + command.config = { runHook: jest.fn().mockResolvedValue({}) } + command.db = global.mockDBInstance + command.rtNamespace = 'test-namespace' + command.debugLogger = { info: jest.fn(), error: jest.fn() } + + // Setup mocks + mockCountDocuments = jest.fn() + mockCollection = { + countDocuments: mockCountDocuments + } + mockClient = { + collection: jest.fn().mockReturnValue(mockCollection) + } + mockConnect = global.mockDBInstance.connect + + // Reset mocks + mockCountDocuments.mockReset() + mockCollection.countDocuments = mockCountDocuments + mockClient.collection.mockClear() + mockConnect.mockClear() + mockConnect.mockResolvedValue(mockClient) + }) + + describe('command structure', () => { + test('has correct description', () => { + expect(Count.description).toBe('Count documents in a collection') + }) + + test('has correct examples', () => { + expect(Count.examples).toEqual([ + '$ aio app db document countDocuments users', + '$ aio app db document countDocuments users \'{"age": {"$gte": 21}}\'', + '$ aio app db document countDocuments products \'{"category": "electronics"}\' --json', + '$ aio app db doc count orders \'{"status": "shipped"}\'' + ]) + }) + + test('has correct args', () => { + expect(Count.args.collection.required).toBe(true) + expect(Count.args.collection.description).toBe('The name of the collection') + expect(Count.args.query.required).toBe(false) + expect(Count.args.query.description).toBe('The query filter document (JSON string). If not provided, counts all documents.') + }) + + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(Count.flags).sort()).toEqual(expectedFlags) + expect(Count.enableJsonFlag).toEqual(true) + }) + }) + + describe('successful count', () => { + test('counts documents successfully', async () => { + await command.init() + + const documentCount = 150 + mockCountDocuments.mockResolvedValue(documentCount) + + const result = await command.run() + + expect(mockConnect).toHaveBeenCalled() + expect(mockClient.collection).toHaveBeenCalledWith('users') + expect(mockCountDocuments).toHaveBeenCalledWith({}, {}) + + expect(result).toEqual({ + collection: 'users', + query: {}, + count: documentCount, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + }) + + test('counts documents with query filter', async () => { + command = new Count(['users', '{"age": {"$gte": 21}}']) + command.config = { runHook: jest.fn().mockResolvedValue({}) } + command.db = global.mockDBInstance + command.rtNamespace = 'test-namespace' + command.debugLogger = { info: jest.fn(), error: jest.fn() } + + await command.init() + + const documentCount = 75 + mockCountDocuments.mockResolvedValue(documentCount) + + const result = await command.run() + + expect(mockCountDocuments).toHaveBeenCalledWith({ age: { $gte: 21 } }, {}) + + expect(result).toEqual({ + collection: 'users', + query: { age: { $gte: 21 } }, + count: documentCount, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + }) + + test('handles zero count result', async () => { + await command.init() + + const documentCount = 0 + mockCountDocuments.mockResolvedValue(documentCount) + + const result = await command.run() + + expect(result.count).toBe(0) + }) + }) + + describe('error handling', () => { + test('handles database connection error', async () => { + await command.init() + + const connectionError = new Error('Connection failed') + mockConnect.mockRejectedValue(connectionError) + + await expect(command.run()).rejects.toThrow('Connection failed') + }) + + test('handles collection countDocuments error', async () => { + await command.init() + + const countError = new Error('Count operation failed') + mockCountDocuments.mockRejectedValue(countError) + + await expect(command.run()).rejects.toThrow('Count operation failed') + }) + + test('handles invalid query JSON', async () => { + command.argv = ['users', 'invalid-json'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Query: JSON parse error') + expect(mockCountDocuments).not.toHaveBeenCalled() + }) + }) + + describe('output formatting', () => { + test('displays success message with count', async () => { + stdout.start() + await command.init() + + mockCountDocuments.mockResolvedValue(150) + + await command.run() + + stdout.stop() + expect(stdout.output).toContain('Found 150 document(s) in collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('displays query filter when provided', async () => { + stdout.start() + command = new Count(['users', '{"age": {"$gte": 21}}']) + command.config = { runHook: jest.fn().mockResolvedValue({}) } + command.db = global.mockDBInstance + command.rtNamespace = 'test-namespace' + command.debugLogger = { info: jest.fn(), error: jest.fn() } + + await command.init() + + mockCountDocuments.mockResolvedValue(75) + + await command.run() + + stdout.stop() + expect(stdout.output).toContain('Using query filter: {"age":{"$gte":21}}') + expect(stdout.output).toContain('Query filter applied: Yes') + }) + + test('displays error message on failure', async () => { + stdout.start() + await command.init() + + const countError = new Error('Count operation failed') + mockCountDocuments.mockRejectedValue(countError) + + try { + await command.run() + } catch (error) { + // Expected to throw + } + + stdout.stop() + expect(stdout.output).toContain('Failed to count documents') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + }) +}) diff --git a/test/commands/app/db/document/delete.test.js b/test/commands/app/db/document/delete.test.js new file mode 100644 index 0000000..097fed2 --- /dev/null +++ b/test/commands/app/db/document/delete.test.js @@ -0,0 +1,330 @@ +/* +Copyright 2025 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 { Delete } from '../../../../../src/commands/app/db/document/delete.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock and set up collection methods +const mockDeleteOne = jest.fn() +const mockCollection = { + deleteOne: mockDeleteOne +} +const mockClient = { + collection: jest.fn().mockReturnValue(mockCollection) +} +const mockConnect = global.mockDBInstance.connect + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Delete.prototype instanceof DBBaseCommand).toBe(true) + }) + + test('args', () => { + expect(Object.keys(Delete.args)).toEqual(['collection', 'filter']) + expect(Delete.args.collection.required).toBe(true) + expect(Delete.args.filter.required).toBe(true) + }) + + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(Delete.flags).sort()).toEqual(expectedFlags) + expect(Delete.enableJsonFlag).toEqual(true) + }) + + test('description', () => { + expect(Delete.description).toBe('Delete a single document from a collection') + }) + + test('examples', () => { + expect(Delete.examples).toBeDefined() + expect(Delete.examples.length).toBeGreaterThan(0) + }) +}) + +describe('run', () => { + let command + + beforeEach(async () => { + command = new Delete(['users', '{"name": "John"}']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.db = global.mockDBInstance + command.rtNamespace = 'test-namespace' + command.debugLogger = { + info: jest.fn(), + error: jest.fn() + } + + // Reset mocks + mockDeleteOne.mockReset() + mockCollection.deleteOne = mockDeleteOne + mockClient.collection.mockClear() + mockConnect.mockClear() + mockConnect.mockResolvedValue(mockClient) + }) + + describe('successful deletion', () => { + test('deletes document successfully', async () => { + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(mockConnect).toHaveBeenCalled() + expect(mockClient.collection).toHaveBeenCalledWith('users') + expect(mockDeleteOne).toHaveBeenCalledWith({ name: 'John' }) + + expect(result).toEqual({ + collection: 'users', + filter: { name: 'John' }, + deletedCount: 1, + acknowledged: true, + namespace: 'test-namespace', + timestamp: expect.any(String), + result: deleteResult + }) + + expect(stdout.output).toContain('Deleting document from collection \'users\'...') + expect(stdout.output).toContain('Document deleted successfully from collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Deleted: 1') + }) + + test('handles no document found', async () => { + await command.init() + + const deleteResult = { + deletedCount: 0, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(result.deletedCount).toBe(0) + expect(stdout.output).toContain('No document found in collection \'users\' matching the filter') + }) + + test('deletes with complex filter', async () => { + const complexFilter = { + $and: [ + { age: { $gte: 18 } }, + { status: 'active' } + ] + } + + command.argv = ['users', JSON.stringify(complexFilter)] + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(mockDeleteOne).toHaveBeenCalledWith(complexFilter) + expect(result.filter).toEqual(complexFilter) + }) + }) + + describe('error handling', () => { + test('handles invalid JSON filter', async () => { + command.argv = ['users', '{"invalid": json}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockDeleteOne).not.toHaveBeenCalled() + }) + + test('handles database connection error', async () => { + await command.init() + + const error = new Error('Database connection failed') + mockConnect.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to delete document from collection \'users\': Database connection failed') + + expect(stdout.output).toContain('Failed to delete document') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('handles delete operation error', async () => { + await command.init() + + const error = new Error('Delete operation failed') + mockDeleteOne.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to delete document from collection \'users\': Delete operation failed') + + expect(stdout.output).toContain('Failed to delete document') + }) + + test('handles permission error', async () => { + await command.init() + + const error = new Error('Insufficient permissions') + mockDeleteOne.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to delete document from collection \'users\': Insufficient permissions') + }) + }) + + describe('JSON parsing', () => { + test('parses simple JSON filter', async () => { + command.argv = ['products', '{"category": "electronics"}'] + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(result.filter).toEqual({ category: 'electronics' }) + }) + + test('parses JSON with operators', async () => { + const operatorFilter = { + price: { $lt: 100 }, + stock: { $gt: 0 } + } + + command.argv = ['products', JSON.stringify(operatorFilter)] + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(result.filter).toEqual(operatorFilter) + }) + + test('handles malformed JSON', async () => { + command.argv = ['users', '{"name": "John", "age":}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockDeleteOne).not.toHaveBeenCalled() + }) + + test('handles empty JSON object', async () => { + command.argv = ['users', '{}'] + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(result.filter).toEqual({}) + }) + }) + + describe('console output', () => { + test('displays proper console output for successful deletion', async () => { + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + await command.run() + + expect(stdout.output).toContain('Deleting document from collection \'users\'...') + expect(stdout.output).toContain('Document deleted successfully from collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Deleted: 1') + expect(stdout.output).toContain('Deleted:') + }) + + test('displays proper console output when no document found', async () => { + await command.init() + + const deleteResult = { + deletedCount: 0, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + await command.run() + + expect(stdout.output).toContain('No document found in collection \'users\' matching the filter') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + }) + + describe('JSON flag', () => { + test('suppresses console output with --json flag', async () => { + command.argv = ['users', '{"name": "John"}', '--json'] + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + const result = await command.run() + + expect(result.deletedCount).toBe(1) + // Should not show console messages with --json + expect(stdout.output).not.toContain('Deleting document from collection') + expect(stdout.output).not.toContain('Document deleted successfully') + }) + }) + + describe('timestamp', () => { + test('includes ISO timestamp in result', async () => { + await command.init() + + const deleteResult = { + deletedCount: 1, + acknowledged: true + } + mockDeleteOne.mockResolvedValue(deleteResult) + + 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/document/find.test.js b/test/commands/app/db/document/find.test.js new file mode 100644 index 0000000..df907cb --- /dev/null +++ b/test/commands/app/db/document/find.test.js @@ -0,0 +1,311 @@ +/* +Copyright 2025 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 { Find } from '../../../../../src/commands/app/db/document/find.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock and set up collection methods +const mockFindArray = jest.fn() +const mockCollection = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Find.prototype instanceof DBBaseCommand).toBe(true) + }) + + test('args', () => { + expect(Object.keys(Find.args)).toEqual(['collection', 'filter']) + expect(Find.args.collection.required).toBe(true) + expect(Find.args.filter.required).toBe(true) + }) + + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['skip', 'limit', 'sort', 'projection']).sort() + expect(Object.keys(Find.flags).sort()).toEqual(expectedFlags) + expect(Find.enableJsonFlag).toEqual(true) + }) + + test('examples', () => { + expect(Find.examples).toBeDefined() + expect(Find.examples.length).toBeGreaterThan(0) + }) +}) + +describe('run', () => { + let command + + beforeEach(async () => { + command = new Find(['users', '{"email":{"$regex":"example\\\\.com$"}}']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + mockCollection.mockReset() + mockFindArray.mockReset() + + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + mockCollection.mockReturnValue({ + findArray: mockFindArray + }) + }) + + describe('successful find', () => { + test('displays proper console output for successful find with no flags', async () => { + command.argv = ['users', '{"name": "John"}'] + await command.init() + + const found = [{ + _id: '507f1f77bcf86cd799439013', + name: 'John', + age: 30 + }] + mockFindArray.mockResolvedValue(found) + + await command.run() + + expect(stdout.output).toContain('Finding documents in collection \'users\'...') + expect(stdout.output).toMatch(/Filter:\n *\{\n +"name": "John"\n *\}/) + expect(stdout.output).toContain('Limit: 20') // Default limit + expect(stdout.output).not.toContain('Skip:') + expect(stdout.output).not.toContain('Sort:') + expect(stdout.output).not.toContain('Projection:') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Retrieved 1 document(s) from collection \'users\'') + expect(stdout.output).toContain('Results:') + expect(stdout.output).toContain('"name": "John"') + expect(stdout.output).toContain('Searched:') + }) + + test('displays proper console output for successful find with multiple results', async () => { + command.argv = ['users', '{"name": "John"}'] + await command.init() + + const found = [ + { _id: '507f1f77bcf86cd799439014', name: 'John', age: 30 }, + { _id: '507f1f77bcf86cd799439015', name: 'Jim', age: 31 } + ] + mockFindArray.mockResolvedValue(found) + + await command.run() + + expect(stdout.output).toContain('Finding documents in collection \'users\'...') + expect(stdout.output).toContain('Retrieved 2 document(s) from collection \'users\'') + expect(stdout.output).toContain('Results:') + expect(stdout.output).toContain('"name": "John"') + expect(stdout.output).toContain('"name": "Jim"') + }) + + test('displays proper console output when no document found', async () => { + await command.init() + + mockFindArray.mockResolvedValue([]) + + await command.run() + + expect(stdout.output).toContain('No documents matching the filter criteria found in collection \'users\'.') + expect(stdout.output).not.toContain('Results:') + }) + + test('displays flag information when used', async () => { + command.argv = [ + 'users', + '{"name": "John"}', + '--limit', '10', + '--skip', '5', + '--sort', '{"age":-1}', + '--projection', '{"name": 1}' + ] + await command.init() + + const found = [{ name: 'John' }] + mockFindArray.mockResolvedValue(found) + + await command.run() + + expect(stdout.output).toContain('Limit: 10') + expect(stdout.output).toContain('Skip: 5') + expect(stdout.output).toMatch(/Sort:\n *\{\n +"age": -1\n *\}/) + expect(stdout.output).toMatch(/Projection:\n *\{\n +"name": 1\n *\}/) + }) + }) + + describe('error handling', () => { + test('handles missing collection name', async () => { + command.argv = ['', '{}'] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + expect(mockFindArray).not.toHaveBeenCalled() + }) + + test('handles missing filter argument', async () => { + command.argv = ['users'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Missing 1 required arg') + expect(mockFindArray).not.toHaveBeenCalled() + }) + + test('handles invalid JSON filter', async () => { + command.argv = ['users', '{"invalid": json}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockFindArray).not.toHaveBeenCalled() + }) + + test('handles invalid JSON projection', async () => { + command.argv = ['users', '{"name": "John"}', '--projection', '{"invalid": json}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockFindArray).not.toHaveBeenCalled() + }) + + test('handles invalid sort flag', async () => { + command.argv = ['users', '{"name": "John"}', '--sort', 'invalid'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockFindArray).not.toHaveBeenCalled() + }) + + test('handles invalid limit value', async () => { + command.argv = ['users', '{"name": "John"}', '--limit', '200'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Expected an integer less than or equal to') + expect(mockFindArray).not.toHaveBeenCalled() + }) + + test('handles database connection error', async () => { + command.argv = ['users', '{"name": "John"}'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Database connection failed')) + + await expect(command.run()).rejects.toThrow('Failed to find documents in collection \'users\': Database connection failed') + + expect(stdout.output).toContain('Failed to find documents') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + }) + + describe('JSON parsing', () => { + test('parses simple JSON filter', async () => { + command.argv = ['products', '{"category": "electronics"}'] + await command.init() + + const found = [{ _id: '1', name: 'Laptop', category: 'electronics' }] + mockFindArray.mockResolvedValue(found) + + const result = await command.run() + + expect(result.filter).toEqual({ category: 'electronics' }) + }) + + test('parses JSON with operators', async () => { + const operatorFilter = { + price: { $lt: 100 }, + stock: { $gt: 0 } + } + + command.argv = ['products', JSON.stringify(operatorFilter)] + await command.init() + + const found = [{ _id: '1', name: 'Widget', price: 50, stock: 10 }] + mockFindArray.mockResolvedValue(found) + + const result = await command.run() + + expect(result.filter).toEqual(operatorFilter) + }) + + test('parses projection JSON', async () => { + const projection = { name: 1, price: 1, _id: 0 } + + command.argv = ['products', '{"name": "Widget"}', '--projection', JSON.stringify(projection)] + await command.init() + + const found = [{ name: 'Widget', price: 50 }] + mockFindArray.mockResolvedValue(found) + + const result = await command.run() + + expect(result.options.projection).toEqual(projection) + }) + + test('handles empty JSON objects', async () => { + command.argv = ['users', '{}'] + await command.init() + + const found = [{ _id: '1', name: 'John' }] + mockFindArray.mockResolvedValue(found) + + const result = await command.run() + + expect(result.filter).toEqual({}) + }) + }) + + describe('JSON flag', () => { + test('suppresses console output with --json flag', async () => { + command.argv = ['users', '{"name": "John"}', '--json'] + await command.init() + + const found = [{ _id: '1', name: 'John' }] + mockFindArray.mockResolvedValue(found) + + const result = await command.run() + + expect(result.results).toEqual(found) + // Should not show console messages with --json + expect(stdout.output).not.toContain('Finding documents in collection') + expect(stdout.output).not.toContain('Results:') + }) + }) + + describe('timestamp', () => { + test('includes ISO timestamp in result', async () => { + await command.init() + + const found = [{ _id: '1', name: 'John' }] + mockFindArray.mockResolvedValue(found) + + 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/document/insert.test.js b/test/commands/app/db/document/insert.test.js new file mode 100644 index 0000000..19bbbba --- /dev/null +++ b/test/commands/app/db/document/insert.test.js @@ -0,0 +1,448 @@ +/* +Copyright 2025 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 { Insert } from '../../../../../src/commands/app/db/document/insert.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockInsertMany = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Insert.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Insert.args)).toEqual(['collection', 'documents']) + expect(Insert.args.collection.required).toBe(true) + expect(Insert.args.documents.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['bypassDocumentValidation']).sort() + expect(Object.keys(Insert.flags).sort()).toEqual(expectedFlags) + expect(Insert.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new Insert(['users', '[{"name": "John", "age": 30}]']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockInsertMany.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + mockCollection.mockResolvedValue({ + insertMany: mockInsertMany + }) + }) + + describe('successful document insertion', () => { + test('inserts array of documents without --json flag', async () => { + command.argv = ['users', '[{"name": "John", "age": 30}, {"name": "Jane", "age": 25}]'] + await command.init() + + const mockResult = { + insertedCount: 2, + insertedIds: { 0: 'id1', 1: 'id2' }, + acknowledged: true + } + mockInsertMany.mockResolvedValue(mockResult) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith('users') + expect(mockInsertMany).toHaveBeenCalledWith([ + { name: 'John', age: 30 }, + { name: 'Jane', age: 25 } + ], {}) + + expect(result).toEqual({ + collection: 'users', + status: 'inserted', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: mockResult + }) + + expect(stdout.output).toContain("Inserting 2 documents into collection 'users'...") + expect(stdout.output).toContain("Successfully inserted 2 documents into collection 'users'") + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Inserted IDs: {"0":"id1","1":"id2"}') + expect(stdout.output).toContain('Inserted:') + }) + + test('inserts single document object without --json flag', async () => { + command.argv = ['users', '{"name": "John", "age": 30}'] + await command.init() + + const mockResult = { + insertedCount: 1, + insertedIds: { 0: 'id1' }, + acknowledged: true + } + mockInsertMany.mockResolvedValue(mockResult) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith('users') + expect(mockInsertMany).toHaveBeenCalledWith([ + { name: 'John', age: 30 } + ], {}) + + expect(result).toEqual({ + collection: 'users', + status: 'inserted', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: mockResult + }) + + expect(stdout.output).toContain("Inserting 1 documents into collection 'users'...") + expect(stdout.output).toContain("Successfully inserted 1 documents into collection 'users'") + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Inserted IDs: {"0":"id1"}') + expect(stdout.output).toContain('Inserted:') + }) + + test('inserts documents with --json flag', async () => { + command.argv = ['products', '[{"id": 1, "name": "Product A"}]', '--json'] + await command.init() + + const mockResult = { + insertedCount: 1, + insertedIds: { 0: 'id1' }, + acknowledged: true + } + mockInsertMany.mockResolvedValue(mockResult) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'products', + status: 'inserted', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: mockResult + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain("Inserting 1 documents into collection 'products'...") + expect(stdout.output).not.toContain("Successfully inserted 1 documents into collection 'products'") + expect(stdout.output).not.toContain('Collection: products') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('inserts documents with minimal result', async () => { + command.argv = ['logs', '[{"level": "info", "message": "test"}]'] + await command.init() + + const mockResult = { + insertedCount: 1 + } + mockInsertMany.mockResolvedValue(mockResult) + + const result = await command.run() + + expect(result).toEqual({ + collection: 'logs', + status: 'inserted', + namespace: 'test-namespace', + timestamp: expect.any(String), + result: mockResult + }) + + expect(stdout.output).toContain("Successfully inserted 1 documents into collection 'logs'") + expect(stdout.output).not.toContain('Inserted IDs:') + }) + + test('inserts documents with bypassDocumentValidation flag', async () => { + command.argv = ['temp', '[{"data": "test"}]', '--bypassDocumentValidation'] + await command.init() + + const mockResult = { + insertedCount: 1, + insertedIds: { 0: 'id1' }, + acknowledged: true + } + mockInsertMany.mockResolvedValue(mockResult) + + const result = await command.run() + + expect(mockInsertMany).toHaveBeenCalledWith( + [{ data: 'test' }], + { bypassDocumentValidation: true } + ) + + expect(result).toEqual({ + collection: 'temp', + status: 'inserted', + namespace: 'test-namespace', + timestamp: expect.any(String), + options: { bypassDocumentValidation: true }, + result: mockResult + }) + + expect(stdout.output).toContain("Successfully inserted 1 documents into collection 'temp'") + expect(stdout.output).toContain('Bypassing document validation') + }) + + test('inserts documents with bypassDocumentValidation flag only', async () => { + command.argv = ['bulk', '[{"field": "value"}]', '--bypassDocumentValidation'] + await command.init() + + const mockResult = { + insertedCount: 1, + insertedIds: { 0: 'id1' }, + acknowledged: true + } + mockInsertMany.mockResolvedValue(mockResult) + + const result = await command.run() + + expect(mockInsertMany).toHaveBeenCalledWith( + [{ field: 'value' }], + { bypassDocumentValidation: true } + ) + + expect(result).toEqual({ + collection: 'bulk', + status: 'inserted', + namespace: 'test-namespace', + timestamp: expect.any(String), + options: { bypassDocumentValidation: true }, + result: mockResult + }) + + expect(stdout.output).toContain("Successfully inserted 1 documents into collection 'bulk'") + expect(stdout.output).toContain('Bypassing document validation') + }) + }) + + describe('argument validation', () => { + test('fails when collection name is missing', async () => { + command = new Insert([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [] + + await expect(command.init()).rejects.toThrow('Missing 2 required args') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents argument is missing', async () => { + command = new Insert(['users']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['users'] + + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when collection name is empty string', async () => { + command = new Insert(['', '[{"name": "John"}]']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['', '[{"name": "John"}]'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents is empty string', async () => { + command.argv = ['users', ''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: Must be a JSON string representing an object or non-empty array') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents is not valid JSON', async () => { + command.argv = ['users', '["John"'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: JSON parse error:') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents is not a JSON object or array', async () => { + command.argv = ['users', 'null'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: Must be a JSON string representing an object or non-empty array') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents array is empty', async () => { + command.argv = ['users', '[]'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: Cannot be empty') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents array contains non-objects', async () => { + command.argv = ['users', '[{"name": "John"}, "not an object"]'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: Element at index 1 must be an object') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents array contains null', async () => { + command.argv = ['users', '[{"name": "John"}, null]'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: Element at index 1 must be an object') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + + test('fails when documents array contains nested arrays', async () => { + command.argv = ['users', '[{"name": "John"}, [1, 2, 3]]'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Documents: Element at index 1 must be an object') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockInsertMany).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = ['users', '[{"name": "John"}]'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to insert documents into collection 'users': Connection failed") + + expect(stdout.output).toContain('Failed to insert documents') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('connection error with --json flag', async () => { + command.argv = ['users', '[{"name": "John"}]', '--json'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to insert documents into collection 'users': Connection failed") + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to insert documents') + expect(stdout.output).not.toContain('Collection: users') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('collection error', async () => { + command.argv = ['users', '[{"name": "John"}]'] + await command.init() + + global.mockDBInstance.connect.mockResolvedValue({ + collection: mockCollection + }) + mockCollection.mockRejectedValue(new Error('Collection not found')) + + await expect(command.run()).rejects.toThrow("Failed to insert documents into collection 'users': Collection not found") + + expect(stdout.output).toContain('Failed to insert documents') + expect(stdout.output).toContain('Error: Collection not found') + }) + + test('insert error', async () => { + command.argv = ['users', '[{"name": "John"}]'] + await command.init() + + global.mockDBInstance.connect.mockResolvedValue({ + collection: mockCollection + }) + mockCollection.mockResolvedValue({ + insertMany: mockInsertMany + }) + mockInsertMany.mockRejectedValue(new Error('Insert failed')) + + await expect(command.run()).rejects.toThrow("Failed to insert documents into collection 'users': Insert failed") + + expect(stdout.output).toContain('Failed to insert documents') + expect(stdout.output).toContain('Error: Insert failed') + }) + + test('authentication error', async () => { + command.argv = ['users', '[{"name": "John"}]'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('401 Unauthorized')) + + await expect(command.run()).rejects.toThrow("Failed to insert documents into collection 'users': 401 Unauthorized") + + expect(stdout.output).toContain('Failed to insert documents') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/document/replace.test.js b/test/commands/app/db/document/replace.test.js new file mode 100644 index 0000000..a6ee6d0 --- /dev/null +++ b/test/commands/app/db/document/replace.test.js @@ -0,0 +1,470 @@ +/* +Copyright 2025 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 { Replace } from '../../../../../src/commands/app/db/document/replace.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock and set up collection methods +const mockReplaceOne = jest.fn() +const mockCollection = { + replaceOne: mockReplaceOne +} +const mockClient = { + collection: jest.fn().mockReturnValue(mockCollection) +} +const mockConnect = global.mockDBInstance.connect + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Replace.prototype instanceof DBBaseCommand).toBe(true) + }) + + test('args', () => { + expect(Object.keys(Replace.args)).toEqual(['collection', 'filter', 'replacement']) + expect(Replace.args.collection.required).toBe(true) + expect(Replace.args.filter.required).toBe(true) + expect(Replace.args.replacement.required).toBe(true) + }) + + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['upsert']).sort() + expect(Object.keys(Replace.flags).sort()).toEqual(expectedFlags) + expect(Replace.flags.upsert.char).toBe('u') + expect(Replace.flags.upsert.default).toBe(false) + expect(Replace.enableJsonFlag).toEqual(true) + }) + + test('description', () => { + expect(Replace.description).toBe('Replace a single document in a collection') + }) + + test('examples', () => { + expect(Replace.examples).toBeDefined() + expect(Replace.examples.length).toBeGreaterThan(0) + }) +}) + +describe('run', () => { + let command + + beforeEach(async () => { + command = new Replace(['users', '{"name": "John"}', '{"name": "John Doe", "age": 30, "status": "active"}']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.db = global.mockDBInstance + command.rtNamespace = 'test-namespace' + command.debugLogger = { + info: jest.fn(), + error: jest.fn() + } + + // Reset mocks + mockReplaceOne.mockReset() + mockCollection.replaceOne = mockReplaceOne + mockClient.collection.mockClear() + mockConnect.mockClear() + mockConnect.mockResolvedValue(mockClient) + }) + + describe('successful replacement', () => { + test('replaces document successfully', async () => { + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(mockConnect).toHaveBeenCalled() + expect(mockClient.collection).toHaveBeenCalledWith('users') + expect(mockReplaceOne).toHaveBeenCalledWith( + { name: 'John' }, + { name: 'John Doe', age: 30, status: 'active' }, + {} + ) + + expect(result).toEqual({ + collection: 'users', + filter: { name: 'John' }, + replacement: { name: 'John Doe', age: 30, status: 'active' }, + namespace: 'test-namespace', + timestamp: expect.any(String), + result: replaceResult + }) + + expect(stdout.output).toContain('Replacing document in collection \'users\'...') + expect(stdout.output).toContain('Document replaced successfully in collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('replaces with upsert flag', async () => { + command.argv = ['users', '{"name": "John"}', '{"name": "John Doe", "age": 30}', '--upsert'] + await command.init() + + const replaceResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: '507f1f77bcf86cd799439011', + upsertedCount: 1 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(mockReplaceOne).toHaveBeenCalledWith( + { name: 'John' }, + { name: 'John Doe', age: 30 }, + { upsert: true } + ) + + expect(result.result.upsertedId).toBe('507f1f77bcf86cd799439011') + expect(result.result.upsertedCount).toBe(1) + expect(stdout.output).toContain('Upsert enabled: Will create document if not found') + expect(stdout.output).toContain('Document created (upserted) in collection \'users\'') + expect(stdout.output).toContain('Upserted ID: 507f1f77bcf86cd799439011') + expect(stdout.output).toContain('Upserted count: 1') + }) + + test('handles no document found without upsert', async () => { + await command.init() + + const replaceResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(result.result.matchedCount).toBe(0) + expect(result.result.modifiedCount).toBe(0) + expect(stdout.output).toContain('No document found in collection \'users\' matching the filter') + }) + + test('replaces with complex filter and replacement', async () => { + const complexFilter = { + $and: [ + { age: { $gte: 18 } }, + { status: 'active' } + ] + } + const complexReplacement = { + name: 'Jane Doe', + age: 25, + status: 'premium', + joinDate: '2024-01-01T00:00:00.000Z', + preferences: { + theme: 'dark', + notifications: true + } + } + + command.argv = ['users', JSON.stringify(complexFilter), JSON.stringify(complexReplacement)] + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(mockReplaceOne).toHaveBeenCalledWith(complexFilter, complexReplacement, {}) + expect(result.filter).toEqual(complexFilter) + expect(result.replacement).toEqual(complexReplacement) + }) + }) + + describe('error handling', () => { + test('handles invalid JSON filter', async () => { + command.argv = ['users', '{"invalid": json}', '{"name": "John"}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockReplaceOne).not.toHaveBeenCalled() + }) + + test('handles invalid JSON replacement', async () => { + command.argv = ['users', '{"name": "John"}', '{"invalid": json}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockReplaceOne).not.toHaveBeenCalled() + }) + + test('handles database connection error', async () => { + await command.init() + + const error = new Error('Database connection failed') + mockConnect.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to replace document in collection \'users\': Database connection failed') + + expect(stdout.output).toContain('Failed to replace document') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('handles replace operation error', async () => { + await command.init() + + const error = new Error('Replace operation failed') + mockReplaceOne.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to replace document in collection \'users\': Replace operation failed') + + expect(stdout.output).toContain('Failed to replace document') + }) + + test('handles validation error', async () => { + await command.init() + + const error = new Error('Document validation failed') + mockReplaceOne.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to replace document in collection \'users\': Document validation failed') + }) + }) + + describe('JSON parsing', () => { + test('parses simple JSON filter and replacement', async () => { + command.argv = ['products', '{"category": "electronics"}', '{"name": "New Product", "category": "electronics", "price": 99.99}'] + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(result.filter).toEqual({ category: 'electronics' }) + expect(result.replacement).toEqual({ name: 'New Product', category: 'electronics', price: 99.99 }) + }) + + test('parses JSON with nested objects', async () => { + const nestedReplacement = { + name: 'Updated Product', + specs: { + cpu: 'Intel i7', + ram: '16GB', + storage: '1TB SSD' + }, + features: ['wireless', 'bluetooth', 'touchscreen'] + } + + command.argv = ['products', '{"_id": "123"}', JSON.stringify(nestedReplacement)] + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(result.replacement).toEqual(nestedReplacement) + }) + + test('handles malformed JSON filter', async () => { + command.argv = ['users', '{"name": "John", "age":}', '{"name": "John"}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockReplaceOne).not.toHaveBeenCalled() + }) + + test('handles malformed JSON replacement', async () => { + command.argv = ['users', '{"name": "John"}', '{"name": "John", "age":}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockReplaceOne).not.toHaveBeenCalled() + }) + + test('handles empty JSON objects', async () => { + command.argv = ['users', '{}', '{"name": "Default User"}'] + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(result.filter).toEqual({}) + expect(result.replacement).toEqual({ name: 'Default User' }) + }) + }) + + describe('console output', () => { + test('displays proper console output for successful replacement', async () => { + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + await command.run() + + expect(stdout.output).toContain('Replacing document in collection \'users\'...') + expect(stdout.output).toContain('Document replaced successfully in collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Replaced:') + }) + + test('displays proper console output for upsert', async () => { + command.argv = ['users', '{"name": "John"}', '{"name": "John Doe", "age": 30}', '--upsert'] + await command.init() + + const replaceResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: '507f1f77bcf86cd799439011', + upsertedCount: 1 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + await command.run() + + expect(stdout.output).toContain('Upsert enabled: Will create document if not found') + expect(stdout.output).toContain('Document created (upserted) in collection \'users\'') + expect(stdout.output).toContain('Upserted ID: 507f1f77bcf86cd799439011') + expect(stdout.output).toContain('Upserted count: 1') + }) + + test('displays proper console output when no document found', async () => { + await command.init() + + const replaceResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + await command.run() + + expect(stdout.output).toContain('No document found in collection \'users\' matching the filter') + }) + + test('does not display upsert message when flag is not used', async () => { + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + await command.run() + + expect(stdout.output).not.toContain('Upsert enabled') + }) + }) + + describe('JSON flag', () => { + test('suppresses console output with --json flag', async () => { + command.argv = ['users', '{"name": "John"}', '{"name": "John Doe", "age": 30}', '--json'] + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + const result = await command.run() + + expect(result.result.matchedCount).toBe(1) + expect(result.result.modifiedCount).toBe(1) + // Should not show console messages with --json + expect(stdout.output).not.toContain('Replacing document in collection') + expect(stdout.output).not.toContain('Document replaced successfully') + }) + }) + + describe('timestamp', () => { + test('includes ISO timestamp in result', async () => { + await command.init() + + const replaceResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockReplaceOne.mockResolvedValue(replaceResult) + + 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/document/update.test.js b/test/commands/app/db/document/update.test.js new file mode 100644 index 0000000..88f2d9c --- /dev/null +++ b/test/commands/app/db/document/update.test.js @@ -0,0 +1,520 @@ +/* +Copyright 2025 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 { Update } from '../../../../../src/commands/app/db/document/update.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock and set up collection methods +const mockUpdateOne = jest.fn() +const mockUpdateMany = jest.fn() +const mockCollection = { + updateOne: mockUpdateOne, + updateMany: mockUpdateMany +} +const mockClient = { + collection: jest.fn().mockReturnValue(mockCollection) +} +const mockConnect = global.mockDBInstance.connect + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Update.prototype instanceof DBBaseCommand).toBe(true) + }) + + test('args', () => { + expect(Object.keys(Update.args)).toEqual(['collection', 'filter', 'update']) + expect(Update.args.collection.required).toBe(true) + expect(Update.args.filter.required).toBe(true) + expect(Update.args.update.required).toBe(true) + }) + + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['upsert', 'many']).sort() + expect(Object.keys(Update.flags).sort()).toEqual(expectedFlags) + expect(Update.flags.upsert.char).toBe('u') + expect(Update.flags.upsert.default).toBe(false) + expect(Update.flags.many.char).toBe('m') + expect(Update.flags.many.default).toBe(false) + expect(Update.enableJsonFlag).toEqual(true) + }) + + test('description', () => { + expect(Update.description).toBe('Update document(s) in a collection') + }) + + test('examples', () => { + expect(Update.examples).toBeDefined() + expect(Update.examples.length).toBeGreaterThan(0) + }) +}) + +describe('run', () => { + let command + + beforeEach(async () => { + command = new Update(['users', '{"name": "John"}', '{"$set": {"age": 31}}']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.db = global.mockDBInstance + command.rtNamespace = 'test-namespace' + command.debugLogger = { + info: jest.fn(), + error: jest.fn() + } + + // Reset mocks + mockUpdateOne.mockReset() + mockCollection.updateOne = mockUpdateOne + mockUpdateMany.mockReset() + mockCollection.updateMany = mockUpdateMany + mockClient.collection.mockClear() + mockConnect.mockClear() + mockConnect.mockResolvedValue(mockClient) + }) + + describe('successful update', () => { + test('updates single document successfully', async () => { + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(mockConnect).toHaveBeenCalled() + expect(mockClient.collection).toHaveBeenCalledWith('users') + expect(mockUpdateOne).toHaveBeenCalledWith({ name: 'John' }, { $set: { age: 31 } }, {}) + expect(mockUpdateMany).not.toHaveBeenCalled() + + expect(result).toEqual({ + collection: 'users', + filter: { name: 'John' }, + update: { $set: { age: 31 } }, + namespace: 'test-namespace', + timestamp: expect.any(String), + result: updateResult + }) + + expect(stdout.output).toContain('Updating document in collection \'users\'...') + expect(stdout.output).toContain('Document updated successfully in collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('updates many documents successfully', async () => { + command.argv = ['users', '{"name": "John"}', '{"$set": {"age": 31}}', '--many'] + await command.init() + + const updateResult = { + matchedCount: 3, + modifiedCount: 3, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + // const error = new Error('Testing updateMany call') + mockUpdateMany.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(mockUpdateMany).toHaveBeenCalledWith({ name: 'John' }, { $set: { age: 31 } }, {}) + expect(mockUpdateOne).not.toHaveBeenCalled() + + expect(result.result.matchedCount).toBe(3) + expect(result.result.modifiedCount).toBe(3) + + expect(stdout.output).toContain('Updating document(s) in collection \'users\'...') + expect(stdout.output).toContain('Document(s) updated successfully in collection \'users\'') + }) + + test('document found but no update necessary', async () => { + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 0, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(mockUpdateOne).toHaveBeenCalledWith({ name: 'John' }, { $set: { age: 31 } }, {}) + expect(mockUpdateMany).not.toHaveBeenCalled() + expect(result.result.matchedCount).toBe(1) + expect(result.result.modifiedCount).toBe(0) + + expect(stdout.output).toContain('1 matching document found in collection \'users\', but no update was necessary') + }) + + test('updates with upsert flag', async () => { + command.argv = ['users', '{"name": "John"}', '{"$set": {"age": 31}}', '--upsert'] + await command.init() + + const updateResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: '507f1f77bcf86cd799439011', + upsertedCount: 1 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(mockUpdateOne).toHaveBeenCalledWith( + { name: 'John' }, + { $set: { age: 31 } }, + { upsert: true } + ) + expect(mockUpdateMany).not.toHaveBeenCalled() + + expect(result.result.upsertedId).toBe('507f1f77bcf86cd799439011') + expect(result.result.upsertedCount).toBe(1) + expect(stdout.output).toContain('Upsert enabled: Will create document if not found') + expect(stdout.output).toContain('Document created (upserted) in collection \'users\'') + expect(stdout.output).toContain('Upserted ID: 507f1f77bcf86cd799439011') + expect(stdout.output).toContain('Upserted count: 1') + }) + + test('handles no document found without upsert', async () => { + await command.init() + + const updateResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(result.result.matchedCount).toBe(0) + expect(result.result.modifiedCount).toBe(0) + expect(stdout.output).toContain('No document found in collection \'users\' matching the filter') + }) + + test('updates with complex filter and update', async () => { + const complexFilter = { + $and: [ + { age: { $gte: 18 } }, + { status: 'active' } + ] + } + const complexUpdate = { + $set: { lastLogin: '2024-01-01T00:00:00.000Z' }, + $inc: { loginCount: 1 } + } + + command.argv = ['users', JSON.stringify(complexFilter), JSON.stringify(complexUpdate)] + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(mockUpdateOne).toHaveBeenCalledWith(complexFilter, complexUpdate, {}) + expect(mockUpdateMany).not.toHaveBeenCalled() + expect(result.filter).toEqual(complexFilter) + expect(result.update).toEqual(complexUpdate) + }) + }) + + describe('error handling', () => { + test('handles invalid JSON filter', async () => { + command.argv = ['users', '{"invalid": json}', '{"$set": {"age": 31}}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockUpdateOne).not.toHaveBeenCalled() + expect(mockUpdateMany).not.toHaveBeenCalled() + }) + + test('handles invalid JSON update', async () => { + command.argv = ['users', '{"name": "John"}', '{"$set": invalid}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockUpdateOne).not.toHaveBeenCalled() + expect(mockUpdateMany).not.toHaveBeenCalled() + }) + + test('handles database connection error', async () => { + await command.init() + + const error = new Error('Database connection failed') + mockConnect.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to update document in collection \'users\': Database connection failed') + + expect(stdout.output).toContain('Failed to update document') + expect(stdout.output).toContain('Collection: users') + expect(stdout.output).toContain('Namespace: test-namespace') + }) + + test('handles update operation error', async () => { + await command.init() + + const error = new Error('Update operation failed') + mockUpdateOne.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to update document in collection \'users\': Update operation failed') + expect(mockUpdateMany).not.toHaveBeenCalled() + + expect(stdout.output).toContain('Failed to update document') + }) + + test('handles validation error', async () => { + await command.init() + + const error = new Error('Document validation failed') + mockUpdateOne.mockRejectedValue(error) + + await expect(command.run()).rejects.toThrow('Failed to update document in collection \'users\': Document validation failed') + expect(mockUpdateMany).not.toHaveBeenCalled() + }) + }) + + describe('JSON parsing', () => { + test('parses simple JSON filter and update', async () => { + command.argv = ['products', '{"category": "electronics"}', '{"$set": {"discount": 0.1}}'] + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(result.filter).toEqual({ category: 'electronics' }) + expect(result.update).toEqual({ $set: { discount: 0.1 } }) + }) + + test('parses JSON with various update operators', async () => { + const updateOps = { + $set: { name: 'Updated Name' }, + $inc: { count: 1 }, + $unset: { oldField: '' }, + $push: { tags: 'new-tag' } + } + + command.argv = ['products', '{"_id": "123"}', JSON.stringify(updateOps)] + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(result.update).toEqual(updateOps) + }) + + test('handles malformed JSON filter', async () => { + command.argv = ['users', '{"name": "John", "age":}', '{"$set": {"age": 31}}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockUpdateOne).not.toHaveBeenCalled() + expect(mockUpdateMany).not.toHaveBeenCalled() + }) + + test('handles malformed JSON update', async () => { + command.argv = ['users', '{"name": "John"}', '{"$set": {"age":}}'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockUpdateOne).not.toHaveBeenCalled() + expect(mockUpdateMany).not.toHaveBeenCalled() + }) + + test('handles empty JSON objects', async () => { + command.argv = ['users', '{}', '{"$set": {"updated": true}}'] + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(result.filter).toEqual({}) + expect(result.update).toEqual({ $set: { updated: true } }) + }) + }) + + describe('console output', () => { + test('displays proper console output for successful update', async () => { + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + await command.run() + + expect(stdout.output).toContain('Updating document in collection \'users\'...') + expect(stdout.output).toContain('Document updated successfully in collection \'users\'') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Updated:') + }) + + test('displays proper console output for upsert', async () => { + command.argv = ['users', '{"name": "John"}', '{"$set": {"age": 31}}', '--upsert'] + await command.init() + + const updateResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: '507f1f77bcf86cd799439011', + upsertedCount: 1 + } + mockUpdateOne.mockResolvedValue(updateResult) + + await command.run() + + expect(stdout.output).toContain('Upsert enabled: Will create document if not found') + expect(stdout.output).toContain('Document created (upserted) in collection \'users\'') + expect(stdout.output).toContain('Upserted ID: 507f1f77bcf86cd799439011') + expect(stdout.output).toContain('Upserted count: 1') + }) + + test('displays proper console output when no document found', async () => { + await command.init() + + const updateResult = { + matchedCount: 0, + modifiedCount: 0, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + await command.run() + + expect(stdout.output).toContain('No document found in collection \'users\' matching the filter') + }) + + test('does not display upsert message when flag is not used', async () => { + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + await command.run() + + expect(stdout.output).not.toContain('Upsert enabled') + }) + }) + + describe('JSON flag', () => { + test('suppresses console output with --json flag', async () => { + command.argv = ['users', '{"name": "John"}', '{"$set": {"age": 31}}', '--json'] + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + const result = await command.run() + + expect(result.result.matchedCount).toBe(1) + expect(result.result.modifiedCount).toBe(1) + // Should not show console messages with --json + expect(stdout.output).not.toContain('Updating document in collection') + expect(stdout.output).not.toContain('Document updated successfully') + }) + }) + + describe('timestamp', () => { + test('includes ISO timestamp in result', async () => { + await command.init() + + const updateResult = { + matchedCount: 1, + modifiedCount: 1, + acknowledged: true, + upsertedId: null, + upsertedCount: 0 + } + mockUpdateOne.mockResolvedValue(updateResult) + + 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/index/create.test.js b/test/commands/app/db/index/create.test.js new file mode 100644 index 0000000..9de75b0 --- /dev/null +++ b/test/commands/app/db/index/create.test.js @@ -0,0 +1,392 @@ +/* +Copyright 2025 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 { Create } from '../../../../../src/commands/app/db/index/create.js' +import { expect, jest, test } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockCreateIndex = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Create.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Create.args)).toEqual(['collection']) + expect(Create.args.collection.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['key', 'name', 'spec', 'unique']).sort() + expect(Object.keys(Create.flags).sort()).toEqual(expectedFlags) + expect(Create.flags.key.atLeastOne.sort()).toEqual(['key', 'spec']) + expect(Create.flags.spec.atLeastOne.sort()).toEqual(['key', 'spec']) + expect(Create.flags.key.multiple).toEqual(true) + expect(Create.flags.spec.multiple).toEqual(true) + expect(Create.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + const collectionName = 'students' + const spec1 = { name: 1 } + const specString1 = '{"name": 1}' + const spec2 = { age: -1 } + const specString2 = '{"age": -1}' + const key1 = 'grade' + const key2 = 'teacher' + const rtNamespace = 'test-namespace' + let command + + beforeEach(async () => { + command = new Create([collectionName]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockCreateIndex.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + mockCollection.mockReturnValue({ + createIndex: mockCreateIndex + }) + }) + + describe('successful index creation', () => { + test('creates index with specification', async () => { + command.argv = [collectionName, '--spec', specString1] + const indexName = 'name_1' + mockCreateIndex.mockResolvedValue(indexName) + await command.init() + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockCreateIndex).toHaveBeenCalledWith([spec1], {}) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [spec1], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain(`Creating index on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + expect(stdout.output).toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).toContain('Created:') + }) + + test('creates index with multiple specifications', async () => { + command.argv = [collectionName, '--spec', specString1, `-s=${specString2}`] + const indexName = 'name_1_age_-1' + mockCreateIndex.mockResolvedValue(indexName) + await command.init() + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockCreateIndex).toHaveBeenCalledWith([spec1, spec2], {}) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [spec1, spec2], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain(`Creating index on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + }) + + test('creates index with key', async () => { + command.argv = [collectionName, '--key', key1] + const indexName = 'grade_1' + mockCreateIndex.mockResolvedValue(indexName) + await command.init() + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockCreateIndex).toHaveBeenCalledWith([key1], {}) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [key1], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain(`Creating index on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + }) + + test('creates index with multiple keys', async () => { + command.argv = [collectionName, '--key', key1, `-k=${key2}`] + const indexName = 'grade_1_teacher_1' + mockCreateIndex.mockResolvedValue(indexName) + await command.init() + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockCreateIndex).toHaveBeenCalledWith([key1, key2], {}) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [key1, key2], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain(`Creating index on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + }) + + test('creates index with multiple keys and specifications in provided order', async () => { + command.argv = [collectionName, '--key', key1, `--spec=${specString1}`, '-s', specString2, `-k=${key2}`] + const indexName = 'grade_1_name_1_age_1_teacher_1' + mockCreateIndex.mockResolvedValue(indexName) + await command.init() + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockCreateIndex).toHaveBeenCalledWith([key1, spec1, spec2, key2], {}) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [key1, spec1, spec2, key2], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain(`Creating index on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + }) + + test('creates index with --json flag', async () => { + command.argv = [collectionName, '--spec', specString1, '--json'] + const indexName = 'name_1' + mockCreateIndex.mockResolvedValue(indexName) + await command.init() + + const result = await command.run() + + // Mock no existing collections + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockCreateIndex).toHaveBeenCalledWith([spec1], {}) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [spec1], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String) + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Creating index on collection') + expect(stdout.output).not.toContain(`Index '${indexName}' created successfully`) + expect(stdout.output).not.toContain('Namespace:') + expect(stdout.output).not.toContain('Created:') + }) + + test('creates index with name flag', async () => { + command.argv = [collectionName, '-s', specString1, '--name', 'custom_index_name'] + await command.init() + + const indexName = 'custom_index_name' + mockCreateIndex.mockResolvedValue(indexName) + + const result = await command.run() + + expect(mockCreateIndex).toHaveBeenCalledWith([spec1], { name: 'custom_index_name' }) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [spec1], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String), + options: { name: 'custom_index_name' } + }) + + expect(stdout.output).toContain(`Creating index '${indexName}' on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + }) + + test('creates index with unique flag', async () => { + command.argv = [collectionName, '-s', specString1, '--unique'] + await command.init() + + const indexName = 'name_1' + mockCreateIndex.mockResolvedValue(indexName) + + const result = await command.run() + + expect(mockCreateIndex).toHaveBeenCalledWith([spec1], { unique: true }) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [spec1], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String), + options: { unique: true } + }) + + expect(stdout.output).toContain(`Creating index on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + expect(stdout.output).toContain('Unique: true') + }) + + test('creates index with multiple flags', async () => { + command.argv = [collectionName, '-s', specString1, '--name', 'custom_index_name', '--unique'] + await command.init() + + const indexName = 'custom_index_name' + mockCreateIndex.mockResolvedValue(indexName) + + const result = await command.run() + + expect(mockCreateIndex).toHaveBeenCalledWith([spec1], { + name: 'custom_index_name', + unique: true + }) + + expect(result).toEqual({ + collection: collectionName, + indexName, + specification: [spec1], + status: 'created', + namespace: rtNamespace, + timestamp: expect.any(String), + options: { + name: 'custom_index_name', + unique: true + } + }) + + expect(stdout.output).toContain(`Creating index '${indexName}' on collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' created successfully in the '${collectionName}' collection`) + expect(stdout.output).toContain('Unique: true') + }) + }) + + describe('parameter validation', () => { + test('fails with missing collection name', async () => { + command.argv = ['', '-s', specString1] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockCollection).not.toHaveBeenCalled() + expect(mockCreateIndex).not.toHaveBeenCalled() + }) + }) + + describe('flag validation', () => { + test('fails with invalid index name', async () => { + command.argv = [collectionName, '-s', specString1, '--name', ''] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Index name: Must be a non-empty string') + expect(mockCollection).not.toHaveBeenCalled() + expect(mockCreateIndex).not.toHaveBeenCalled() + }) + + test('fails if specification is malformed json', async () => { + command.argv = [collectionName, '--spec', '{"bad object": 1'] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('JSON parse error:') + expect(mockCollection).not.toHaveBeenCalled() + expect(mockCreateIndex).not.toHaveBeenCalled() + }) + + test('fails if specification is not an object', async () => { + command.argv = [collectionName, '--spec', '["arrays", "are", "not", "allowed"]'] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('is not a JSON object') + expect(mockCollection).not.toHaveBeenCalled() + expect(mockCreateIndex).not.toHaveBeenCalled() + }) + + test('fails if key is an empty string', async () => { + command.argv = [collectionName, '--key', ''] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Index key: Must be a non-empty string') + expect(mockCollection).not.toHaveBeenCalled() + expect(mockCreateIndex).not.toHaveBeenCalled() + }) + + test('fails is neither key nor spec is provided', async () => { + command.argv = [collectionName] + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('At least one of the following must be provided: --key, --spec') + expect(mockCollection).not.toHaveBeenCalled() + expect(mockCreateIndex).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + test('fails with connection error', async () => { + command.argv = [collectionName, '-s', specString1] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow("Failed to create index on collection 'students': Connection failed") + + expect(stdout.output).toContain('Failed to create index') + expect(stdout.output).toContain(`Collection: ${collectionName}`) + expect(stdout.output).toContain('Namespace:') + }) + }) +}) diff --git a/test/commands/app/db/index/drop.test.js b/test/commands/app/db/index/drop.test.js new file mode 100644 index 0000000..7f0cfa6 --- /dev/null +++ b/test/commands/app/db/index/drop.test.js @@ -0,0 +1,179 @@ +/* +Copyright 2025 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 { Drop } from '../../../../../src/commands/app/db/index/drop.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockDropIndex = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Drop.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Drop.args).sort()).toEqual(['collection', 'indexName']) + expect(Drop.args.collection.required).toBe(true) + expect(Drop.args.indexName.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(Drop.flags).sort()).toEqual(expectedFlags) + expect(Drop.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + const collectionName = 'users' + const indexName = 'price_1' + const rtNamespace = 'test-namespace' + + let command + beforeEach(async () => { + command = new Drop([collectionName, indexName]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockDropIndex.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + // Mock the collection.dropIndex() method + mockCollection.mockReturnValue({ + dropIndex: mockDropIndex + }) + }) + + describe('successful index drop', () => { + test('drops index without --json flag', async () => { + command.argv = [collectionName, indexName] + await command.init() + + // Mock collection drop response + mockDropIndex.mockResolvedValue({ ok: 1, nIndexesWas: 3 }) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockDropIndex).toHaveBeenCalledWith(indexName) + + expect(result).toEqual({ + collection: collectionName, + indexName, + status: 'dropped', + namespace: rtNamespace, + timestamp: expect.any(String), + result: { ok: 1, nIndexesWas: 3 } + }) + + expect(stdout.output).toContain(`Dropping index '${indexName}' from collection '${collectionName}'...`) + expect(stdout.output).toContain(`Index '${indexName}' dropped successfully`) + expect(stdout.output).toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).toContain('Dropped:') + }) + + test('drops index with --json flag', async () => { + command.argv = [collectionName, indexName, '--json'] + await command.init() + + // Mock collection drop response + mockDropIndex.mockResolvedValue({ ok: 1, nIndexesWas: 3 }) + + const result = await command.run() + + expect(result).toEqual({ + collection: collectionName, + indexName, + status: 'dropped', + namespace: rtNamespace, + timestamp: expect.any(String), + result: { ok: 1, nIndexesWas: 3 } + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain(`Dropping index '${indexName}' from collection '${collectionName}'...`) + expect(stdout.output).not.toContain(`Index '${indexName}' dropped successfully`) + expect(stdout.output).not.toContain('Namespace:') + }) + }) + + describe('argument validation', () => { + test('fails when a required parameter is missing', async () => { + command = new Drop([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['collectionName'] + + // oclif will throw validation error during init() for missing required args + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockDropIndex).not.toHaveBeenCalled() + }) + + test('fails when collection name is empty string', async () => { + command = new Drop(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['', 'indexName'] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockDropIndex).not.toHaveBeenCalled() + }) + + test('fails when index name is empty string', async () => { + command = new Drop(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = ['collectionName', ''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Index name: Must be a non-empty string') + + expect(mockDropIndex).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = [collectionName, indexName] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow(`Failed to drop index '${indexName}' from collection '${collectionName}': Connection failed`) + + expect(stdout.output).toContain('Failed to drop index') + expect(stdout.output).toContain(`Collection: ${collectionName}`) + expect(stdout.output).toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).toContain('Error: Connection failed') + }) + }) +}) diff --git a/test/commands/app/db/index/list.test.js b/test/commands/app/db/index/list.test.js new file mode 100644 index 0000000..9a0b7c2 --- /dev/null +++ b/test/commands/app/db/index/list.test.js @@ -0,0 +1,198 @@ +/* +Copyright 2025 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 { List } from '../../../../../src/commands/app/db/index/list.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockCollection = jest.fn() +const mockGetIndexes = jest.fn() +const successVal = [ + { + v: 2, + key: { _id: 1 }, + name: '_id_' + }, + { + v: 2, + key: { category: 1 }, + name: 'CategoryPriceIndex' + }, + { + v: 2, + key: { price: 1 }, + name: 'price_1' + } +] + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(List.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(List.args)).toEqual(['collection']) + expect(List.args.collection.required).toBe(true) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(List.flags).sort()).toEqual(expectedFlags) + expect(List.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + const collectionName = 'users' + const rtNamespace = 'test-namespace' + + let command + beforeEach(async () => { + command = new List([collectionName]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockCollection.mockReset() + mockGetIndexes.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + collection: mockCollection + }) + + // Mock the collection.drop() method + mockCollection.mockReturnValue({ + getIndexes: mockGetIndexes + }) + }) + + describe('successfully retrieves indexes', () => { + test('Gets indexes without --json flag', async () => { + command.argv = [collectionName] + await command.init() + + // Mock collection drop response + mockGetIndexes.mockResolvedValue(successVal) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockCollection).toHaveBeenCalledWith(collectionName) + expect(mockGetIndexes).toHaveBeenCalled() + + expect(result).toEqual({ + collection: collectionName, + namespace: rtNamespace, + timestamp: expect.any(String), + indexes: successVal + }) + + expect(stdout.output).toContain(`Getting indexes from collection '${collectionName}'...`) + expect(stdout.output).toContain('Indexes retrieved successfully') + expect(stdout.output).toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).toContain('Indexes:') + }) + + test('gets empty list of indexes', async () => { + command.argv = [collectionName] + await command.init() + mockGetIndexes.mockResolvedValue([]) + + const result = await command.run() + + expect(result).toEqual({ + collection: collectionName, + namespace: rtNamespace, + timestamp: expect.any(String), + indexes: [] + }) + + expect(stdout.output).toContain(`Getting indexes from collection '${collectionName}'...`) + expect(stdout.output).toContain('Indexes retrieved successfully') + expect(stdout.output).toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).toContain('No indexes found for this collection') + }) + + test('Gets indexes with --json flag', async () => { + command.argv = [collectionName, '--json'] + await command.init() + + // Mock collection drop response + mockGetIndexes.mockResolvedValue(successVal) + + const result = await command.run() + + expect(result).toEqual({ + collection: collectionName, + namespace: rtNamespace, + timestamp: expect.any(String), + indexes: successVal + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain(`Getting indexes from collection '${collectionName}'...`) + expect(stdout.output).not.toContain('Indexes retrieved successfully') + expect(stdout.output).not.toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).not.toContain('Indexes:') + }) + }) + + describe('missing collection name', () => { + test('fails when collection name is missing', async () => { + command = new List([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [] + + // oclif will throw validation error during init() for missing required args + await expect(command.init()).rejects.toThrow('Missing 1 required arg') + + expect(mockGetIndexes).not.toHaveBeenCalled() + }) + + test('fails when collection name is empty string', async () => { + command = new List(['']) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + command.argv = [''] + + await expect(async () => { + await command.init() + await command.run() + }).rejects.toThrow('Collection name: Must be a non-empty string') + + expect(mockGetIndexes).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = [collectionName] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()) + .rejects + .toThrow(`Failed to retrieve indexes from collection '${collectionName}': Connection failed`) + + expect(stdout.output).toContain('Failed to retrieve indexes') + expect(stdout.output).toContain(`Collection: ${collectionName}`) + expect(stdout.output).toContain(`Namespace: ${rtNamespace}`) + expect(stdout.output).toContain('Error: Connection failed') + }) + }) +}) diff --git a/test/commands/app/db/ping.test.js b/test/commands/app/db/ping.test.js new file mode 100644 index 0000000..4b096ea --- /dev/null +++ b/test/commands/app/db/ping.test.js @@ -0,0 +1,282 @@ +/* +Copyright 2025 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', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(Ping.flags).sort()).toEqual(expectedFlags) + 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 + }) + }) + + 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..5c4bd16 --- /dev/null +++ b/test/commands/app/db/provision.test.js @@ -0,0 +1,441 @@ +/* +Copyright 2025 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, stderr } 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 +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', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['yes']).sort() + expect(Object.keys(Provision.flags).sort()).toEqual(expectedFlags) + expect(Provision.flags.yes.type).toBe('boolean') + expect(Provision.flags.yes.default).toBe(false) + 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('existing database status', () => { + test('database not yet provisioned', async () => { + command.argv = [] + await command.init() + + const unprovisionedStatus = { + status: DB_STATUS.NOT_PROVISIONED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(unprovisionedStatus) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.REQUESTED, + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('requested') + expect(mockProvisionRequest).toHaveBeenCalled() + }) + + test('database already provisioned', async () => { + command.argv = [] + await command.init() + + const existingStatus = { + status: DB_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', + details: existingStatus + }) + expect(mockProvisionRequest).not.toHaveBeenCalled() + }) + + test('database in progress', async () => { + command.argv = [] + await command.init() + + const inProgressStatus = { + status: DB_STATUS.PROCESSING, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(inProgressStatus) + + const result = await command.run() + + expect(result).toEqual({ + status: 'in_progress', + namespace: 'test-namespace', + details: inProgressStatus + }) + expect(mockProvisionRequest).not.toHaveBeenCalled() + }) + + test('provision request already pending', async () => { + command.argv = [] + await command.init() + + const pendingStatus = { + status: DB_STATUS.REQUESTED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(pendingStatus) + + const result = await command.run() + + expect(result).toEqual({ + status: 'in_progress', + namespace: 'test-namespace', + details: pendingStatus + }) + expect(mockProvisionRequest).not.toHaveBeenCalled() + }) + + test('previous provision failed, continues with new attempt', async () => { + command.argv = [] + await command.init() + + const failedStatus = { + status: DB_STATUS.FAILED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(failedStatus) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.REQUESTED, + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('requested') + expect(mockProvisionRequest).toHaveBeenCalled() + }) + + 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).toHaveBeenCalled() + expect(stdout.output).toContain('Previous database provisioning request was rejected') + expect(stdout.output).toContain('If the problem persists, please contact the App Builder team') + }) + + test('unknown current status, continues with new attempt', async () => { + command.argv = [] + await command.init() + + const unknownStatus = { + status: 'UNKNOWN_STATUS', + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(unknownStatus) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.REQUESTED, + region: 'amer' + }) + + const result = await command.run() + + expect(result.status).toBe('requested') + expect(mockProvisionRequest).toHaveBeenCalled() + expect(stdout.output).toContain('Database status is \'UNKNOWN_STATUS\' - attempting to provision...') + expect(stdout.output).toContain('If you encounter issues, please contact the App Builder team') + }) + }) + + 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: DB_STATUS.REQUESTED, + region: 'amer' + }) + + const result = await command.run() + + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Provision database for namespace 'test-namespace'?", + default: false + }) + expect(mockProvisionRequest).toHaveBeenCalled() + expect(result).toEqual({ + status: 'requested', + namespace: 'test-namespace', + timestamp: expect.any(String), + details: { + status: DB_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 --yes flag skips confirmation', async () => { + command.argv = ['--yes'] + await command.init() + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.REQUESTED, + region: 'amer' + }) + + const result = await command.run() + + expect(mockConfirm).not.toHaveBeenCalled() + expect(mockProvisionRequest).toHaveBeenCalled() + expect(result).toEqual({ + status: 'requested', + namespace: 'test-namespace', + timestamp: expect.any(String), + details: { + status: DB_STATUS.REQUESTED, + region: 'amer' + } + }) + }) + + test('provision with custom region', async () => { + command.argv = ['--region', 'emea'] + await command.init() + expect(global.mockDBInit).toHaveBeenCalledWith({ ow: expect.any(Object), region: 'emea' }) + + mockProvisionStatus.mockRejectedValue(new Error('not found')) + mockConfirm.mockResolvedValue(true) + mockProvisionRequest.mockResolvedValue({ + status: DB_STATUS.PROVISIONED, + region: 'emea' + }) + + const result = await command.run() + + expect(mockProvisionRequest).toHaveBeenCalled() + expect(result.details.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: DB_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: DB_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: DB_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: 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 fallback output if error message is missing + mockProvisionRequest.mockResolvedValue({ status: DB_STATUS.FAILED }) + await expect(command.run()).rejects.toThrow('Database provisioning failed: Unknown error') + }) + + 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 fallback output if error message is missing + mockProvisionRequest.mockResolvedValue({ status: DB_STATUS.REJECTED }) + await expect(command.run()).rejects.toThrow('Database provisioning request was rejected: Unknown reason') + }) + + test('provision missing status', async () => { + command.argv = [] + await command.init() + + mockProvisionRequest.mockResolvedValue({ region: 'amer' }) + + const result = await command.run() + + expect(result.status).toBe('unknown') + expect(stderr.output).toContain('Database provisioning request returned unrecognized status \'undefined\'') + expect(stderr.output).toContain('If the issue persists, please contact the App Builder team.') + }) + + test('provision unexpected 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 unexpected status \'NEW_UNKNOWN_STATUS\'') + expect(stderr.output).toContain('If the issue persists, please contact the App Builder team') + }) + }) + + 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: DB_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/stats.test.js b/test/commands/app/db/stats.test.js new file mode 100644 index 0000000..d05f20e --- /dev/null +++ b/test/commands/app/db/stats.test.js @@ -0,0 +1,247 @@ +/* +Copyright 2025 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 { Stats } from '../../../../src/commands/app/db/stats.js' +import { expect, jest } from '@jest/globals' +import { stdout } from 'stdout-stderr' +import { DBBaseCommand } from '../../../../src/DBBaseCommand.js' + +// Use the global DB mock +const mockDbStats = jest.fn() + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Stats.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Stats.args)).toEqual([]) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).sort() + expect(Object.keys(Stats.flags).sort()).toEqual(expectedFlags) + expect(Stats.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new Stats([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockDbStats.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + dbStats: mockDbStats + }) + }) + + describe('successful stats retrieval', () => { + test('returns and displays database statistics without --json flag', async () => { + command.argv = [] + await command.init() + + const statsData = { + totalCollections: 5, + totalDocuments: 1000, + totalSize: 2048, + avgDocumentSize: 2.048, + lastUpdated: '2025-01-01T00:00:00Z' + } + mockDbStats.mockResolvedValue(statsData) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockDbStats).toHaveBeenCalled() + expect(result).toEqual({ + ...statsData, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain('Fetching database statistics...') + expect(stdout.output).toContain('Database Statistics:') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('totalCollections: 5') + expect(stdout.output).toContain('totalDocuments: 1,000') + expect(stdout.output).toContain('totalSize: 2,048') + expect(stdout.output).toContain('avgDocumentSize: 2.048') + expect(stdout.output).toContain('Retrieved:') + }) + + test('returns database statistics with --json flag', async () => { + command.argv = ['--json'] + await command.init() + + const statsData = { + totalCollections: 3, + totalDocuments: 300, + totalSize: 1024 + } + mockDbStats.mockResolvedValue(statsData) + + const result = await command.run() + + expect(result).toEqual({ + ...statsData, + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Fetching database statistics...') + expect(stdout.output).not.toContain('Database Statistics:') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('handles empty/null stats', async () => { + command.argv = [] + await command.init() + + mockDbStats.mockResolvedValue(null) + + const result = await command.run() + + expect(result).toEqual({ + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + expect(stdout.output).toContain('Raw Stats: null') + }) + + test('handles empty/null stats with --json flag', async () => { + command.argv = ['--json'] + await command.init() + + mockDbStats.mockResolvedValue(null) + + const result = await command.run() + + expect(result).toEqual({ + namespace: 'test-namespace', + timestamp: expect.any(String) + }) + + expect(stdout.output).not.toContain('Raw Stats: null') + }) + + test('handles object stats', async () => { + command.argv = [] + await command.init() + + const complexStats = { + collections: { + users: { count: 100 }, + products: { count: 50 } + }, + metadata: { + version: '1.0.0' + } + } + mockDbStats.mockResolvedValue(complexStats) + + const result = await command.run() + + expect(result.collections).toEqual(complexStats.collections) + expect(stdout.output).toContain('collections:') + expect(stdout.output).toContain('metadata:') + }) + }) + + describe('formatValue method', () => { + test('formats numbers with commas', async () => { + command.argv = [] + await command.init() + + const formatted = command.formatValue(1000) + expect(formatted).toBe('1,000') + }) + + test('formats objects as JSON', async () => { + command.argv = [] + await command.init() + + const obj = { key: 'value' } + const formatted = command.formatValue(obj) + expect(formatted).toMatch(/\{\n +"key": "value"\n *\}/) + }) + + test('formats other types as strings', async () => { + command.argv = [] + await command.init() + + expect(command.formatValue('test')).toBe('test') + expect(command.formatValue(true)).toBe('true') + }) + }) + + describe('error handling', () => { + test('connection error without --json flag', async () => { + command.argv = [] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow('Failed to fetch database statistics: Connection failed') + + expect(stdout.output).toContain('Failed to retrieve database statistics') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Connection failed') + }) + + test('connection error with --json flag', async () => { + command.argv = ['--json'] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('Connection failed')) + + await expect(command.run()).rejects.toThrow('Failed to fetch database statistics: Connection failed') + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to retrieve database statistics') + expect(stdout.output).not.toContain('Namespace:') + }) + + test('dbStats error', async () => { + command.argv = [] + await command.init() + + global.mockDBInstance.connect.mockResolvedValue({ + dbStats: mockDbStats + }) + mockDbStats.mockRejectedValue(new Error('Query failed')) + + await expect(command.run()).rejects.toThrow('Failed to fetch database statistics: Query failed') + + expect(stdout.output).toContain('Failed to retrieve database statistics') + expect(stdout.output).toContain('Error: Query failed') + }) + + test('authentication error', async () => { + command.argv = [] + await command.init() + + global.mockDBInstance.connect.mockRejectedValue(new Error('401 Unauthorized')) + + await expect(command.run()).rejects.toThrow('Failed to fetch database statistics: 401 Unauthorized') + + expect(stdout.output).toContain('Failed to retrieve database statistics') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/status.test.js b/test/commands/app/db/status.test.js new file mode 100644 index 0000000..4bd2811 --- /dev/null +++ b/test/commands/app/db/status.test.js @@ -0,0 +1,382 @@ +/* +Copyright 2025 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' +import { DB_STATUS } from '../../../../src/constants/db.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() + +/** + * Mock setTimeout to immediately call the callback a few times but not infinitely + * + * @param {number} times - The number of times to execute the callback + */ +function mockWatchLoop (times = 5) { + for (let i = 0; i < times; i++) { + mockSetTimeout.mockImplementationOnce((callback) => { + callback() + return 'mock-timeout-id' + }) + } + mockSetTimeout.mockImplementationOnce(() => 'mock-timeout-id') +} + +describe('prototype', () => { + test('extends DBBaseCommand', () => { + expect(Status.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(Status.args)).toEqual([]) + }) + test('flags', () => { + const expectedFlags = Object.keys(DBBaseCommand.flags).concat(['watch']).sort() + expect(Object.keys(Status.flags).sort()).toEqual(expectedFlags) + 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: DB_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') + }) + + test('database processing', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: DB_STATUS.PROCESSING, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('PROCESSING') + expect(stdout.output).toContain('Database Status: PROCESSING') + }) + + test('database requested', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: DB_STATUS.REQUESTED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('REQUESTED') + expect(stdout.output).toContain('Database Status: REQUESTED') + }) + + test('database failed', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: DB_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('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('Message: Policy violation') + }) + + test('database not found (404)', async () => { + command.argv = [] + await command.init() + + const error = new Error('404 not found') + error.httpStatusCode = 404 + mockProvisionStatus.mockRejectedValue(error) + + const result = await command.run() + + expect(result).toEqual({ + status: DB_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')) + + await expect(command.run()).rejects.toThrow('Failed to check database status: Network error') + }) + }) + + 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: DB_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: DB_STATUS.PROVISIONED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('PROVISIONED') + expect(stdout.output).toContain('Stopping watch mode.') + }) + + test('watch stops on failed status', async () => { + command.argv = ['--watch'] + await command.init() + + const statusResponse = { + status: DB_STATUS.FAILED, + region: 'amer' + } + mockProvisionStatus.mockResolvedValue(statusResponse) + + const result = await command.run() + + expect(result.status).toBe('FAILED') + expect(stdout.output).toContain('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('Stopping watch mode.') + }) + + test('watch continues after error', async () => { + mockWatchLoop() + + command.argv = ['--watch'] + await command.init() + + mockProvisionStatus + .mockResolvedValueOnce({ + status: DB_STATUS.PROCESSING, + region: 'amer' + }) + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Another error')) + .mockResolvedValueOnce({ + status: DB_STATUS.FAILED, + region: 'amer' + }) + + await command.run() + + expect(stdout.output).toContain('Database Status: PROCESSING') + expect(stdout.output).toContain('Error: Network error') + expect(stdout.output).toContain('Error: Another error') + expect(stdout.output).toContain('Database Status: FAILED') + expect(stdout.output).toContain('Stopping watch mode.') + }) + + test('watch only displays status changes', async () => { + mockWatchLoop() + + command.argv = ['--watch'] + await command.init() + + const processing = { + status: DB_STATUS.PROCESSING, + region: 'amer' + } + const provisioned = { + status: DB_STATUS.PROVISIONED, + region: 'amer' + } + + mockProvisionStatus + .mockResolvedValueOnce(processing) + .mockResolvedValueOnce(processing) + .mockResolvedValueOnce(processing) + .mockResolvedValueOnce(provisioned) + + await command.run() + + expect(stdout.output).toContain('Database Status: PROCESSING') + const processingCount = (stdout.output.match(/Database Status: PROCESSING/g) || []).length + expect(processingCount).toBe(1) // Should only show once + expect(stdout.output).toContain('Database Status: PROVISIONED') + expect(stdout.output).toContain('Stopping watch mode.') + }) + }) + + describe('displayStatus', () => { + test('shows all status information', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: DB_STATUS.PROVISIONED, + message: 'Database ready', + submitted: '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('Message: Database ready') + expect(stdout.output).toContain('Submitted:') + expect(stdout.output).toContain('Checked:') + }) + + test('hides timestamp in watch mode', async () => { + command.argv = [] + await command.init() + + const statusResponse = { + status: DB_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('json output', () => { + test('json flag works correctly', async () => { + command.argv = ['--json'] + await command.init() + + const statusResponse = { + status: DB_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 ffb3221..17940e6 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']) @@ -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/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([]) 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..0d95630 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -49,6 +49,21 @@ 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(), + deleteDatabase: jest.fn(), + connect: jest.fn() +} +jest.unstable_mockModule('@adobe/aio-lib-db', () => ({ + init: mockDBInit +})) +global.mockDBInit = mockDBInit +global.mockDBInstance = mockDBInstance + // mock prompt const mockPrompt = { input: jest.fn() @@ -58,6 +73,12 @@ jest.unstable_mockModule('@inquirer/prompts', () => ({ })) global.getPromptInstanceMock = () => mockPrompt +const mockEnv = jest.fn() +jest.unstable_mockModule('@adobe/aio-lib-env', () => ({ + getCliEnv: mockEnv +})) +global.getCliEnvMock = () => mockEnv + beforeEach(() => { // trap console log stdout.start() @@ -66,16 +87,26 @@ beforeEach(() => { // config fakes global.fakeConfig = { 'state.region': null, - 'runtime.namespace': '11111-ns', + 'runtime.namespace': 'test-namespace', 'runtime.auth': 'auth', - 'state.endpoint': null + 'state.endpoint': null, + 'db.endpoint': null, + 'db.region': null } delete process.env.AIO_STATE_ENDPOINT + delete process.env.AIO_DB_ENDPOINT mockInit.mockReset() 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()) + + mockEnv.mockReset() + mockEnv.mockReturnValue('prod') }) afterEach(() => { stdout.stop(); stderr.stop() }) diff --git a/test/utils/inputValidation.test.js b/test/utils/inputValidation.test.js new file mode 100644 index 0000000..6229e3b --- /dev/null +++ b/test/utils/inputValidation.test.js @@ -0,0 +1,99 @@ +/* +Copyright 2025 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 { asObject, isNonEmptyString, isProductionNamespace } from '../../src/utils/inputValidation.js' + +describe('asObject()', () => { + test('successfully parses string as object', () => { + const input = '{"key1": "value", "key2": [123, 456], "key3": {"nestedKey": "nestedValue"}}' + const result = asObject(input) + expect(result).toEqual({ + key1: 'value', + key2: [123, 456], + key3: { nestedKey: 'nestedValue' } + }) + }) + + test('returns object directly', () => { + const input = { key1: 'value', key2: [123, 456] } + const result = asObject(input) + expect(result).toBe(input) + }) + + test('throws error for invalid JSON string', () => { + const input = '{"key1": "value", "key2": [123, 456], "key3": {"nestedKey": "nestedValue"' + expect(() => asObject(input)).toThrow('JSON parse error:') + }) + + test('throws error for empty input', () => { + const input = '' + expect(() => asObject(input)).toThrow("Value '' is not a JSON object") + }) + + test('throws error for non-object input', () => { + const input = ['not', 'an', 'object'] + expect(() => asObject(input)).toThrow("Value 'not,an,object' is not a JSON object") + }) + + test('uses custom label in error messages', () => { + expect(() => asObject(null, 'Test Label')).toThrow("Test Label: Value 'null' is not a JSON object") + }) +}) + +describe('isNonEmptyString()', () => { + test('validates non-empty string', () => { + const input = 'valid string' + const result = isNonEmptyString(input) + expect(result).toBe(input) + }) + + test('throws error for empty string', () => { + const input = '' + expect(() => isNonEmptyString(input)).toThrow('Must be a non-empty string') + }) + + test('throws error for non-string input', () => { + const input = 12345 + expect(() => isNonEmptyString(input)).toThrow('Must be a non-empty string') + }) + + test('uses custom label in error messages', () => { + expect(() => isNonEmptyString('', 'Test Label')).toThrow('Test Label: Must be a non-empty string') + }) +}) + +describe('isProductionNamespace()', () => { + test('validates production namespace without prefix or suffix', () => { + expect(() => isProductionNamespace('123456-testNamespace123')).not.toThrow() + expect(isProductionNamespace('123456-testNamespace123')).toBe(true) + }) + + test('validates production namespace with development prefix', () => { + expect(() => isProductionNamespace('development-123456-testNamespace123')).not.toThrow() + expect(isProductionNamespace('development-123456-testNamespace123')).toBe(true) + }) + + test('invalidates namespace with workspace suffix', () => { + expect(() => isProductionNamespace('123456-testNamespace123-dev')).not.toThrow() + expect(isProductionNamespace('123456-testNamespace123-dev')).toBe(false) + }) + + test('invalidates namespace with improper format', () => { + expect(() => isProductionNamespace('invalidNamespace')).not.toThrow() + expect(isProductionNamespace('invalidNamespace')).toBe(false) + }) + + test('throws error for non-string or empty input', () => { + expect(() => isProductionNamespace('')).toThrow('Invalid runtime namespace') + expect(() => isProductionNamespace(null)).toThrow('Invalid runtime namespace') + expect(() => isProductionNamespace(12345)).toThrow('Invalid runtime namespace') + }) +}) diff --git a/test/utils/output.test.js b/test/utils/output.test.js new file mode 100644 index 0000000..d4a9f23 --- /dev/null +++ b/test/utils/output.test.js @@ -0,0 +1,38 @@ +/* +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 { prettyJson } from '../../src/utils/output.js' + +describe('prettyJson', () => { + test('pretty prints JSON with default indent', () => { + const input = { key1: 'value1', key2: 'value2' } + const expectedOutput = ` { + "key1": "value1", + "key2": "value2" + }` + expect(prettyJson(input)).toBe(expectedOutput) + }) + + test('pretty prints JSON with no indent', () => { + const input = { key1: 'value1', key2: 'value2' } + const expectedOutput = `{ + "key1": "value1", + "key2": "value2" +}` + expect(prettyJson(input, 0)).toBe(expectedOutput) + }) + + test('returns indented original string if JSON parsing fails', () => { + const input = 'Invalid JSON string' + expect(prettyJson(input)).toBe(' Invalid JSON string') + }) +})