From 7d215c082f50b48f91a83c82b14e4ba64ad9f13f Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Fri, 29 May 2026 15:15:54 -0700 Subject: [PATCH 1/5] Refactor Postgres command implementations to remove the shared src/lib/pg/sdk-adapter.ts abstraction and call SDK APIs directly in backups, credentials, and upgrade command handlers --- src/commands/pg/backups/index.ts | 8 +++--- src/commands/pg/credentials.ts | 8 +++--- src/commands/pg/upgrade/cancel.ts | 9 +++--- src/commands/pg/upgrade/dryrun.ts | 8 +++--- src/commands/pg/upgrade/prepare.ts | 8 +++--- src/commands/pg/upgrade/run.ts | 8 +++--- src/commands/pg/upgrade/wait.ts | 10 +++---- src/lib/pg/sdk-adapter.ts | 45 ------------------------------ 8 files changed, 29 insertions(+), 75 deletions(-) delete mode 100644 src/lib/pg/sdk-adapter.ts diff --git a/src/commands/pg/backups/index.ts b/src/commands/pg/backups/index.ts index 4b886549f1..bf0e3fb406 100644 --- a/src/commands/pg/backups/index.ts +++ b/src/commands/pg/backups/index.ts @@ -3,10 +3,10 @@ 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 {transferExtensions} from '@heroku/sdk/extensions/data' import backupsFactory from '../../../lib/pg/backups.js' -import {listTransfersByApp} from '../../../lib/pg/sdk-adapter.js' +import type {BackupTransfer} from '../../../lib/pg/types.js' export default class Index extends Command { static description = 'list database backups' @@ -26,8 +26,8 @@ export default class Index extends Command { public async run(): Promise { const {flags: {app}} = await this.parse(Index) - const {data} = new HerokuSDK() - const transfers = await listTransfersByApp(data, app) + const {data} = new HerokuSDK({extensions: [transferExtensions]}) + const transfers = await data.transfer.listByApp(app) as unknown as BackupTransfer[] // 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 dcea38c187..7899aa00a9 100644 --- a/src/commands/pg/credentials.ts +++ b/src/commands/pg/credentials.ts @@ -4,9 +4,9 @@ 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 {postgresDatabaseExtensions} from '@heroku/sdk/extensions/data' -import {listCredentials} from '../../lib/pg/sdk-adapter.js' +import type {NonAdvancedCredentialInfo} from '../../lib/data/types.js' import {presentCredentialAttachments} from '../../lib/pg/util.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' import {nls} from '../../nls.js' @@ -34,8 +34,8 @@ export default class Credentials extends Command { const dbResolver = new utils.pg.DatabaseResolver(this.heroku) const {addon} = await dbResolver.getAttachment(app, database) - const {data} = new HerokuSDK() - const credentials = await listCredentials(data, addon.id) + const {data} = new HerokuSDK({extensions: [postgresDatabaseExtensions]}) + const credentials = await data.postgresDatabase.listCredentials(app, addon.name) as NonAdvancedCredentialInfo[] const sortedCredentials = this.sortByDefaultAndName(credentials) const {body: attachments} = await this.heroku.get[]>(`/addons/${addon.id}/addon-attachments`) diff --git a/src/commands/pg/upgrade/cancel.ts b/src/commands/pg/upgrade/cancel.ts index ff702440aa..1baf319e8d 100644 --- a/src/commands/pg/upgrade/cancel.ts +++ b/src/commands/pg/upgrade/cancel.ts @@ -1,12 +1,11 @@ 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 {databaseExtensions} from '@heroku/sdk/extensions/data' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {getDatabaseInfo} from '../../../lib/pg/sdk-adapter.js' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -38,8 +37,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 {data} = new HerokuSDK() - const replica = await getDatabaseInfo(data, db.id) + const {data} = new HerokuSDK({extensions: [databaseExtensions]}) + const replica = await data.database.describe(app, db.name) 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.`) @@ -52,7 +51,7 @@ export default class Upgrade extends Command { try { ux.action.start(`Cancelling upgrade on ${color.addon(db.name)}`) - const response: DatabaseCancelUpgradeResult = await data.database.cancelUpgrade(db.id) + const response = await data.database.cancelUpgrade(db.id) ux.action.stop('done\n' + formatResponseWithCommands(response.message)) } catch (error: any) { if (error.id && error.message) { diff --git a/src/commands/pg/upgrade/dryrun.ts b/src/commands/pg/upgrade/dryrun.ts index d8ece92a87..0bdac0592d 100644 --- a/src/commands/pg/upgrade/dryrun.ts +++ b/src/commands/pg/upgrade/dryrun.ts @@ -1,11 +1,11 @@ import {Command, flags} from '@heroku-cli/command' import {color, utils} from '@heroku/heroku-cli-util' import {HerokuSDK} from '@heroku/sdk' +import {databaseExtensions} from '@heroku/sdk/extensions/data' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {dryRunUpgrade, getDatabaseInfo} from '../../../lib/pg/sdk-adapter.js' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -39,8 +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 {data} = new HerokuSDK() - const replica = await getDatabaseInfo(data, db.id) + const {data} = new HerokuSDK({extensions: [databaseExtensions]}) + const replica = await data.database.describe(app, db.name) 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.`) @@ -50,7 +50,7 @@ export default class Upgrade extends Command { try { ux.action.start(`Starting a test upgrade on ${color.datastore(db.name)}`) - const response = await dryRunUpgrade(data, db.id, {version}) + const response = await data.database.dryRunUpgrade(app, db.name, {version}) ux.action.stop('done\n' + formatResponseWithCommands(response.message)) } catch (error: any) { if (error.id && error.message) { diff --git a/src/commands/pg/upgrade/prepare.ts b/src/commands/pg/upgrade/prepare.ts index cad74d19d2..994a42ada7 100644 --- a/src/commands/pg/upgrade/prepare.ts +++ b/src/commands/pg/upgrade/prepare.ts @@ -5,7 +5,7 @@ import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {getDatabaseInfo, prepareUpgrade} from '../../../lib/pg/sdk-adapter.js' +import {databaseExtensions} from '@heroku/sdk/extensions/data' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -39,8 +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 {data} = new HerokuSDK() - const replica = await getDatabaseInfo(data, db.id) + const {data} = new HerokuSDK({extensions: [databaseExtensions]}) + const replica = await data.database.describe(app, db.name) 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.`) @@ -52,7 +52,7 @@ export default class Upgrade extends Command { try { ux.action.start(`Preparing upgrade on ${color.addon(db.name)}`) - const response = await prepareUpgrade(data, db.id, {version}) + const response = await data.database.prepareUpgrade(app, db.name, {version}) ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.message)}`)) } catch (error: any) { if (error.id && error.message) { diff --git a/src/commands/pg/upgrade/run.ts b/src/commands/pg/upgrade/run.ts index 4aeb49c017..02bb0b5a27 100644 --- a/src/commands/pg/upgrade/run.ts +++ b/src/commands/pg/upgrade/run.ts @@ -6,7 +6,7 @@ import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' import ConfirmCommand from '../../../lib/confirm-command.js' -import {getDatabaseInfo, runUpgrade} from '../../../lib/pg/sdk-adapter.js' +import {databaseExtensions} from '@heroku/sdk/extensions/data' import {databaseNameFromUrl, formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -58,8 +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 {data} = new HerokuSDK() - const replica = await getDatabaseInfo(data, db.id) + const {data} = new HerokuSDK({extensions: [databaseExtensions]}) + const replica = await data.database.describe(app, db.name) if (utils.pg.isEssentialDatabase(db)) { await new ConfirmCommand().confirm(app, confirm, heredoc(` @@ -89,7 +89,7 @@ export default class Upgrade extends Command { try { ux.action.start(`Starting upgrade on ${color.datastore(db.name)}`) - const response = await runUpgrade(data, db.id, {version}) + const response = await data.database.runUpgrade(app, db.name, {version}) ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.message)}`)) } catch (error: any) { if (error.id && error.message) { diff --git a/src/commands/pg/upgrade/wait.ts b/src/commands/pg/upgrade/wait.ts index 0c1841c90a..4792245f0e 100644 --- a/src/commands/pg/upgrade/wait.ts +++ b/src/commands/pg/upgrade/wait.ts @@ -6,8 +6,8 @@ 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 {databaseExtensions} from '@heroku/sdk/extensions/data' +import type {DatabaseUpgradeWaitResult} from '@heroku/sdk/resources/data/database' import {formatResponseWithCommands} from '../../../lib/pg/util.js' import {nls} from '../../../nls.js' @@ -50,17 +50,17 @@ export default class Wait extends Command { const dbName = args.database const pgDebug = debug('pg') - const {data} = new HerokuSDK() + const {data} = new HerokuSDK({extensions: [databaseExtensions]}) const waitFor = async (db: pg.ExtendedAddonAttachment['addon']) => { const interval = (!waitInterval || waitInterval < 0) ? 5 : waitInterval - let status: PgUpgradeStatus + let status: DatabaseUpgradeWaitResult let waiting = false let retries = 20 const notFoundMessage = 'Waiting to provision...' while (true) { try { - status = await getUpgradeWaitStatus(data, db.id) + status = await data.database.upgradeWaitStatus(app, db.name) } catch (error: any) { if (!retries || error.statusCode !== 404) { pgDebug(error) diff --git a/src/lib/pg/sdk-adapter.ts b/src/lib/pg/sdk-adapter.ts deleted file mode 100644 index 8fb2eabe14..0000000000 --- a/src/lib/pg/sdk-adapter.ts +++ /dev/null @@ -1,45 +0,0 @@ -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}> -} From a6891955ffab2a355a3d6c155d0b488048965960 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Fri, 29 May 2026 15:27:33 -0700 Subject: [PATCH 2/5] Refactor Postgres backups/credentials command paths and related PG upgrade tests to align with direct SDK call patterns and remove redundant test mocking/setup codem-app --- src/commands/pg/backups/index.ts | 6 ++--- src/commands/pg/credentials.ts | 12 +++++----- .../commands/pg/backups/index.unit.test.ts | 20 ++++++----------- .../unit/commands/pg/credentials.unit.test.ts | 22 ++++++++----------- .../commands/pg/upgrade/cancel.unit.test.ts | 2 +- .../commands/pg/upgrade/dryrun.unit.test.ts | 2 +- .../commands/pg/upgrade/prepare.unit.test.ts | 2 +- .../unit/commands/pg/upgrade/run.unit.test.ts | 2 +- 8 files changed, 29 insertions(+), 39 deletions(-) diff --git a/src/commands/pg/backups/index.ts b/src/commands/pg/backups/index.ts index bf0e3fb406..8ef8030ffb 100644 --- a/src/commands/pg/backups/index.ts +++ b/src/commands/pg/backups/index.ts @@ -1,9 +1,8 @@ import {Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' -import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' -import {transferExtensions} from '@heroku/sdk/extensions/data' +import {utils} from '@heroku/heroku-cli-util' import backupsFactory from '../../../lib/pg/backups.js' import type {BackupTransfer} from '../../../lib/pg/types.js' @@ -26,8 +25,7 @@ export default class Index extends Command { public async run(): Promise { const {flags: {app}} = await this.parse(Index) - const {data} = new HerokuSDK({extensions: [transferExtensions]}) - const transfers = await data.transfer.listByApp(app) as unknown as BackupTransfer[] + const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) // 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 7899aa00a9..5bf30b5688 100644 --- a/src/commands/pg/credentials.ts +++ b/src/commands/pg/credentials.ts @@ -1,11 +1,8 @@ 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 {postgresDatabaseExtensions} from '@heroku/sdk/extensions/data' - import type {NonAdvancedCredentialInfo} from '../../lib/data/types.js' import {presentCredentialAttachments} from '../../lib/pg/util.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' @@ -34,8 +31,13 @@ export default class Credentials extends Command { const dbResolver = new utils.pg.DatabaseResolver(this.heroku) const {addon} = await dbResolver.getAttachment(app, database) - const {data} = new HerokuSDK({extensions: [postgresDatabaseExtensions]}) - const credentials = await data.postgresDatabase.listCredentials(app, addon.name) as NonAdvancedCredentialInfo[] + 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 sortedCredentials = this.sortByDefaultAndName(credentials) const {body: attachments} = await this.heroku.get[]>(`/addons/${addon.id}/addon-attachments`) diff --git a/test/unit/commands/pg/backups/index.unit.test.ts b/test/unit/commands/pg/backups/index.unit.test.ts index 7bfb087c2a..5c862f5521 100644 --- a/test/unit/commands/pg/backups/index.unit.test.ts +++ b/test/unit/commands/pg/backups/index.unit.test.ts @@ -1,30 +1,28 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import {stub} from 'sinon' +import nock from 'nock' 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 sdkMock: MockSDK - let listByAppStub: ReturnType + let pg: nock.Scope let transfers: BackupTransfer[] beforeEach(function () { - listByAppStub = stub().resolves(transfers) - sdkMock = mockSDKData({ - transfer: {listByApp: listByAppStub}, - }) + pg = nock('https://api.data.heroku.com') + pg.get('/client/v11/apps/myapp/transfers') + .reply(200, transfers) }) afterEach(function () { - sdkMock.restore() + nock.cleanAll() + pg.done() }) describe('with no backups/restores/copies', function () { @@ -33,7 +31,6 @@ describe('pg:backups', function () { }) it('shows empty message', async function () { - listByAppStub.resolves(transfers) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -108,7 +105,6 @@ describe('pg:backups', function () { }) it('shows backups', async function () { - listByAppStub.resolves(transfers) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -153,7 +149,6 @@ 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', @@ -196,7 +191,6 @@ 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 362fbc1c00..cc8da482ca 100644 --- a/test/unit/commands/pg/credentials.unit.test.ts +++ b/test/unit/commands/pg/credentials.unit.test.ts @@ -5,7 +5,6 @@ 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. */ @@ -20,23 +19,17 @@ describe('pg:credentials', function () { plan: {name: 'heroku-postgresql:standard-0'}, } let api: nock.Scope - let sdkMock: MockSDK - let listCredentialsStub: ReturnType + let pg: nock.Scope beforeEach(function () { api = nock('https://api.heroku.com') - listCredentialsStub = stub() - sdkMock = mockSDKData({ - postgresDatabase: { - listCredentials: listCredentialsStub, - }, - }) + pg = nock('https://api.data.heroku.com') }) afterEach(function () { nock.cleanAll() restore() - sdkMock.restore() + pg.done() api.done() }) @@ -108,7 +101,8 @@ describe('pg:credentials', function () { .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - listCredentialsStub.resolves(credentials) + pg.get('/postgres/v0/databases/1/credentials') + .reply(200, credentials) const {stdout} = await runCommand(Cmd, [ '--app', @@ -197,7 +191,8 @@ describe('pg:credentials', function () { .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - listCredentialsStub.resolves(credentials) + pg.get('/postgres/v0/databases/1/credentials') + .reply(200, credentials) const {stdout} = await runCommand(Cmd, [ '--app', @@ -231,7 +226,8 @@ describe('pg:credentials', function () { .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - listCredentialsStub.resolves(credentials) + pg.get('/postgres/v0/databases/1/credentials') + .reply(200, credentials) const tableStub = stub(hux, 'table') await runCommand(Cmd, ['--app', 'myapp', '--no-wrap']) diff --git a/test/unit/commands/pg/upgrade/cancel.unit.test.ts b/test/unit/commands/pg/upgrade/cancel.unit.test.ts index cbc0857af5..3c52204176 100644 --- a/test/unit/commands/pg/upgrade/cancel.unit.test.ts +++ b/test/unit/commands/pg/upgrade/cancel.unit.test.ts @@ -38,7 +38,7 @@ describe('pg:upgrade:cancel', function () { infoStub = stub() cancelUpgradeStub = stub() sdkMock = mockSDKData({ - database: {info: infoStub, cancelUpgrade: cancelUpgradeStub}, + database: {describe: infoStub, cancelUpgrade: cancelUpgradeStub}, }) }) diff --git a/test/unit/commands/pg/upgrade/dryrun.unit.test.ts b/test/unit/commands/pg/upgrade/dryrun.unit.test.ts index b583cc5d0f..64e02106cd 100644 --- a/test/unit/commands/pg/upgrade/dryrun.unit.test.ts +++ b/test/unit/commands/pg/upgrade/dryrun.unit.test.ts @@ -38,7 +38,7 @@ describe('pg:upgrade:dryrun', function () { infoStub = stub() dryRunUpgradeStub = stub() sdkMock = mockSDKData({ - database: {info: infoStub, dryRunUpgrade: dryRunUpgradeStub}, + database: {describe: infoStub, dryRunUpgrade: dryRunUpgradeStub}, }) }) diff --git a/test/unit/commands/pg/upgrade/prepare.unit.test.ts b/test/unit/commands/pg/upgrade/prepare.unit.test.ts index 592e5785e3..5dea96ddbe 100644 --- a/test/unit/commands/pg/upgrade/prepare.unit.test.ts +++ b/test/unit/commands/pg/upgrade/prepare.unit.test.ts @@ -38,7 +38,7 @@ describe('pg:upgrade:prepare', function () { infoStub = stub() prepareUpgradeStub = stub() sdkMock = mockSDKData({ - database: {info: infoStub, prepareUpgrade: prepareUpgradeStub}, + database: {describe: infoStub, prepareUpgrade: prepareUpgradeStub}, }) }) diff --git a/test/unit/commands/pg/upgrade/run.unit.test.ts b/test/unit/commands/pg/upgrade/run.unit.test.ts index ec1cf2693d..ba5838fcc6 100644 --- a/test/unit/commands/pg/upgrade/run.unit.test.ts +++ b/test/unit/commands/pg/upgrade/run.unit.test.ts @@ -40,7 +40,7 @@ describe('pg:upgrade:run', function () { infoStub = stub() runUpgradeStub = stub() sdkMock = mockSDKData({ - database: {info: infoStub, runUpgrade: runUpgradeStub}, + database: {describe: infoStub, runUpgrade: runUpgradeStub}, }) }) From 00c6fa21a14b87bf3a437958fa1c2050053f847b Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Mon, 1 Jun 2026 12:24:44 -0700 Subject: [PATCH 3/5] Refactor addons and pipelines SDK integrations (including disambiguation and log display paths) and update pipeline/addon test fixtures to match the new call patterns and response handling --- src/commands/addons/index.ts | 15 ++-- src/commands/pipelines/create.ts | 12 ++-- src/commands/pipelines/destroy.ts | 6 +- src/commands/pipelines/index.ts | 6 +- src/commands/pipelines/promote.ts | 11 +-- src/commands/pipelines/update.ts | 8 +-- src/lib/pipelines/disambiguate.ts | 6 +- src/lib/run/log-displayer.ts | 21 +++--- test/helpers/mock-sdk.ts | 8 ++- test/unit/commands/addons/create.unit.test.ts | 4 +- .../unit/commands/addons/upgrade.unit.test.ts | 4 +- .../commands/pipelines/create.unit.test.ts | 70 +++++++++---------- .../commands/pipelines/destroy.unit.test.ts | 11 ++- .../commands/pipelines/index.unit.test.ts | 41 +++++------ .../commands/pipelines/promote.unit.test.ts | 2 +- .../commands/pipelines/transfer.unit.test.ts | 25 ++++--- .../commands/pipelines/update.unit.test.ts | 23 +++--- 17 files changed, 142 insertions(+), 131 deletions(-) diff --git a/src/commands/addons/index.ts b/src/commands/addons/index.ts index b9a9a04853..3ea208114c 100644 --- a/src/commands/addons/index.ts +++ b/src/commands/addons/index.ts @@ -14,6 +14,13 @@ const ADDON_EXPANSION_HEADERS = { 'Accept-Expansion': 'addon_service,plan', } +// SDK resource types use required fields while @heroku-cli/schema uses +// optional fields for the same shapes. This cast bridges the gap until +// the codebase fully migrates off @heroku-cli/schema. +function asSchemaBased(value: unknown): T { + return value as T +} + const topic = 'addons' export default class Addons extends Command { @@ -72,22 +79,22 @@ async function addonGetter(api: APIClient, app?: string) { let attachmentsResponse: null | Promise = null let addonsResponse: Promise if (app) { // don't display attachments globally - addonsResponse = platformWithExpansion.addOn.listByApp(app) as unknown as Promise + addonsResponse = platformWithExpansion.addOn.listByApp(app).then(asSchemaBased) const sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}') // eslint-disable-next-line unicorn/prefer-ternary if (sudoHeaders['X-Heroku-Sudo'] && !sudoHeaders['X-Heroku-Sudo-User']) { // because the root /addon-attachments endpoint won't include relevant // attachments when sudo-ing for another app, we will use the more // specific API call and sacrifice listing foreign attachments. - attachmentsResponse = platform.addOnAttachment.listByApp(app) as unknown as Promise + attachmentsResponse = platform.addOnAttachment.listByApp(app).then(asSchemaBased) } else { // In order to display all foreign attachments, we'll get out entire // attachment list - attachmentsResponse = platform.addOnAttachment.list() as unknown as Promise + attachmentsResponse = platform.addOnAttachment.list().then(asSchemaBased) } } else { // The global /addons endpoint doesn't support Accept-Expansion. - addonsResponse = platform.addOn.list() as unknown as Promise + addonsResponse = platform.addOn.list().then(asSchemaBased) } // Get addons and attachments in parallel diff --git a/src/commands/pipelines/create.ts b/src/commands/pipelines/create.ts index 25e7318fa3..2be4ba0fcb 100644 --- a/src/commands/pipelines/create.ts +++ b/src/commands/pipelines/create.ts @@ -1,7 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import {StageCompletion} from '@heroku-cli/command/lib/completions.js' import * as color from '@heroku/heroku-cli-util/color' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {PipelineCreateOpts} from '@heroku/types/3.sdk' import {Args, ux} from '@oclif/core' import {type Answers, type InputQuestion, type ListQuestion} from 'inquirer' @@ -84,12 +84,12 @@ export default class Create extends Command { const ownerType = teamName ? 'team' : 'user' - const heroku = createPlatformClient() + const {platform} = new HerokuSDK() // If team or org is not specified, we assign ownership to the user creating const ownerRecord = teamName - ? await heroku.team.info(teamName) - : await heroku.account.infoByUser('~') + ? await platform.team.info(teamName) + : await platform.account.infoByUser('~') const ownerID = ownerRecord.id! const answers: Answers = await inquirer.prompt(questions) @@ -103,11 +103,11 @@ export default class Create extends Command { name, owner: {id: ownerID, type: ownerType}, } - const pipeline = await heroku.pipeline.create(body) + const pipeline = await platform.pipeline.create(body) ux.action.stop() ux.action.start(`Adding ${color.app(app)} to ${color.pipeline(pipeline.name || '')} pipeline as ${stage}`) - await heroku.pipelineCoupling.create({ + await platform.pipelineCoupling.create({ app, pipeline: pipeline.id!, stage, diff --git a/src/commands/pipelines/destroy.ts b/src/commands/pipelines/destroy.ts index 28b7cf8cae..c3aeb538a0 100644 --- a/src/commands/pipelines/destroy.ts +++ b/src/commands/pipelines/destroy.ts @@ -1,7 +1,7 @@ import {Command} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import disambiguate from '../../lib/pipelines/disambiguate.js' @@ -23,8 +23,8 @@ export default class PipelinesDestroy extends Command { const pipeline: Heroku.Pipeline = await disambiguate(this.heroku, args.pipeline) ux.action.start(`Destroying ${color.pipeline(pipeline.name!)} pipeline`) - const heroku = createPlatformClient() - await heroku.pipeline.delete(pipeline.id!) + const {platform} = new HerokuSDK() + await platform.pipeline.delete(pipeline.id!) ux.action.stop() } } diff --git a/src/commands/pipelines/index.ts b/src/commands/pipelines/index.ts index e49e5229de..45a7550c09 100644 --- a/src/commands/pipelines/index.ts +++ b/src/commands/pipelines/index.ts @@ -1,6 +1,6 @@ import {Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' export default class Pipelines extends Command { @@ -15,8 +15,8 @@ export default class Pipelines extends Command { async run() { const {flags} = await this.parse(Pipelines) - const heroku = createPlatformClient() - const pipelines = await heroku.pipeline.list() + const {platform} = new HerokuSDK() + const pipelines = await platform.pipeline.list() if (flags.json) { hux.styledJSON(pipelines) diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index 6cbf9ea764..b894510703 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -1,3 +1,4 @@ +import type {PipelinePromotionTarget} from '@heroku/types/3.sdk' import type {AppWithPipelineCoupling} from '@heroku/sdk/resources/platform/pipeline-coupling' import {APIClient, Command, flags} from '@heroku-cli/command' @@ -118,12 +119,12 @@ export default class Promote extends Command { const appsByID = keyBy(allApps, 'id') - const styledTargets = promotionTargets.reduce((memo: Heroku.App, target: any) => { + const styledTargets = promotionTargets.reduce((memo: Record, target: PipelinePromotionTarget) => { const app = appsByID[target.app.id] - const details = [target.status] + const details: string[] = [target.status] if (isFailed(target)) { - details.push(target.error_message) + details.push(target.error_message ?? '') } memo[app.name] = details @@ -158,10 +159,10 @@ async function getCoupling(heroku: APIClient, app: string): Promise { - if (err.code === 'EPIPE') { - // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit - process.exit(0) - } else { - ux.error(err.message ?? String(err), {exit: 1}) - } -}) +// MaxListeners warning). Skip in test environments to avoid killing +// the test runner on piped-output scenarios. +if (!process.env.IS_HEROKU_TEST_ENV) { + process.stdout.on('error', err => { + if (err.code === 'EPIPE') { + // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit + process.exit(0) + } else { + ux.error(err.message ?? String(err), {exit: 1}) + } + }) +} export async function displayLogs(options: LogDisplayerOptions): Promise { const controller = new AbortController() diff --git a/test/helpers/mock-sdk.ts b/test/helpers/mock-sdk.ts index 8b448d6dbb..6a8dce8ce9 100644 --- a/test/helpers/mock-sdk.ts +++ b/test/helpers/mock-sdk.ts @@ -2,11 +2,13 @@ import {HerokuSDK} from '@heroku/sdk' import {SinonStub, stub} from 'sinon' type DeepPartial = { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] + [P in keyof T]?: T[P] extends (...args: any[]) => any ? T[P] : T[P] extends object ? DeepPartial : T[P] } -type StubbedDataClient = DeepPartial -type StubbedPlatformClient = DeepPartial +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type StubbedDataClient = Record +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type StubbedPlatformClient = Record export interface MockSDK { dataStub?: SinonStub diff --git a/test/unit/commands/addons/create.unit.test.ts b/test/unit/commands/addons/create.unit.test.ts index cfc3becd5a..28cd318120 100644 --- a/test/unit/commands/addons/create.unit.test.ts +++ b/test/unit/commands/addons/create.unit.test.ts @@ -245,7 +245,7 @@ describe('addons:create', function () { it('notifies when provisioning failure occurs', async function () { const notifySpy = sandbox.spy(Cmd, 'notifier') const deprovisionedAddon = {..._.clone(addon), state: 'deprovisioned'} - const createAndWaitStub = stub().rejects(new AddonProvisioningFailedError(deprovisionedAddon)) + const createAndWaitStub = stub().rejects(new AddonProvisioningFailedError(deprovisionedAddon as any)) sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) try { @@ -269,7 +269,7 @@ describe('addons:create', function () { it('shows that it failed to provision', async function () { const deprovisionedAddon = _.clone(addon) deprovisionedAddon.state = 'deprovisioned' - const createAndWaitStub = stub().rejects(new AddonProvisioningFailedError(deprovisionedAddon)) + const createAndWaitStub = stub().rejects(new AddonProvisioningFailedError(deprovisionedAddon as any)) sdkMock = mockSDKPlatform({addOn: {createAndWait: createAndWaitStub}}) const {error} = await runCommand(Cmd, [ diff --git a/test/unit/commands/addons/upgrade.unit.test.ts b/test/unit/commands/addons/upgrade.unit.test.ts index fad36c4212..b7da191db6 100644 --- a/test/unit/commands/addons/upgrade.unit.test.ts +++ b/test/unit/commands/addons/upgrade.unit.test.ts @@ -208,7 +208,7 @@ describe('addons:upgrade', function () { }) it('displays an error when multiple matches exist', async function () { - const upgradeStub = stub().rejects(new AddonAmbiguousError([{name: 'addon-1'}, {name: 'addon-2'}])) + const upgradeStub = stub().rejects(new AddonAmbiguousError([{name: 'addon-1'}, {name: 'addon-2'}] as any)) sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) try { await runCommand(Cmd, [ @@ -224,7 +224,7 @@ describe('addons:upgrade', function () { }) it('handles multiple add-ons', async function () { - const upgradeStub = stub().rejects(new AddonAmbiguousError([{name: 'db1-swiftly-123'}, {name: 'db1-swiftly-456'}])) + const upgradeStub = stub().rejects(new AddonAmbiguousError([{name: 'db1-swiftly-123'}, {name: 'db1-swiftly-456'}] as any)) sdkMock = mockSDKPlatform({addOn: {listPlans: stub().resolves([]), upgrade: upgradeStub}}) try { await runCommand(Cmd, [ diff --git a/test/unit/commands/pipelines/create.unit.test.ts b/test/unit/commands/pipelines/create.unit.test.ts index 43fe7ca244..4b96ea2fa4 100644 --- a/test/unit/commands/pipelines/create.unit.test.ts +++ b/test/unit/commands/pipelines/create.unit.test.ts @@ -1,11 +1,14 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {restore, stub} from 'sinon' import PipelinesCreate from '../../../../src/commands/pipelines/create.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('pipelines:create', function () { let api: nock.Scope + let sdkMock: MockSDK beforeEach(function () { api = nock('https://api.heroku.com') @@ -14,30 +17,26 @@ describe('pipelines:create', function () { afterEach(function () { api.done() nock.cleanAll() + sdkMock.restore() + restore() }) describe('successful pipeline creation', function () { context('when not specifying ownership', function () { it('creates a pipeline with default user ownership', async function () { - api - .post('/pipeline-couplings') - .reply(201, {id: '0123', stage: 'production'}) - .get('/users/~') - .reply(200, {id: '1234-567'}) - .post('/pipelines', { - generation: {name: 'fir'}, - name: 'example-pipeline', - owner: {id: '1234-567', type: 'user'}, - }) - .reply(201, { - id: '0123', - name: 'example-pipeline', - owner: {id: '1234-567', type: 'user'}, - }) + const pipeline = {id: '0123', name: 'example-pipeline', owner: {id: '1234-567', type: 'user'}} - nock('https://api.heroku.com') - .get('/apps/example-app') - .reply(200, {generation: 'fir', id: '0123', name: 'example-app'}) + // getGenerationByAppId still uses this.heroku + api.get('/apps/example-app').reply(200, {generation: 'fir', id: '0123', name: 'example-app'}) + + const infoByUserStub = stub().resolves({id: '1234-567'}) + const pipelineCreateStub = stub().resolves(pipeline) + const couplingCreateStub = stub().resolves({id: '0123', stage: 'production'}) + sdkMock = mockSDKPlatform({ + account: {infoByUser: infoByUserStub}, + pipeline: {create: pipelineCreateStub}, + pipelineCoupling: {create: couplingCreateStub}, + }) const {stderr, stdout} = await runCommand(PipelinesCreate, [ '--app', @@ -50,30 +49,27 @@ describe('pipelines:create', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Creating example-pipeline pipeline... done') expect(stderr).to.contain('Adding ⬢ example-app to example-pipeline pipeline as production... done') + expect(infoByUserStub.calledOnceWith('~')).to.be.true + expect(pipelineCreateStub.calledOnce).to.be.true + expect(pipelineCreateStub.firstCall.args[0]).to.deep.include({name: 'example-pipeline', owner: {id: '1234-567', type: 'user'}}) + expect(couplingCreateStub.calledOnce).to.be.true }) }) context('when specifying a team as owner', function () { it('creates a pipeline with team ownership', async function () { - nock('https://api.heroku.com') - .post('/pipeline-couplings') - .reply(201, {id: '0123', stage: 'production'}) - .get('/teams/my-team') - .reply(200, {id: '89-0123-456'}) - .post('/pipelines', { - generation: {name: 'fir'}, - name: 'example-pipeline', - owner: {id: '89-0123-456', type: 'team'}, - }) - .reply(201, { - id: '0123', - name: 'example-pipeline', - owner: {id: '89-0123-456', type: 'team'}, - }) + const pipeline = {id: '0123', name: 'example-pipeline', owner: {id: '89-0123-456', type: 'team'}} + + api.get('/apps/example-app').reply(200, {generation: 'fir', id: '0123', name: 'example-app'}) - nock('https://api.heroku.com') - .get('/apps/example-app') - .reply(200, {generation: 'fir', id: '0123', name: 'example-app'}) + const teamInfoStub = stub().resolves({id: '89-0123-456'}) + const pipelineCreateStub = stub().resolves(pipeline) + const couplingCreateStub = stub().resolves({id: '0123', stage: 'production'}) + sdkMock = mockSDKPlatform({ + pipeline: {create: pipelineCreateStub}, + pipelineCoupling: {create: couplingCreateStub}, + team: {info: teamInfoStub}, + }) const {stderr, stdout} = await runCommand(PipelinesCreate, [ '--app', @@ -88,6 +84,8 @@ describe('pipelines:create', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Creating example-pipeline pipeline... done') expect(stderr).to.contain('Adding ⬢ example-app to example-pipeline pipeline as production... done') + expect(teamInfoStub.calledOnceWith('my-team')).to.be.true + expect(pipelineCreateStub.firstCall.args[0]).to.deep.include({name: 'example-pipeline', owner: {id: '89-0123-456', type: 'team'}}) }) }) }) diff --git a/test/unit/commands/pipelines/destroy.unit.test.ts b/test/unit/commands/pipelines/destroy.unit.test.ts index dd86be3f08..f1c9ccef45 100644 --- a/test/unit/commands/pipelines/destroy.unit.test.ts +++ b/test/unit/commands/pipelines/destroy.unit.test.ts @@ -1,11 +1,14 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import nock from 'nock' +import {restore, stub} from 'sinon' import PipelinesDestroy from '../../../../src/commands/pipelines/destroy.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('pipelines:destroy', function () { let api: nock.Scope + let sdkMock: MockSDK beforeEach(function () { api = nock('https://api.heroku.com') @@ -14,6 +17,8 @@ describe('pipelines:destroy', function () { afterEach(function () { api.done() nock.cleanAll() + sdkMock.restore() + restore() }) it('displays the right messages', async function () { @@ -22,11 +27,13 @@ describe('pipelines:destroy', function () { api .get(`/pipelines?eq[name]=${pipeline.name}`) .reply(200, [pipeline]) - .delete(`/pipelines/${pipeline.id}`) - .reply(200, pipeline) + + const deleteStub = stub().resolves(pipeline) + sdkMock = mockSDKPlatform({pipeline: {delete: deleteStub}}) const {stderr} = await runCommand(PipelinesDestroy, ['example']) expect(stderr).to.include('Destroying example pipeline... done') + expect(deleteStub.calledOnceWith(pipeline.id)).to.be.true }) }) diff --git a/test/unit/commands/pipelines/index.unit.test.ts b/test/unit/commands/pipelines/index.unit.test.ts index 100abe6663..66839f1b5a 100644 --- a/test/unit/commands/pipelines/index.unit.test.ts +++ b/test/unit/commands/pipelines/index.unit.test.ts @@ -1,28 +1,24 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {restore, stub} from 'sinon' import Pipelines from '../../../../src/commands/pipelines/index.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('pipelines', function () { - let api: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() + restore() }) it('shows a list of pipelines', async function () { - api - .get('/pipelines') - .reply(200, [ - {id: '0123', name: 'Betelgeuse'}, - {id: '9876', name: 'Sirius'}, - ]) + const listStub = stub().resolves([ + {id: '0123', name: 'Betelgeuse'}, + {id: '9876', name: 'Sirius'}, + ]) + sdkMock = mockSDKPlatform({pipeline: {list: listStub}}) const {stderr, stdout} = await runCommand(Pipelines, []) @@ -33,19 +29,16 @@ describe('pipelines', function () { }) it('shows a list of pipelines, json formatted', async function () { - api - .get('/pipelines') - .reply(200, [ - {id: '0123', name: 'Betelgeuse'}, - {id: '9876', name: 'Sirius'}, - ]) + const pipelines = [ + {id: '0123', name: 'Betelgeuse'}, + {id: '9876', name: 'Sirius'}, + ] + const listStub = stub().resolves(pipelines) + sdkMock = mockSDKPlatform({pipeline: {list: listStub}}) const {stderr, stdout} = await runCommand(Pipelines, ['--json']) expect(stderr).to.contain('') - expect(JSON.parse(stdout)).to.eql([ - {id: '0123', name: 'Betelgeuse'}, - {id: '9876', name: 'Sirius'}, - ]) + expect(JSON.parse(stdout)).to.eql(pipelines) }) }) diff --git a/test/unit/commands/pipelines/promote.unit.test.ts b/test/unit/commands/pipelines/promote.unit.test.ts index 5d0a48320c..ff9f7dc55d 100644 --- a/test/unit/commands/pipelines/promote.unit.test.ts +++ b/test/unit/commands/pipelines/promote.unit.test.ts @@ -163,7 +163,7 @@ describe('pipelines:promote', function () { const promoteStub = stub(Cmd, 'promotePipeline').callsFake(async (_ctx, _body, options) => { await options!.onReleaseStream!({ stream: streamBody, - target: {app: {id: targetApp1.id}, status: 'pending'}, + target: {app: {id: targetApp1.id}, error_message: null, id: 'target-1', pipeline_promotion: {id: 'promo-1'}, release: {id: 'release-1'}, status: 'pending'}, }) return { promotion, diff --git a/test/unit/commands/pipelines/transfer.unit.test.ts b/test/unit/commands/pipelines/transfer.unit.test.ts index cd885789a4..6dbae6cb86 100644 --- a/test/unit/commands/pipelines/transfer.unit.test.ts +++ b/test/unit/commands/pipelines/transfer.unit.test.ts @@ -5,6 +5,7 @@ import nock from 'nock' import {restore, stub} from 'sinon' import TransferCommand from '../../../../src/commands/pipelines/transfer.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('pipelines:transfer', function () { const pipeline = { @@ -31,9 +32,11 @@ describe('pipelines:transfer', function () { const app = { id: coupling.app.id, name: 'my-app', + pipelineCoupling: coupling, } let api: nock.Scope + let sdkMock: MockSDK beforeEach(function () { api = nock('https://api.heroku.com') @@ -42,23 +45,23 @@ describe('pipelines:transfer', function () { afterEach(function () { api.done() nock.cleanAll() + sdkMock.restore() restore() }) - function setupCommonMocks() { - api - .get(`/pipelines/${pipeline.id}`) - .reply(200, pipeline) - .get(`/pipelines/${pipeline.id}/pipeline-couplings`) - .reply(200, [coupling]) - .post('/filters/apps') - .reply(200, [app]) + function setupSDKMocks() { + const infoStub = stub().resolves(pipeline) + const listAppsStub = stub().resolves([app]) + sdkMock = mockSDKPlatform({ + pipeline: {info: infoStub}, + pipelineCoupling: {listApps: listAppsStub}, + }) } it('transfers to a team', async function () { this.retries(2) - setupCommonMocks() + setupSDKMocks() api .get(`/teams/${team.name}`) @@ -75,7 +78,7 @@ describe('pipelines:transfer', function () { }) it('transfers to an account', async function () { - setupCommonMocks() + setupSDKMocks() api .get(`/users/${account.email}`) @@ -94,7 +97,7 @@ describe('pipelines:transfer', function () { it('does not pass confirm flag', async function () { const promptStub = stub(hux, 'prompt').onFirstCall().resolves(pipeline.name) - setupCommonMocks() + setupSDKMocks() api .get(`/users/${account.email}`) diff --git a/test/unit/commands/pipelines/update.unit.test.ts b/test/unit/commands/pipelines/update.unit.test.ts index 618b866831..5ee4286f4d 100644 --- a/test/unit/commands/pipelines/update.unit.test.ts +++ b/test/unit/commands/pipelines/update.unit.test.ts @@ -1,34 +1,31 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {restore, stub} from 'sinon' import PipelinesUpdate from '../../../../src/commands/pipelines/update.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('pipelines:update', function () { const app = 'example' const id = '0123' const stage = 'production' const coupling = {id, stage} - let api: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() + restore() }) it('displays the right messages', async function () { - api - .get(`/apps/${app}/pipeline-couplings`) - .reply(200, coupling) - .patch(`/pipeline-couplings/${id}`) - .reply(200, coupling) + const infoByAppStub = stub().resolves(coupling) + const updateStub = stub().resolves(coupling) + sdkMock = mockSDKPlatform({pipelineCoupling: {infoByApp: infoByAppStub, update: updateStub}}) const {stderr} = await runCommand(PipelinesUpdate, [`--app=${app}`, `--stage=${stage}`]) expect(stderr).to.include(`Changing ⬢ ${app} to ${stage}... done`) + expect(infoByAppStub.calledOnceWith(app)).to.be.true + expect(updateStub.calledOnceWith(id, {stage})).to.be.true }) }) From 030d29cff3264a684a19b33204a7004d373cafa2 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Mon, 1 Jun 2026 13:53:25 -0700 Subject: [PATCH 4/5] Refactor Postgres backups and credentials commands to match updated SDK request/response handling, update their unit tests for the new behavior --- src/commands/pg/backups/index.ts | 9 +- src/commands/pg/credentials.ts | 23 +-- .../commands/pg/backups/index.unit.test.ts | 19 +- .../unit/commands/pg/credentials.unit.test.ts | 173 ++++++++---------- 4 files changed, 99 insertions(+), 125 deletions(-) diff --git a/src/commands/pg/backups/index.ts b/src/commands/pg/backups/index.ts index 8ef8030ffb..4dcb1631ff 100644 --- a/src/commands/pg/backups/index.ts +++ b/src/commands/pg/backups/index.ts @@ -1,9 +1,9 @@ import {Command, flags} from '@heroku-cli/command' -import {color, hux} from '@heroku/heroku-cli-util' +import {color, hux, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {transferExtensions} from '@heroku/sdk/extensions/data' import {ux} from '@oclif/core/ux' -import {utils} from '@heroku/heroku-cli-util' - import backupsFactory from '../../../lib/pg/backups.js' import type {BackupTransfer} from '../../../lib/pg/types.js' @@ -25,7 +25,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({extensions: [transferExtensions]}) + const transfers = await data.transfer.listByApp(app) as BackupTransfer[] // 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 5bf30b5688..bb97dead74 100644 --- a/src/commands/pg/credentials.ts +++ b/src/commands/pg/credentials.ts @@ -1,9 +1,11 @@ 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 {postgresDatabaseExtensions} from '@heroku/sdk/extensions/data' +import type {CredentialInfo} from '@heroku/sdk/resources/data/postgres-database' import {Args} from '@oclif/core' -import type {NonAdvancedCredentialInfo} from '../../lib/data/types.js' import {presentCredentialAttachments} from '../../lib/pg/util.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' import {nls} from '../../nls.js' @@ -20,7 +22,7 @@ export default class Credentials extends Command { } static topic = 'pg' - protected isDefaultCredential(cred: NonAdvancedCredentialInfo): boolean { + protected isDefaultCredential(cred: CredentialInfo): boolean { return cred.name === 'default' } @@ -31,21 +33,16 @@ 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({extensions: [postgresDatabaseExtensions]}) + const credentials = await data.postgresDatabase.listCredentials(app, addon.name) const sortedCredentials = this.sortByDefaultAndName(credentials) const {body: attachments} = await this.heroku.get[]>(`/addons/${addon.id}/addon-attachments`) - const presentCredential = (cred: NonAdvancedCredentialInfo): string => { + const presentCredential = (cred: CredentialInfo): string => { let credAttachments = [] as Required[] credAttachments = cred.name === 'default' ? attachments.filter(a => a.namespace === null) : attachments.filter(a => a.namespace === `credential:${cred.name}`) - return presentCredentialAttachments(app, credAttachments, sortedCredentials, cred.name) + return presentCredentialAttachments(app, credAttachments, sortedCredentials as any, cred.name) } hux.table(credentials, { @@ -58,8 +55,8 @@ export default class Credentials extends Command { }, huxTableNoWrapOptions(flags['no-wrap'])) } - protected sortByDefaultAndName(credentials: NonAdvancedCredentialInfo[]) { - return credentials.sort((a: NonAdvancedCredentialInfo, b: NonAdvancedCredentialInfo) => { + protected sortByDefaultAndName(credentials: CredentialInfo[]) { + return credentials.sort((a: CredentialInfo, b: CredentialInfo) => { const isDefaultA = this.isDefaultCredential(a) const isDefaultB = this.isDefaultCredential(b) diff --git a/test/unit/commands/pg/backups/index.unit.test.ts b/test/unit/commands/pg/backups/index.unit.test.ts index 5c862f5521..4f776bb611 100644 --- a/test/unit/commands/pg/backups/index.unit.test.ts +++ b/test/unit/commands/pg/backups/index.unit.test.ts @@ -1,28 +1,23 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {restore, 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 {type MockSDK, mockSDKData} 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 transfers: BackupTransfer[] - beforeEach(function () { - pg = nock('https://api.data.heroku.com') - pg.get('/client/v11/apps/myapp/transfers') - .reply(200, transfers) - }) - afterEach(function () { - nock.cleanAll() - pg.done() + sdkMock.restore() + restore() }) describe('with no backups/restores/copies', function () { @@ -31,6 +26,7 @@ describe('pg:backups', function () { }) it('shows empty message', async function () { + sdkMock = mockSDKData({transfer: {listByApp: stub().resolves(transfers)}}) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -105,6 +101,7 @@ describe('pg:backups', function () { }) it('shows backups', async function () { + sdkMock = mockSDKData({transfer: {listByApp: stub().resolves(transfers)}}) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -149,6 +146,7 @@ No copies found. Use heroku pg:copy to copy a database to another }) it('shows restore', async function () { + sdkMock = mockSDKData({transfer: {listByApp: stub().resolves(transfers)}}) const {stdout} = await runCommand(Cmd, [ '--app', 'myapp', @@ -191,6 +189,7 @@ No copies found. Use heroku pg:copy to copy a database to another }) it('shows copy', async function () { + sdkMock = mockSDKData({transfer: {listByApp: stub().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..191f7ec472 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. */ @@ -18,91 +19,91 @@ describe('pg:credentials', function () { name: 'postgres-1', plan: {name: 'heroku-postgresql:standard-0'}, } + + const credentials = [ + { + credentials: [], + database: 'd123', + host: 'localhost', + name: 'ransom', + port: 5442, + state: 'active', + uuid: 'aaaa', + }, + { + credentials: [], + database: 'd123', + host: 'localhost', + name: 'default', + port: 5442, + state: 'active', + uuid: 'aaab', + }, + { + credentials: [ + { + connections: 0, + state: 'revoking', + user: 'jeff', + }, + { + connections: 2, + state: 'active', + user: 'jeff-rotating', + }, + ], + database: 'd123', + host: 'localhost', + name: 'jeff', + port: 5442, + state: 'rotating', + uuid: 'aabb', + }, + ] + + const attachments = [ + { + app: {name: 'main-app'}, + name: 'DATABASE', + namespace: null, + }, + { + app: {name: 'main-app'}, + name: 'HEROKU_POSTGRESQL_GREEN', + namespace: 'credential:jeff', + }, + { + app: {name: 'another-app'}, + name: 'HEROKU_POSTGRESQL_PINK', + namespace: 'credential:jeff', + }, + { + app: {name: 'yet-another-app'}, + name: 'HEROKU_POSTGRESQL_BLUE', + namespace: 'credential:ransom', + }, + ] + let api: nock.Scope - let pg: nock.Scope + let sdkMock: MockSDK beforeEach(function () { api = nock('https://api.heroku.com') - pg = nock('https://api.data.heroku.com') }) afterEach(function () { nock.cleanAll() + sdkMock.restore() restore() - pg.done() api.done() }) it('shows the correct credentials', async function () { - const credentials = [ - { - credentials: [], - database: 'd123', - host: 'localhost', - name: 'ransom', - port: 5442, - state: 'active', - uuid: 'aaaa', - }, - { - credentials: [], - database: 'd123', - host: 'localhost', - name: 'default', - port: 5442, - state: 'active', - uuid: 'aaab', - }, - { - credentials: [ - { - connections: 0, - state: 'revoking', - user: 'jeff', - }, - { - connections: 2, - state: 'active', - user: 'jeff-rotating', - }, - ], - database: 'd123', - host: 'localhost', - name: 'jeff', - port: 5442, - state: 'rotating', - uuid: 'aabb', - }, - ] - const attachments = [ - { - app: {name: 'main-app'}, - name: 'DATABASE', - namespace: null, - }, - { - app: {name: 'main-app'}, - name: 'HEROKU_POSTGRESQL_GREEN', - namespace: 'credential:jeff', - }, - { - app: {name: 'another-app'}, - name: 'HEROKU_POSTGRESQL_PINK', - namespace: 'credential:jeff', - }, - { - app: {name: 'yet-another-app'}, - name: 'HEROKU_POSTGRESQL_BLUE', - namespace: 'credential:ransom', - }, - ] - api.post('/actions/addon-attachments/resolve', {addon_attachment: 'DATABASE_URL', app: 'myapp'}) .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - pg.get('/postgres/v0/databases/1/credentials') - .reply(200, credentials) + sdkMock = mockSDKData({postgresDatabase: {listCredentials: stub().resolves(credentials)}}) const {stdout} = await runCommand(Cmd, [ '--app', @@ -126,7 +127,7 @@ describe('pg:credentials', function () { }) it('shows the correct rotation information if no connection information is available yet', async function () { - const credentials = [ + const rotatingCredentials = [ { credentials: [], database: 'd123', @@ -164,35 +165,12 @@ describe('pg:credentials', function () { uuid: 'aabb', }, ] - const attachments = [ - { - app: {name: 'main-app'}, - name: 'DATABASE', - namespace: null, - }, - { - app: {name: 'main-app'}, - name: 'HEROKU_POSTGRESQL_GREEN', - namespace: 'credential:jeff', - }, - { - app: {name: 'another-app'}, - name: 'HEROKU_POSTGRESQL_PINK', - namespace: 'credential:jeff', - }, - { - app: {name: 'yet-another-app'}, - name: 'HEROKU_POSTGRESQL_BLUE', - namespace: 'credential:ransom', - }, - ] api.post('/actions/addon-attachments/resolve', {addon_attachment: 'DATABASE_URL', app: 'myapp'}) .reply(200, [{addon}]) .get('/addons/1/addon-attachments') .reply(200, attachments) - pg.get('/postgres/v0/databases/1/credentials') - .reply(200, credentials) + sdkMock = mockSDKData({postgresDatabase: {listCredentials: stub().resolves(rotatingCredentials)}}) const {stdout} = await runCommand(Cmd, [ '--app', @@ -213,21 +191,20 @@ describe('pg:credentials', function () { }) it('passes no-wrap option through to table rendering', async function () { - const credentials = [ + const simpleCredentials = [ { credentials: [], database: 'd123', host: 'localhost', name: 'default', port: 5442, state: 'active', uuid: 'aaaa', }, ] - const attachments = [ + const simpleAttachments = [ {app: {name: 'main-app'}, name: 'DATABASE', namespace: null}, ] api.post('/actions/addon-attachments/resolve', {addon_attachment: 'DATABASE_URL', app: 'myapp'}) .reply(200, [{addon}]) .get('/addons/1/addon-attachments') - .reply(200, attachments) - pg.get('/postgres/v0/databases/1/credentials') - .reply(200, credentials) + .reply(200, simpleAttachments) + sdkMock = mockSDKData({postgresDatabase: {listCredentials: stub().resolves(simpleCredentials)}}) const tableStub = stub(hux, 'table') await runCommand(Cmd, ['--app', 'myapp', '--no-wrap']) From 6ff8c40376e407dcf4e510bfa62278aa8570cb5b Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Mon, 1 Jun 2026 14:01:57 -0700 Subject: [PATCH 5/5] Update the Postgres upgrade cancel command and package.json dependency wiring to keep SDK integration references consistent --- src/commands/pg/upgrade/cancel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/pg/upgrade/cancel.ts b/src/commands/pg/upgrade/cancel.ts index 1baf319e8d..dcf1fb2f8d 100644 --- a/src/commands/pg/upgrade/cancel.ts +++ b/src/commands/pg/upgrade/cancel.ts @@ -51,7 +51,7 @@ export default class Upgrade extends Command { try { ux.action.start(`Cancelling upgrade on ${color.addon(db.name)}`) - const response = await data.database.cancelUpgrade(db.id) + const response = await data.database.cancelUpgrade(app, db.name) ux.action.stop('done\n' + formatResponseWithCommands(response.message)) } catch (error: any) { if (error.id && error.message) {