diff --git a/README.md b/README.md index afeceba..699ca73 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,11 @@ $ aio app db --help # Commands +* [`aio app db getCollectionInfos`](#aio-app-db-getcollectioninfos) +* [`aio app db getCollectionNames`](#aio-app-db-getcollectionnames) * [`aio app db ping`](#aio-app-db-ping) * [`aio app db provision`](#aio-app-db-provision) +* [`aio app db stats`](#aio-app-db-stats) * [`aio app db status`](#aio-app-db-status) * [`aio app state delete [KEYS]`](#aio-app-state-delete-keys) * [`aio app state get KEY`](#aio-app-state-get-key) @@ -37,6 +40,66 @@ $ aio app db --help * [`aio app state stats`](#aio-app-state-stats) * [`aio help [COMMAND]`](#aio-help-command) +## `aio app db getCollectionInfos` + +Get details about your App Builder database + +``` +USAGE + $ aio app db getCollectionInfos [--json] + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Get details about your App Builder database + +EXAMPLES + $ aio app db getCollectionInfos + + $ aio app db getCollectionInfos --json +``` + +## `aio app db getCollectionNames` + +Get collection names from your App Builder database + +``` +USAGE + $ aio app db getCollectionNames [--json] + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Get collection names from your App Builder database + +EXAMPLES + $ aio app db getCollectionNames + + $ aio app db getCollectionNames --json +``` + +## `aio app db stats` + +Get statistics about your App Builder database + +``` +USAGE + $ aio app db stats [--json] + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Get statistics about your App Builder database + +EXAMPLES + $ aio app db stats + + $ aio app db stats --json +``` + ## `aio app state delete [KEYS]` Delete key-values diff --git a/src/commands/app/db/getCollectionInfos.js b/src/commands/app/db/getCollectionInfos.js new file mode 100644 index 0000000..7fb2b7b --- /dev/null +++ b/src/commands/app/db/getCollectionInfos.js @@ -0,0 +1,88 @@ +/* +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 GetCollectionInfos extends DBBaseCommand { + async run () { + try { + this.log(chalk.blue('Fetching collection info...')) + + const client = await this.db.connect() + const collectionInfo = await client.listCollections() + + this.debugLogger?.info?.(`Retrieved ${collectionInfo.length} collections with full details:`, collectionInfo) + + if (this.flags.json) { + return 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}`)) + this.log('') + + collectionInfo.forEach((collection, index) => { + this.log(chalk.cyan(` Collection ${index + 1}:`)) + Object.entries(collection).forEach(([key, value]) => { + this.log(chalk.dim(` ${key}: ${this.formatValue(value)}`)) + }) + if (index < collectionInfo.length - 1) { + this.log('') + } + }) + } else { + this.log(chalk.dim(' No collections found')) + } + + this.log('') + this.log(chalk.dim(` Retrieved: ${new Date().toLocaleString()}`)) + + return collectionInfo + } catch (error) { + this.debugLogger?.error?.('Error fetching collection info', error) + + this.log(chalk.red('Failed to retrieve collection information')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(`Failed to fetch collection information: ${error.message}`) + } + } + + formatValue (value) { + if (typeof value === 'number') { + // Format large numbers with commas + return value.toLocaleString() + } + if (typeof value === 'object' && value !== null) { + return JSON.stringify(value, null, 2) + } + return String(value) + } +} + +GetCollectionInfos.description = 'Get details of your App Builder database collections' + +GetCollectionInfos.examples = [ + '$ aio app db getCollectionInfos', + '$ aio app db getCollectionInfos --json' +] + +GetCollectionInfos.flags = { + ...DBBaseCommand.flags +} + +GetCollectionInfos.args = {} diff --git a/src/commands/app/db/getCollectionNames.js b/src/commands/app/db/getCollectionNames.js new file mode 100644 index 0000000..a1866b1 --- /dev/null +++ b/src/commands/app/db/getCollectionNames.js @@ -0,0 +1,60 @@ +/* +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 GetCollectionNames extends DBBaseCommand { + async run () { + try { + this.log(chalk.blue('Fetching collection names...')) + + const client = await this.db.connect() + const collectionInfo = await client.listCollections() + + this.debugLogger?.info?.(`Retrieved ${collectionInfo.length} collections from database`) + + // Extract only the names from the collection info + const collectionNames = collectionInfo.map(collection => collection.name) + + if (this.flags.json) { + return collectionNames + } + + this.log(chalk.green('Collection names:')) + this.log(JSON.stringify(collectionNames, null, 2)) + + return collectionNames + } catch (error) { + this.debugLogger?.error?.('Error fetching collection names', error) + + this.log(chalk.red('Error fetching collection names')) + this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) + this.log(chalk.dim(` Error: ${error.message}`)) + + this.error(`Failed to fetch collection names: ${error.message}`) + } + } +} + +GetCollectionNames.description = 'Get collection names of your App Builder database' + +GetCollectionNames.examples = [ + '$ aio app db GetCollectionNames', + '$ aio app db GetCollectionNames --json' +] + +GetCollectionNames.flags = { + ...DBBaseCommand.flags +} + +GetCollectionNames.args = {} diff --git a/src/commands/app/db/ping.js b/src/commands/app/db/ping.js index b4bb291..436e69e 100644 --- a/src/commands/app/db/ping.js +++ b/src/commands/app/db/ping.js @@ -15,8 +15,6 @@ import chalk from 'chalk' export class Ping extends DBBaseCommand { async run () { - this.debugLogger?.info?.('Starting database ping test') - try { this.log(chalk.blue('Testing database connectivity...')) @@ -25,7 +23,7 @@ export class Ping extends DBBaseCommand { const endTime = Date.now() const responseTime = endTime - startTime - this.debugLogger?.info?.('Ping result:', pingResult) + this.debugLogger?.info?.(`Database ping completed in ${responseTime}ms:`, pingResult) this.log(chalk.green('Database connection successful')) this.log(chalk.dim(` Namespace: ${this.rtNamespace}`)) @@ -43,9 +41,7 @@ export class Ping extends DBBaseCommand { timestamp: new Date().toISOString() } - if (!this.flags.json) { - this.log(chalk.dim('Database is ready for operations')) - } + this.log(chalk.dim('Database is ready for operations')) return result } catch (error) { diff --git a/src/commands/app/db/provision.js b/src/commands/app/db/provision.js index e6f8322..34d86f0 100644 --- a/src/commands/app/db/provision.js +++ b/src/commands/app/db/provision.js @@ -19,8 +19,6 @@ export class Provision extends DBBaseCommand { async run () { const { region } = this.flags - this.debugLogger?.info?.('Starting database provisioning process') - try { // First check if database is already provisioned this.log(chalk.blue('Checking current provisioning status...')) diff --git a/src/commands/app/db/show/collections.js b/src/commands/app/db/show/collections.js new file mode 100644 index 0000000..003b6a3 --- /dev/null +++ b/src/commands/app/db/show/collections.js @@ -0,0 +1,33 @@ +/* +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 { GetCollectionNames } from '../getCollectionNames.js' + +export class Collections extends GetCollectionNames { + async run () { + // Delegate to the parent GetCollectionNames implementation + return super.run() + } +} + +Collections.description = 'Show collection names of your App Builder database (alias for getCollectionNames)' + +Collections.examples = [ + '$ aio app db show collections', + '$ aio app db show collections --json' +] + +Collections.flags = { + ...GetCollectionNames.flags +} + +Collections.args = {} diff --git a/src/commands/app/db/stats.js b/src/commands/app/db/stats.js new file mode 100644 index 0000000..150c199 --- /dev/null +++ b/src/commands/app/db/stats.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 chalk from 'chalk' + +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() + } + + if (this.flags.json) { + return result + } + + 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: ${JSON.stringify(stats, null, 2)}`)) + } + + 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 JSON.stringify(value, null, 2) + } + return String(value) + } +} + +Stats.description = 'Get statistics of your App Builder database' + +Stats.examples = [ + '$ aio app db stats', + '$ aio app db stats --json' +] + +Stats.flags = { + ...DBBaseCommand.flags +} + +Stats.args = {} diff --git a/test/commands/app/db/getCollectionInfos.test.js b/test/commands/app/db/getCollectionInfos.test.js new file mode 100644 index 0000000..47860c3 --- /dev/null +++ b/test/commands/app/db/getCollectionInfos.test.js @@ -0,0 +1,207 @@ +/* +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 { GetCollectionInfos } from '../../../../src/commands/app/db/getCollectionInfos.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(GetCollectionInfos.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(GetCollectionInfos.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(GetCollectionInfos.flags).sort()).toEqual(['region']) + expect(GetCollectionInfos.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new GetCollectionInfos([]) + 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 info retrieval', () => { + test('returns full collection information without --json flag', async () => { + command.argv = [] + await command.init() + + const collectionInfo = [ + { name: 'users', documentCount: 100, size: 1024 }, + { name: 'products', documentCount: 50, size: 512 }, + { name: 'orders', documentCount: 200, size: 2048 } + ] + 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 collection info...') + expect(stdout.output).toContain('Collection Information:') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Total Collections: 3') + expect(stdout.output).toContain('Collection 1:') + expect(stdout.output).toContain('name: users') + expect(stdout.output).toContain('documentCount: 100') + expect(stdout.output).toContain('size: 1,024') + expect(stdout.output).toContain('Retrieved:') + }) + + test('returns collection information with --json flag', async () => { + command.argv = ['--json'] + await command.init() + + const collectionInfo = [ + { name: 'users', documentCount: 100, size: 1024 }, + { name: 'products', documentCount: 50, size: 512 } + ] + 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('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('handles empty collection list with --json flag', async () => { + command.argv = ['--json'] + await command.init() + + mockListCollections.mockResolvedValue([]) + + const result = await command.run() + + expect(result).toEqual([]) + expect(stdout.output).not.toContain('No collections found') + }) + }) + + 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).toContain('{\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 collection information: Connection failed') + + expect(stdout.output).toContain('Failed to retrieve collection information') + 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 collection information: Connection failed') + + // Should not show console messages with --json + expect(stdout.output).not.toContain('Failed to retrieve collection information') + expect(stdout.output).not.toContain('Namespace:') + }) + + 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 collection information: Query failed') + + expect(stdout.output).toContain('Failed to retrieve collection information') + 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 collection information: 401 Unauthorized') + + expect(stdout.output).toContain('Failed to retrieve collection information') + expect(stdout.output).toContain('401 Unauthorized') + }) + }) +}) diff --git a/test/commands/app/db/getCollectionNames.test.js b/test/commands/app/db/getCollectionNames.test.js new file mode 100644 index 0000000..b175350 --- /dev/null +++ b/test/commands/app/db/getCollectionNames.test.js @@ -0,0 +1,140 @@ +/* +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 { GetCollectionNames } from '../../../../src/commands/app/db/getCollectionNames.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(GetCollectionNames.prototype instanceof DBBaseCommand).toBe(true) + }) + test('args', () => { + expect(Object.keys(GetCollectionNames.args)).toEqual([]) + }) + test('flags', () => { + expect(Object.keys(GetCollectionNames.flags).sort()).toEqual(['region']) + expect(GetCollectionNames.enableJsonFlag).toEqual(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new GetCollectionNames([]) + 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 collection names...') + expect(stdout.output).toContain('Collection names:') + 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 names:') + expect(stdout.output).toContain('[]') + }) + }) + + describe('json output', () => { + 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('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 collection names: Connection failed') + + expect(stdout.output).toContain('Error fetching collection names') + 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 collection names: Query failed') + + expect(stdout.output).toContain('Error fetching collection names') + expect(stdout.output).toContain('Namespace: test-namespace') + expect(stdout.output).toContain('Error: Query failed') + }) + }) +}) diff --git a/test/commands/app/db/show/collections.test.js b/test/commands/app/db/show/collections.test.js new file mode 100644 index 0000000..51b7358 --- /dev/null +++ b/test/commands/app/db/show/collections.test.js @@ -0,0 +1,72 @@ +/* +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 { Collections } from '../../../../../src/commands/app/db/show/collections.js' +import { GetCollectionNames } from '../../../../../src/commands/app/db/getCollectionNames.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 GetCollectionNames', () => { + expect(Collections.prototype instanceof GetCollectionNames).toBe(true) + }) + + test('extends DBBaseCommand', () => { + expect(Collections.prototype instanceof DBBaseCommand).toBe(true) + }) +}) + +describe('run', () => { + let command + beforeEach(async () => { + command = new Collections([]) + command.config = { + runHook: jest.fn().mockResolvedValue({}) + } + + // Reset mocks + mockListCollections.mockReset() + + // Mock the db client connection + global.mockDBInstance.connect = jest.fn().mockResolvedValue({ + listCollections: mockListCollections + }) + }) + + describe('alias behavior', () => { + test('delegates to GetCollectionNames implementation', async () => { + command.argv = [] + await command.init() + + const collectionInfo = [ + { name: 'collection1', documentCount: 100 }, + { name: 'collection2', documentCount: 50 } + ] + mockListCollections.mockResolvedValue(collectionInfo) + + const result = await command.run() + + expect(global.mockDBInstance.connect).toHaveBeenCalled() + expect(mockListCollections).toHaveBeenCalled() + expect(result).toEqual(['collection1', 'collection2']) + + expect(stdout.output).toContain('Fetching collection names...') + expect(stdout.output).toContain('Collection names:') + expect(stdout.output).toContain('collection1') + expect(stdout.output).toContain('collection2') + }) + }) +}) diff --git a/test/commands/app/db/stats.test.js b/test/commands/app/db/stats.test.js new file mode 100644 index 0000000..b395eeb --- /dev/null +++ b/test/commands/app/db/stats.test.js @@ -0,0 +1,246 @@ +/* +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', () => { + expect(Object.keys(Stats.flags).sort()).toEqual(['region']) + 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).toContain('{\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') + }) + }) +})