diff --git a/src/commands/data/maintenances/index.ts b/src/commands/data/maintenances/index.ts index 5e4356493d..c3da4ddea1 100644 --- a/src/commands/data/maintenances/index.ts +++ b/src/commands/data/maintenances/index.ts @@ -1,13 +1,16 @@ -import {flags as Flags} from '@heroku-cli/command' +import {Command, flags as Flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {MaintenanceInfoByAppResult} from '@heroku/types/data' import {ux} from '@oclif/core/ux' -import BaseCommand from '../../../lib/data/base-command.js' -import {Maintenance, MaintenanceStatus} from '../../../lib/data/types.js' +import {MaintenanceStatus} from '../../../lib/data/types.js' import {constructSortFilterTableOptions, constructTableColumns, outputCSV} from '../../../lib/utils/table-utils.js' -export default class DataMaintenancesIndex extends BaseCommand { +type MaintenanceItem = MaintenanceInfoByAppResult['maintenances'][number] + +export default class DataMaintenancesIndex extends Command { static description = 'list maintenances for an app\'s data addons' static examples = [ '$ heroku data:maintenances --app production-app', @@ -50,38 +53,36 @@ export default class DataMaintenancesIndex extends BaseCommand { private async fetchMaintenances(appName: string) { ux.action.start('Fetching maintenances') const {body: app} = await this.heroku.get(`/apps/${appName}`) - const {body: {maintenances}} = await this.dataApi.get<{maintenances: Maintenance[]}>( - `/data/maintenances/v1/apps/${app.id}`, - this.dataApi.defaults, - ) + const {data} = new HerokuSDK() + const result = await data.maintenance.infoByApp(app.id!) ux.action.stop() - return maintenances + return result.maintenances } private getTableColumns(extended: boolean, columns: string | undefined) { /* eslint-disable perfectionist/sort-objects */ const allTableColumns = { addon: { - get: (row: Maintenance) => row.addon && row.addon.name, + get: (row: MaintenanceItem) => row.addon && row.addon.name, header: 'Addon', }, attachments: { - get(row: Maintenance) { + get(row: MaintenanceItem) { const attachments = (row && row.addon && row.addon.attachments) || [] return attachments.join(', ') }, header: 'Attachments', }, window: { - get: (row: Maintenance) => row && row.addon && row.addon.window, + get: (row: MaintenanceItem) => row && row.addon && row.addon.window, header: 'Scheduling Window', }, status: { header: 'Status', }, required_by: { - get(row: Maintenance) { + get(row: MaintenanceItem) { if (row.status === MaintenanceStatus.completed) { return '-' } @@ -91,7 +92,7 @@ export default class DataMaintenancesIndex extends BaseCommand { header: 'Required by', }, scheduled_for: { - get(row: Maintenance) { + get(row: MaintenanceItem) { if (row.status === MaintenanceStatus.completed) { return '-' } @@ -101,11 +102,11 @@ export default class DataMaintenancesIndex extends BaseCommand { header: 'Scheduled for', }, kind: { - get: (row: Maintenance) => row.addon.kind, + get: (row: MaintenanceItem) => row.addon.kind, header: 'Kind', }, plan: { - get: (row: Maintenance) => row.addon.plan, + get: (row: MaintenanceItem) => row.addon.plan, header: 'Plan', }, } @@ -116,7 +117,7 @@ export default class DataMaintenancesIndex extends BaseCommand { return constructTableColumns(allTableColumns, baseColumnNames, extended, columns) } - private renderTable(maintenances: Maintenance[], tableColumns: Record, flags: Record) { + private renderTable(maintenances: MaintenanceItem[], tableColumns: Record, flags: Record) { const tableOptions = constructSortFilterTableOptions(flags, tableColumns) hux.table(maintenances, tableColumns, tableOptions) } diff --git a/src/commands/data/maintenances/info.ts b/src/commands/data/maintenances/info.ts index 9236b5cb16..f7b0415120 100644 --- a/src/commands/data/maintenances/info.ts +++ b/src/commands/data/maintenances/info.ts @@ -1,17 +1,16 @@ -import {flags} from '@heroku-cli/command' +import {Command, flags} from '@heroku-cli/command' import {color, hux, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {MaintenanceInfoResult} from '@heroku/types/data' import {Args, ux} from '@oclif/core' -import BaseCommand from '../../../lib/data/base-command.js' -import {Maintenance} from '../../../lib/data/types.js' import {lazyModuleLoader} from '../../../lib/lazy-module-loader.js' -interface StyledMaintenance extends Maintenance { - [key: string]: any; +type StyledMaintenance = Record & { duration_approximate?: string; } -export default class DataMaintenancesInfo extends BaseCommand { +export default class DataMaintenancesInfo extends Command { static args = { addon: Args.string({ description: 'data addon to show maintenance for', @@ -31,27 +30,16 @@ export default class DataMaintenancesInfo extends BaseCommand { } // a prettier display of the information - protected createStyledMaintenance(maintenance: Maintenance, addSeconds: any, formatDistance: any) { - // make a copy of the maintenance + protected createStyledMaintenance(maintenance: MaintenanceInfoResult, addSeconds: any, formatDistance: any) { + // make a copy of the maintenance, omitting uuids for display + const {uuid: _appUuid, ...appWithoutUuid} = maintenance.app + const {uuid: _addonUuid, ...addonWithoutUuid} = maintenance.addon + const styledMaintenance: StyledMaintenance = { ...maintenance, - addon: { - ...maintenance.addon, - }, - app: { - ...maintenance.app, - }, - } - - // remove app uuid - if (styledMaintenance.app && styledMaintenance.app.uuid) { - delete styledMaintenance.app.uuid - } - - // remove addon uuid - if (styledMaintenance.addon && styledMaintenance.addon.uuid) { - delete styledMaintenance.addon.uuid - } + addon: addonWithoutUuid, + app: appWithoutUuid, + }; ['app', 'addon'].forEach((key: string) => { for (const childKey of Object.keys(styledMaintenance[key])) { @@ -87,10 +75,19 @@ export default class DataMaintenancesInfo extends BaseCommand { const addon = await addonResolver.resolve(args.addon, app) ux.action.start(`Fetching maintenance for ${color.addon(addon.name!)}`) - const {body: maintenance} = await this.dataApi.get( - `/data/maintenances/v1/${addon!.id}`, - this.dataApi.defaults, - ) + const {data} = new HerokuSDK() + let maintenance: MaintenanceInfoResult + try { + maintenance = await data.maintenance.info(addon.id!) + } catch (error: any) { + ux.action.stop() + if (error.statusCode === 404) { + this.error('no maintenance found for this add-on') + } + + throw error + } + ux.action.stop() if (json) { diff --git a/src/commands/data/maintenances/run.ts b/src/commands/data/maintenances/run.ts index 617c97743e..2bcc4683cb 100644 --- a/src/commands/data/maintenances/run.ts +++ b/src/commands/data/maintenances/run.ts @@ -1,6 +1,7 @@ import {flags as Flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import BaseCommand from '../../../lib/data/base-command.js' @@ -63,10 +64,8 @@ export default class DataMaintenancesRun extends BaseCommand { await this.confirmMaintenanceMode(addon, confirm, force || false) ux.action.start('Triggering maintenance') - await this.dataApi.post( - `/data/maintenances/v1/${addon.id}/run`, - this.dataApi.defaults, - ) + const {data} = new HerokuSDK() + await data.maintenance.run(addon.id!) ux.action.stop('maintenance triggered') if (wait) { diff --git a/src/commands/data/maintenances/schedule.ts b/src/commands/data/maintenances/schedule.ts index 1771978677..188a84e8f4 100644 --- a/src/commands/data/maintenances/schedule.ts +++ b/src/commands/data/maintenances/schedule.ts @@ -1,13 +1,13 @@ -import {flags} from '@heroku-cli/command' +import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {MaintenanceInfoResult, MaintenanceScheduleResult} from '@heroku/types/data' import {Args, ux} from '@oclif/core' -import BaseCommand from '../../../lib/data/base-command.js' -import {Maintenance} from '../../../lib/data/types.js' import {lazyModuleLoader} from '../../../lib/lazy-module-loader.js' -export default class DataMaintenancesSchedule extends BaseCommand { +export default class DataMaintenancesSchedule extends Command { static args = { addon: Args.string({ description: 'addon to schedule or re-schedule maintenance for', @@ -36,13 +36,10 @@ export default class DataMaintenancesSchedule extends BaseCommand { }), } - protected async computeDelayWeeks(addon: Heroku.AddOn, week: string, differenceInCalendarWeeks: any) { - const {body: maintenance} = await this.dataApi.get( - `/data/maintenances/v1/${addon!.id}`, - this.dataApi.defaults, - ) + protected async computeDelayWeeks(addon: Heroku.AddOn, week: string, differenceInCalendarWeeks: any, data: HerokuSDK['data']) { + const maintenance: MaintenanceInfoResult = await data.maintenance.info(addon.id!) - const scheduled = (maintenance.status === 'completed' || maintenance.scheduled_for === null) + const scheduled = (maintenance.status === 'completed' || !maintenance.scheduled_for) ? Date.now() : Date.parse(maintenance.scheduled_for) @@ -62,28 +59,21 @@ export default class DataMaintenancesSchedule extends BaseCommand { const {app, week, weeks} = flags const addon = await addonResolver.resolve(args.addon, app) + const {data} = new HerokuSDK() const delayWeeks = week === undefined ? weeks - : await this.computeDelayWeeks(addon, week, differenceInCalendarWeeks) + : await this.computeDelayWeeks(addon, week, differenceInCalendarWeeks, data) - await this.scheduleMaintenance(addon, delayWeeks) + await this.scheduleMaintenance(addon, delayWeeks, data) } - protected async scheduleMaintenance(addon: Heroku.AddOn, delayWeeks: string) { + protected async scheduleMaintenance(addon: Heroku.AddOn, delayWeeks: string, data: HerokuSDK['data']) { ux.action.start(`Scheduling maintenance for ${color.addon(addon.name!)}`) - const {body: schedule} = await this.dataApi.post( - `/data/maintenances/v1/${addon.id}/schedule`, - { - ...this.dataApi.defaults, - body: { - delay_weeks: delayWeeks, - }, - }, - ) + const schedule: MaintenanceScheduleResult = await data.maintenance.schedule(addon.id!, {delay_weeks: delayWeeks}) ux.action.stop('maintenance scheduled') - const alreadyScheduled = schedule.previously_scheduled_for !== null + const alreadyScheduled = !!schedule.previously_scheduled_for if (alreadyScheduled) { this.log(`Scheduled maintenance for ${color.addon(addon.name!)} changed from ${schedule.previously_scheduled_for} to ${schedule.scheduled_for}`) diff --git a/src/commands/data/maintenances/wait.ts b/src/commands/data/maintenances/wait.ts index ea33bcd50b..5e901e6270 100644 --- a/src/commands/data/maintenances/wait.ts +++ b/src/commands/data/maintenances/wait.ts @@ -1,9 +1,10 @@ import {flags as Flags} from '@heroku-cli/command' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import BaseCommand from '../../../lib/data/base-command.js' -import {Maintenance, MaintenanceStatus} from '../../../lib/data/types.js' +import {MaintenanceStatus} from '../../../lib/data/types.js' import {waitUntilMaintenanceComplete} from '../../../lib/data/utils.js' export default class DataMaintenancesWait extends BaseCommand { @@ -30,10 +31,8 @@ export default class DataMaintenancesWait extends BaseCommand { this.error('You can\'t await maintenance on an Essential tier database.') } - const {body: maintenance} = await this.dataApi.get( - `/data/maintenances/v1/${addon.id}`, - this.dataApi.defaults, - ) + const {data} = new HerokuSDK() + const maintenance = await data.maintenance.info(addon.id!) if (maintenance.status !== MaintenanceStatus.running) { this.error(`There currently isn't any maintenance in progress for ${color.addon(addon.name!)}`) diff --git a/src/commands/data/maintenances/window/index.ts b/src/commands/data/maintenances/window/index.ts index dd456bbd86..c2f405e8f8 100644 --- a/src/commands/data/maintenances/window/index.ts +++ b/src/commands/data/maintenances/window/index.ts @@ -1,11 +1,9 @@ -import {flags as Flags} from '@heroku-cli/command' +import {Command, flags as Flags} from '@heroku-cli/command' import {color, hux, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' -import BaseCommand from '../../../../lib/data/base-command.js' -import {Window} from '../../../../lib/data/types.js' - -export default class DataMaintenancesWindow extends BaseCommand { +export default class DataMaintenancesWindow extends Command { static args = { addon: Args.string({ description: 'addon to show window for', @@ -29,10 +27,8 @@ export default class DataMaintenancesWindow extends BaseCommand { const addon = await addonResolver.resolve(args.addon, flags.app) ux.action.start(`Fetching maintenance window for ${color.addon(addon.name!)}`) - const {body: window} = await this.dataApi.get( - `/data/maintenances/v1/${addon.id}/window`, - this.dataApi.defaults, - ) + const {data} = new HerokuSDK() + const window = await data.maintenance.window(addon.id!) ux.action.stop() if (flags.json) { diff --git a/src/commands/data/maintenances/window/update.ts b/src/commands/data/maintenances/window/update.ts index 5eae4498e5..1414ad8fd8 100644 --- a/src/commands/data/maintenances/window/update.ts +++ b/src/commands/data/maintenances/window/update.ts @@ -1,11 +1,9 @@ -import {flags as Flags} from '@heroku-cli/command' +import {Command, flags as Flags} from '@heroku-cli/command' import {color, hux, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' -import BaseCommand from '../../../../lib/data/base-command.js' -import {Window} from '../../../../lib/data/types.js' - -export default class DataMaintenancesWindowUpdate extends BaseCommand { +export default class DataMaintenancesWindowUpdate extends Command { static args = { addon: Args.string({ description: 'addon to change window for', @@ -40,16 +38,11 @@ export default class DataMaintenancesWindowUpdate extends BaseCommand { const combinedWindowLabel = `${args.day_of_week} ${args.time_of_day}` ux.action.start(`Setting maintenance window for ${color.addon(addon.name!)} to ${combinedWindowLabel}`) - const {body: result} = await this.dataApi.post( - `/data/maintenances/v1/${addon.id}/window`, - { - ...this.dataApi.defaults, - body: { - day_of_week: args.day_of_week, - time_of_day: args.time_of_day, - }, - }, - ) + const {data} = new HerokuSDK() + const result = await data.maintenance.updateWindow(addon.id!, { + day_of_week: args.day_of_week, + time_of_day: args.time_of_day, + }) ux.action.stop() if (flags.json) { diff --git a/src/commands/pg/backups/index.ts b/src/commands/pg/backups/index.ts index d7aecbe834..4b886549f1 100644 --- a/src/commands/pg/backups/index.ts +++ b/src/commands/pg/backups/index.ts @@ -1,10 +1,12 @@ import {Command, flags} from '@heroku-cli/command' -import {color, hux, utils} from '@heroku/heroku-cli-util' +import {color, hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' import type {BackupTransfer} from '../../../lib/pg/types.js' import backupsFactory from '../../../lib/pg/backups.js' +import {listTransfersByApp} from '../../../lib/pg/sdk-adapter.js' export default class Index extends Command { static description = 'list database backups' @@ -24,7 +26,8 @@ export default class Index extends Command { public async run(): Promise { const {flags: {app}} = await this.parse(Index) - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + const {data} = new HerokuSDK() + const transfers = await listTransfersByApp(data, app) // NOTE that the sort order is descending transfers.sort((transferA, transferB) => transferB.created_at.localeCompare(transferA.created_at)) diff --git a/src/commands/pg/credentials.ts b/src/commands/pg/credentials.ts index fb851a39d9..dcea38c187 100644 --- a/src/commands/pg/credentials.ts +++ b/src/commands/pg/credentials.ts @@ -1,10 +1,12 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {hux, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args} from '@oclif/core' import type {NonAdvancedCredentialInfo} from '../../lib/data/types.js' +import {listCredentials} from '../../lib/pg/sdk-adapter.js' import {presentCredentialAttachments} from '../../lib/pg/util.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' import {nls} from '../../nls.js' @@ -32,15 +34,8 @@ export default class Credentials extends Command { const dbResolver = new utils.pg.DatabaseResolver(this.heroku) const {addon} = await dbResolver.getAttachment(app, database) - const {body: credentials} = await this.heroku.get( - `/postgres/v0/databases/${addon.id}/credentials`, - { - headers: { - Authorization: `Basic ${Buffer.from(`:${this.heroku.auth}`).toString('base64')}`, - }, - hostname: utils.pg.host(), - }, - ) + const {data} = new HerokuSDK() + const credentials = await listCredentials(data, addon.id) const sortedCredentials = this.sortByDefaultAndName(credentials) const {body: attachments} = await this.heroku.get[]>(`/addons/${addon.id}/addon-attachments`) diff --git a/src/commands/pg/info.ts b/src/commands/pg/info.ts index 7b5d9520d6..89fc394b52 100644 --- a/src/commands/pg/info.ts +++ b/src/commands/pg/info.ts @@ -3,6 +3,7 @@ import * as Heroku from '@heroku-cli/schema' import { color, hux, pg, utils, } from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import {PgDatabaseTenant} from '../../lib/pg/types.js' @@ -49,19 +50,15 @@ export default class Info extends Command { } } + const {data} = new HerokuSDK() let dbs: DBObject[] = await Promise.all(addons.map(async addon => { - const pgResponse = await this.heroku.get( - `/client/v11/databases/${addon.id}`, - { - hostname: utils.pg.host(), - }, - ) + const dbInfo = await (data.database.info(addon.id) as Promise) .catch(error => { if (error.statusCode !== 404) throw error ux.warn(`${color.datastore(addon.name)} is not yet provisioned.\nRun ${color.code('heroku addons:wait')} to wait until the db is provisioned.`) + return null }) - const {body: dbInfo} = pgResponse || {body: null} return { addon, config, diff --git a/src/commands/pg/upgrade/cancel.ts b/src/commands/pg/upgrade/cancel.ts index bcbb10ec72..ff702440aa 100644 --- a/src/commands/pg/upgrade/cancel.ts +++ b/src/commands/pg/upgrade/cancel.ts @@ -1,10 +1,12 @@ import {Command, flags} from '@heroku-cli/command' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {DatabaseCancelUpgradeResult} from '@heroku/types/data' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {PgDatabase, PgUpgradeError, PgUpgradeResponse} from '../../../lib/pg/types.js' +import {getDatabaseInfo} from '../../../lib/pg/sdk-adapter.js' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -36,7 +38,8 @@ export default class Upgrade extends Command { if (utils.pg.isEssentialDatabase(db)) ux.error(`You can't use ${color.code('pg:upgrade:cancel')} on Essential-tier databases. You can only use this command on Standard-tier and higher leader databases.`) - const {body: replica} = await this.heroku.get(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()}) + const {data} = new HerokuSDK() + const replica = await getDatabaseInfo(data, db.id) if (replica.following) ux.error(`You can't use ${color.code('pg:upgrade:cancel')} on follower databases. You can only use this command on Standard-tier and higher leader databases.`) @@ -49,11 +52,14 @@ export default class Upgrade extends Command { try { ux.action.start(`Cancelling upgrade on ${color.addon(db.name)}`) - const response = await this.heroku.post(`/client/v11/databases/${db.id}/upgrade/cancel`, {body: {}, hostname: utils.pg.host()}) - ux.action.stop('done\n' + formatResponseWithCommands(response.body.message)) - } catch (error) { - const response = error as PgUpgradeError - ux.error(formatResponseWithCommands(response.body.message) + `\n\nError ID: ${response.body.id}`) + const response: DatabaseCancelUpgradeResult = await data.database.cancelUpgrade(db.id) + ux.action.stop('done\n' + formatResponseWithCommands(response.message)) + } catch (error: any) { + if (error.id && error.message) { + ux.error(formatResponseWithCommands(error.message) + `\n\nError ID: ${error.id}`) + } else { + throw error + } } } } diff --git a/src/commands/pg/upgrade/dryrun.ts b/src/commands/pg/upgrade/dryrun.ts index da1f1fc58e..d8ece92a87 100644 --- a/src/commands/pg/upgrade/dryrun.ts +++ b/src/commands/pg/upgrade/dryrun.ts @@ -1,10 +1,11 @@ import {Command, flags} from '@heroku-cli/command' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {PgDatabase, PgUpgradeError, PgUpgradeResponse} from '../../../lib/pg/types.js' +import {dryRunUpgrade, getDatabaseInfo} from '../../../lib/pg/sdk-adapter.js' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -38,7 +39,8 @@ export default class Upgrade extends Command { ux.error(`You can't use ${color.code('pg:upgrade:dryrun')} on Essential-tier databases. You can only use this command on Standard-tier and higher leader databases.`) const versionPhrase = version ? heredoc(`Postgres version ${version}`) : heredoc('the latest supported Postgres version') - const {body: replica} = await this.heroku.get(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()}) + const {data} = new HerokuSDK() + const replica = await getDatabaseInfo(data, db.id) if (replica.following) ux.error(`You can't use ${color.code('pg:upgrade:dryrun')} on follower databases. You can only use this command on Standard-tier and higher leader databases.`) @@ -47,13 +49,15 @@ export default class Upgrade extends Command { `)) try { - const data = {version} ux.action.start(`Starting a test upgrade on ${color.datastore(db.name)}`) - const response = await this.heroku.post(`/client/v11/databases/${db.id}/upgrade/dry_run`, {body: data, hostname: utils.pg.host()}) - ux.action.stop('done\n' + formatResponseWithCommands(response.body.message)) - } catch (error) { - const response = error as PgUpgradeError - ux.error(formatResponseWithCommands(response.body.message) + `\n\nError ID: ${response.body.id}`) + const response = await dryRunUpgrade(data, db.id, {version}) + ux.action.stop('done\n' + formatResponseWithCommands(response.message)) + } catch (error: any) { + if (error.id && error.message) { + ux.error(formatResponseWithCommands(error.message) + `\n\nError ID: ${error.id}`) + } else { + throw error + } } } } diff --git a/src/commands/pg/upgrade/prepare.ts b/src/commands/pg/upgrade/prepare.ts index c85c319564..cad74d19d2 100644 --- a/src/commands/pg/upgrade/prepare.ts +++ b/src/commands/pg/upgrade/prepare.ts @@ -1,10 +1,11 @@ import {Command, flags} from '@heroku-cli/command' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {PgDatabase, PgUpgradeError, PgUpgradeResponse} from '../../../lib/pg/types.js' +import {getDatabaseInfo, prepareUpgrade} from '../../../lib/pg/sdk-adapter.js' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -38,7 +39,8 @@ export default class Upgrade extends Command { ux.error(`You can only use ${color.code('heroku pg:upgrade:prepare')} on Standard-tier and higher leader databases. For Essential-tier databases, use ${color.code('heroku pg:upgrade:run')} instead.`) const versionPhrase = version ? heredoc(`Postgres version ${version}`) : heredoc('the latest supported Postgres version') - const {body: replica} = await this.heroku.get(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()}) + const {data} = new HerokuSDK() + const replica = await getDatabaseInfo(data, db.id) if (replica.following) ux.error(`You can only use ${color.code('heroku pg:upgrade:prepare')} on Standard-tier and higher leader databases. For follower databases, use ${color.code('heroku pg:upgrade:run')} instead.`) @@ -49,13 +51,15 @@ export default class Upgrade extends Command { `)) try { - const data = {version} ux.action.start(`Preparing upgrade on ${color.addon(db.name)}`) - const response = await this.heroku.post(`/client/v11/databases/${db.id}/upgrade/prepare`, {body: data, hostname: utils.pg.host()}) - ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.body.message)}`)) - } catch (error) { - const response = error as PgUpgradeError - ux.error(heredoc(`${formatResponseWithCommands(response.body.message)}\n\nError ID: ${response.body.id}`)) + const response = await prepareUpgrade(data, db.id, {version}) + ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.message)}`)) + } catch (error: any) { + if (error.id && error.message) { + ux.error(heredoc(`${formatResponseWithCommands(error.message)}\n\nError ID: ${error.id}`)) + } else { + throw error + } } } } diff --git a/src/commands/pg/upgrade/run.ts b/src/commands/pg/upgrade/run.ts index c172a7f591..4aeb49c017 100644 --- a/src/commands/pg/upgrade/run.ts +++ b/src/commands/pg/upgrade/run.ts @@ -1,11 +1,12 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {PgDatabase, PgUpgradeError, PgUpgradeResponse} from '../../../lib/pg/types.js' +import {getDatabaseInfo, runUpgrade} from '../../../lib/pg/sdk-adapter.js' import {databaseNameFromUrl, formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -57,7 +58,8 @@ export default class Upgrade extends Command { ux.error(`You can only use ${color.code('pg:upgrade:*')} commands on Essential-* and higher plans.`) const versionPhrase = version ? heredoc(`Postgres version ${version}`) : heredoc('the latest supported Postgres version') - const {body: replica} = await this.heroku.get(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()}) + const {data} = new HerokuSDK() + const replica = await getDatabaseInfo(data, db.id) if (utils.pg.isEssentialDatabase(db)) { await new ConfirmCommand().confirm(app, confirm, heredoc(` @@ -86,14 +88,12 @@ export default class Upgrade extends Command { } try { - const data = {version} ux.action.start(`Starting upgrade on ${color.datastore(db.name)}`) - const response = await this.heroku.post(`/client/v11/databases/${db.id}/upgrade/run`, {body: data, hostname: utils.pg.host()}) - ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.body.message)}`)) - } catch (error) { - if (error instanceof Error && 'body' in error) { - const response = error as PgUpgradeError - ux.error(heredoc(`${formatResponseWithCommands(response.body.message)}\n\nError ID: ${response.body.id}`)) + const response = await runUpgrade(data, db.id, {version}) + ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.message)}`)) + } catch (error: any) { + if (error.id && error.message) { + ux.error(heredoc(`${formatResponseWithCommands(error.message)}\n\nError ID: ${error.id}`)) } else { throw error } diff --git a/src/commands/pg/upgrade/wait.ts b/src/commands/pg/upgrade/wait.ts index 326fd5eafd..0c1841c90a 100644 --- a/src/commands/pg/upgrade/wait.ts +++ b/src/commands/pg/upgrade/wait.ts @@ -1,11 +1,12 @@ import {Command, flags} from '@heroku-cli/command' import {color, pg, utils} from '@heroku/heroku-cli-util' -import {HTTPError} from '@heroku/http-call' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import debug from 'debug' import tsheredoc from 'tsheredoc' import notify from '../../../lib/notify.js' +import {getUpgradeWaitStatus} from '../../../lib/pg/sdk-adapter.js' import {PgUpgradeStatus} from '../../../lib/pg/types.js' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -49,28 +50,30 @@ export default class Wait extends Command { const dbName = args.database const pgDebug = debug('pg') + const {data} = new HerokuSDK() const waitFor = async (db: pg.ExtendedAddonAttachment['addon']) => { const interval = (!waitInterval || waitInterval < 0) ? 5 : waitInterval - let status + let status: PgUpgradeStatus let waiting = false let retries = 20 const notFoundMessage = 'Waiting to provision...' while (true) { try { - ({body: status} = await this.heroku.get( - `/client/v11/databases/${db.id}/upgrade/wait_status`, - {hostname: utils.pg.host()}, - )) - } catch (error) { - if (error instanceof HTTPError && (!retries || error.statusCode !== 404)) { - const httpError = error as HTTPError - pgDebug(httpError) - throw httpError + status = await getUpgradeWaitStatus(data, db.id) + } catch (error: any) { + if (!retries || error.statusCode !== 404) { + pgDebug(error) + throw error } retries-- - status = {message: notFoundMessage, 'waiting?': true} + status = { + 'error?': false, + message: notFoundMessage, + step: '', + 'waiting?': true, + } } let message = formatResponseWithCommands(status.message) diff --git a/src/lib/pg/sdk-adapter.ts b/src/lib/pg/sdk-adapter.ts new file mode 100644 index 0000000000..8fb2eabe14 --- /dev/null +++ b/src/lib/pg/sdk-adapter.ts @@ -0,0 +1,45 @@ +import type {HerokuSDK} from '@heroku/sdk' + +import type {BackupTransfer, PgDatabase, PgUpgradeStatus} from './types.js' +import type {NonAdvancedCredentialInfo} from '../data/types.js' + +// Temporary adapter: the SDK's data client return types are incomplete in @heroku/types. +// These wrappers cast once so command files stay type-safe. +// Remove when heroku-types provides: +// - DatabaseInfoResult.following +// - DatabaseWaitStatusResult.error?, .step +// - TransferListByAppResult as typed array +// - PostgresDatabaseListCredentialsResult as typed array + +type DataClient = HerokuSDK['data'] + +export async function getDatabaseInfo(data: DataClient, addonId: string): Promise { + return data.database.info(addonId) as unknown as PgDatabase +} + +export async function getUpgradeWaitStatus(data: DataClient, addonId: string): Promise { + return data.database.upgradeWaitStatus(addonId) as unknown as PgUpgradeStatus +} + +export async function listTransfersByApp(data: DataClient, appIdentity: string): Promise { + return data.transfer.listByApp(appIdentity) as unknown as BackupTransfer[] +} + +export async function listCredentials(data: DataClient, addonId: string): Promise { + return data.postgresDatabase.listCredentials(addonId) as unknown as NonAdvancedCredentialInfo[] +} + +export async function runUpgrade(data: DataClient, addonId: string, body: {version?: string}): Promise<{message: string}> { + const fn = data.database.runUpgrade as (name: string, body: {version?: string}) => Promise + return fn(addonId, body) as Promise<{message: string}> +} + +export async function prepareUpgrade(data: DataClient, addonId: string, body: {version?: string}): Promise<{message: string}> { + const fn = data.database.prepareUpgrade as (name: string, body: {version?: string}) => Promise + return fn(addonId, body) as Promise<{message: string}> +} + +export async function dryRunUpgrade(data: DataClient, addonId: string, body: {version?: string}): Promise<{message: string}> { + const fn = data.database.dryRunUpgrade as (name: string, body: {version?: string}) => Promise + return fn(addonId, body) as Promise<{message: string}> +} diff --git a/src/lib/pg/types.ts b/src/lib/pg/types.ts index a58d827d4c..ca12b35b93 100644 --- a/src/lib/pg/types.ts +++ b/src/lib/pg/types.ts @@ -173,16 +173,6 @@ export type PgDatabaseTenant = { export type PgDatabase = PgDatabaseService & PgDatabaseTenant -export type PgUpgradeResponse = { - message: string -} - -export type PgUpgradeError = { - body: { - id: string, - message: string, - } -} // Updated according to https://github.com/heroku/shogun/blob/main/lib/shogun/serializers/link_serializer.rb export type Link = { diff --git a/test/helpers/init.mjs b/test/helpers/init.mjs index 304abebf25..e4f6edc900 100644 --- a/test/helpers/init.mjs +++ b/test/helpers/init.mjs @@ -10,6 +10,10 @@ process.env.IS_HEROKU_TEST_ENV = 'true' process.env.HEROKU_SKIP_NEW_VERSION_CHECK = 'true' +// Provide a fake API key so @heroku/sdk's token provider doesn't throw +// during tests (nock intercepts all HTTP calls regardless) +process.env.HEROKU_API_KEY = process.env.HEROKU_API_KEY || 'test-fake-token' + process.env.HEROKU_DATA_CONTROL_PLANE = 'test-control-plane' // Force ANSI color support for tests while they run in non-tty diff --git a/test/helpers/mock-sdk.ts b/test/helpers/mock-sdk.ts new file mode 100644 index 0000000000..8b448d6dbb --- /dev/null +++ b/test/helpers/mock-sdk.ts @@ -0,0 +1,44 @@ +import {HerokuSDK} from '@heroku/sdk' +import {SinonStub, stub} from 'sinon' + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] +} + +type StubbedDataClient = DeepPartial +type StubbedPlatformClient = DeepPartial + +export interface MockSDK { + dataStub?: SinonStub + platformStub?: SinonStub + restore: () => void +} + +export function mockSDKData(fakeData: StubbedDataClient): MockSDK { + const dataStub = stub(HerokuSDK.prototype, 'data').get(() => fakeData) + return { + dataStub, + restore: () => dataStub.restore(), + } +} + +export function mockSDKPlatform(fakePlatform: StubbedPlatformClient): MockSDK { + const platformStub = stub(HerokuSDK.prototype, 'platform').get(() => fakePlatform) + return { + platformStub, + restore: () => platformStub.restore(), + } +} + +export function mockSDK(fakeData?: StubbedDataClient, fakePlatform?: StubbedPlatformClient): MockSDK { + const dataStub = fakeData ? stub(HerokuSDK.prototype, 'data').get(() => fakeData) : undefined + const platformStub = fakePlatform ? stub(HerokuSDK.prototype, 'platform').get(() => fakePlatform) : undefined + return { + dataStub, + platformStub, + restore: () => { + dataStub?.restore() + platformStub?.restore() + }, + } +} diff --git a/test/unit/commands/addons/create.unit.test.ts b/test/unit/commands/addons/create.unit.test.ts index 2edf7971ce..cfc3becd5a 100644 --- a/test/unit/commands/addons/create.unit.test.ts +++ b/test/unit/commands/addons/create.unit.test.ts @@ -1,17 +1,18 @@ import * as Heroku from '@heroku-cli/schema' import {runCommand} from '@heroku-cli/test-utils' -import {HTTPError} from '@heroku/http-call' import ansis from 'ansis' import {expect} from 'chai' import _ from 'lodash' -import nock from 'nock' -import {createSandbox} from 'sinon' +import {createSandbox, stub} from 'sinon' import Cmd from '../../../../src/commands/addons/create.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' import {unwrap} from '../../../helpers/utils/unwrap.js' +import {AddonConfirmationRequiredError, AddonProvisioningFailedError} from '@heroku/sdk/resources/platform/add-on' + describe('addons:create', function () { - let api: ReturnType + let sdkMock: MockSDK const addon: Heroku.AddOn = { addon_service: {name: 'heroku-postgresql'}, @@ -30,22 +31,15 @@ describe('addons:create', function () { state: 'provisioned', } - beforeEach(async function () { - api = nock('https://api.heroku.com:443') - }) - afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() }) + context('creating a db with a name', function () { - beforeEach(function () { - api.post('/apps/myapp/addons', { - attachment: {}, config: {}, name: 'foobar', plan: 'heroku-postgresql:standard-0', - }) - .reply(200, addon) - }) it('passes name through to the API', async function () { + const createAndWaitStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + await runCommand(Cmd, [ '--app', 'myapp', @@ -53,10 +47,16 @@ describe('addons:create', function () { 'foobar', 'heroku-postgresql:standard-0', ]) + expect(createAndWaitStub.calledOnce).to.be.true + const [appArg, bodyArg] = createAndWaitStub.firstCall.args + expect(appArg).to.equal('myapp') + expect(bodyArg.name).to.equal('foobar') + expect(bodyArg.plan).to.equal('heroku-postgresql:standard-0') }) }) context('calling addons:create without a plan', function () { it('errors out with usage', async function () { + sdkMock = mockSDKPlatform({addOn: {createAndWait: stub()}}) return runCommand(Cmd, [ '--app', 'myapp', @@ -71,15 +71,10 @@ describe('addons:create', function () { }) }) context('creating a db', function () { - beforeEach(function () { - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, - config: {follow: 'otherdb', foo: true, rollback: true}, - plan: 'heroku-postgresql:standard-0', - }) - .reply(200, addon) - }) it('creates an add-on with proper output', async function () { + const createAndWaitStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -94,8 +89,15 @@ describe('addons:create', function () { ]) expect(stderr).to.contain('Creating heroku-postgresql:standard-0 on ⬢ myapp... ~$0.139/hour (max $100/month)') expect(stdout).to.equal('provision message\nCreated postgresql-swiftly-123 as DATABASE_URL\nRun heroku addons:docs heroku-postgresql to view documentation.\n') + const [appArg, bodyArg] = createAndWaitStub.firstCall.args + expect(appArg).to.equal('myapp') + expect(bodyArg.config).to.deep.equal({follow: 'otherdb', foo: true, rollback: true}) + expect(bodyArg.attachment).to.deep.equal({name: 'mydb'}) }) it('creates an add-on with proper output using old syntax with deprecation message', async function () { + const createAndWaitStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr} = await runCommand(Cmd, [ '--app', 'myapp', @@ -113,6 +115,9 @@ describe('addons:create', function () { expect(unwrap(stderr)).to.contain('See https://devcenter.heroku.com/changelog-items/2925 for more info.') }) it('creates an addon with = args', async function () { + const createAndWaitStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + await runCommand(Cmd, [ '--app', 'myapp', @@ -124,8 +129,13 @@ describe('addons:create', function () { '--follow=otherdb', '--foo', ]) + const [, bodyArg] = createAndWaitStub.firstCall.args + expect(bodyArg.config).to.deep.equal({follow: 'otherdb', foo: true, rollback: true}) }) it('turns args value true into literal true, not a string', async function () { + const createAndWaitStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + await runCommand(Cmd, [ '--app', 'myapp', @@ -137,18 +147,17 @@ describe('addons:create', function () { '--follow=otherdb', '--foo=true', ]) + const [, bodyArg] = createAndWaitStub.firstCall.args + expect(bodyArg.config).to.deep.equal({follow: 'otherdb', foo: true, rollback: true}) }) }) context('when add-on is async', function () { context('provisioning message and config vars provided by add-on provider', function () { - beforeEach(function () { - const asyncAddon = {..._.clone(addon), config_vars: [], state: 'provisioning'} - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, asyncAddon) - }) it('creates an add-on with output about async provisioning', async function () { + const asyncAddon = {..._.clone(addon), config_vars: [], state: 'provisioning'} + const createAndWaitStub = stub().resolves(asyncAddon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -161,16 +170,13 @@ describe('addons:create', function () { }) }) context('and no provision message supplied', function () { - beforeEach(function () { + it('creates an add-on with output about async provisioning', async function () { const asyncAddon = { ..._.clone(addon), config_vars: [], provision_message: undefined, state: 'provisioning', } - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, asyncAddon) - }) - it('creates an add-on with output about async provisioning', async function () { + const createAndWaitStub = stub().resolves(asyncAddon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -183,14 +189,11 @@ describe('addons:create', function () { }) }) context('and no config vars supplied by add-on provider', function () { - beforeEach(function () { - const asyncAddon = {..._.clone(addon), config_vars: undefined, state: 'provisioning'} - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, asyncAddon) - }) it('creates an add-on with output about async provisioning', async function () { + const asyncAddon = {..._.clone(addon), config_vars: undefined, state: 'provisioning'} + const createAndWaitStub = stub().resolves(asyncAddon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -212,15 +215,17 @@ describe('addons:create', function () { }) it('waits for response and notifies', async function () { const notifySpy = sandbox.spy(Cmd, 'notifier') - const asyncAddon = {..._.clone(addon), state: 'provisioning'} - const post = api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {wait: true}, plan: 'heroku-postgresql:standard-0', + // When wait is true, createAndWait calls onProvisioning then returns provisioned addon + const createAndWaitStub = stub().callsFake(async (_app, _body, options) => { + if (options?.onProvisioning) { + const asyncAddon = {..._.clone(addon), config_vars: [], state: 'provisioning'} + options.onProvisioning(asyncAddon) + } + + return addon }) - .reply(200, asyncAddon) - const provisioningResponse = api.get('/apps/myapp/addons/postgresql-swiftly-123') - .reply(200, asyncAddon) - const provisionedResponse = api.get('/apps/myapp/addons/postgresql-swiftly-123') - .reply(200, addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -236,26 +241,15 @@ describe('addons:create', function () { expect(stderr).to.contain('Creating heroku-postgresql:standard-0 on ⬢ myapp... ~$0.139/hour (max $100/month)') expect(stderr).to.contain('Creating postgresql-swiftly-123... done') expect(stdout).to.equal('provision message\nWaiting for postgresql-swiftly-123...\nCreated postgresql-swiftly-123 as DATABASE_URL\nRun heroku addons:docs heroku-postgresql to view documentation.\n') - post.done() - provisioningResponse.done() - provisionedResponse.done() }) it('notifies when provisioning failure occurs', async function () { const notifySpy = sandbox.spy(Cmd, 'notifier') - const asyncAddon = _.clone(addon) - asyncAddon.state = 'provisioning' - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {wait: true}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, asyncAddon) - api.get('/apps/myapp/addons/postgresql-swiftly-123') - .reply(200, asyncAddon) - const deprovisionedAddon = _.clone(addon) - deprovisionedAddon.state = 'deprovisioned' - api.get('/apps/myapp/addons/postgresql-swiftly-123') - .reply(200, deprovisionedAddon) + const deprovisionedAddon = {..._.clone(addon), state: 'deprovisioned'} + const createAndWaitStub = stub().rejects(new AddonProvisioningFailedError(deprovisionedAddon)) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + try { - return await runCommand(Cmd, [ + await runCommand(Cmd, [ '--app', 'myapp', '--as', @@ -275,10 +269,9 @@ describe('addons:create', function () { it('shows that it failed to provision', async function () { const deprovisionedAddon = _.clone(addon) deprovisionedAddon.state = 'deprovisioned' - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, deprovisionedAddon) + const createAndWaitStub = stub().rejects(new AddonProvisioningFailedError(deprovisionedAddon)) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {error} = await runCommand(Cmd, [ '--app', 'myapp', @@ -286,16 +279,16 @@ describe('addons:create', function () { 'mydb', 'heroku-postgresql:standard-0', ]) - expect((error as HTTPError)?.message).to.equal('The add-on was unable to be created, with status deprovisioned.') + expect(error?.message).to.equal('The add-on was unable to be created, with status deprovisioned.') }) }) }) context('creating a db requiring confirmation', function () { - it('aborts if confirmation does not match', function () { - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, confirm: 'not-my-app', plan: 'heroku-postgresql:standard-0', - }) - .reply(423, {id: 'confirmation_required', message: 'This add-on is not automatically networked with this Private Space. '}, {'X-Confirmation-Required': 'myapp-confirm'}) + it('aborts if confirmation does not match', async function () { + const createAndWaitStub = stub() + .onFirstCall().rejects(new AddonConfirmationRequiredError('This add-on is not automatically networked with this Private Space. ')) + .onSecondCall().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) return runCommand(Cmd, [ '--app', @@ -317,10 +310,11 @@ describe('addons:create', function () { }) it('succeeds if confirmation does match', async function () { - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, confirm: 'myapp', plan: 'heroku-postgresql:standard-0', - }) - .reply(200, addon) + const createAndWaitStub = stub() + .onFirstCall().rejects(new AddonConfirmationRequiredError('This add-on is not automatically networked with this Private Space. ')) + .onSecondCall().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -340,14 +334,11 @@ describe('addons:create', function () { }) }) context('--follow=--otherdb', function () { - beforeEach(function () { - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {follow: '--otherdb', foo: true, rollback: true}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, addon) - }) - it('creates an addon with =-- args', function () { - return runCommand(Cmd, [ + it('creates an addon with =-- args', async function () { + const createAndWaitStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) + + await runCommand(Cmd, [ '--app', 'myapp', '--as', @@ -358,18 +349,16 @@ describe('addons:create', function () { '--follow=--otherdb', '--foo', ]) + const [, bodyArg] = createAndWaitStub.firstCall.args + expect(bodyArg.config).to.deep.equal({follow: '--otherdb', foo: true, rollback: true}) }) }) context('no config vars supplied by add-on provider', function () { - beforeEach(function () { + it('creates an add-on without the config vars listed', async function () { const noConfigAddon = {..._.clone(addon), config_vars: undefined} + const createAndWaitStub = stub().resolves(noConfigAddon) + sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) - api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', - }) - .reply(200, noConfigAddon) - }) - it('creates an add-on without the config vars listed', async function () { const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', diff --git a/test/unit/commands/addons/detach.unit.test.ts b/test/unit/commands/addons/detach.unit.test.ts index fd86b61d5e..bf0026df56 100644 --- a/test/unit/commands/addons/detach.unit.test.ts +++ b/test/unit/commands/addons/detach.unit.test.ts @@ -1,33 +1,33 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/addons/detach.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('addons:detach', function () { - let api: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() }) it('detaches an add-on', async function () { - api - .get('/apps/myapp/addon-attachments/redis-123') - .reply(200, {addon: {name: 'redis'}, id: 100, name: 'redis-123'}) - .delete('/addon-attachments/100') - .reply(200, {id: 100, name: 'redis-123'}) - .get('/apps/myapp/releases') - .reply(200, [{version: 10}]) + const infoByAppStub = stub().resolves({addon: {name: 'redis'}, id: 100, name: 'redis-123'}) + const deleteStub = stub().resolves({id: 100, name: 'redis-123'}) + const listReleasesStub = stub().resolves([{version: 10}]) + const fakePlatform = { + addOnAttachment: {delete: deleteStub, infoByApp: infoByAppStub}, + release: {list: listReleasesStub}, + withHeaders: stub().returns({release: {list: listReleasesStub}}), + } + sdkMock = mockSDKPlatform(fakePlatform) const {stderr, stdout} = await runCommand(Cmd, ['--app', 'myapp', 'redis-123']) expect(stdout).to.equal('') expect(stderr).to.contain('Detaching redis-123 to redis from ⬢ myapp... done') expect(stderr).to.contain('Unsetting redis-123 config vars and restarting ⬢ myapp... done, v10') + expect(infoByAppStub.calledOnceWith('myapp', 'redis-123')).to.be.true + expect(deleteStub.calledOnceWith(100)).to.be.true }) }) diff --git a/test/unit/commands/addons/index.unit.test.ts b/test/unit/commands/addons/index.unit.test.ts index 3de313e195..5cde275a3a 100644 --- a/test/unit/commands/addons/index.unit.test.ts +++ b/test/unit/commands/addons/index.unit.test.ts @@ -3,29 +3,18 @@ import * as Heroku from '@heroku-cli/schema' import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {hux} from '@heroku/heroku-cli-util' import {expect} from 'chai' -import nock from 'nock' -import {restore, stub} from 'sinon' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/addons/index.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' import removeAllWhitespace from '../../../helpers/utils/remove-whitespaces.js' describe('addons', function () { - let api: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com', { - reqheaders: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - 'Accept-Expansion': 'addon_service,plan', - }, - }) - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() - restore() + sdkMock?.restore() }) describe('--all', function () { @@ -38,15 +27,15 @@ describe('addons', function () { }) context('with add-ons', function () { - beforeEach(function () { - // Global /addons doesn't accept Accept-Expansion, so use a - // separate scope without the expansion reqheaders. - nock('https://api.heroku.com') - .get('/addons') - .reply(200, addons) - }) - it('prints add-ons in a table', async function () { + const listStub = stub().resolves(addons) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, []) const actual = removeAllWhitespace(stdout) const expectedHeader = removeAllWhitespace(` @@ -59,20 +48,44 @@ describe('addons', function () { expect(actual).to.include(expected) }) it('orders by app, then by add-on name', async function () { + const listStub = stub().resolves(addons) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, []) expect(stdout.indexOf('acme-inc-api')).to.be.lt(stdout.indexOf('acme-inc-www')) expect(stdout.indexOf('www-db')).to.be.lt(stdout.indexOf('www-redis')) }) it('passes no-wrap option through to table rendering', async function () { + const listStub = stub().resolves(addons) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) const tableStub = stub(hux, 'table') await runCommand(Cmd, ['--all', '--no-wrap']) const callArgs = tableStub.firstCall.args expect(callArgs[2]).to.include({maxWidth: 'none', overflow: 'truncate'}) + tableStub.restore() }) context('--json', function () { it('prints the output in json format', async function () { + const listStub = stub().resolves(addons) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, [ '--json', ]) @@ -81,14 +94,16 @@ describe('addons', function () { }) }) context('with a grandfathered add-on', function () { - beforeEach(function () { - const addon = fixtures.addons['dwh-db'] - addon.billed_price = {cents: 10_000} - nock('https://api.heroku.com') - .get('/addons') - .reply(200, [addon]) - }) it('prints add-ons in a table with the grandfathered price', async function () { + const addon = structuredClone({...fixtures.addons['dwh-db'], billed_price: {cents: 10_000}}) + const listStub = stub().resolves([addon]) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, []) const actual = removeAllWhitespace(stdout) const expectedHeader = removeAllWhitespace(` @@ -100,14 +115,16 @@ describe('addons', function () { }) }) context('with a contract add-on', function () { - beforeEach(function () { - const addon = fixtures.addons['dwh-db'] - addon.billed_price = {cents: 0, contract: true} - nock('https://api.heroku.com') - .get('/addons') - .reply(200, [addon]) - }) it('prints add-ons in a table with contract', async function () { + const addon = structuredClone({...fixtures.addons['dwh-db'], billed_price: {cents: 0, contract: true}}) + const listStub = stub().resolves([addon]) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, []) const actual = removeAllWhitespace(stdout) const expectedHeader = removeAllWhitespace(` @@ -120,22 +137,29 @@ describe('addons', function () { }) it('prints message when there are no add-ons', async function () { - nock('https://api.heroku.com') - .get('/addons') - .reply(200, []) + const listStub = stub().resolves([]) + const fakePlatform = { + addOn: {list: listStub, listByApp: stub()}, + addOnAttachment: {list: stub(), listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: stub()}}), + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, []) expectOutput(stdout, 'No add-ons.') }) }) describe('--app', function () { - function mockAPI(appName: string, addons: Heroku.AddOn[] = [], attachments: Heroku.AddOnAttachment[] = []) { - api - .get(`/apps/${appName}/addons`) - .reply(200, addons) - nock('https://api.heroku.com') - .get('/addon-attachments') - .reply(200, attachments) + function setupMock(appName: string, addons: Heroku.AddOn[] = [], attachments: Heroku.AddOnAttachment[] = []) { + const listByAppStub = stub().resolves(addons) + const allAttachmentsStub = stub().resolves(attachments) + const fakePlatform = { + addOn: {list: stub(), listByApp: listByAppStub}, + addOnAttachment: {list: allAttachmentsStub, listByApp: stub()}, + withHeaders: stub().returns({addOn: {listByApp: listByAppStub}}), + } + sdkMock = mockSDKPlatform(fakePlatform) } async function run(app: string, cb: (result: {stderr: string, stdout: string}) => any) { @@ -148,14 +172,14 @@ describe('addons', function () { it('prints message when there are no add-ons', function () { const appName = 'acme-inc-www' - mockAPI(appName) + setupMock(appName) return run(appName, function ({stdout}) { expectOutput(stdout, 'No add-ons for app acme-inc-www.') }) }) context('with add-ons', function () { it('prints add-ons in a table with attachments', function () { - mockAPI('acme-inc-www', [ + setupMock('acme-inc-www', [ fixtures.addons['www-db'], fixtures.addons['www-redis'], ], [ fixtures.attachments['acme-inc-www::DATABASE'], fixtures.attachments['acme-inc-www::REDIS'], @@ -174,7 +198,7 @@ describe('addons', function () { }) }) it('shows attachments to foreign apps for owned add-ons', function () { - mockAPI('acme-inc-www', [fixtures.addons['www-db']], [ + setupMock('acme-inc-www', [fixtures.addons['www-db']], [ fixtures.attachments['acme-inc-www::DATABASE'], fixtures.attachments['acme-inc-dwh::WWW_DB'], ]) return run('acme-inc-www', function ({stdout}) { @@ -190,7 +214,7 @@ describe('addons', function () { }) }) it('shows add-ons owned by foreign apps if attached to targeted app', function () { - mockAPI('acme-inc-dwh', [fixtures.addons['www-db']], [ + setupMock('acme-inc-dwh', [fixtures.addons['www-db']], [ fixtures.attachments['acme-inc-www::DATABASE'], fixtures.attachments['acme-inc-dwh::WWW_DB'], ]) return run('acme-inc-dwh', function ({stdout}) { @@ -206,14 +230,14 @@ describe('addons', function () { }) }) it("doesn't show attachments that are not related to the targeted app", function () { - mockAPI('acme-inc-dwh', [], [fixtures.attachments['acme-inc-www::DATABASE']]) + setupMock('acme-inc-dwh', [], [fixtures.attachments['acme-inc-www::DATABASE']]) return run('acme-inc-dwh', function ({stdout}) { expectOutput(stdout, 'No add-ons for app acme-inc-dwh.') }) }) describe('attachment app info', function () { beforeEach(function () { - mockAPI('acme-inc-dwh', [fixtures.addons['www-db']], [ + setupMock('acme-inc-dwh', [fixtures.addons['www-db']], [ fixtures.attachments['acme-inc-www::DATABASE'], fixtures.attachments['acme-inc-dwh::WWW_DB'], ]) }) @@ -233,7 +257,7 @@ describe('addons', function () { describe('sorting', function () { context('add-ons', function () { it('sorts owned add-ons first, foreign add-ons second', function () { - mockAPI('acme-inc-dwh', [ + setupMock('acme-inc-dwh', [ fixtures.addons['dwh-db'], fixtures.addons['www-db'], ], [ fixtures.attachments['acme-inc-dwh::DATABASE'], fixtures.attachments['acme-inc-dwh::WWW_DB'], @@ -243,7 +267,7 @@ describe('addons', function () { }) }) it('sorts add-ons of same ownership by service', function () { - mockAPI('acme-inc-www', [ + setupMock('acme-inc-www', [ fixtures.addons['www-redis'], fixtures.addons['www-db'], ], [ fixtures.attachments['acme-inc-www::REDIS'], fixtures.attachments['acme-inc-www::DATABASE'], @@ -253,7 +277,7 @@ describe('addons', function () { }) }) it('sorts add-ons of same ownership and service by plan', function () { - mockAPI('acme-inc-dwh', [ + setupMock('acme-inc-dwh', [ fixtures.addons['dwh-db'], fixtures.addons['dwh-test-db'], ], [ fixtures.attachments['acme-inc-dwh::DATABASE'], fixtures.attachments['acme-inc-dwh::TEST'], @@ -263,7 +287,7 @@ describe('addons', function () { }) }) it('sorts add-ons of same ownership and service and plan by name', function () { - mockAPI('acme-inc-dwh', [ + setupMock('acme-inc-dwh', [ fixtures.addons['dwh-db-2'], fixtures.addons['dwh-db'], ], [ fixtures.attachments['acme-inc-dwh::DATABASE'], fixtures.attachments['acme-inc-dwh::DATABASE_FOLLOWER'], @@ -275,7 +299,7 @@ describe('addons', function () { }) context('attachments', function () { it('sorts local attachments first', function () { - mockAPI('acme-inc-dwh', [ + setupMock('acme-inc-dwh', [ fixtures.addons['www-db'], ], [ fixtures.attachments['acme-inc-www::DATABASE'], fixtures.attachments['acme-inc-dwh::WWW_DB'], @@ -285,7 +309,7 @@ describe('addons', function () { }) }) it('sorts local attachments by name', function () { - mockAPI('acme-inc-www', [fixtures.addons['www-db']], [ + setupMock('acme-inc-www', [fixtures.addons['www-db']], [ fixtures.attachments['acme-inc-www::HEROKU_POSTGRESQL_RED'], fixtures.attachments['acme-inc-www::DATABASE'], ]) return run('acme-inc-www', function ({stdout}) { @@ -293,7 +317,7 @@ describe('addons', function () { }) }) it('sorts foreign attachments by app', function () { - mockAPI('acme-inc-api', [fixtures.addons['www-db']], [ + setupMock('acme-inc-api', [fixtures.addons['www-db']], [ fixtures.attachments['acme-inc-api::WWW_DB'], fixtures.attachments['acme-inc-dwh::WWW_DB'], fixtures.attachments['acme-inc-www::DATABASE'], ]) return run('acme-inc-api', function ({stdout}) { @@ -301,7 +325,7 @@ describe('addons', function () { }) }) it('sorts foreign attachments for same app by name', function () { - mockAPI('acme-inc-api', [fixtures.addons['www-db']], [ + setupMock('acme-inc-api', [fixtures.addons['www-db']], [ fixtures.attachments['acme-inc-api::WWW_DB'], fixtures.attachments['acme-inc-www::DATABASE'], fixtures.attachments['acme-inc-www::HEROKU_POSTGRESQL_RED'], ]) return run('acme-inc-api', function ({stdout}) { @@ -312,16 +336,9 @@ describe('addons', function () { }) }) context('with a grandfathered add-on', function () { - beforeEach(function () { - const addon = fixtures.addons['dwh-db'] - addon.billed_price = {cents: 10_000} - mockAPI('acme-inc-dwh', [ - addon, - ], [ - fixtures.attachments['acme-inc-dwh::DATABASE'], - ]) - }) it('prints add-ons in a table with the grandfathered price', function () { + const addon = structuredClone({...fixtures.addons['dwh-db'], billed_price: {cents: 10_000}}) + setupMock('acme-inc-dwh', [addon], [fixtures.attachments['acme-inc-dwh::DATABASE']]) return run('acme-inc-dwh', function ({stdout}) { const actual = removeAllWhitespace(stdout) const expectedHeader = removeAllWhitespace(` @@ -335,16 +352,9 @@ describe('addons', function () { }) }) context('with a contract add-on', function () { - beforeEach(function () { - const addon = fixtures.addons['dwh-db'] - addon.billed_price = {cents: 0, contract: true} - mockAPI('acme-inc-dwh', [ - addon, - ], [ - fixtures.attachments['acme-inc-dwh::DATABASE'], - ]) - }) it('prints add-ons in a table with contract', function () { + const addon = structuredClone({...fixtures.addons['dwh-db'], billed_price: {cents: 0, contract: true}}) + setupMock('acme-inc-dwh', [addon], [fixtures.attachments['acme-inc-dwh::DATABASE']]) return run('acme-inc-dwh', function ({stdout}) { const actual = removeAllWhitespace(stdout) const expectedHeader = removeAllWhitespace(` @@ -359,7 +369,7 @@ describe('addons', function () { }) it('prints add-on line for attachment when add-on info is missing from API (e.g. no permissions on billing app)', function () { - mockAPI('acme-inc-api', [], [fixtures.attachments['acme-inc-api::WWW_DB']]) + setupMock('acme-inc-api', [], [fixtures.attachments['acme-inc-api::WWW_DB']]) return run('acme-inc-api', function ({stdout}) { const actual = removeAllWhitespace(stdout) const expectedHeader = removeAllWhitespace(` diff --git a/test/unit/commands/addons/info.unit.test.ts b/test/unit/commands/addons/info.unit.test.ts index a6a5c16f25..e03199ec84 100644 --- a/test/unit/commands/addons/info.unit.test.ts +++ b/test/unit/commands/addons/info.unit.test.ts @@ -1,37 +1,26 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/addons/info.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('addons:info', function () { - let api: nock.Scope - let apiSdk: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com') - apiSdk = nock('https://api.heroku.com', { - reqheaders: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - 'Accept-Expansion': 'addon_service,plan', - }, - }) - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - apiSdk.done() - nock.cleanAll() + sdkMock.restore() }) context('with add-ons', function () { - beforeEach(function () { - apiSdk - .post('/actions/addons/resolve', {addon: 'www-db'}) - .reply(200, [fixtures.addons['www-db']]) - api.get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`).reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) - }) it('prints add-ons in a table', async function () { + const addon = { + ...fixtures.addons['www-db'], + attachments: [fixtures.attachments['acme-inc-www::DATABASE']], + } + const describeStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ 'www-db', ]) @@ -49,16 +38,14 @@ State: created\n }) context('with app add-ons', function () { - beforeEach(function () { - apiSdk - .post('/actions/addons/resolve', {addon: 'www-db', app: 'example'}) - .reply(200, [fixtures.addons['www-db']]) - api - .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) - }) - it('prints add-ons in a table', async function () { + const addon = { + ...fixtures.addons['www-db'], + attachments: [fixtures.attachments['acme-inc-www::DATABASE']], + } + const describeStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ '--app', 'example', @@ -77,20 +64,14 @@ State: created\n }) }) context('with app but not an app add-on', function () { - beforeEach(function () { - // The SDK's resolver tries app-scoped first, falls back to global on 404 add_on. - apiSdk - .post('/actions/addons/resolve', {addon: 'www-db', app: 'example'}) - .reply(404, {id: 'not_found', resource: 'add_on'}) - apiSdk - .post('/actions/addons/resolve', {addon: 'www-db'}) - .reply(200, [fixtures.addons['www-db']]) - api - .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) - }) - it('prints add-ons in a table', async function () { + const addon = { + ...fixtures.addons['www-db'], + attachments: [fixtures.attachments['acme-inc-www::DATABASE']], + } + const describeStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ '--app', 'example', @@ -110,18 +91,16 @@ State: created\n }) context('with add-ons with grandfathered pricing', function () { - beforeEach(function () { - const addon = fixtures.addons['dwh-db'] - addon.billed_price = {cents: 10_000} - apiSdk - .post('/actions/addons/resolve', {addon: 'dwh-db'}) - .reply(200, [addon]) - api - .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) - }) - it('prints add-ons in a table with grandfathered price', async function () { + const addon = { + ...fixtures.addons['dwh-db'], + attachments: [fixtures.attachments['acme-inc-dwh::DATABASE']], + billed_price: {cents: 10_000}, + plan: {...fixtures.addons['dwh-db'].plan, price: {cents: 10_000, unit: 'month'}}, + } + const describeStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ 'dwh-db', ]) @@ -139,18 +118,16 @@ State: created\n }) context('with a contract add-on', function () { - beforeEach(function () { - const addon = fixtures.addons['dwh-db'] - addon.billed_price = {cents: 0, contract: true} - apiSdk - .post('/actions/addons/resolve', {addon: 'dwh-db'}) - .reply(200, [addon]) - api - .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) - }) - it('prints add-ons in a table with contract', async function () { + const addon = { + ...fixtures.addons['dwh-db'], + attachments: [fixtures.attachments['acme-inc-dwh::DATABASE']], + billed_price: {cents: 0, contract: true}, + plan: {...fixtures.addons['dwh-db'].plan, price: {cents: 0, contract: true, unit: 'month'}}, + } + const describeStub = stub().resolves(addon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ 'dwh-db', ]) @@ -168,17 +145,14 @@ State: created\n }) context('provisioning add-on', function () { - beforeEach(function () { - const provisioningAddon = fixtures.addons['www-redis'] - apiSdk - .post('/actions/addons/resolve', {addon: 'www-redis'}) - .reply(200, [provisioningAddon]) - api - .get(`/addons/${provisioningAddon.id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) - }) - it('prints add-ons in a table with humanized state', async function () { + const provisioningAddon = { + ...fixtures.addons['www-redis'], + attachments: [fixtures.attachments['acme-inc-www::REDIS']], + } + const describeStub = stub().resolves(provisioningAddon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ 'www-redis', ]) @@ -196,17 +170,14 @@ State: creating\n }) context('deprovisioning add-on', function () { - beforeEach(function () { - const deprovisioningAddon = fixtures.addons['www-redis-2'] - apiSdk - .post('/actions/addons/resolve', {addon: 'www-redis-2'}) - .reply(200, [deprovisioningAddon]) - api - .get(`/addons/${deprovisioningAddon.id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) - }) - it('prints add-ons in a table with humanized state', async function () { + const deprovisioningAddon = { + ...fixtures.addons['www-redis-2'], + attachments: [fixtures.attachments['acme-inc-www::REDIS']], + } + const describeStub = stub().resolves(deprovisioningAddon) + sdkMock = mockSDKPlatform({addOn: {describe: describeStub}}) + const {stdout} = await runCommand(Cmd, [ 'www-redis-2', ]) diff --git a/test/unit/commands/addons/plans.unit.test.ts b/test/unit/commands/addons/plans.unit.test.ts index 38f3d4dc94..6f372d5b79 100644 --- a/test/unit/commands/addons/plans.unit.test.ts +++ b/test/unit/commands/addons/plans.unit.test.ts @@ -1,26 +1,33 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/addons/plans.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' import normalizeTableOutput from '../../../helpers/utils/normalize-table-output.js' describe('addons:plans', function () { + let sdkMock: MockSDK + + afterEach(function () { + sdkMock?.restore() + }) + context('with non-metered plans', function () { - beforeEach(function () { + it('shows add-on plans', async function () { const plans = [ fixtures.plans['heroku-postgresql:mini'], fixtures.plans['heroku-postgresql:standard-2'], fixtures.plans['heroku-postgresql:premium-3'], fixtures.plans['heroku-postgresql:private-4'], ] - nock('https://api.heroku.com') - .get('/addon-services/daservice/plans') - .reply(200, plans) - }) + const listPlansStub = stub().resolves(plans) + const fakePlatform = { + addOn: {listPlans: listPlansStub}, + } + sdkMock = mockSDKPlatform(fakePlatform) - it('shows add-on plans', async function () { const {stdout} = await runCommand(Cmd, ['daservice']) const [header, body] = stdout.split(/\s[-─]+\s/gm) const actualHeader = normalizeTableOutput(header) @@ -40,18 +47,18 @@ describe('addons:plans', function () { }) context('with metered plans', function () { - beforeEach(function () { + it('formats price for metered usage plans', async function () { const meteredPlans = [ fixtures.plans['heroku-inference:plan-1'], fixtures.plans['heroku-inference:plan-2'], fixtures.plans['heroku-inference:plan-3'], ] - nock('https://api.heroku.com') - .get('/addon-services/metered-service/plans') - .reply(200, meteredPlans) - }) + const listPlansStub = stub().resolves(meteredPlans) + const fakePlatform = { + addOn: {listPlans: listPlansStub}, + } + sdkMock = mockSDKPlatform(fakePlatform) - it('formats price for metered usage plans', async function () { const {stdout} = await runCommand(Cmd, ['metered-service']) const [header, body] = stdout.split(/\s[-─]+\s/gm) const actualHeader = normalizeTableOutput(header) diff --git a/test/unit/commands/addons/rename.unit.test.ts b/test/unit/commands/addons/rename.unit.test.ts index 615418fff7..e2e1cb52f0 100644 --- a/test/unit/commands/addons/rename.unit.test.ts +++ b/test/unit/commands/addons/rename.unit.test.ts @@ -1,36 +1,43 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/addons/rename.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('addons:rename', function () { - context('when the add-on exists', function () { - let redis_name: string - let renameRequest: nock.Scope + let sdkMock: MockSDK - beforeEach(function () { - const redis = fixtures.addons['www-redis']! - redis_name = redis.name! - nock('https://api.heroku.com') - .get(`/addons/${redis.name}`) - .reply(200, redis) - renameRequest = nock('https://api.heroku.com') - .patch(`/apps/${redis.app?.id}/addons/${redis.id}`, {name: 'cache-redis', plan: redis.plan!.name}) - .reply(201, {...redis, name: 'cache-redis'}) - }) + afterEach(function () { + sdkMock.restore() + }) + + context('when the add-on exists', function () { it('renames the add-on', async function () { + const redis = fixtures.addons['www-redis']! + const redis_name = redis.name! + const infoStub = stub().resolves(redis) + const updateStub = stub().resolves({...redis, name: 'cache-redis'}) + const fakePlatform = { + addOn: {info: infoStub, update: updateStub}, + } + sdkMock = mockSDKPlatform(fakePlatform) + const {stdout} = await runCommand(Cmd, [redis_name, 'cache-redis']) - expect(renameRequest.isDone()).to.equal(true) + expect(infoStub.calledOnceWith(redis_name)).to.be.true + expect(updateStub.calledOnceWith(redis.app?.id, redis.id, {name: 'cache-redis', plan: redis.plan!.name})).to.be.true expectOutput(stdout, `${redis_name} successfully renamed to cache-redis.`) }) }) context('when the add-on does not exist', function () { it('displays an appropriate error', async function () { - nock('https://api.heroku.com') - .get('/addons/not-an-addon') - .reply(404, {id: 'not_found', message: "Couldn't find that add-on.", resource: 'addon'}) + const infoStub = stub().rejects(new Error("Couldn't find that add-on.")) + const fakePlatform = { + addOn: {info: infoStub, update: stub()}, + } + sdkMock = mockSDKPlatform(fakePlatform) + await runCommand(Cmd, ['not-an-addon', 'cache-redis']) .catch(error => expect(error.message).to.contain("Couldn't find that add-on.")) }) diff --git a/test/unit/commands/addons/upgrade.unit.test.ts b/test/unit/commands/addons/upgrade.unit.test.ts index 1150439d12..fad36c4212 100644 --- a/test/unit/commands/addons/upgrade.unit.test.ts +++ b/test/unit/commands/addons/upgrade.unit.test.ts @@ -2,20 +2,19 @@ import {AddOn} from '@heroku-cli/schema' import {runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/addons/upgrade.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' -describe('addons:upgrade', function () { - let api: ReturnType +// Import the error classes from the SDK +import {AddonAmbiguousError} from '@heroku/sdk/resources/platform/add-on' - beforeEach(function () { - api = nock('https://api.heroku.com') - }) +describe('addons:upgrade', function () { + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() }) it('upgrades an add-on', async function () { @@ -26,11 +25,14 @@ describe('addons:upgrade', function () { name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } - api - .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) - .reply(200, [addon]) - .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-kafka:hobby'}) - .reply(200, {plan: {price: {cents: 0}}, provision_message: 'provision msg'}) + const upgradeStub = stub().callsFake(async (_addonIdentity, _plan, options) => { + if (options?.onResolved) { + options.onResolved(addon) + } + + return {plan: {price: {cents: 0}}, provision_message: 'provision msg'} + }) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -51,11 +53,14 @@ describe('addons:upgrade', function () { plan: {name: 'premium-0'}, } - api - .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) - .reply(200, [addon]) - .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-kafka:standard'}) - .reply(200, {plan: {price: {cents: 2500, unit: 'month'}}, provision_message: 'provision msg'}) + const upgradeStub = stub().callsFake(async (_addonIdentity, _plan, options) => { + if (options?.onResolved) { + options.onResolved(addon) + } + + return {plan: {price: {cents: 2500, unit: 'month'}}, provision_message: 'provision msg'} + }) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -76,11 +81,14 @@ describe('addons:upgrade', function () { plan: {name: 'premium-0'}, } - api - .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) - .reply(200, [addon]) - .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-kafka:hobby'}) - .reply(200, {plan: {}, provision_message: 'provision msg'}) + const upgradeStub = stub().callsFake(async (_addonIdentity, _plan, options) => { + if (options?.onResolved) { + options.onResolved(addon) + } + + return {plan: {}, provision_message: 'provision msg'} + }) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -101,11 +109,14 @@ describe('addons:upgrade', function () { plan: {name: 'free'}, } - api - .post('/actions/addons/resolve', {addon: 'heroku-connect', app: 'myapp'}) - .reply(200, [addon]) - .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-connect:contract'}) - .reply(200, {plan: {price: {cents: 0, contract: true}}, provision_message: 'provision msg'}) + const upgradeStub = stub().callsFake(async (_addonIdentity, _plan, options) => { + if (options?.onResolved) { + options.onResolved(addon) + } + + return {plan: {price: {cents: 0, contract: true}}, provision_message: 'provision msg'} + }) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -125,11 +136,14 @@ describe('addons:upgrade', function () { name: 'postgresql-swiftly-123', plan: {name: 'premium-0'}, } - api - .post('/actions/addons/resolve', {addon: 'heroku-postgresql', app: 'myapp'}) - .reply(200, [addon]) - .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-postgresql:hobby'}) - .reply(200, {plan: {price: {cents: 0}}}) + const upgradeStub = stub().callsFake(async (_addonIdentity, _plan, options) => { + if (options?.onResolved) { + options.onResolved(addon) + } + + return {plan: {price: {cents: 0}}} + }) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -141,6 +155,7 @@ describe('addons:upgrade', function () { }) it('errors with no plan', async function () { + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: stub()}}) try { await runCommand(Cmd, [ '--app', @@ -163,17 +178,21 @@ describe('addons:upgrade', function () { plan: {name: 'premium-0'}, } - api - .post('/actions/addons/resolve', {addon: 'heroku-db1', app: 'myapp'}) - .reply(200, [addon]) - .get('/addon-services/heroku-db1/plans') - .reply(200, [ - {name: 'heroku-db1:free', plan: {cents: 0}}, - {name: 'heroku-db1:basic', plan: {cents: 25}}, - {name: 'heroku-db1:premium-0', price: {cents: 3500}}, - ]) - .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-db1:invalid'}) - .reply(422, {message: 'Couldn\'t find either the add-on service or the add-on plan of "heroku-db1:invalid".'}) + const apiError = new Error('Couldn\'t find either the add-on service or the add-on plan of "heroku-db1:invalid".') as Error & {statusCode: number} + apiError.statusCode = 422 + const upgradeStub = stub().callsFake(async (_addonIdentity, _plan, options) => { + if (options?.onResolved) { + options.onResolved(addon) + } + + throw apiError + }) + const listPlansStub = stub().resolves([ + {name: 'heroku-db1:free', plan: {cents: 0}}, + {name: 'heroku-db1:basic', plan: {cents: 25}}, + {name: 'heroku-db1:premium-0', price: {cents: 3500}}, + ]) + sdkMock = mockSDKPlatform({addOn: {listPlans: listPlansStub, upgrade: upgradeStub}}) try { await runCommand(Cmd, [ @@ -189,8 +208,8 @@ describe('addons:upgrade', function () { }) it('displays an error when multiple matches exist', async function () { - api.post('/actions/addons/resolve', {addon: 'heroku-postgresql', app: 'myapp'}) - .reply(422, {id: 'multiple_matches', message: 'Multiple matches'}) + const upgradeStub = stub().rejects(new AddonAmbiguousError([{name: 'addon-1'}, {name: 'addon-2'}])) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) try { await runCommand(Cmd, [ '--app', @@ -205,8 +224,8 @@ describe('addons:upgrade', function () { }) it('handles multiple add-ons', async function () { - api.post('/actions/addons/resolve', {addon: 'heroku-redis'}) - .reply(200, [{name: 'db1-swiftly-123'}, {name: 'db1-swiftly-456'}]) + const upgradeStub = stub().rejects(new AddonAmbiguousError([{name: 'db1-swiftly-123'}, {name: 'db1-swiftly-456'}])) + sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) try { await runCommand(Cmd, [ 'heroku-redis:invalid', diff --git a/test/unit/commands/addons/wait.unit.test.ts b/test/unit/commands/addons/wait.unit.test.ts index 22c5daf98e..b20906a824 100644 --- a/test/unit/commands/addons/wait.unit.test.ts +++ b/test/unit/commands/addons/wait.unit.test.ts @@ -2,16 +2,16 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import _ from 'lodash' import nock from 'nock' -import {createSandbox, type SinonFakeTimers} from 'sinon' +import {createSandbox, stub, type SinonFakeTimers} from 'sinon' import Cmd from '../../../../src/commands/addons/wait.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' - -const expansionHeaders = {'Accept-Expansion': 'addon_service,plan'} +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('addons:wait', function () { let sandbox: any let clock: SinonFakeTimers + let sdkMock: MockSDK beforeEach(function () { sandbox = createSandbox() @@ -24,13 +24,22 @@ describe('addons:wait', function () { afterEach(function () { sandbox.restore() + sdkMock?.restore() + nock.cleanAll() }) context('waiting for an individual add-on to provision', function () { context('when the add-on is provisioned', function () { beforeEach(function () { - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) + // resolveAddon uses this.heroku to POST /actions/addons/resolve + nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-db', app: null}) .reply(200, [fixtures.addons['www-db']]) + // No SDK polling needed since www-db is already provisioned + const infoByAppStub = stub().resolves(fixtures.addons['www-db']) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) }) it('prints output indicating that it is done', async function () { const {stderr, stdout} = await runCommand(Cmd, [ @@ -42,18 +51,23 @@ describe('addons:wait', function () { }) context('for an add-on that is still provisioning', function () { it('waits until the add-on is provisioned, then shows config vars', async function () { + // resolveAddon uses this.heroku nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) .reply(200, [fixtures.addons['www-redis']]) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, fixtures.addons['www-redis']) + + // waitForAddonProvisioning uses SDK for polling const provisionedAddon = _.clone(fixtures.addons['www-redis']) provisionedAddon.state = 'provisioned' provisionedAddon.config_vars = ['REDIS_URL'] - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, provisionedAddon) + const infoByAppStub = stub() + .onFirstCall().resolves(fixtures.addons['www-redis']) + .onSecondCall().resolves(provisionedAddon) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + const {stderr, stdout} = await runCommand(Cmd, [ '--wait-interval', '1', @@ -71,12 +85,16 @@ Created www-redis as REDIS_URL nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) .reply(200, [fixtures.addons['www-redis']]) + const provisionedAddon = _.clone(fixtures.addons['www-redis']) provisionedAddon.state = 'provisioned' provisionedAddon.config_vars = ['REDIS_URL'] - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, () => provisionedAddon) + const infoByAppStub = stub().resolves(provisionedAddon) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + await runCommand(Cmd, [ '--wait-interval', '1', @@ -91,15 +109,19 @@ Created www-redis as REDIS_URL nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) .reply(200, [fixtures.addons['www-redis']]) + const provisionedAddon = _.clone(fixtures.addons['www-redis']) provisionedAddon.state = 'provisioned' provisionedAddon.config_vars = ['REDIS_URL'] - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, () => { - clock.tick(5000) - return provisionedAddon - }) + const infoByAppStub = stub().callsFake(() => { + clock.tick(5000) + return Promise.resolve(provisionedAddon) + }) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + await runCommand(Cmd, [ '--wait-interval', '1', @@ -116,14 +138,16 @@ Created www-redis as REDIS_URL nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) .reply(200, [fixtures.addons['www-redis']]) - nock('https://api.heroku.com') - .get('/addons/www-redis') - .reply(200, fixtures.addons['www-redis']) + + // waitForAddonProvisioning uses SDK - returns deprovisioned const deprovisionedAddon = _.clone(fixtures.addons['www-redis']) deprovisionedAddon.state = 'deprovisioned' - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, deprovisionedAddon) + const infoByAppStub = stub().resolves(deprovisionedAddon) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + await runCommand(Cmd, ['www-redis']) .catch(error => { expect(error.message).to.equal('The add-on was unable to be created, with status deprovisioned') @@ -135,13 +159,15 @@ Created www-redis as REDIS_URL nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) .reply(200, [fixtures.addons['www-redis']]) - .get('/addons/www-redis') - .reply(200, fixtures.addons['www-redis']) + const deprovisionedAddon = _.clone(fixtures.addons['www-redis']) deprovisionedAddon.state = 'deprovisioned' - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, deprovisionedAddon) + const infoByAppStub = stub().resolves(deprovisionedAddon) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + const {error} = await runCommand(Cmd, [ 'www-redis', ]) @@ -152,13 +178,24 @@ Created www-redis as REDIS_URL context('waiting for an individual add-on to deprovision', function () { context('for an add-on that is still deprovisioning', function () { it('waits until the add-on is deprovisioned', async function () { + // resolveAddon uses this.heroku nock('https://api.heroku.com') .post('/actions/addons/resolve', {addon: 'www-redis-2', app: null}) .reply(200, [fixtures.addons['www-redis-2']]) + // waitForAddonDeprovisioning uses this.heroku for polling + nock('https://api.heroku.com') .get('/apps/acme-inc-www/addons/www-redis-2') .reply(200, fixtures.addons['www-redis-2']) .get('/apps/acme-inc-www/addons/www-redis-2') .reply(404, {id: 'not_found', message: 'Not found.'}) + + // SDK mock needed since waitForAddonProvisioning is imported + const infoByAppStub = stub() + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + const {stderr, stdout} = await runCommand(Cmd, [ '--wait-interval', '1', @@ -180,6 +217,13 @@ Destroying www-redis-2... done .reply(200, [deprovisioningAddon]) .get('/apps/acme-inc-www/addons/www-redis-3') .reply(404, {id: 'not_found', message: 'Not found.'}) + + const infoByAppStub = stub() + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + await runCommand(Cmd, [ '--wait-interval', '1', @@ -204,6 +248,13 @@ Destroying www-redis-2... done }) .get('/apps/acme-inc-www/addons/www-redis-4') .reply(404, {id: 'not_found', message: 'Not found.'}) + + const infoByAppStub = stub() + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + await runCommand(Cmd, [ '--wait-interval', '1', @@ -227,33 +278,45 @@ Destroying www-redis-2... done // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore redis2Addon.state = 'deprovisioning' + + // this.heroku.get for listing addons by app nock('https://api.heroku.com') .get('/apps/acme-inc-www/addons') .reply(200, [ignoredAddon, wwwAddon, redisAddon, redis2Addon]) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-db') - .reply(200, wwwAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, redisAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) + + // waitForAddonDeprovisioning uses this.heroku for polling + nock('https://api.heroku.com') .get('/apps/acme-inc-www/addons/www-redis-2') .reply(200, redis2Addon) + .get('/apps/acme-inc-www/addons/www-redis-2') + .reply(404, {id: 'not_found', message: 'Not found.'}) + + // waitForAddonProvisioning uses SDK const provisionedWwwAddon = _.clone(fixtures.addons['www-db']) provisionedWwwAddon.state = 'provisioned' provisionedWwwAddon.config_vars = ['WWW_URL'] const provisionedRedisAddon = _.clone(fixtures.addons['www-redis']) provisionedRedisAddon.state = 'provisioned' provisionedRedisAddon.config_vars = ['REDIS_URL'] - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, provisionedRedisAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-db') - .reply(200, provisionedWwwAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis-2') - .reply(404, {id: 'not_found', message: 'Not found.'}) + + const callCounts: Record = {} + const infoByAppStub = stub().callsFake((_app: string, name: string) => { + callCounts[name] = (callCounts[name] || 0) + 1 + if (name === 'www-db') { + return Promise.resolve(callCounts[name] === 1 ? wwwAddon : provisionedWwwAddon) + } + + if (name === 'www-redis') { + return Promise.resolve(callCounts[name] === 1 ? redisAddon : provisionedRedisAddon) + } + + return Promise.resolve(null) + }) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + const {stderr, stdout} = await runCommand(Cmd, [ '--wait-interval', '1', @@ -283,33 +346,45 @@ Created www-redis as REDIS_URL // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore redis2Addon.state = 'deprovisioning' + + // this.heroku.get for listing all addons nock('https://api.heroku.com') .get('/addons') .reply(200, [ignoredAddon, wwwAddon, redisAddon, redis2Addon]) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-db') - .reply(200, wwwAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, redisAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) + + // waitForAddonDeprovisioning uses this.heroku for polling + nock('https://api.heroku.com') .get('/apps/acme-inc-www/addons/www-redis-2') .reply(200, redis2Addon) + .get('/apps/acme-inc-www/addons/www-redis-2') + .reply(404, {id: 'not_found', message: 'Not found.'}) + + // waitForAddonProvisioning uses SDK const provisionedWwwAddon = _.clone(fixtures.addons['www-db']) provisionedWwwAddon.state = 'provisioned' provisionedWwwAddon.config_vars = ['WWW_URL'] const provisionedRedisAddon = _.clone(fixtures.addons['www-redis']) provisionedRedisAddon.state = 'provisioned' provisionedRedisAddon.config_vars = ['REDIS_URL'] - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis') - .reply(200, provisionedRedisAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-db') - .reply(200, provisionedWwwAddon) - nock('https://api.heroku.com', {reqheaders: expansionHeaders}) - .get('/apps/acme-inc-www/addons/www-redis-2') - .reply(404, {id: 'not_found', message: 'Not found.'}) + + const callCounts: Record = {} + const infoByAppStub = stub().callsFake((_app: string, name: string) => { + callCounts[name] = (callCounts[name] || 0) + 1 + if (name === 'www-db') { + return Promise.resolve(callCounts[name] === 1 ? wwwAddon : provisionedWwwAddon) + } + + if (name === 'www-redis') { + return Promise.resolve(callCounts[name] === 1 ? redisAddon : provisionedRedisAddon) + } + + return Promise.resolve(null) + }) + sdkMock = mockSDKPlatform({ + addOn: {infoByApp: infoByAppStub}, + withHeaders: stub().returns({addOn: {infoByApp: infoByAppStub}}), + }) + const {stderr, stdout} = await runCommand(Cmd, [ '--wait-interval', '1', diff --git a/test/unit/commands/data/maintenances/index.unit.test.ts b/test/unit/commands/data/maintenances/index.unit.test.ts index d243e961bd..582a0dbbd0 100644 --- a/test/unit/commands/data/maintenances/index.unit.test.ts +++ b/test/unit/commands/data/maintenances/index.unit.test.ts @@ -1,9 +1,11 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesIndex from '../../../../../src/commands/data/maintenances/index.js' import {Maintenance, MaintenanceStatus} from '../../../../../src/lib/data/types.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' import removeAllWhitespace from '../../../../helpers/utils/remove-whitespaces.js' const appId = '30f93b8f-c592-4004-8d8a-3efb20395484' @@ -65,26 +67,22 @@ const maintenances = [redisAddonMaintenance, postgresAddonMaintenance] describe('data:maintenances', function () { let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('shows a list of maintenances for a given app', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .get('/apps/test-app') .reply(200, app) - dataApi - .get(`/data/maintenances/v1/apps/${appId}`) - .reply(200, {maintenances}) + + const infoByAppStub = stub().resolves({maintenances}) + sdkMock = mockSDKData({maintenance: {infoByApp: infoByAppStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesIndex, ['--app=test-app']) const actualStdout = removeAllWhitespace(stdout) @@ -96,12 +94,13 @@ describe('data:maintenances', function () { }) it('includes extended columns', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .get('/apps/test-app') .reply(200, app) - dataApi - .get(`/data/maintenances/v1/apps/${appId}`) - .reply(200, {maintenances}) + + const infoByAppStub = stub().resolves({maintenances}) + sdkMock = mockSDKData({maintenance: {infoByApp: infoByAppStub}}) const {stdout} = await runCommand(DataMaintenancesIndex, ['--app=test-app', '--extended', '--sort=Addon']) const actualStdout = removeAllWhitespace(stdout) @@ -111,12 +110,13 @@ describe('data:maintenances', function () { }) it('only includes specified columns', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .get('/apps/test-app') .reply(200, app) - dataApi - .get(`/data/maintenances/v1/apps/${appId}`) - .reply(200, {maintenances}) + + const infoByAppStub = stub().resolves({maintenances}) + sdkMock = mockSDKData({maintenance: {infoByApp: infoByAppStub}}) const {stdout} = await runCommand(DataMaintenancesIndex, ['--app=test-app', '--extended', '--columns=addon,attachments']) const actualStdout = removeAllWhitespace(stdout) @@ -126,12 +126,13 @@ describe('data:maintenances', function () { }) it('shows a list of maintenances for a given app with the json flag', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .get('/apps/test-app') .reply(200, app) - dataApi - .get(`/data/maintenances/v1/apps/${appId}`) - .reply(200, {maintenances}) + + const infoByAppStub = stub().resolves({maintenances}) + sdkMock = mockSDKData({maintenance: {infoByApp: infoByAppStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesIndex, ['--app=test-app', '--json']) @@ -140,6 +141,7 @@ describe('data:maintenances', function () { }) it('shows an error if the app is not found', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .get('/apps/test-app') .reply(404, { @@ -147,18 +149,22 @@ describe('data:maintenances', function () { message: "Couldn't find that app.", }) + const infoByAppStub = stub().resolves({maintenances: []}) + sdkMock = mockSDKData({maintenance: {infoByApp: infoByAppStub}}) + const {error} = await runCommand(DataMaintenancesIndex, ['--app=test-app']) const {message} = error as {message: string} expect(message).to.contain("Couldn't find that app.\n\nError ID: not_found") }) it('shows an error message if there are no maintenances', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .get('/apps/test-app') .reply(200, app) - dataApi - .get(`/data/maintenances/v1/apps/${appId}`) - .reply(200, {maintenances: []}) + + const infoByAppStub = stub().resolves({maintenances: []}) + sdkMock = mockSDKData({maintenance: {infoByApp: infoByAppStub}}) const {error} = await runCommand(DataMaintenancesIndex, ['--app=test-app']) const {message} = error as {message: string} diff --git a/test/unit/commands/data/maintenances/info.unit.test.ts b/test/unit/commands/data/maintenances/info.unit.test.ts index adc8ebedea..328dbcb0a4 100644 --- a/test/unit/commands/data/maintenances/info.unit.test.ts +++ b/test/unit/commands/data/maintenances/info.unit.test.ts @@ -1,9 +1,11 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesInfo from '../../../../../src/commands/data/maintenances/info.js' import {addon, nonPostgresAddon} from '../../../../fixtures/data/pg/fixtures.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' import {unwrap} from '../../../../helpers/utils/unwrap.js' describe('data:maintenances:info', function () { @@ -12,12 +14,18 @@ describe('data:maintenances:info', function () { } const maintenance = { - addon_attachments: 'DATABASE_URL', - addon_kind: 'heroku-postgresql', - addon_name: 'postgresql-sinuous-83720', - addon_plan: 'standard-0', - addon_window: 'Thursdays 22:00 to Fridays 02:00 UTC', - app_name: 'test-app', + addon: { + attachments: ['DATABASE_URL'], + kind: 'heroku-postgresql', + name: 'postgresql-sinuous-83720', + plan: 'standard-0', + uuid: '44c1a07e-e44f-46ee-8da5-5cd4a3049348', + window: 'Thursdays 22:00 to Fridays 02:00 UTC', + }, + app: { + name: 'test-app', + uuid: 'app-uuid-1234', + }, method: 'changeover', previously_scheduled_for: '2019-11-05 22:00:00 +0000', reason: 'routine_maintenance', @@ -29,37 +37,27 @@ describe('data:maintenances:info', function () { } let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('shows a maintenance for an addon in styled object format by default', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, maintenance) + + const infoStub = stub().resolves(maintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesInfo, [addon.name]) expect(unwrap(stderr)).to.contain('Fetching maintenance for advanced-horizontal-01234... done\n') - expect(stdout).to.equal(`addon_attachments: DATABASE_URL -addon_kind: heroku-postgresql -addon_name: postgresql-sinuous-83720 -addon_plan: standard-0 -addon_window: Thursdays 22:00 to Fridays 02:00 UTC -app_name: test-app -method: changeover + expect(stdout).to.equal(`method: changeover previously_scheduled_for: 2019-11-05 22:00:00 +0000 reason: routine_maintenance required_by: 2019-11-12 17:57:01 +0000 @@ -67,27 +65,28 @@ scheduled_for: 2019-11-07 22:00:00 +0000 server_created_at: 2019-10-24 23:24:47 +0000 status: none window: Thursdays 22:00 to Fridays 02:00 UTC +app_name: test-app +addon_attachments: DATABASE_URL +addon_kind: heroku-postgresql +addon_name: postgresql-sinuous-83720 +addon_plan: standard-0 +addon_window: Thursdays 22:00 to Fridays 02:00 UTC `) }) it('shows a maintenance for an addon with duration_seconds and generated approximate duration', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, {...maintenance, duration_seconds: 872.976_767}) + + const infoStub = stub().resolves({...maintenance, duration_seconds: 872.976_767}) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesInfo, [addon.name]) expect(unwrap(stderr)).to.contain('Fetching maintenance for advanced-horizontal-01234... done\n') - expect(stdout).to.equal(`addon_attachments: DATABASE_URL -addon_kind: heroku-postgresql -addon_name: postgresql-sinuous-83720 -addon_plan: standard-0 -addon_window: Thursdays 22:00 to Fridays 02:00 UTC -app_name: test-app -method: changeover + expect(stdout).to.equal(`method: changeover previously_scheduled_for: 2019-11-05 22:00:00 +0000 reason: routine_maintenance required_by: 2019-11-12 17:57:01 +0000 @@ -96,28 +95,29 @@ server_created_at: 2019-10-24 23:24:47 +0000 status: none window: Thursdays 22:00 to Fridays 02:00 UTC duration_seconds: 872.976767 +app_name: test-app +addon_attachments: DATABASE_URL +addon_kind: heroku-postgresql +addon_name: postgresql-sinuous-83720 +addon_plan: standard-0 +addon_window: Thursdays 22:00 to Fridays 02:00 UTC duration_approximate: ~ 15 minutes `) }) it('shows a maintenance for an addon scoped by the app flag', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, maintenance) + + const infoStub = stub().resolves(maintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesInfo, [addon.name, `--app=${app.name}`]) expect(unwrap(stderr)).to.contain('Fetching maintenance for advanced-horizontal-01234... done\n') - expect(stdout).to.equal(`addon_attachments: DATABASE_URL -addon_kind: heroku-postgresql -addon_name: postgresql-sinuous-83720 -addon_plan: standard-0 -addon_window: Thursdays 22:00 to Fridays 02:00 UTC -app_name: test-app -method: changeover + expect(stdout).to.equal(`method: changeover previously_scheduled_for: 2019-11-05 22:00:00 +0000 reason: routine_maintenance required_by: 2019-11-12 17:57:01 +0000 @@ -125,16 +125,23 @@ scheduled_for: 2019-11-07 22:00:00 +0000 server_created_at: 2019-10-24 23:24:47 +0000 status: none window: Thursdays 22:00 to Fridays 02:00 UTC +app_name: test-app +addon_attachments: DATABASE_URL +addon_kind: heroku-postgresql +addon_name: postgresql-sinuous-83720 +addon_plan: standard-0 +addon_window: Thursdays 22:00 to Fridays 02:00 UTC `) }) it('shows a maintenance for an addon in json format', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, maintenance) + + const infoStub = stub().resolves(maintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesInfo, [addon.name, '--json']) @@ -143,25 +150,27 @@ window: Thursdays 22:00 to Fridays 02:00 UTC }) it('shows 404 error when maintenance is not found', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(404, {message: 'not found'}) + + const infoStub = stub().rejects({statusCode: 404}) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {error} = await runCommand(DataMaintenancesInfo, [addon.name, `--app=${app.name}`]) const {message} = error as {message: string} - expect(message).to.equal('not found') + expect(message).to.equal('no maintenance found for this add-on') }) it('shows maintenance for non-postgres add-ons', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve', body => body.addon_service === undefined) .reply(200, [nonPostgresAddon]) - dataApi - .get(`/data/maintenances/v1/${nonPostgresAddon.id}`) - .reply(200, maintenance) + + const infoStub = stub().resolves(maintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesInfo, [nonPostgresAddon.name, '--json']) diff --git a/test/unit/commands/data/maintenances/run.unit.test.ts b/test/unit/commands/data/maintenances/run.unit.test.ts index c10ac8c3fe..13838503c5 100644 --- a/test/unit/commands/data/maintenances/run.unit.test.ts +++ b/test/unit/commands/data/maintenances/run.unit.test.ts @@ -2,12 +2,14 @@ import {runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesRun from '../../../../../src/commands/data/maintenances/run.js' import {Maintenance, MaintenanceStatus} from '../../../../../src/lib/data/types.js' import {cedarApp} from '../../../../fixtures/apps/fixtures.js' import {maintenance, maintenancesResponse} from '../../../../fixtures/data/maintenances/fixtures.js' import {addon, legacyEssentialAddon, nonPostgresAddon} from '../../../../fixtures/data/pg/fixtures.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' const appInMaintenance = { ...cedarApp, @@ -16,20 +18,16 @@ const appInMaintenance = { describe('data:maintenances:run', function () { let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('runs maintenance in window with app flag', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) @@ -38,9 +36,8 @@ describe('data:maintenances:run', function () { .get(`/apps/${addon.app.id}`) .reply(200, appInMaintenance) - dataApi - .post(`/data/maintenances/v1/${addon.id}/run`) - .reply(200, maintenancesResponse) + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesRun, [`--app=${appInMaintenance.name}`, addon.name]) @@ -49,6 +46,7 @@ describe('data:maintenances:run', function () { }) it('runs maintenance in window without app flag', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) @@ -57,9 +55,8 @@ describe('data:maintenances:run', function () { .get(`/apps/${addon.app.id}`) .reply(200, appInMaintenance) - dataApi - .post(`/data/maintenances/v1/${addon.id}/run`) - .reply(200, maintenancesResponse) + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesRun, [addon.name]) @@ -68,6 +65,7 @@ describe('data:maintenances:run', function () { }) it('runs maintenance for non-postgres add-ons', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve', body => body.addon_service === undefined) .reply(200, [nonPostgresAddon]) @@ -76,9 +74,8 @@ describe('data:maintenances:run', function () { .get(`/apps/${nonPostgresAddon.app.id}`) .reply(200, appInMaintenance) - dataApi - .post(`/data/maintenances/v1/${nonPostgresAddon.id}/run`) - .reply(200, maintenancesResponse) + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesRun, [nonPostgresAddon.name]) @@ -87,6 +84,7 @@ describe('data:maintenances:run', function () { }) it('runs maintenance out of window with --confirm', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) @@ -95,9 +93,8 @@ describe('data:maintenances:run', function () { .get(`/apps/${addon.app.id}`) .reply(200, cedarApp) - dataApi - .post(`/data/maintenances/v1/${addon.id}/run`) - .reply(200, maintenancesResponse) + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesRun, [ `--confirm=${cedarApp.name}`, @@ -110,6 +107,7 @@ describe('data:maintenances:run', function () { }) it('shows an error trying to run maintenance out of window without --force', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) @@ -118,22 +116,30 @@ describe('data:maintenances:run', function () { .get(`/apps/${addon.app.id}`) .reply(200, cedarApp) + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) + const {error} = await runCommand(DataMaintenancesRun, [`--app=${cedarApp.name}`, addon.name]) const {message} = error as {message: string} expect(ansis.strip(message)).to.equal('To proceed, put the application into maintenance mode or re-run the command with --confirm my-cedar-app') }) it('shows an error trying to run maintenance on an essential tier', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [legacyEssentialAddon]) + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) + const {error} = await runCommand(DataMaintenancesRun, [`--app=${appInMaintenance.name}`, legacyEssentialAddon.name]) const {message} = error as {message: string} expect(message).to.equal('You can\'t trigger maintenance on an Essential tier database.') }) it('waits until maintenance is complete when using wait flag', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) @@ -142,14 +148,14 @@ describe('data:maintenances:run', function () { .get(`/apps/${addon.app.id}`) .reply(200, appInMaintenance) - // call maintenance - dataApi - .post(`/data/maintenances/v1/${addon.id}/run`) - .reply(200, maintenancesResponse) + // SDK call for data.maintenance.run() + const runStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {run: runStub}}) - // polling for maintenance status 3 times + // polling for maintenance status via BaseDataCommand's dataApi (api.data.heroku.com) + const legacyDataApi = nock('https://api.data.heroku.com') let pollingCalls = 0 - dataApi + legacyDataApi .get(`/data/maintenances/v1/${addon.id}`) .thrice() .reply(() => { @@ -175,5 +181,6 @@ describe('data:maintenances:run', function () { expect(stderr).to.contain('maintenance triggered') expect(stderr).to.contain('maintenance completed') expect(stdout).to.equal('') + legacyDataApi.done() }) }) diff --git a/test/unit/commands/data/maintenances/schedule.unit.test.ts b/test/unit/commands/data/maintenances/schedule.unit.test.ts index 27a9208269..1418a5004b 100644 --- a/test/unit/commands/data/maintenances/schedule.unit.test.ts +++ b/test/unit/commands/data/maintenances/schedule.unit.test.ts @@ -1,10 +1,12 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesSchedule from '../../../../../src/commands/data/maintenances/schedule.js' import {maintenance, maintenancesResponse} from '../../../../fixtures/data/maintenances/fixtures.js' import {addon, nonPostgresAddon} from '../../../../fixtures/data/pg/fixtures.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' const unscheduledScheduleResponse = { ...maintenancesResponse, @@ -17,26 +19,22 @@ describe('data:maintenances:schedule', function () { } let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('schedules a maintenance for an addon which has maintenance already scheduled', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .post(`/data/maintenances/v1/${addon.id}/schedule`) - .reply(200, maintenancesResponse) + + const scheduleStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {schedule: scheduleStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesSchedule, [addon.name]) @@ -45,12 +43,13 @@ describe('data:maintenances:schedule', function () { }) it('schedules a maintenance for an addon that does not have maintenance already scheduled', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .post(`/data/maintenances/v1/${addon.id}/schedule`) - .reply(200, unscheduledScheduleResponse) + + const scheduleStub = stub().resolves(unscheduledScheduleResponse) + sdkMock = mockSDKData({maintenance: {schedule: scheduleStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesSchedule, [addon.name]) @@ -59,12 +58,13 @@ describe('data:maintenances:schedule', function () { }) it('schedules a maintenance for an addon scoped to an app', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .post(`/data/maintenances/v1/${addon.id}/schedule`) - .reply(200, unscheduledScheduleResponse) + + const scheduleStub = stub().resolves(unscheduledScheduleResponse) + sdkMock = mockSDKData({maintenance: {schedule: scheduleStub}}) const {stderr} = await runCommand(DataMaintenancesSchedule, [addon.name, `--app=${app.name}`]) @@ -72,12 +72,13 @@ describe('data:maintenances:schedule', function () { }) it('schedules a maintenance for a specified number of weeks', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .post(`/data/maintenances/v1/${addon.id}/schedule`) - .reply(200, maintenancesResponse) + + const scheduleStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {schedule: scheduleStub}}) const {stderr} = await runCommand(DataMaintenancesSchedule, [addon.name, '--weeks=4']) @@ -85,12 +86,13 @@ describe('data:maintenances:schedule', function () { }) it('schedules maintenance for non-postgres add-ons', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve', body => body.addon_service === undefined) .reply(200, [nonPostgresAddon]) - dataApi - .post(`/data/maintenances/v1/${nonPostgresAddon.id}/schedule`) - .reply(200, unscheduledScheduleResponse) + + const scheduleStub = stub().resolves(unscheduledScheduleResponse) + sdkMock = mockSDKData({maintenance: {schedule: scheduleStub}}) const {stderr} = await runCommand(DataMaintenancesSchedule, [nonPostgresAddon.name]) @@ -98,15 +100,14 @@ describe('data:maintenances:schedule', function () { }) it('schedules a maintenance for a specific week', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, maintenance) - dataApi - .post(`/data/maintenances/v1/${addon.id}/schedule`) - .reply(200, maintenancesResponse) + + const infoStub = stub().resolves(maintenance) + const scheduleStub = stub().resolves(maintenancesResponse) + sdkMock = mockSDKData({maintenance: {info: infoStub, schedule: scheduleStub}}) const {stderr} = await runCommand(DataMaintenancesSchedule, [addon.name, '--week=2019-11-01']) diff --git a/test/unit/commands/data/maintenances/wait.unit.test.ts b/test/unit/commands/data/maintenances/wait.unit.test.ts index a787566768..ec4f664357 100644 --- a/test/unit/commands/data/maintenances/wait.unit.test.ts +++ b/test/unit/commands/data/maintenances/wait.unit.test.ts @@ -2,11 +2,13 @@ import {runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesWait from '../../../../../src/commands/data/maintenances/wait.js' import {Maintenance, MaintenanceStatus} from '../../../../../src/lib/data/types.js' import {maintenance} from '../../../../fixtures/data/maintenances/fixtures.js' import {addon, nonPostgresAddon} from '../../../../fixtures/data/pg/fixtures.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' const completedMaintenance: Maintenance = { ...maintenance, @@ -20,29 +22,28 @@ const runningMaintenance: Maintenance = { describe('data:maintenances:wait', function () { let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('waits until maintenance is complete', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) + // Initial info call via SDK + const infoStub = stub().resolves(runningMaintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) + + // Polling via BaseDataCommand's dataApi (api.data.heroku.com) + const legacyDataApi = nock('https://api.data.heroku.com') let pollingCalls = 0 - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, runningMaintenance) - dataApi + legacyDataApi .get(`/data/maintenances/v1/${addon.id}`) .thrice() .reply(() => { @@ -57,18 +58,23 @@ describe('data:maintenances:wait', function () { expect(stderr).to.contain(`Waiting for maintenance on ${addon.name} to complete`) expect(stderr).to.contain('maintenance completed') + legacyDataApi.done() }) it('waits until maintenance is complete scoped by optional app flag', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) + // Initial info call via SDK + const infoStub = stub().resolves(runningMaintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) + + // Polling via BaseDataCommand's dataApi (api.data.heroku.com) + const legacyDataApi = nock('https://api.data.heroku.com') let pollingCalls = 0 - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, runningMaintenance) - dataApi + legacyDataApi .get(`/data/maintenances/v1/${addon.id}`) .thrice() .reply(() => { @@ -83,16 +89,18 @@ describe('data:maintenances:wait', function () { expect(stderr).to.contain(`Waiting for maintenance on ${addon.name} to complete`) expect(stderr).to.contain('maintenance completed') + legacyDataApi.done() }) it('shows error if initial maintenance state is not running', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}`) - .reply(200, completedMaintenance) + // Initial info call via SDK returns completed maintenance + const infoStub = stub().resolves(completedMaintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) const {error} = await runCommand(DataMaintenancesWait, [addon.name]) const {message} = error as {message: string} @@ -100,15 +108,19 @@ describe('data:maintenances:wait', function () { }) it('waits for non-postgres add-ons', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve', body => body.addon_service === undefined) .reply(200, [nonPostgresAddon]) + // Initial info call via SDK + const infoStub = stub().resolves(runningMaintenance) + sdkMock = mockSDKData({maintenance: {info: infoStub}}) + + // Polling via BaseDataCommand's dataApi (api.data.heroku.com) + const legacyDataApi = nock('https://api.data.heroku.com') let pollingCalls = 0 - dataApi - .get(`/data/maintenances/v1/${nonPostgresAddon.id}`) - .reply(200, runningMaintenance) - dataApi + legacyDataApi .get(`/data/maintenances/v1/${nonPostgresAddon.id}`) .thrice() .reply(() => { @@ -123,5 +135,6 @@ describe('data:maintenances:wait', function () { expect(stderr).to.contain(`Waiting for maintenance on ${nonPostgresAddon.name} to complete`) expect(stderr).to.contain('maintenance completed') + legacyDataApi.done() }) }) diff --git a/test/unit/commands/data/maintenances/window/index.unit.test.ts b/test/unit/commands/data/maintenances/window/index.unit.test.ts index 9b26492bce..c031f83b5f 100644 --- a/test/unit/commands/data/maintenances/window/index.unit.test.ts +++ b/test/unit/commands/data/maintenances/window/index.unit.test.ts @@ -1,10 +1,12 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesWindow from '../../../../../../src/commands/data/maintenances/window/index.js' import {maintenanceWindow} from '../../../../../fixtures/data/maintenances/fixtures.js' import {addon, nonPostgresAddon} from '../../../../../fixtures/data/pg/fixtures.js' +import {type MockSDK, mockSDKData} from '../../../../../helpers/mock-sdk.js' describe('data:maintenances:window', function () { const app = { @@ -12,26 +14,22 @@ describe('data:maintenances:window', function () { } let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('can fetch a window for an addon', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}/window`) - .reply(200, maintenanceWindow) + + const windowStub = stub().resolves(maintenanceWindow) + sdkMock = mockSDKData({maintenance: {window: windowStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesWindow, [addon.name]) @@ -40,12 +38,13 @@ describe('data:maintenances:window', function () { }) it('can fetch a window for an addon scoped by an app', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .get(`/data/maintenances/v1/${addon.id}/window`) - .reply(200, maintenanceWindow) + + const windowStub = stub().resolves(maintenanceWindow) + sdkMock = mockSDKData({maintenance: {window: windowStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesWindow, [addon.name, `--app=${app.name}`]) @@ -54,12 +53,13 @@ describe('data:maintenances:window', function () { }) it('can fetch a window for a non-postgres addon', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve', body => body.addon_service === undefined) .reply(200, [nonPostgresAddon]) - dataApi - .get(`/data/maintenances/v1/${nonPostgresAddon.id}/window`) - .reply(200, maintenanceWindow) + + const windowStub = stub().resolves(maintenanceWindow) + sdkMock = mockSDKData({maintenance: {window: windowStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesWindow, [nonPostgresAddon.name]) diff --git a/test/unit/commands/data/maintenances/window/update.unit.test.ts b/test/unit/commands/data/maintenances/window/update.unit.test.ts index 9c03bb5bbe..73e0656ad8 100644 --- a/test/unit/commands/data/maintenances/window/update.unit.test.ts +++ b/test/unit/commands/data/maintenances/window/update.unit.test.ts @@ -1,10 +1,12 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import DataMaintenancesWindowUpdate from '../../../../../../src/commands/data/maintenances/window/update.js' import {maintenanceWindow} from '../../../../../fixtures/data/maintenances/fixtures.js' import {addon, nonPostgresAddon} from '../../../../../fixtures/data/pg/fixtures.js' +import {type MockSDK, mockSDKData} from '../../../../../helpers/mock-sdk.js' describe('data:maintenances:window:update', function () { const app = { @@ -12,29 +14,22 @@ describe('data:maintenances:window:update', function () { } let herokuApi: nock.Scope - let dataApi: nock.Scope - - beforeEach(function () { - herokuApi = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { herokuApi.done() - dataApi.done() nock.cleanAll() + sdkMock?.restore() }) it('can change a window for an addon', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .post(`/data/maintenances/v1/${addon.id}/window`, { - day_of_week: 'tuesday', - time_of_day: '5:30PM', - }) - .reply(200, maintenanceWindow) + + const updateWindowStub = stub().resolves(maintenanceWindow) + sdkMock = mockSDKData({maintenance: {updateWindow: updateWindowStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesWindowUpdate, [addon.name, 'tuesday', '5:30PM']) @@ -44,15 +39,13 @@ describe('data:maintenances:window:update', function () { }) it('can change a window for an addon scoped by an app', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve') .reply(200, [addon]) - dataApi - .post(`/data/maintenances/v1/${addon.id}/window`, { - day_of_week: 'tuesday', - time_of_day: '5:30PM', - }) - .reply(200, maintenanceWindow) + + const updateWindowStub = stub().resolves(maintenanceWindow) + sdkMock = mockSDKData({maintenance: {updateWindow: updateWindowStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesWindowUpdate, [addon.name, 'tuesday', '5:30PM', `--app=${app.name}`]) @@ -62,15 +55,13 @@ describe('data:maintenances:window:update', function () { }) it('can change a window for a non-postgres addon', async function () { + herokuApi = nock('https://api.heroku.com') herokuApi .post('/actions/addons/resolve', body => body.addon_service === undefined) .reply(200, [nonPostgresAddon]) - dataApi - .post(`/data/maintenances/v1/${nonPostgresAddon.id}/window`, { - day_of_week: 'tuesday', - time_of_day: '5:30PM', - }) - .reply(200, maintenanceWindow) + + const updateWindowStub = stub().resolves(maintenanceWindow) + sdkMock = mockSDKData({maintenance: {updateWindow: updateWindowStub}}) const {stderr, stdout} = await runCommand(DataMaintenancesWindowUpdate, [nonPostgresAddon.name, 'tuesday', '5:30PM']) diff --git a/test/unit/commands/pg/backups/index.unit.test.ts b/test/unit/commands/pg/backups/index.unit.test.ts index 5c862f5521..7bfb087c2a 100644 --- a/test/unit/commands/pg/backups/index.unit.test.ts +++ b/test/unit/commands/pg/backups/index.unit.test.ts @@ -1,28 +1,30 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import tsheredoc from 'tsheredoc' import type {BackupTransfer} from '../../../../../src/lib/pg/types.js' import Cmd from '../../../../../src/commands/pg/backups/index.js' +import {mockSDKData, MockSDK} from '../../../../helpers/mock-sdk.js' import normalizeTableOutput from '../../../../helpers/utils/normalize-table-output.js' const heredoc = tsheredoc.default describe('pg:backups', function () { - let pg: nock.Scope + let sdkMock: MockSDK + let listByAppStub: ReturnType let transfers: BackupTransfer[] beforeEach(function () { - pg = nock('https://api.data.heroku.com') - pg.get('/client/v11/apps/myapp/transfers') - .reply(200, transfers) + listByAppStub = stub().resolves(transfers) + sdkMock = mockSDKData({ + transfer: {listByApp: listByAppStub}, + }) }) afterEach(function () { - nock.cleanAll() - pg.done() + sdkMock.restore() }) describe('with no backups/restores/copies', function () { @@ -31,6 +33,7 @@ describe('pg:backups', function () { }) it('shows empty message', async function () { + listByAppStub.resolves(transfers) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -105,6 +108,7 @@ describe('pg:backups', function () { }) it('shows backups', async function () { + listByAppStub.resolves(transfers) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -149,6 +153,7 @@ No copies found. Use heroku pg:copy to copy a database to another }) it('shows restore', async function () { + listByAppStub.resolves(transfers) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -191,6 +196,7 @@ No copies found. Use heroku pg:copy to copy a database to another }) it('shows copy', async function () { + listByAppStub.resolves(transfers) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', diff --git a/test/unit/commands/pg/credentials.unit.test.ts b/test/unit/commands/pg/credentials.unit.test.ts index cc8da482ca..362fbc1c00 100644 --- a/test/unit/commands/pg/credentials.unit.test.ts +++ b/test/unit/commands/pg/credentials.unit.test.ts @@ -5,6 +5,7 @@ import nock from 'nock' import {restore, stub} from 'sinon' import Cmd from '../../../../src/commands/pg/credentials.js' +import {type MockSDK, mockSDKData} from '../../../helpers/mock-sdk.js' import normalizeTableOutput from '../../../helpers/utils/normalize-table-output.js' /** Strip app icon (⬢) so assertions pass whether or not the CLI outputs it. */ @@ -19,17 +20,23 @@ describe('pg:credentials', function () { plan: {name: 'heroku-postgresql:standard-0'}, } let api: nock.Scope - let pg: nock.Scope + let sdkMock: MockSDK + let listCredentialsStub: ReturnType beforeEach(function () { api = nock('https://api.heroku.com') - pg = nock('https://api.data.heroku.com') + listCredentialsStub = stub() + sdkMock = mockSDKData({ + postgresDatabase: { + listCredentials: listCredentialsStub, + }, + }) }) afterEach(function () { nock.cleanAll() restore() - pg.done() + sdkMock.restore() api.done() }) @@ -101,8 +108,7 @@ describe('pg:credentials', function () { .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - pg.get('/postgres/v0/databases/1/credentials') - .reply(200, credentials) + listCredentialsStub.resolves(credentials) const {stdout} = await runCommand(Cmd, [ '--app', @@ -191,8 +197,7 @@ describe('pg:credentials', function () { .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - pg.get('/postgres/v0/databases/1/credentials') - .reply(200, credentials) + listCredentialsStub.resolves(credentials) const {stdout} = await runCommand(Cmd, [ '--app', @@ -226,8 +231,7 @@ describe('pg:credentials', function () { .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - pg.get('/postgres/v0/databases/1/credentials') - .reply(200, credentials) + listCredentialsStub.resolves(credentials) const tableStub = stub(hux, 'table') await runCommand(Cmd, ['--app', 'myapp', '--no-wrap']) diff --git a/test/unit/commands/pg/info.unit.test.ts b/test/unit/commands/pg/info.unit.test.ts index 066e997312..4364326d20 100644 --- a/test/unit/commands/pg/info.unit.test.ts +++ b/test/unit/commands/pg/info.unit.test.ts @@ -1,29 +1,32 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/pg/info.js' +import {type MockSDK, mockSDKData} from '../../../helpers/mock-sdk.js' describe('pg:info', function () { let api: nock.Scope - let pg: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com:443') - pg = nock('https://api.data.heroku.com:443') - }) + let sdkMock: MockSDK afterEach(function () { api.done() - pg.done() nock.cleanAll() + sdkMock?.restore() }) + context('with 0 dbs', function () { it('shows empty state', async function () { + api = nock('https://api.heroku.com:443') api.get('/apps/myapp/config-vars') .reply(200, {}) .get('/apps/myapp/addon-attachments') .reply(200, []) + + const databaseInfoStub = stub().resolves({}) + sdkMock = mockSDKData({database: {info: databaseInfoStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -44,17 +47,18 @@ describe('pg:info', function () { app: {name: 'myapp'}, id: 2, name: 'postgres-2', plan, }, ] - const dbA = { + const dbA = () => ({ info: [ {name: 'Plan', values: ['Hobby-dev']}, {name: 'Empty', values: []}, {name: 'Following', resolve_db_name: true, values: ['postgres://ec2-54-111-111-1.compute-1.amazonaws.com:5452/dxxxxxxxxxxxx']}, ], resource_url: config.DATABASE_URL, - } - const dbB = { + }) + const dbB = () => ({ info: [ {name: 'Plan', values: ['Hobby-dev']}, {name: 'Following', resolve_db_name: true, values: ['postgres://ec2-55-111-111-1.compute-1.amazonaws.com/dxxxxxxxxxxxx']}, ], resource_url: config.HEROKU_POSTGRESQL_PURPLE_URL, - } + }) it('shows postgres info', async function () { + api = nock('https://api.heroku.com:443') api.get('/apps/myapp/config-vars') .reply(200, config) .get('/apps/myapp/addon-attachments') @@ -63,10 +67,12 @@ describe('pg:info', function () { {addon: addons[0], config_vars: ['DATABASE_URL'], name: 'DATABASE'}, {addon: addons[1], config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], name: 'HEROKU_POSTGRESQL_PURPLE'}, ]) - pg.get('/client/v11/databases/1') - .reply(200, dbA) - .get('/client/v11/databases/2') - .reply(200, dbB) + + const databaseInfoStub = stub() + databaseInfoStub.withArgs(1).resolves(dbA()) + databaseInfoStub.withArgs(2).resolves(dbB()) + sdkMock = mockSDKData({database: {info: databaseInfoStub}}) + const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -86,6 +92,7 @@ describe('pg:info', function () { app: {name: 'myapp'}, attachment_names: ['HEROKU_POSTGRESQL_PURPLE'], id: 2, name: 'postgres-2', plan, }, ] + api = nock('https://api.heroku.com:443') api.get('/apps/myapp/config-vars') .reply(200, config) .get('/apps/myapp/addon-attachments') @@ -94,10 +101,11 @@ describe('pg:info', function () { {addon: all[0], config_vars: ['ATTACHMENT_NAME_URL'], name: 'ATTACHMENT_NAME'}, {addon: all[1], config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], name: 'HEROKU_POSTGRESQL_PURPLE'}, ]) - pg.get('/client/v11/databases/1') - .reply(200, dbA) - .get('/client/v11/databases/2') - .reply(200, dbB) + + const databaseInfoStub = stub() + databaseInfoStub.withArgs(1).resolves(dbA()) + databaseInfoStub.withArgs(2).resolves(dbB()) + sdkMock = mockSDKData({database: {info: databaseInfoStub}}) const {stdout} = await runCommand(Cmd, [ '--app', @@ -107,12 +115,15 @@ describe('pg:info', function () { }) it('shows postgres info for single database when arg sent in', async function () { const addon = addons[1] + api = nock('https://api.heroku.com:443') api.get('/apps/myapp/config-vars') .reply(200, config) .post('/actions/addon-attachments/resolve', {addon_attachment: 'postgres-2', app: 'myapp'}) .reply(200, [{addon}]) - pg.get('/client/v11/databases/2') - .reply(200, dbB) + + const databaseInfoStub = stub() + databaseInfoStub.withArgs(2).resolves(dbB()) + sdkMock = mockSDKData({database: {info: databaseInfoStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -123,6 +134,7 @@ describe('pg:info', function () { expect(stderr).to.equal('') }) it('shows warning for 404', async function () { + api = nock('https://api.heroku.com:443') api.get('/apps/myapp/config-vars') .reply(200, config) .get('/apps/myapp/addon-attachments') @@ -131,10 +143,11 @@ describe('pg:info', function () { {addon: addons[0], config_vars: ['DATABASE_URL'], name: 'DATABASE'}, {addon: addons[1], config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], name: 'HEROKU_POSTGRESQL_PURPLE'}, ]) - pg.get('/client/v11/databases/1') - .reply(404) - .get('/client/v11/databases/2') - .reply(200, dbB) + + const databaseInfoStub = stub() + databaseInfoStub.withArgs(1).rejects({statusCode: 404}) + databaseInfoStub.withArgs(2).resolves(dbB()) + sdkMock = mockSDKData({database: {info: databaseInfoStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', diff --git a/test/unit/commands/pg/upgrade/cancel.unit.test.ts b/test/unit/commands/pg/upgrade/cancel.unit.test.ts index b36ed570cd..cbc0857af5 100644 --- a/test/unit/commands/pg/upgrade/cancel.unit.test.ts +++ b/test/unit/commands/pg/upgrade/cancel.unit.test.ts @@ -2,24 +2,27 @@ import * as Heroku from '@heroku-cli/schema' import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' +import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' import * as sinon from 'sinon' +import {stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/upgrade/cancel.js' import * as fixtures from '../../../../fixtures/addons/fixtures.js' +import {mockSDKData, MockSDK} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default -import ansis from 'ansis' - describe('pg:upgrade:cancel', function () { let addon: Heroku.AddOn let uxWarnStub: sinon.SinonStub let uxPromptStub: sinon.SinonStub let api: nock.Scope - let dataApi: nock.Scope + let sdkMock: MockSDK + let infoStub: ReturnType + let cancelUpgradeStub: ReturnType before(function () { uxWarnStub = sinon.stub(ux, 'warn') @@ -29,14 +32,19 @@ describe('pg:upgrade:cancel', function () { beforeEach(async function () { addon = fixtures.addons['dwh-db'] api = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') uxWarnStub.resetHistory() uxPromptStub.resetHistory() + + infoStub = stub() + cancelUpgradeStub = stub() + sdkMock = mockSDKData({ + database: {info: infoStub, cancelUpgrade: cancelUpgradeStub}, + }) }) afterEach(async function () { + sdkMock.restore() api.done() - dataApi.done() nock.cleanAll() }) @@ -84,15 +92,13 @@ describe('pg:upgrade:cancel', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200, { - following: 'postgres://xxx.com:5432/abcdefghijklmn', - leader: { - addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', - name: 'postgresql-leader', - }, - }) + infoStub.resolves({ + following: 'postgres://xxx.com:5432/abcdefghijklmn', + leader: { + addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', + name: 'postgresql-leader', + }, + }) const {error} = await runCommand(Cmd, [ '--app', 'myapp', @@ -108,12 +114,8 @@ describe('pg:upgrade:cancel', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/cancel`) - .reply(200, {message: 'You canceled the upgrade.'}) + infoStub.resolves({}) + cancelUpgradeStub.resolves({message: 'You canceled the upgrade.'}) const message = heredoc(` Destructive action @@ -140,12 +142,8 @@ describe('pg:upgrade:cancel', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/cancel`) - .reply(422, {id: 'bad_request', message: "You haven't scheduled an upgrade on your database. Run `pg:upgrade:prepare` to schedule an upgrade."}) + infoStub.resolves({}) + cancelUpgradeStub.rejects({statusCode: 422, id: 'bad_request', message: "You haven't scheduled an upgrade on your database. Run `pg:upgrade:prepare` to schedule an upgrade."}) const {error, stderr} = await runCommand(Cmd, [ '--app', @@ -168,12 +166,8 @@ describe('pg:upgrade:cancel', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/cancel`) - .reply(422, {id: 'bad_request', message: "You can't cancel the upgrade because it's currently in progress."}) + infoStub.resolves({}) + cancelUpgradeStub.rejects({statusCode: 422, id: 'bad_request', message: "You can't cancel the upgrade because it's currently in progress."}) const {error, stderr} = await runCommand(Cmd, [ '--app', diff --git a/test/unit/commands/pg/upgrade/dryrun.unit.test.ts b/test/unit/commands/pg/upgrade/dryrun.unit.test.ts index c6bbcbbc89..b583cc5d0f 100644 --- a/test/unit/commands/pg/upgrade/dryrun.unit.test.ts +++ b/test/unit/commands/pg/upgrade/dryrun.unit.test.ts @@ -6,10 +6,12 @@ import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' import * as sinon from 'sinon' +import {stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/upgrade/dryrun.js' import * as fixtures from '../../../../fixtures/addons/fixtures.js' +import {mockSDKData, MockSDK} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default @@ -18,7 +20,9 @@ describe('pg:upgrade:dryrun', function () { let uxWarnStub: sinon.SinonStub let uxPromptStub: sinon.SinonStub let api: nock.Scope - let dataApi: nock.Scope + let sdkMock: MockSDK + let infoStub: ReturnType + let dryRunUpgradeStub: ReturnType before(function () { uxWarnStub = sinon.stub(ux, 'warn') @@ -28,14 +32,19 @@ describe('pg:upgrade:dryrun', function () { beforeEach(async function () { addon = fixtures.addons['dwh-db'] api = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') uxWarnStub.resetHistory() uxPromptStub.resetHistory() + + infoStub = stub() + dryRunUpgradeStub = stub() + sdkMock = mockSDKData({ + database: {info: infoStub, dryRunUpgrade: dryRunUpgradeStub}, + }) }) afterEach(async function () { + sdkMock.restore() api.done() - dataApi.done() nock.cleanAll() }) @@ -83,15 +92,13 @@ describe('pg:upgrade:dryrun', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200, { - following: 'postgres://xxx.com:5432/abcdefghijklmn', - leader: { - addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', - name: 'postgresql-leader', - }, - }) + infoStub.resolves({ + following: 'postgres://xxx.com:5432/abcdefghijklmn', + leader: { + addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', + name: 'postgresql-leader', + }, + }) const {error} = await runCommand(Cmd, [ '--app', 'myapp', @@ -106,12 +113,8 @@ describe('pg:upgrade:dryrun', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/dry_run`) - .reply(200, {message: "Started test upgrade. We'll notify you via email when it's complete."}) + infoStub.resolves({}) + dryRunUpgradeStub.resolves({message: "Started test upgrade. We'll notify you via email when it's complete."}) const message = heredoc(` This command starts a test upgrade for ⛁ ${addon.name} to Postgres version 15. @@ -137,12 +140,8 @@ describe('pg:upgrade:dryrun', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/dry_run`) - .reply(200, {message: "Started test upgrade. We'll notify you via email when it's complete."}) + infoStub.resolves({}) + dryRunUpgradeStub.resolves({message: "Started test upgrade. We'll notify you via email when it's complete."}) const message = heredoc(` This command starts a test upgrade for ⛁ ${addon.name} to the latest supported Postgres version. @@ -166,12 +165,8 @@ describe('pg:upgrade:dryrun', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/dry_run`) - .reply(422, {id: 'unprocessable_entity', message: 'database is in the middle of a version upgrade. To perform this action, wait until the upgrade is complete and try again.'}) + infoStub.resolves({}) + dryRunUpgradeStub.rejects({statusCode: 422, id: 'unprocessable_entity', message: 'database is in the middle of a version upgrade. To perform this action, wait until the upgrade is complete and try again.'}) const {error} = await runCommand(Cmd, [ '--app', diff --git a/test/unit/commands/pg/upgrade/prepare.unit.test.ts b/test/unit/commands/pg/upgrade/prepare.unit.test.ts index 8f1fd7574d..592e5785e3 100644 --- a/test/unit/commands/pg/upgrade/prepare.unit.test.ts +++ b/test/unit/commands/pg/upgrade/prepare.unit.test.ts @@ -6,10 +6,12 @@ import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' import * as sinon from 'sinon' +import {stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/upgrade/prepare.js' import * as fixtures from '../../../../fixtures/addons/fixtures.js' +import {mockSDKData, MockSDK} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default @@ -18,7 +20,9 @@ describe('pg:upgrade:prepare', function () { let uxWarnStub: sinon.SinonStub let uxPromptStub: sinon.SinonStub let api: nock.Scope - let dataApi: nock.Scope + let sdkMock: MockSDK + let infoStub: ReturnType + let prepareUpgradeStub: ReturnType before(function () { uxWarnStub = sinon.stub(ux, 'warn') @@ -27,13 +31,19 @@ describe('pg:upgrade:prepare', function () { beforeEach(async function () { api = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') addon = fixtures.addons['dwh-db'] uxWarnStub.resetHistory() uxPromptStub.resetHistory() + + infoStub = stub() + prepareUpgradeStub = stub() + sdkMock = mockSDKData({ + database: {info: infoStub, prepareUpgrade: prepareUpgradeStub}, + }) }) afterEach(async function () { + sdkMock.restore() nock.cleanAll() }) @@ -81,15 +91,13 @@ describe('pg:upgrade:prepare', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon}]) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200, { - following: 'postgres://xxx.com:5432/abcdefghijklmn', - leader: { - addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', - name: 'postgresql-leader', - }, - }) + infoStub.resolves({ + following: 'postgres://xxx.com:5432/abcdefghijklmn', + leader: { + addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', + name: 'postgresql-leader', + }, + }) const {error} = await runCommand(Cmd, [ '--app', 'myapp', @@ -108,12 +116,8 @@ describe('pg:upgrade:prepare', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/prepare`) - .reply(200, {message: 'Your database is scheduled for upgrade during your next available maintenance window.\nRun heroku pg:upgrade:wait to track its status.\nYou can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it\'s fully prepared, which can take up to a day.'}) + infoStub.resolves({}) + prepareUpgradeStub.resolves({message: 'Your database is scheduled for upgrade during your next available maintenance window.\nRun heroku pg:upgrade:wait to track its status.\nYou can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it\'s fully prepared, which can take up to a day.'}) const message = heredoc(` Destructive action @@ -145,12 +149,8 @@ describe('pg:upgrade:prepare', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/prepare`) - .reply(200, {message: 'Your database is scheduled for upgrade during your next available maintenance window.\nRun heroku pg:upgrade:wait to track its status.\nYou can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it\'s fully prepared, which can take up to a day.'}) + infoStub.resolves({}) + prepareUpgradeStub.resolves({message: 'Your database is scheduled for upgrade during your next available maintenance window.\nRun heroku pg:upgrade:wait to track its status.\nYou can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it\'s fully prepared, which can take up to a day.'}) const message = heredoc(` Destructive action @@ -180,12 +180,8 @@ describe('pg:upgrade:prepare', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/prepare`) - .reply(422, {id: 'unprocessable_entity', message: 'database has an upgrade already scheduled, please check `pg:upgrade:wait` for more information on the status of your upgrade.'}) + infoStub.resolves({}) + prepareUpgradeStub.rejects({statusCode: 422, id: 'unprocessable_entity', message: 'database has an upgrade already scheduled, please check `pg:upgrade:wait` for more information on the status of your upgrade.'}) const {error} = await runCommand(Cmd, [ '--app', diff --git a/test/unit/commands/pg/upgrade/run.unit.test.ts b/test/unit/commands/pg/upgrade/run.unit.test.ts index faf4d2f56d..ec1cf2693d 100644 --- a/test/unit/commands/pg/upgrade/run.unit.test.ts +++ b/test/unit/commands/pg/upgrade/run.unit.test.ts @@ -6,10 +6,12 @@ import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' import * as sinon from 'sinon' +import {stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/upgrade/run.js' import * as fixtures from '../../../../fixtures/addons/fixtures.js' +import {mockSDKData, MockSDK} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default @@ -19,7 +21,9 @@ describe('pg:upgrade:run', function () { let uxWarnStub: sinon.SinonStub let uxPromptStub: sinon.SinonStub let api: nock.Scope - let dataApi: nock.Scope + let sdkMock: MockSDK + let infoStub: ReturnType + let runUpgradeStub: ReturnType before(function () { uxWarnStub = sinon.stub(ux, 'warn') @@ -28,14 +32,20 @@ describe('pg:upgrade:run', function () { beforeEach(async function () { api = nock('https://api.heroku.com') - dataApi = nock('https://api.data.heroku.com') hobbyAddon = fixtures.addons['www-db'] addon = fixtures.addons['dwh-db'] uxWarnStub.resetHistory() uxPromptStub.resetHistory() + + infoStub = stub() + runUpgradeStub = stub() + sdkMock = mockSDKData({ + database: {info: infoStub, runUpgrade: runUpgradeStub}, + }) }) afterEach(async function () { + sdkMock.restore() nock.cleanAll() api.done() }) @@ -67,12 +77,8 @@ describe('pg:upgrade:run', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200, {following: 'postgres://db1'}) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/run`) - .reply(200, {message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait`.'}) + infoStub.resolves({following: 'postgres://db1'}) + runUpgradeStub.resolves({message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait`.'}) const message = heredoc(` Destructive action @@ -104,11 +110,8 @@ describe('pg:upgrade:run', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200, {following: 'postgres://db1'}) - .post(`/client/v11/databases/${addon.id}/upgrade/run`) - .reply(200, {message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait`.'}) + infoStub.resolves({following: 'postgres://db1'}) + runUpgradeStub.resolves({message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait`.'}) const message = heredoc(` Destructive action @@ -142,11 +145,8 @@ describe('pg:upgrade:run', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${essentialAddon.id}`) - .reply(200) - .post(`/client/v11/databases/${essentialAddon.id}/upgrade/run`) - .reply(200, {message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait.`'}) + infoStub.resolves({}) + runUpgradeStub.resolves({message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait.`'}) const message = heredoc(` Destructive action @@ -176,12 +176,8 @@ describe('pg:upgrade:run', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/run`) - .reply(400, {id: 'bad_request', message: "You haven't scheduled a version upgrade on your database. Run `heroku pg:upgrade:prepare` to schedule an upgrade."}) + infoStub.resolves({}) + runUpgradeStub.rejects({statusCode: 400, id: 'bad_request', message: "You haven't scheduled a version upgrade on your database. Run `heroku pg:upgrade:prepare` to schedule an upgrade."}) const {error} = await runCommand(Cmd, [ '--app', @@ -203,12 +199,8 @@ describe('pg:upgrade:run', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/run`) - .reply(400, {id: 'bad_request', message: 'Your database is not ready for upgrade. Please try running your upgrade later. You can check the status of your upgrade with `heroku pg:upgrade:wait`.'}) + infoStub.resolves({}) + runUpgradeStub.rejects({statusCode: 400, id: 'bad_request', message: 'Your database is not ready for upgrade. Please try running your upgrade later. You can check the status of your upgrade with `heroku pg:upgrade:wait`.'}) const {error} = await runCommand(Cmd, [ '--app', @@ -230,12 +222,8 @@ describe('pg:upgrade:run', function () { api .get('/apps/myapp/config-vars') .reply(200, {DATABASE_URL: 'postgres://db1'}) - dataApi - .get(`/client/v11/databases/${addon.id}`) - .reply(200) - dataApi - .post(`/client/v11/databases/${addon.id}/upgrade/run`) - .reply(200, {message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait.`'}) + infoStub.resolves({}) + runUpgradeStub.resolves({message: 'Started the upgrade. You can monitor the progress with `heroku pg:upgrade:wait.`'}) const message = heredoc(` Destructive action diff --git a/test/unit/commands/pg/upgrade/wait.unit.test.ts b/test/unit/commands/pg/upgrade/wait.unit.test.ts index fd346f2d2d..a0b74772ed 100644 --- a/test/unit/commands/pg/upgrade/wait.unit.test.ts +++ b/test/unit/commands/pg/upgrade/wait.unit.test.ts @@ -2,9 +2,11 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {Errors} from '@oclif/core' import {expect} from 'chai' import nock from 'nock' +import {stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/upgrade/wait.js' +import {mockSDKData, MockSDK} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default @@ -15,15 +17,19 @@ const all = [ describe('pg:upgrade:wait', function () { let api: nock.Scope - let pg: nock.Scope + let sdkMock: MockSDK + let upgradeWaitStatusStub: ReturnType beforeEach(function () { api = nock('https://api.heroku.com') - pg = nock('https://api.data.heroku.com') + upgradeWaitStatusStub = stub() + sdkMock = mockSDKData({ + database: {upgradeWaitStatus: upgradeWaitStatusStub}, + }) }) afterEach(function () { - pg.done() + sdkMock.restore() nock.cleanAll() }) @@ -31,9 +37,9 @@ describe('pg:upgrade:wait', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon: all[0]}]) - pg - .get('/client/v11/databases/1/upgrade/wait_status').reply(200, {message: 'preparing upgrade service', 'waiting?': true}) - .get('/client/v11/databases/1/upgrade/wait_status').reply(200, {message: 'recreating followers', step: '7/7', 'waiting?': false}) + upgradeWaitStatusStub + .onFirstCall().resolves({message: 'preparing upgrade service', 'waiting?': true}) + .onSecondCall().resolves({message: 'recreating followers', step: '7/7', 'waiting?': false}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -52,8 +58,7 @@ describe('pg:upgrade:wait', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon: all[0]}]) - pg - .get('/client/v11/databases/1/upgrade/wait_status').reply(200, {message: 'upgrade is scheduled on 2025-04-17 20:30:00 UTC. You could also run the upgrade immediately using `heroku pg:upgrade:run`.', 'waiting?': false}) + upgradeWaitStatusStub.resolves({message: 'upgrade is scheduled on 2025-04-17 20:30:00 UTC. You could also run the upgrade immediately using `heroku pg:upgrade:run`.', 'waiting?': false}) const {stdout} = await runCommand(Cmd, [ '--app', @@ -79,8 +84,7 @@ describe('pg:upgrade:wait', function () { api .post('/actions/addon-attachments/resolve') .reply(200, [{addon: all[0]}]) - pg - .get('/client/v11/databases/1/upgrade/wait_status').reply(200, {'error?': true, message: 'this is an error message'}) + upgradeWaitStatusStub.resolves({'error?': true, message: 'this is an error message'}) const {error} = await runCommand(Cmd, [ '--app',