From 87732645b9dde6e6a60494f579440dc33477a41d Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Tue, 3 Feb 2026 12:37:50 -0300 Subject: [PATCH 01/11] Migrating command 'data:pg:create', utils and tests --- cspell-dictionary.txt | 1 + cspell.json | 4 +- src/commands/data/pg/create.ts | 348 +++++ src/lib/addons/create_addon.ts | 3 +- src/lib/data/poolConfig.ts | 195 +++ src/lib/data/utils.ts | 176 +++ test/fixtures/data/pg/fixtures.ts | 1201 +++++++++++++++++ test/helpers/init.mjs | 3 + .../unit/commands/data/pg/create.unit.test.ts | 702 ++++++++++ 9 files changed, 2630 insertions(+), 3 deletions(-) create mode 100644 src/commands/data/pg/create.ts create mode 100644 src/lib/data/poolConfig.ts create mode 100644 src/lib/data/utils.ts create mode 100644 test/fixtures/data/pg/fixtures.ts create mode 100644 test/unit/commands/data/pg/create.unit.test.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 6d6f18504f..dfa743fc58 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -217,6 +217,7 @@ netrc newkey newname newpassword +NGPG nodir noeviction notaninteger diff --git a/cspell.json b/cspell.json index b6fea4ebca..bdd7705a41 100644 --- a/cspell.json +++ b/cspell.json @@ -13,7 +13,7 @@ "./CHANGELOG.md", "**/package-lock.json", "**/node_modules/**", - "**/coverage/**" + "**/coverage/**", + "**/fixtures/**" ] } - \ No newline at end of file diff --git a/src/commands/data/pg/create.ts b/src/commands/data/pg/create.ts new file mode 100644 index 0000000000..fe1464aeeb --- /dev/null +++ b/src/commands/data/pg/create.ts @@ -0,0 +1,348 @@ +import {color, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' +import {ux} from '@oclif/core' +import inquirer from 'inquirer' +import tsheredoc from 'tsheredoc' + +import createAddon from '../../../lib/addons/create_addon.js' +import BaseCommand from '../../../lib/data/baseCommand.js' +import createPool from '../../../lib/data/createPool.js' +import {parseProvisionOpts} from '../../../lib/data/parseProvisionOpts.js' +import PoolConfig from '../../../lib/data/poolConfig.js' +import {ExtendedPostgresLevelInfo} from '../../../lib/data/types.js' +import {fetchLevelsAndPricing, renderPricingInfo} from '../../../lib/data/utils.js' +import notify from '../../../lib/notify.js' + +const heredoc = tsheredoc.default +// eslint-disable-next-line import/no-named-as-default-member +const {Separator, prompt} = inquirer + +export default class DataPgCreate extends BaseCommand { + static description = 'create a Postgres Advanced database' + + static examples = ['<%= config.bin %> <%= command.id %> --level 4G-Performance -a example-app'] + + static flags = { + app: Flags.app({ + required: true, + }), + as: Flags.string({description: 'name for the initial database attachment'}), + confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}), + followers: Flags.integer({ + dependsOn: ['level'], + description: 'provision a follower instance pool with the specified number of instances', + max: 13, + min: 1, + }), + 'high-availability': Flags.boolean({ + allowNo: true, + dependsOn: ['level'], + description: 'enable or disable high availability on the leader pool by provisioning a warm standby instance', + }), + level: Flags.string({ + description: 'set compute scale', + }), + name: Flags.string({description: 'name for the database'}), + network: Flags.string({description: 'set network for the database', options: ['private', 'shield']}), + 'provision-option': Flags.string({ + description: 'additional options for provisioning in KEY:VALUE or KEY format, and VALUE defaults to "true" (example: \'foo:bar\' or \'foo\')', + multiple: true, + }), + remote: Flags.remote(), + version: Flags.string({description: 'Postgres version for the database'}), + wait: Flags.boolean({ + dependsOn: ['level'], + description: 'watch database creation status and exit when complete', + }), + } + + private addon: Heroku.AddOn | undefined + private extendedLevelsInfo: ExtendedPostgresLevelInfo[] | undefined + private followerInstanceCount: number = 0 + private highAvailability: boolean | undefined + private leaderLevel: string | undefined + + public async prompt(...args: Parameters>): Promise { + return prompt(...args) + } + + public async run(): Promise { + const {flags} = await this.parse(DataPgCreate) + const {app, as, confirm, name, network, 'provision-option': provisionOpts, version, wait} = flags + const {followers, 'high-availability': highAvailability, level} = flags + const service = utils.pg.addonService() + + const useProd = process.env.HEROKU_PROD_NGPG === 'true' + const planSuffix = useProd ? '' : '-beta' + const plan = `advanced${network ? `-${network}` : ''}${planSuffix}` + const servicePlan = `${service}:${plan}` + + // Parse provision options + let provisionConfig: Record = {} + if (provisionOpts) { + try { + provisionConfig = parseProvisionOpts(provisionOpts) + } catch (error) { + ux.error(error instanceof Error ? error.message : String(error)) + } + } + + // Leader Pool Configuration Stage + + if (level) { + this.leaderLevel = level + this.highAvailability = highAvailability + } else { + // Fetch the information on levels and pricing for rendering choices + const {extendedLevelsInfo} = await fetchLevelsAndPricing(plan, this.dataApi) + this.extendedLevelsInfo = extendedLevelsInfo + + // Start the interactive mode + await this.leaderPoolConfig() + } + + // Database cluster provisioning (leader pool) + const config: Record = { + 'high-availability': this.highAvailability, + level: this.leaderLevel, + version, + ...provisionConfig, + } + + try { + this.addon = await createAddon(this.heroku, app, servicePlan, confirm, wait, { + actionStartMessage: `Creating a ${color.cyan(this.leaderLevel)} database on ${color.app(app)}`, + actionStopMessage: 'done', + as, config, name, + }) + + if (wait) { + notify('heroku data:pg:create', 'We successfully provisioned the database') + } + } catch (error) { + ux.action.stop() + + if (wait) { + notify( + 'heroku data:pg:create', + 'We can’t provision the database. Try again or open a ticket with Heroku Support: https://help.heroku.com/.', + false, + ) + } + + throw error + } + + // Follower Pool(s) Configuration Stage + + if (!level) { + // Interactive mode + await this.followerPoolConfigLoop() + process.stderr.write( + `Running ${color.code(`heroku data:pg:info ${this.addon.name!} --app=${app}`)}...\n\n`, + ) + await this.runCommand('data:pg:info', [this.addon.name!, `--app=${app}`]) + } else if (followers && followers > 0) { + const poolInfo = await createPool(this.dataApi, this.addon!, { + count: followers, + level: this.leaderLevel!, + }) + ux.stdout(heredoc` + ${color.green('Success:')} we're provisioning ${color.bold(poolInfo.name)} follower pool on ${color.addon(this.addon.name!)}. + Run ${color.code(`heroku data:pg:info ${this.addon!.name} -a ${this.addon!.app?.name}`)} to check creation progress. + `) + } + } + + public async runCommand(command: string, args: string[]): Promise { + await this.config.runCommand(command, args) + } + + private async followerPoolConfigLoop(): Promise { + process.stderr.write('\n') + const {action} = await this.prompt<{action: string}>({ + choices: [ + {name: 'Configure a follower pool', value: 'configure'}, + {name: 'Exit', value: 'exit'}, + ], + message: 'You can configure a follower pool while the leader pool is being configured.', + name: 'action', + type: 'list', + }) + + let oneMore: boolean = false + do { + process.stderr.write('\n') + if (action === 'configure') { + const poolConfig = new PoolConfig(this.extendedLevelsInfo!, this.followerInstanceCount) + const {count, level, name} = await poolConfig.followerInteractiveConfig() + try { + ux.action.start('Configuring follower pool') + const poolInfo = await createPool(this.dataApi, this.addon!, {count, level, name}) + ux.action.stop() + ux.stdout(heredoc` + ${color.green('Success:')} we're provisioning ${color.bold(poolInfo.name)} follower pool on ${color.addon(this.addon!.name!)}. + Run ${color.code(`heroku data:pg:info ${this.addon!.name} -a ${this.addon!.app?.name}`)} to check creation progress. + `) + } catch (error) { + ux.action.stop() + throw error + } + + process.stderr.write('\n') + this.followerInstanceCount += count + if (this.followerInstanceCount >= 13) { + oneMore = false + } else { + oneMore = (await this.prompt<{oneMore: boolean}>({ + default: false, + message: 'Configure another follower pool?', + name: 'oneMore', + type: 'confirm', + })).oneMore + } + } else { + oneMore = false + } + } while (oneMore) + } + + private async highAvailabilityStep(): Promise { + process.stderr.write( + 'The leader pool has high availability enabled and includes a standby instance for redundancy.\n' + + 'If you disable high availability, you remove the standby and you won\'t have redundancy on your database.\n\n', + ) + + const leaderPricing = this.extendedLevelsInfo!.find(level => level.name === this.leaderLevel)?.pricing + const {action} = await this.prompt<{action: string}>({ + choices: [ + {name: 'Keep high availability (HA)', value: 'keep'}, + { + name: 'Remove high availability' + ( + renderPricingInfo(leaderPricing) === 'free' + ? '' + : ` ${color.yellowBright(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}` + ), + value: 'remove', + }, + new Separator(), + {name: 'Go back', value: 'back'}, + ], + message: 'Do you want to keep the high availability standby instance?', + name: 'action', + type: 'list', + }) + process.stderr.write('\n') + + return action + } + + private async leaderConfirmationStep(): Promise { + const leaderLevelInfo = this.extendedLevelsInfo!.find(level => level.name === this.leaderLevel) + const totalPrice = this.highAvailability + ? renderPricingInfo(leaderLevelInfo?.pricing, 2) + : renderPricingInfo(leaderLevelInfo?.pricing) + const instancePrice = renderPricingInfo(leaderLevelInfo?.pricing) + process.stderr.write(heredoc` + ${`${color.green('✓ Configure Leader Pool')} ${totalPrice}`} + ${color.dim( + `${this.leaderLevel} ${leaderLevelInfo?.vcpu} ${color.inverse('vCPU')} ` + + `${leaderLevelInfo?.memory_in_gb} GB ${color.inverse('MEM')} ` + + instancePrice, + )} + `) + if (this.highAvailability) { + process.stderr.write(color.dim(` Standby (High Availability) ${instancePrice}\n`)) + } + + process.stderr.write('\n') + + const {action} = await this.prompt<{action: string}>({ + choices: [ + {name: 'Confirm', value: 'confirm'}, + {name: 'Go back', value: 'back'}, + ], + message: 'Confirm provisioning?', + name: 'action', + type: 'list', + }) + process.stderr.write('\n') + + return action + } + + private async leaderLevelStep(): Promise { + const poolConfig = new PoolConfig(this.extendedLevelsInfo!, this.followerInstanceCount) + const level = await poolConfig.levelStep('Leader') + process.stderr.write('\n') + + this.leaderLevel = level + } + + private async leaderPoolConfig(): Promise { + process.stderr.write(heredoc` + + Create a Heroku Postgres Advanced database + ${color.dim('Press Ctrl+C to cancel')} + + `) + + process.stderr.write(heredoc` + → Configure Leader Pool + ${color.dim(' Configure Follower Pool(s)')}\n + `) + + let configReady = false + let currentStep = 'leaderLevel' + + while (!configReady) { + switch (currentStep) { + case 'leaderLevel': { + await this.leaderLevelStep() + currentStep = 'highAvailability' + break + } + + case 'highAvailability': { + switch (await this.highAvailabilityStep()) { + case 'keep': { + this.highAvailability = true + currentStep = 'confirmation' + break + } + + case 'remove': { + this.highAvailability = false + currentStep = 'confirmation' + break + } + + case 'back': { + currentStep = 'leaderLevel' + break + } + } + + break + } + + case 'confirmation': { + switch (await this.leaderConfirmationStep()) { + case 'confirm': { + configReady = true + break + } + + case 'back': { + currentStep = 'highAvailability' + break + } + } + + break + } + } + } + } +} diff --git a/src/lib/addons/create_addon.ts b/src/lib/addons/create_addon.ts index 5eec6fb010..b9c41d5531 100644 --- a/src/lib/addons/create_addon.ts +++ b/src/lib/addons/create_addon.ts @@ -16,6 +16,7 @@ function formatConfigVarsMessage(addon: Heroku.AddOn) { return `Created ${color.addon(addon.name || '')}` } +// eslint-disable-next-line max-params export default async function ( heroku: APIClient, app: string, @@ -26,7 +27,7 @@ export default async function ( actionStartMessage?: string, actionStopMessage?: string, as?: string, - config: Record, + config: Record, name?: string, }, ) { diff --git a/src/lib/data/poolConfig.ts b/src/lib/data/poolConfig.ts new file mode 100644 index 0000000000..120372bdbe --- /dev/null +++ b/src/lib/data/poolConfig.ts @@ -0,0 +1,195 @@ +import type {DistinctChoice, ListChoiceMap} from 'inquirer' + +import {color} from '@heroku/heroku-cli-util' +import inquirer from 'inquirer' +import tsheredoc from 'tsheredoc' + +import {ExtendedPostgresLevelInfo, PoolInfoResponse} from './types.js' +import {renderLevelChoices, renderPricingInfo} from './utils.js' + +const heredoc = tsheredoc.default +// eslint-disable-next-line import/no-named-as-default-member +const {Separator, prompt} = inquirer + +export default class PoolConfig { + private followerCount: number | undefined + private followerLevel: string | undefined + private followerName: string | undefined + + constructor( + private readonly extendedLevelsInfo: ExtendedPostgresLevelInfo[], + private readonly followerInstanceCount: number, + ) {} + + public async followerInteractiveConfig(): Promise<{count: number, level: string, name?: string}> { + let configReady = false + let currentStep = 'poolLevelSelection' + let selection: string | undefined + + while (!configReady) { + switch (currentStep) { + case 'poolLevelSelection': { + this.followerLevel = await this.levelStep('Follower') + currentStep = 'poolInstancesSelection' + break + } + + case 'poolInstancesSelection': { + selection = await this.instanceCountStep() + switch (selection) { + case '__go_back': { + currentStep = 'poolLevelSelection' + break + } + + default: { + this.followerCount = Number(selection) + currentStep = 'poolNameSelection' + break + } + } + + break + } + + case 'poolNameSelection': { + switch (await this.followerNameStep()) { + case '__go_back': { + currentStep = 'poolInstancesSelection' + break + } + + default: { + currentStep = 'confirmation' + break + } + } + + break + } + + case 'confirmation': { + switch (await this.followerConfirmationStep()) { + case '__confirm': { + configReady = true + break + } + + case '__go_back': { + currentStep = 'poolNameSelection' + break + } + } + + break + } + } + } + + return { + count: this.followerCount!, + level: this.followerLevel!, + name: this.followerName, + } + } + + public async instanceCountStep(pool?: PoolInfoResponse): Promise { + process.stderr.write(heredoc` + + A cluster can have up to 13 follower instances. Two or more instances in a pool enables high availability for redundancy. + Adding more instances distributes the load in the follower pool. + + `) + + const choices: Array>> + = Array + .from({length: 13 - this.followerInstanceCount}, (_, index) => index + 1) + .map((i: number) => ({disabled: i === pool?.expected_count ? 'current amount' : false, name: `${i} instance${i === 1 ? '' : 's'}`, value: i.toString()})) + choices.push( + new Separator(), + {name: 'Go back', value: '__go_back'}, + ) + + const {action} = await prompt<{action: string}>({ + choices, + message: 'Select the number of instances for this pool:', + name: 'action', + pageSize: 13 - this.followerInstanceCount + 2, + type: 'list', + }) + process.stderr.write('\n') + + return action + } + + public async levelStep(kind: 'Follower' | 'Leader', pool?: PoolInfoResponse, withGoBack: boolean = false): Promise { + const {level} = await prompt<{level: string}>({ + choices: await renderLevelChoices(this.extendedLevelsInfo, pool, withGoBack), + message: `Select a ${kind} Pool Level:`, + name: 'level', + pageSize: 12, + type: 'list', + }) + process.stderr.write('\n') + + return level + } + + private async followerConfirmationStep(): Promise { + const followerLevelInfo = this.extendedLevelsInfo.find(level => level.name === this.followerLevel) + const totalPrice = renderPricingInfo(followerLevelInfo?.pricing, this.followerCount) + + process.stderr.write('\n') + process.stderr.write(heredoc` + ${`${color.green('✓ Configure Follower Pool')} ${totalPrice}`} + ${this.followerName ? `${color.bold(this.followerName)}\n ${color.dim(this.followerLevel)}` : `${color.dim(this.followerLevel)}`} + ${color.dim(`${this.followerCount} instance${this.followerCount! > 1 ? 's (High Availability)' : ''}`)} + `) + process.stderr.write('\n') + + const {action} = await prompt<{action: string}>({ + choices: [ + {name: 'Confirm', value: '__confirm'}, + {name: 'Go back', value: '__go_back'}, + ], + message: 'Confirm provisioning?', + name: 'action', + type: 'list', + }) + process.stderr.write('\n') + + return action + } + + private async followerNameStep(): Promise { + const {action} = await prompt<{action: string}>({ + choices: [ + {name: 'Yes', value: '__yes'}, + {name: 'No, assign a random name', value: '__no'}, + new Separator(), + {name: 'Go back', value: '__go_back'}, + ], + message: 'Do you want to name this follower pool?', + name: 'action', + type: 'list', + }) + + let name: string | undefined + switch (action) { + case '__yes': { + process.stderr.write('\n') + name = ( + await prompt<{name: string}>({ + message: 'Enter a unique pool name (3-32 lowercase letters and numbers, no spaces):', + name: 'name', + type: 'input', + }) + ).name + break + } + } + + this.followerName = name + return action + } +} diff --git a/src/lib/data/utils.ts b/src/lib/data/utils.ts new file mode 100644 index 0000000000..f4f3ed4c19 --- /dev/null +++ b/src/lib/data/utils.ts @@ -0,0 +1,176 @@ +import type {DistinctChoice, ListChoiceMap} from 'inquirer' + +import {color, hux} from '@heroku/heroku-cli-util' +import {APIClient} from '@heroku-cli/command' +import * as inquirer from 'inquirer' +import printf from 'printf' + +import { + ExtendedPostgresLevelInfo, + PoolInfoResponse, + PostgresLevelsResponse, + PricingInfo, + PricingInfoResponse, +} from './types.js' + +const {Separator} = inquirer + +/** + * @description Formats pricing information into a human-readable text with both hourly and monthly rates + * @param pricingInfo - The PricingInfo object + * @param count - The number of units to calculate the pricing for + * @returns A formatted string with colored pricing information, or empty string if no pricing info provided + */ +export function renderPricingInfo(pricingInfo?: PricingInfo | null, count: number = 1) { + if (!pricingInfo) return '' + + const priceHourly = hux.formatPrice(pricingInfo.rate * count, true) + const priceMonthly = hux.formatPrice(pricingInfo.rate * count) + + if (priceHourly === 'free') return priceHourly + + if (pricingInfo.billing_unit === 'gigabyte') { + return `${priceMonthly}/GB·month` + } + + return `${priceHourly}/hour (${priceMonthly}/month)` +} + +/** + * @description Cache for Postgres levels and pricing data to avoid redundant API calls. + * Maps cache keys (format: `levels-pricing-${tier}`) to promises that resolve with + * extended level information and optional optimized storage pricing. + * + * The cache stores promises rather than resolved values to prevent duplicate concurrent + * requests for the same tier. If a request fails, the cache entry is removed to allow + * retries on subsequent calls. + * + * @type {Map>} + * + * @property {string} key - Cache key in the format `levels-pricing-${tier}` where tier is the Postgres tier (e.g., 'advanced') + * @property {Promise<{extendedLevelsInfo: ExtendedPostgresLevelInfo[], optimizedStoragePricing?: PricingInfo}>} value - Promise that resolves to: + * - `extendedLevelsInfo`: Array of Postgres level information with associated pricing data + * - `optimizedStoragePricing`: Optional pricing information for storage-optimized plans + */ +const levelsAndPricingCache: Map> = new Map() + +/** + * @description Clears the cache of Postgres levels and pricing data. + * Removes all cached entries, allowing subsequent calls to fetch levels and pricing to start fresh. + * This is useful for testing or when you want to ensure fresh data is fetched. + * @returns void + */ +export function clearLevelsAndPricingCache(): void { + levelsAndPricingCache.clear() +} + +/** + * @description Fetches Postgres levels and pricing information for a given tier, with caching to avoid redundant API calls. + * Makes parallel requests to fetch levels and pricing data, then combines them by matching level names with product descriptions. + * Results are cached per tier to prevent duplicate requests. If a request fails, the cache entry is removed to allow retries. + * + * @param tier - The Postgres tier to fetch levels and pricing for (e.g., 'advanced') + * @param dataApi - The API client instance used to make HTTP requests to the Heroku Data API + * @returns Promise that resolves to an object containing: + * - `extendedLevelsInfo`: Array of Postgres level information with associated pricing data matched by product description + * - `optimizedStoragePricing`: Optional pricing information for storage-optimized plans, if available for the tier + * + * @throws {Error} Re-throws any errors from the API requests. The cache entry is removed on error to allow retries. + * + * @example + * const {extendedLevelsInfo, optimizedStoragePricing} = await fetchLevelsAndPricing('advanced', dataApi) + * // extendedLevelsInfo contains levels with their matching pricing information + * // optimizedStoragePricing may contain storage-optimized pricing if available + */ +export async function fetchLevelsAndPricing( + tier: string, + dataApi: APIClient, +): Promise<{ + extendedLevelsInfo: ExtendedPostgresLevelInfo[] + optimizedStoragePricing?: PricingInfo +}> { + const cacheKey = `levels-pricing-${tier}` + + if (!levelsAndPricingCache.has(cacheKey)) { + const promise = (async () => { + const levelsPromise = dataApi.get('/data/postgres/v1/levels/advanced') + const pricingPromise = dataApi.get('/data/postgres/v1/pricing') + + const [{body: levels}, {body: pricing}] = await Promise.all([ + levelsPromise, + pricingPromise, + ]) + + const extendedLevelsInfo = levels.items.map(level => ({ + ...level, + pricing: Object.entries(pricing[tier]).find( + ([_, value]) => value.product_description === level.name, // eslint-disable-line @typescript-eslint/no-unused-vars + )?.[1], + })) + + const optimizedStoragePricing = Object.entries(pricing[tier]).find( + ([key, _]) => key === 'storage-optimized', // eslint-disable-line @typescript-eslint/no-unused-vars + )?.[1] + + return {extendedLevelsInfo, optimizedStoragePricing} + })().catch(error => { + // Remove from cache on error + levelsAndPricingCache.delete(cacheKey) + throw error + }) + + levelsAndPricingCache.set(cacheKey, promise) + } + + return levelsAndPricingCache.get(cacheKey)! +} + +/** + * @description Renders Postgres level information as formatted inquirer choices for interactive selection. + * Formats each level with aligned columns showing name, vCPU count, memory, and pricing information. + * The current level (if it matches the pool's expected level) is disabled in the choices. + * Optionally includes a "Go back" option at the end of the list. + * + * @param extendedLevelsInfo - Array of Postgres level information with associated pricing data to render as choices + * @param pool - Optional pool information used to identify and disable the current level in the choices + * @param withGoBack - If true, adds a separator and "Go back" option at the end of the choices list (default: false) + * @returns Promise that resolves to an array of inquirer choice objects, where each choice: + * - `name`: Formatted string with aligned columns showing level name, vCPU, memory, and pricing + * - `value`: The level name (used as the selected value) + * - `disabled`: Either `false` (selectable) or `'current level'` (if it matches the pool's expected level) + * + * @example + * const choices = await renderLevelChoices(extendedLevelsInfo, pool, true) + * // Returns choices array with formatted level options and optional "Go back" option + * // Current level (if matching pool) will be disabled + */ +export async function renderLevelChoices( + extendedLevelsInfo: ExtendedPostgresLevelInfo[], + pool?: PoolInfoResponse, + withGoBack: boolean = false, +): Promise>>> { + const choices: string[][] = [] + + extendedLevelsInfo.forEach(level => { + const columns: string[] = [] + columns.push( + `${level.name}`, + `${printf('%3d', level.vcpu)} ${color.inverse('vCPU')} `, + `${printf('%4d', level.memory_in_gb)} GB ${color.inverse('MEM')} `, + `starting at ${color.green(renderPricingInfo(level.pricing))}`, + ) + choices.push(columns) + }) + const alignedChoiceNames = hux.alignColumns(choices) + return [ + ...alignedChoiceNames.map(choice => { + const levelName = choice.split(' ')[0] + return { + disabled: levelName === pool?.expected_level ? 'current level' : false, + name: choice, + value: levelName, + } + }), + ...(withGoBack ? [new Separator(), {name: 'Go back', value: '__go_back'}] : []), + ] +} diff --git a/test/fixtures/data/pg/fixtures.ts b/test/fixtures/data/pg/fixtures.ts new file mode 100644 index 0000000000..695bf527da --- /dev/null +++ b/test/fixtures/data/pg/fixtures.ts @@ -0,0 +1,1201 @@ +import {pg} from '@heroku/heroku-cli-util' +import * as Heroku from '@heroku-cli/schema' + +import { + CredentialInfo, + CredentialsInfo, + DeepRequired, + InfoResponse, + NonAdvancedCredentialInfo, + PoolInfoResponse, + PostgresLevelsResponse, + PricingInfoResponse, + Quota, + Quotas, + ScaleResponse, + SettingsChangeResponse, + SettingsResponse, +} from '../../../../src/lib/data/types.js' + +export const addon: DeepRequired = { + actions: [], + addon_service: { + id: 'a67ff466-7f79-4f18-8343-51515771e8f9', + name: 'heroku-postgresql', + }, + app: { + id: 'a3bbf89e-a908-4275-b573-8bdf6409764b', + name: 'myapp', + }, + billed_price: { + cents: 0, + contract: false, + unit: 'month', + }, + billing_entity: { + id: 'a3bbf89e-a908-4275-b573-8bdf6409764b', + name: 'myapp', + type: 'app', + }, + config_vars: [], + created_at: '2025-01-01T12:00:00Z', + id: '9e2c5a11-f51b-42d9-8fd9-255148140194', + name: 'advanced-horizontal-01234', + plan: { + addon_service: { + id: 'a67ff466-7f79-4f18-8343-51515771e8f9', + name: 'heroku-postgresql', + }, + compliance: [], + created_at: '2025-01-01T12:00:00Z', + default: false, + description: 'Heroku Postgres Advanced Beta', + human_name: 'Advanced Beta', + id: 'ab0490b3-b7cd-4bf6-b840-9db509f3d075', + installable_inside_private_network: false, + installable_outside_private_network: true, + name: 'heroku-postgresql:advanced-beta', + price: { + cents: 0, + contract: false, + unit: 'month', + }, + space_default: false, + state: 'ga', + updated_at: '2025-01-01T12:00:00Z', + visible: true, + }, + provider_id: '9e2c5a11-f51b-42d9-8fd9-255148140194', + state: 'provisioning', + updated_at: '2025-01-01T12:00:00Z', + web_url: 'https://addons-sso.heroku.com/apps/a3bbf89e-a908-4275-b573-8bdf6409764b/addons/9e2c5a11-f51b-42d9-8fd9-255148140194', +} + +export const nonAdvancedAddon: DeepRequired = { + ...addon, + name: 'standard-database', + plan: {...addon.plan, name: 'heroku-postgresql:standard-0'}, +} + +export const legacyEssentialAddon: DeepRequired = { + ...addon, + plan: {...addon.plan, name: 'heroku-postgresql:mini-0'}, +} + +export const nonPostgresAddon: DeepRequired = { + ...addon, + addon_service: { + id: '3db562b4-0241-4074-babc-f56c014c4779', + name: 'heroku-redis', + }, + name: 'redis-database', + plan: { + ...addon.plan, + addon_service: { + id: '3db562b4-0241-4074-babc-f56c014c4779', + name: 'heroku-redis', + }, + description: 'Heroku Redis Premium 0', + human_name: 'Premium 0', + name: 'heroku-redis:premium-0', + }, +} + +export const essentialAddon: DeepRequired = { + ...addon, + plan: {...addon.plan, name: 'heroku-postgresql:essential-0'}, +} + +export const levelsResponse: PostgresLevelsResponse = { + items: [ + {memory_in_gb: 4, name: '4G-Performance', vcpu: 2}, + {memory_in_gb: 8, name: '8G-Performance', vcpu: 4}, + ], +} + +export const pricingResponse: PricingInfoResponse = { + advanced: { + 'instance-4G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '4G-Performance', + rate: 6000, + }, + 'instance-8G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '8G-Performance', + rate: 20000, + }, + 'storage-optimized': { + billing_period: 'month', + billing_unit: 'gigabyte', + included_units: 100, + product_description: 'Optimized Storage', + rate: 20, + }, + }, + 'advanced-beta': { + 'instance-4G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '4G-Performance', + rate: 6000, + }, + 'instance-8G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '8G-Performance', + rate: 20000, + }, + 'storage-optimized': { + billing_period: 'month', + billing_unit: 'gigabyte', + included_units: 100, + product_description: 'Optimized Storage', + rate: 20, + }, + }, + 'advanced-private': { + 'instance-4G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '4G-Performance', + rate: 7200, + }, + 'instance-8G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '8G-Performance', + rate: 24000, + }, + 'storage-optimized': { + billing_period: 'month', + billing_unit: 'gigabyte', + included_units: 100, + product_description: 'Optimized Storage', + rate: 24, + }, + }, + 'advanced-private-beta': { + 'instance-4G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '4G-Performance', + rate: 7200, + }, + 'instance-8G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '8G-Performance', + rate: 24000, + }, + 'storage-optimized': { + billing_period: 'month', + billing_unit: 'gigabyte', + included_units: 100, + product_description: 'Optimized Storage', + rate: 24, + }, + }, + 'advanced-shield': { + 'instance-4G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '4G-Performance', + rate: 8400, + }, + 'instance-8G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '8G-Performance', + rate: 28000, + }, + 'storage-optimized': { + billing_period: 'month', + billing_unit: 'gigabyte', + included_units: 100, + product_description: 'Optimized Storage', + rate: 28, + }, + }, + 'advanced-shield-beta': { + 'instance-4G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '4G-Performance', + rate: 8400, + }, + 'instance-8G': { + billing_period: 'month', + billing_unit: 'compute', + product_description: '8G-Performance', + rate: 28000, + }, + 'storage-optimized': { + billing_period: 'month', + billing_unit: 'gigabyte', + included_units: 100, + product_description: 'Optimized Storage', + rate: 28, + }, + }, +} + +export const scaleResponse: ScaleResponse = { + changes: [ + { + current: '8G-Performance', name: 'level', pool: 'leader', previous: '4G-Performance', + }, + { + current: 'false', name: 'high-availability', pool: 'leader', previous: 'true', + }, + ], +} + +export const emptyScaleResponse: ScaleResponse = { + changes: [], +} + +export const createAddonResponse: DeepRequired = { + ...addon, + provision_message: 'Your database is being provisioned', +} + +export const pgInfo: InfoResponse = { + addon: { + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id!, + name: addon.app.name!, + }, + created_at: '2025-01-01T00:00:00+00:00', + features: { + continuous_protection: { + enabled: true, + }, + credentials: { + current_count: 1, + enabled: true, + }, + data_encryption: { + enabled: true, + }, + fork: { + enabled: true, + }, + highly_available: { + enabled: true, + }, + rollback: { + earliest_time: '2025-01-02T00:00:00+00:00', + enabled: true, + latest_time: '2025-01-10T00:00:00+00:00', + }, + }, + forked_from: null, + plan_limits: [ + { + current: 10, + limit: 4000, + name: 'table-limit', + }, + { + current: 1.1, + limit: 128_000, + name: 'storage-limit-in-gb', + }, + ], + pools: [ + { + compute_instances: [ + { + id: 'i3r507gt6dbscn', + level: '4G-Performance', + name: 'i3r507gt6dbscn', + role: 'leader', + status: 'available', + updated_at: '2025-01-01T12:00:00+00:00', + }, + { + id: 'i7fquhvs4efu74', + level: '4G-Performance', + name: 'i7fquhvs4efu74', + role: 'standby', + status: 'available', + updated_at: '2025-01-01T06:00:00+00:00', + }, + ], + endpoints: [ + { + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + port: 5432, + status: 'available', + }, + ], + expected_count: 2, + expected_level: '4G-Performance', + id: '199cc055-ad6b-48dd-8344-ef1a7688659e', + name: 'leader', + status: 'available', + }, + { + compute_instances: [ + { + id: 'ic7mb4lq0rkurk', + level: '4G-Performance', + name: 'ic7mb4lq0rkurk', + role: 'follower', + status: 'available', + updated_at: '2025-01-01T12:00:00+00:00', + }, + { + id: 'i7q78mp2fg4v15', + level: '4G-Performance', + name: 'i7q78mp2fg4v15', + role: 'follower', + status: 'available', + updated_at: '2025-01-01T06:00:00+00:00', + }, + ], + endpoints: [ + { + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + port: 5432, + status: 'available', + }, + ], + expected_count: 2, + expected_level: '4G-Performance', + id: 'b2492c07-c2d4-4956-b739-c15e9a6d6485', + name: 'analytics', + status: 'available', + }, + ], + quotas: [ + { + critical_gb: null, + current_gb: 1.1, + enforcement_action: 'none', + enforcement_active: false, + type: 'storage', + warning_gb: null, + }, + ], + region: 'us', + status: 'available', + tier: 'advanced', + version: '17.5', +} + +export const pgInfoWithDisabledFeatures: InfoResponse = { + ...pgInfo, + features: { + continuous_protection: { + enabled: false, + }, + credentials: { + current_count: 0, + enabled: false, + }, + data_encryption: { + enabled: false, + }, + fork: { + enabled: false, + }, + highly_available: { + enabled: false, + }, + rollback: { + earliest_time: '', + enabled: false, + latest_time: '', + }, + }, + pools: [ + { + compute_instances: [ + { + id: 'i3r507gt6dbscn', + level: '4G-Performance', + name: 'i3r507gt6dbscn', + role: 'leader', + status: 'available', + updated_at: '2025-01-01T12:00:00+00:00', + }, + ], + endpoints: [ + { + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + port: 5432, + status: 'available', + }, + ], + expected_count: 1, + expected_level: '4G-Performance', + id: '199cc055-ad6b-48dd-8344-ef1a7688659e', + name: 'leader', + status: 'available', + }, + ], +} + +export const pgInfoWithUncompliantPlanLimits: InfoResponse = { + ...pgInfoWithDisabledFeatures, + plan_limits: [ + {current: 4001, limit: 4000, name: 'table-limit'}, + {current: 128_050, limit: 128_000, name: 'storage-limit-in-gb'}, + ], + quotas: [ + {...pgInfo.quotas[0], current_gb: 128_050}, + ], +} + +export const destroyedAddonResponse: DeepRequired = { + ...addon, + state: 'deprovisioned', +} + +export const settingsGetResponse: SettingsResponse = { + items: [ + { + current: true, + default: false, + name: 'log_connections', + reboot_required: false, + }, + { + current: true, + default: true, + name: 'log_lock_waits', + reboot_required: false, + }, + { + current: 500, + default: -1, + name: 'log_min_duration_statement', + reboot_required: false, + }, + { + current: 'info', + default: 'error', + name: 'log_min_error_statement', + reboot_required: false, + }, + { + current: 'ddl', + default: 'none', + name: 'log_statement', + reboot_required: false, + }, + { + current: 'pl', + default: 'none', + name: 'track_functions', + reboot_required: false, + }, + { + current: null, + default: false, + name: 'auto_explain.log_analyze', + reboot_required: false, + }, + { + current: null, + default: false, + name: 'auto_explain.log_buffers', + reboot_required: false, + }, + { + current: null, + default: 'text', + name: 'auto_explain.log_format', + reboot_required: false, + }, + { + current: null, + default: -1, + name: 'auto_explain.log_min_duration', + reboot_required: false, + }, + { + current: null, + default: false, + name: 'auto_explain.log_nested_statements', + reboot_required: false, + }, + { + current: null, + default: false, + name: 'auto_explain.log_triggers', + reboot_required: false, + }, + { + current: null, + default: false, + name: 'auto_explain.log_verbose', + reboot_required: false, + }, + ], +} + +export const settingsPutResponse: SettingsChangeResponse = { + changes: [ + {current: '500', name: 'log_min_duration_statement', previous: '550'}, + {current: '864000', name: 'idle_in_transaction_session_timeout', previous: '80000'}, + ], +} + +export const emptySettingsChangeResponse: SettingsChangeResponse = { + changes: [], +} + +export const singleAttachmentResponse: Heroku.AddOnAttachment[] = [ + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + namespace: null, + }, +] + +export const multipleAttachmentsResponse: Heroku.AddOnAttachment[] = [ + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + namespace: null, + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4', + name: 'DATABASE_ANALYST', + namespace: 'role:analyst', + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_ANALYTICS_URL'], + id: 'fa7b348b-34dc-498e-aa23-0e7da817523d', + name: 'DATABASE_ANALYTICS', + namespace: 'pool:analytics', + }, +] + +export const attachmentWithMissingNamespace: Heroku.AddOnAttachment[] = [ + { + // namespace is missing + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + }, +] + +export const emptyAttachmentsResponse: Heroku.AddOnAttachment[] = [] + +export const addonResponse = { + id: '01234567-89ab-cdef-0123-456789abcdef', + name: 'postgres-1', + plan: { + name: 'heroku-postgresql:essential-1', + }, +} + +export const credentialConfigResponse = [ + { + name: 'role:mycredential', + value: 'some-value', + }, +] + +export const poolConfigResponse = [ + { + name: 'pool:mypool', + value: 'some-value', + }, +] + +export const releasesResponse = [ + { + id: '01234567-89ab-cdef-0123-456789abcdef', + status: 'succeeded', + version: 123, + }, +] + +export const createPoolResponse: PoolInfoResponse = { + compute_instances: [], + endpoints: [], + expected_count: 2, + expected_level: '4G-Performance', + id: '12345678-90ab-cdef-0123-456789abcdef', + name: 'readers', + status: 'modifying', +} + +export const advancedCredentialsResponse: CredentialsInfo = { + items: [ + { + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + id: '3d1a0a2d-3e27-4f34-99fa-c701627c0e92', + name: 'u2vi1nt40t3mcq', + port: '5432', + roles: [ + { + password: 'secret1', + state: 'active', + user: 'u2vi1nt40t3mcq', + }, + ], + state: 'active', + type: 'owner', + }, + { + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + id: '9eb68dd8-5b3e-410a-890a-e44de90356d3', + name: 'analyst', + port: '5432', + roles: [ + { + password: 'secret2', + state: 'active', + user: 'analyst', + }, + ], + state: 'active', + type: 'additional', + }, + ], +} + +export const nonAdvancedCredentialsResponse: NonAdvancedCredentialInfo[] = [ + { + credentials: [ + { + password: 'secret1', + state: 'active', + user: 'u2vi1nt40t3mcq', + }, + ], + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + name: 'default', + port: '5432', + state: 'active', + uuid: '3d1a0a2d-3e27-4f34-99fa-c701627c0e92', + }, + { + credentials: [ + { + password: 'secret2', + state: 'active', + user: 'analyst', + }, + ], + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + name: 'analyst', + port: '5432', + state: 'active', + uuid: '9eb68dd8-5b3e-410a-890a-e44de90356d3', + }, +] + +export const nonAdvancedInactiveCredentialResponse: NonAdvancedCredentialInfo = { + ...nonAdvancedCredentialsResponse[1], + credentials: [ + { + password: 'secret2', + state: 'revoked', + user: 'analyst', + }, + ], + state: 'archived', +} + +export const essentialCredentialsResponse: NonAdvancedCredentialInfo[] = [ + { + credentials: [ + { + password: 'secret1', + state: 'active', + user: 'u2vi1nt40t3mcq', + }, + ], + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + name: 'a1b2c3d4e5f6g7', + port: '5432', + state: 'active', + uuid: '3d1a0a2d-3e27-4f34-99fa-c701627c0e92', + }, +] + +export const advancedCredentialsAttachmentsResponse: Heroku.AddOnAttachment[] = [ + { + addon: { + app: { + id: addon.app!.id, + name: addon.app!.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app!.id, + name: addon.app!.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + namespace: null, + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4', + name: 'DATABASE_ANALYST', + namespace: 'role:analyst', + }, +] + +export const nonAdvancedCredentialsAttachmentsResponse: Heroku.AddOnAttachment[] = [ + { + addon: { + app: { + id: addon.app!.id, + name: addon.app!.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app!.id, + name: addon.app!.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + namespace: null, + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4', + name: 'DATABASE_ANALYST', + namespace: 'credential:analyst', + }, +] + +export const advancedCredentialsMultipleAttachmentsResponse: Heroku.AddOnAttachment[] = [ + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + namespace: null, + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4', + name: 'DATABASE_ANALYST', + namespace: 'role:analyst', + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: '2ef2b408-12ae-4c7c-ac16-1327eb891399', + name: 'myapp2', + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '913fe503-e95e-4128-9183-7c4e58c924c8', + name: 'DATABASE_ANALYST', + namespace: 'role:analyst', + }, +] + +export const nonAdvancedCredentialsMultipleAttachmentsResponse: Heroku.AddOnAttachment[] = [ + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_URL'], + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + name: 'DATABASE', + namespace: null, + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4', + name: 'DATABASE_ANALYST', + namespace: 'credential:analyst', + }, + { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: '2ef2b408-12ae-4c7c-ac16-1327eb891399', + name: 'myapp2', + }, + config_vars: ['DATABASE_ANALYST_URL'], + id: '913fe503-e95e-4128-9183-7c4e58c924c8', + name: 'DATABASE_ANALYST', + namespace: 'credential:analyst', + }, +] + +export const createCredentialResponse: CredentialInfo = { + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + id: '3d1a0a2d-3e27-4f34-99fa-c701627c0e92', + name: 'my-credential', + port: '5432', + roles: [ + { + password: 'secret123', + state: 'active', + user: 'my-credential', + }, + ], + state: 'active', + type: 'additional', +} + +export const inactiveCredentialResponse: CredentialInfo = { + database: 'd4w8akz45kmru7', + host: 'cc3hipc68aca1l.cluster-caqt9jk3hth8.us-east-1.rds.amazonaws.com', + id: '9eb68dd8-5b3e-410a-890a-e44de90356d3', + name: 'analyst', + port: '5432', + roles: [ + { + password: 'secret2', + state: 'inactive', + user: 'analyst', + }, + ], + state: 'inactive', + type: 'additional', +} + +export const createAttachmentResponse: Required = { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + created_at: '2025-01-01T12:00:00Z', + id: '0484a63c-8ceb-453d-95c8-2aaf8861c40a', + log_input_url: null, + name: 'TEST', + namespace: null, + updated_at: '2025-01-01T12:00:00Z', + web_url: addon.web_url, +} + +export const createForeignAttachmentResponse: Required = { + ...createAttachmentResponse, + app: { + id: '2ef2b408-12ae-4c7c-ac16-1327eb891399', + name: 'myapp2', + }, + created_at: '2025-01-01T12:00:00Z', + id: 'df05357b-9950-403b-bcdf-aed3d60ec94e', + log_input_url: null, + name: 'TEST2', + namespace: null, + updated_at: '2025-01-01T12:00:00Z', + web_url: `https://addons-sso.heroku.com/apps/2ef2b408-12ae-4c7c-ac16-1327eb891399/addons/${addon.id}`, +} + +export const createCredentialAttachmentResponse: Required = { + ...createAttachmentResponse, + id: 'fc5ce939-663e-4417-8b00-cb7e6e662564', + name: 'MYCREDENTIAL', + namespace: 'role:mycredential', +} + +export const createPoolAttachmentResponse: Required = { + ...createAttachmentResponse, + id: '711a83cd-d9b1-430d-b46d-b35b8847f346', + name: 'MYPOOL', + namespace: 'pool:mypool', +} + +export const createForkResponse: DeepRequired = { + ...addon, + id: 'cef4651d-ccba-4989-9cf7-66b8b7532acf', + name: 'advanced-oblique-01234', + provision_message: 'Your forked database is being provisioned', +} + +export const quotasResponse: Quotas = { + items: [ + { + critical_gb: null, + current_gb: 1.1, + enforcement_action: 'none', + enforcement_active: false, + type: 'storage', + warning_gb: null, + }, + { + critical_gb: 100, + current_gb: 1.1, + enforcement_action: 'notify', + enforcement_active: true, + type: 'otherQuota', + warning_gb: 50, + }, + ], +} + +export const storageQuotaResponse: Quota = { + critical_gb: 100, + current_gb: null, + enforcement_action: 'none', + enforcement_active: false, + type: 'storage', + warning_gb: 50, +} + +export const storageQuotaResponseRestricted: Quota = { + critical_gb: 100, + current_gb: 150, + enforcement_action: 'restrict', + enforcement_active: true, + type: 'storage', + warning_gb: 50, +} + +export const storageQuotaResponseCriticalNotify: Quota = { + critical_gb: 100, + current_gb: 150, + enforcement_action: 'notify', + enforcement_active: false, + type: 'storage', + warning_gb: 50, +} + +export const storageQuotaResponseWarning: Quota = { + critical_gb: 100, + current_gb: 75, + enforcement_action: 'none', + enforcement_active: false, + type: 'storage', + warning_gb: 50, +} + +export const advancedAddonAttachment: pg.ExtendedAddonAttachment = { + addon: { + app: { + id: addon.app.id, + name: addon.app.name, + }, + id: addon.id, + name: addon.name, + plan: { + id: addon.plan.id, + name: addon.plan.name, + }, + }, + app: { + id: addon.app.id, + name: addon.app.name, + }, + config_vars: ['DATABASE_URL'], + created_at: '2025-01-01T12:00:00Z', + id: 'c61eb5ce-0ce2-447e-817e-ba34afe8b95f', + log_input_url: null, + name: 'DATABASE', + namespace: null, + updated_at: '2025-01-01T12:00:00Z', + web_url: addon.web_url, +} + +export const nonAdvancedAddonAttachment: pg.ExtendedAddonAttachment = { + ...advancedAddonAttachment, + addon: { + app: { + id: nonAdvancedAddon.app.id, + name: nonAdvancedAddon.app.name, + }, + id: nonAdvancedAddon.id, + name: nonAdvancedAddon.name, + plan: { + id: nonAdvancedAddon.plan.id, + name: nonAdvancedAddon.plan.name, + }, + }, + config_vars: ['STANDARD_DATABASE_URL'], + id: 'b16d7e16-55de-4bca-a4e9-f631561e6090', + name: 'STANDARD_DATABASE', +} + +export const nonPostgresAddonAttachment: pg.ExtendedAddonAttachment = { + ...advancedAddonAttachment, + addon: { + app: { + id: nonPostgresAddon.app.id, + name: nonPostgresAddon.app.name, + }, + id: nonPostgresAddon.id, + name: nonPostgresAddon.name, + plan: { + id: nonPostgresAddon.plan.id, + name: nonPostgresAddon.plan.name, + }, + }, + config_vars: ['REDIS_URL'], + id: '0e8e72a3-7922-452e-a490-09cf45797f7e', + name: 'REDIS', +} diff --git a/test/helpers/init.mjs b/test/helpers/init.mjs index 8b02ad0df4..8f8eb9b706 100644 --- a/test/helpers/init.mjs +++ b/test/helpers/init.mjs @@ -21,6 +21,9 @@ process.env.IS_HEROKU_TEST_ENV = 'true' process.env.HEROKU_SKIP_NEW_VERSION_CHECK = 'true' +process.env.HEROKU_DATA_HOST = 'test.data.heroku.com' +process.env.HEROKU_DATA_CONTROL_PLANE = 'test-control-plane' + nock.disableNetConnect() if (process.env.ENABLE_NET_CONNECT === 'true') { nock.enableNetConnect() diff --git a/test/unit/commands/data/pg/create.unit.test.ts b/test/unit/commands/data/pg/create.unit.test.ts new file mode 100644 index 0000000000..512ac4e5bf --- /dev/null +++ b/test/unit/commands/data/pg/create.unit.test.ts @@ -0,0 +1,702 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import sinon from 'sinon' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgCreate from '../../../../../src/commands/data/pg/create.js' +import PoolConfig from '../../../../../src/lib/data/poolConfig.js' +import {clearLevelsAndPricingCache} from '../../../../../src/lib/data/utils.js' +import { + createAddonResponse, + createPoolResponse, + levelsResponse, + pricingResponse, +} from '../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:create', function () { + let promptStub: sinon.SinonStub + let poolConfigStubs: { + followerInteractiveConfig: sinon.SinonStub + instanceCountStep: sinon.SinonStub + levelStep: sinon.SinonStub + } + + beforeEach(function () { + promptStub = sinon.stub() + + // Create stubs for PoolConfig methods + poolConfigStubs = { + followerInteractiveConfig: sinon.stub(), + instanceCountStep: sinon.stub(), + levelStep: sinon.stub(), + } + + sinon.stub(PoolConfig.prototype, 'followerInteractiveConfig').callsFake(poolConfigStubs.followerInteractiveConfig) + sinon.stub(PoolConfig.prototype, 'instanceCountStep').callsFake(poolConfigStubs.instanceCountStep) + sinon.stub(PoolConfig.prototype, 'levelStep').callsFake(poolConfigStubs.levelStep) + sinon.stub(DataPgCreate.prototype, 'prompt').callsFake(promptStub) + sinon.stub(DataPgCreate.prototype, 'runCommand').resolves() + }) + + afterEach(function () { + clearLevelsAndPricingCache() + sinon.restore() + }) + + describe('non-interactive mode (--level flag provided)', function () { + it('creates an advanced database', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons') + .reply(200, createAddonResponse) + await runCommand(DataPgCreate, ['--app=myapp', '--level=4G-Performance']) + + herokuApi.done() + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a 4G-Performance database on ⬢ myapp... done + `) + }) + + it('creates database with a follower pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: {level: '4G-Performance'}, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + const dataApi = nock('https://test.data.heroku.com') + .post(`/data/postgres/v1/${createAddonResponse.id}/pools`, { + count: 2, + level: '4G-Performance', + }) + .reply(200, createPoolResponse) + + await runCommand(DataPgCreate, ['--app=myapp', '--level=4G-Performance', '--followers=2']) + + herokuApi.done() + dataApi.done() + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + Success: we're provisioning readers follower pool on advanced-horizontal-01234. + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + + `), + ) + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a 4G-Performance database on ⬢ myapp... done + `) + }) + + it('creates database in private network', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: {level: '4G-Performance'}, + plan: {name: 'heroku-postgresql:advanced-private-beta'}, + }) + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--network=private', + '--level=4G-Performance', + ]) + + herokuApi.done() + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a 4G-Performance database on ⬢ myapp... done + `) + }) + + it('creates database in shield network', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: {level: '4G-Performance'}, + plan: {name: 'heroku-postgresql:advanced-shield-beta'}, + }) + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--network=shield', + '--level=4G-Performance', + ]) + + herokuApi.done() + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a 4G-Performance database on ⬢ myapp... done + `) + }) + + describe('with HEROKU_PROD_NGPG env var set', function () { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(function () { + originalEnv = {...process.env} + process.env.HEROKU_PROD_NGPG = 'true' + }) + + afterEach(function () { + process.env = originalEnv + }) + + it('creates database with non-beta plan', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: {level: '4G-Performance'}, + plan: {name: 'heroku-postgresql:advanced'}, + }) + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, ['--app=myapp', '--level=4G-Performance']) + + herokuApi.done() + }) + + it('creates database in private network with non-beta plan', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: {level: '4G-Performance'}, + plan: {name: 'heroku-postgresql:advanced-private'}, + }) + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--network=private', + '--level=4G-Performance', + ]) + + herokuApi.done() + }) + + it('creates database in shield network with non-beta plan', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: {level: '4G-Performance'}, + plan: {name: 'heroku-postgresql:advanced-shield'}, + }) + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--network=shield', + '--level=4G-Performance', + ]) + + herokuApi.done() + }) + + it('uses non-beta pricing in interactive mode', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Level selection + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'exit'}) // Exit at follower pool configuration + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + }) + }) + + it('creates database with provision options', async function () { + const herokuApi = nock('https://api.heroku.com') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .post('/apps/myapp/addons', (body: any) => body.config.level === '4G-Performance' + && body.config.follow === 'otherdb' + && body.config.rollback === 'true' + && body.config.foo === 'true' + && body.config.bar === 'true' + && body.config.key === 'value:with:colons' + && body.config.timestamp === '2025-11-17T15:20:00') + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--level=4G-Performance', + '--provision-option=follow:otherdb', + '--provision-option=rollback:true', + '--provision-option=foo:', + '--provision-option=bar', + '--provision-option=key:value:with:colons', + '--provision-option=timestamp:2025-11-17T15:20:00', + ]) + + herokuApi.done() + }) + + it('creates database with confirm flag', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: '4G-Performance', + }, + confirm: 'myapp', + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--level=4G-Performance', + '--confirm=myapp', + '--high-availability', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a 4G-Performance database on ⬢ myapp... done + `) + }) + }) + + describe('interactive mode (--level flag omitted)', function () { + it('provisions a database with high availability, without follower pools', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Level selection + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'exit'}) // Exit at follower pool configuration + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.contain('4G-Performance 2 vCPU 4 GB MEM ~$0.083/hour ($60/month)') + expect(ansis.strip(stderr.output)).to.contain('Standby (High Availability) ~$0.083/hour ($60/month)') + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + + it('provisions a database with provision options in interactive mode', async function () { + const herokuApi = nock('https://api.heroku.com') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .post('/apps/myapp/addons', (body: any) => body.config.level === levelsResponse.items[0].name + && body.config['high-availability'] === true + && body.config.follow === 'otherdb' + && body.config.rollback === 'true' + && body.config.foo === 'true') + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Level selection + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'exit'}) // Exit at follower pool configuration + + await runCommand(DataPgCreate, [ + '--app=myapp', + '--provision-option=rollback:true', + '--provision-option=follow:otherdb', + '--provision-option=foo:true', + ]) + + herokuApi.done() + dataApi.done() + }) + + it('allows the user to remove the high availability standby', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': false, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Level selection + promptStub + .onCall(0).resolves({action: 'remove'}) // High availability selection + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'exit'}) // Exit at follower pool configuration + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.contain('4G-Performance 2 vCPU 4 GB MEM ~$0.083/hour ($60/month)') + expect(ansis.strip(stderr.output)).not.to.contain('Standby (High Availability) ~$0.083/hour ($60/month)') + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + + it('allows the user to correct the leader level after initial selection', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: levelsResponse.items[1].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + + poolConfigStubs.levelStep + .onCall(0).resolves(levelsResponse.items[0].name) // Level selection + .onCall(1).resolves(levelsResponse.items[1].name) // Level selection (selects a different level) + promptStub + .onCall(0).resolves({action: 'back'}) // High availability selection (go back to leader level selection) + .onCall(1).resolves({action: 'keep'}) // High availability selection (keep high availability) + .onCall(2).resolves({action: 'confirm'}) // Confirmation + .onCall(3).resolves({action: 'exit'}) // Exit at follower pool configuration + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.contain('8G-Performance 4 vCPU 8 GB MEM ~$0.278/hour ($200/month)') + expect(ansis.strip(stderr.output)).to.contain('Standby (High Availability) ~$0.278/hour ($200/month)') + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + + it('allows the user to correct the high availability after initial selection', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': false, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Level selection + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection (keep high availability) + .onCall(1).resolves({action: 'back'}) // Confirmation (go back to high availability selection) + .onCall(2).resolves({action: 'remove'}) // High availability selection (remove high availability) + .onCall(3).resolves({action: 'confirm'}) // Confirmation + .onCall(4).resolves({action: 'exit'}) // Exit at follower pool configuration + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + `), + ) + expect(ansis.strip(stderr.output)).to.contain('Configure Leader Pool ~$0.167/hour ($120/month)') + expect(ansis.strip(stderr.output)).to.contain('Configure Leader Pool ~$0.083/hour ($60/month)') + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + + it('allows the user to configure a follower pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .post(`/data/postgres/v1/${createAddonResponse.id}/pools`, { + count: 2, + level: levelsResponse.items[0].name, + name: 'readonly', + }) + .reply(200, createPoolResponse) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Leader level selection + poolConfigStubs.followerInteractiveConfig.resolves({ + count: 2, + level: levelsResponse.items[0].name, + name: 'readonly', + }) // Follower pool configuration + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection (keep high availability) + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'configure'}) // Configure a follower pool + .onCall(3).resolves({oneMore: false}) // One more? (no) + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + Success: we're provisioning readers follower pool on advanced-horizontal-01234. + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + + `), + ) + expect(ansis.strip(stderr.output)).to.contain('Configure Leader Pool ~$0.167/hour ($120/month)') + expect(ansis.strip(stderr.output)).to.contain('Configuring follower pool... done') + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + + it('allows the user to configure more than one follower pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .post(`/data/postgres/v1/${createAddonResponse.id}/pools`, { + count: 2, + level: levelsResponse.items[0].name, + name: 'readonly', + }) + .reply(200, {...createPoolResponse, name: 'readonly'}) + .post(`/data/postgres/v1/${createAddonResponse.id}/pools`, { + count: 1, + level: levelsResponse.items[0].name, + name: 'readonly2', + }) + .reply(200, {...createPoolResponse, name: 'readonly2'}) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Leader level selection + poolConfigStubs.followerInteractiveConfig + .onCall(0).resolves({ + count: 2, + level: levelsResponse.items[0].name, + name: 'readonly', + }) // First follower pool configuration + .onCall(1).resolves({ + count: 1, + level: levelsResponse.items[0].name, + name: 'readonly2', + }) // Second follower pool configuration + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection (keep high availability) + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'configure'}) // Configure a follower pool + .onCall(3).resolves({oneMore: true}) // One more? (yes) + .onCall(4).resolves({action: 'configure'}) // Configure another follower pool + .onCall(5).resolves({oneMore: false}) // One more? (no) + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + Success: we're provisioning readonly follower pool on advanced-horizontal-01234. + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + + Success: we're provisioning readonly2 follower pool on advanced-horizontal-01234. + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + + `), + ) + expect(ansis.strip(stderr.output)).to.contain('Configure Leader Pool ~$0.167/hour ($120/month)') + expect(ansis.strip(stderr.output)).to.contain(heredoc` + Configuring follower pool... done + + + Configuring follower pool... done + `) + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + + it('doesn\'t ask to configure another follower pool when the maximum number of follower pools is reached', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/apps/myapp/addons', { + attachment: {}, + config: { + 'high-availability': true, + level: levelsResponse.items[0].name, + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createAddonResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .post(`/data/postgres/v1/${createAddonResponse.id}/pools`, { + count: 13, + level: levelsResponse.items[0].name, + name: 'readonly', + }) + .reply(200, {...createPoolResponse, name: 'readonly'}) + + poolConfigStubs.levelStep.resolves(levelsResponse.items[0].name) // Leader level selection + poolConfigStubs.followerInteractiveConfig + .onCall(0).resolves({ + count: 13, + level: levelsResponse.items[0].name, + name: 'readonly', + }) // Follower pool configuration + promptStub + .onCall(0).resolves({action: 'keep'}) // High availability selection (keep high availability) + .onCall(1).resolves({action: 'confirm'}) // Confirmation + .onCall(2).resolves({action: 'configure'}) // Configure a follower pool + + await runCommand(DataPgCreate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + + expect(stdout.output).to.equal( + heredoc(` + Your database is being provisioned + advanced-horizontal-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + Success: we're provisioning readonly follower pool on advanced-horizontal-01234. + Run heroku data:pg:info advanced-horizontal-01234 -a myapp to check creation progress. + + `), + ) + expect(ansis.strip(stderr.output)).to.contain('Configure Leader Pool ~$0.167/hour ($120/month)') + expect(ansis.strip(stderr.output)).to.contain(heredoc` + Configuring follower pool... done + `) + expect(ansis.strip(stderr.output)).to.contain('Running heroku data:pg:info advanced-horizontal-01234 --app=myapp...') + }) + }) +}) From f36c4fa6d3729355e8fc84a195479bf6294f130b Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Tue, 3 Feb 2026 16:17:11 -0300 Subject: [PATCH 02/11] Migrating command 'data:pg:destroy' and tests --- src/commands/data/pg/destroy.ts | 54 +++++++++ .../commands/data/pg/destroy.unit.test.ts | 111 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/commands/data/pg/destroy.ts create mode 100644 test/unit/commands/data/pg/destroy.unit.test.ts diff --git a/src/commands/data/pg/destroy.ts b/src/commands/data/pg/destroy.ts new file mode 100644 index 0000000000..5a6a09708b --- /dev/null +++ b/src/commands/data/pg/destroy.ts @@ -0,0 +1,54 @@ +import {color, hux, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' + +import destroyAddon from '../../../lib/addons/destroy_addon.js' +import BaseCommand from '../../../lib/data/baseCommand.js' + +export default class DataPgDestroy extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'destroy a Postgres Advanced database' + + static examples = ['<%= config.bin %> <%= command.id %> database_name'] + + static flags = { + app: Flags.app(), + confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}), + force: Flags.boolean({char: 'f', description: 'destroy even if connected to other apps'}), + remote: Flags.remote(), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgDestroy) + const {app, confirm} = flags + const {database: databaseName} = args + const force = flags.force || process.env.HEROKU_FORCE === '1' + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(databaseName, app, utils.pg.addonService()) + + if (!utils.pg.isPostgresAddon(addon)) { + ux.error(`You can only use this command to delete Heroku Postgres databases. Run ${color.code(`heroku addons:destroy ${addon.name}`)} instead.`) + } + + // prevent deletion of add-on when context.app is set but the addon is + // attached to a different app + const addonApp = addon.app!.name! + const isAppMismatch = app && addonApp !== app + if (isAppMismatch) { + ux.error(`Database ${color.yellow(addon.name!)} is on ${color.magenta(addonApp)} not ${color.magenta(app)}. Try again with the correct app.`) + } + + await hux.confirmCommand({abortedMessage: `Your database ${addon.name} still exists.`, comparison: addon.app!.name!, confirmation: confirm}) + + await destroyAddon(this.heroku, addon, force).catch(error => { + throw error + }) + ux.stdout('We successfully destroyed your database.') + } +} diff --git a/test/unit/commands/data/pg/destroy.unit.test.ts b/test/unit/commands/data/pg/destroy.unit.test.ts new file mode 100644 index 0000000000..4084cc2bd0 --- /dev/null +++ b/test/unit/commands/data/pg/destroy.unit.test.ts @@ -0,0 +1,111 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgDestroy from '../../../../../src/commands/data/pg/destroy.js' +import {addon, destroyedAddonResponse, nonPostgresAddon} from '../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:destroy', function () { + it('destroys a advanced addon', async function () { + const resolveApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) + const destroyApi = nock('https://api.heroku.com') + .delete(`/apps/${addon.app?.id}/addons/${addon.id}`) + .reply(200, destroyedAddonResponse) + + await runCommand(DataPgDestroy, [addon.name!, '--app=myapp', '--confirm=myapp']) + + resolveApi.done() + destroyApi.done() + + expect(ansis.strip(stderr.output)).to.equal( + heredoc(` + Destroying advanced-horizontal-01234 on ⬢ myapp... done + `), + ) + expect(stdout.output).to.equal('We successfully destroyed your database.\n') + }) + + it('bails if incorrect confirmation', async function () { + const resolveApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + try { + await runCommand(DataPgDestroy, [addon.name!, '--app=myapp', '--confirm=another-app']) + } catch (error: unknown) { + resolveApi.done() + expect(ansis.strip((error as Error).message)).to.equal( + `Confirmation another-app did not match myapp. Your database ${addon.name} still exists.`, + ) + } + }) + + it('prevents destruction of add-ons other than Heroku Postgres', async function () { + const resolveApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonPostgresAddon]) + + try { + await runCommand(DataPgDestroy, [addon.name!, '--app=myapp', '--confirm=myapp']) + } catch (error: unknown) { + resolveApi.done() + expect(ansis.strip((error as Error).message)).to.equal( + 'You can only use this command to delete Heroku Postgres databases. Run heroku addons:destroy redis-database instead.', + ) + } + }) + + it('prevents destruction of addons attached to a different app', async function () { + const resolveApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + try { + await runCommand(DataPgDestroy, [addon.name!, '--app=another-app']) + } catch (error: unknown) { + resolveApi.done() + expect(ansis.strip((error as Error).message)).to.equal( + 'Database advanced-horizontal-01234 is on myapp not another-app. Try again with the correct app.', + ) + } + }) + + it('displays the correct error message when the addon is not destroyed', async function () { + const resolveApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + const destroyApi = nock('https://api.heroku.com') + .delete(`/apps/${addon.app?.id}/addons/${addon.id}`) + .reply(404, {message: 'Test error'}) + + try { + await runCommand(DataPgDestroy, [addon.name!, '--app=myapp', '--confirm=myapp']) + } catch (error: unknown) { + resolveApi.done() + destroyApi.done() + expect(ansis.strip((error as Error).message)).to.equal( + 'We can\'t destroy your database due to an error: Test error. Try again or open a ticket with Heroku Support: https://help.heroku.com/', + ) + } + }) + + it('displays the correct error message when the addon status is not "deprovisioned"', async function () { + const resolveApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) + const destroyApi = nock('https://api.heroku.com') + .delete(`/apps/${addon.app?.id}/addons/${addon.id}`) + .reply(200, {...destroyedAddonResponse, state: 'provisioning'}) + + try { + await runCommand(DataPgDestroy, [addon.name!, '--app=myapp', '--confirm=myapp']) + } catch (error: unknown) { + resolveApi.done() + destroyApi.done() + expect(ansis.strip((error as Error).message)).to.equal('You can\'t destroy a database with a provisioning status.') + } + }) +}) From 5909845d41364370cd7e7388a1e40ed1c0a3398b Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Tue, 3 Feb 2026 16:22:07 -0300 Subject: [PATCH 03/11] Migrating command 'data:pg:docs' --- src/commands/data/pg/docs.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/commands/data/pg/docs.ts diff --git a/src/commands/data/pg/docs.ts b/src/commands/data/pg/docs.ts new file mode 100644 index 0000000000..47e71053cd --- /dev/null +++ b/src/commands/data/pg/docs.ts @@ -0,0 +1,20 @@ +import {hux} from '@heroku/heroku-cli-util' +import {flags} from '@heroku-cli/command' + +import BaseCommand from '../../../lib/data/baseCommand.js' + +export default class DataPgDocs extends BaseCommand { + static defaultUrl = 'https://devcenter.heroku.com/categories/heroku-postgres' + static description = 'open documentation for Heroku Postgres in your web browser' + static flags = { + browser: flags.string({description: 'browser to open docs with (example: "firefox", "safari")'}), + } + + public async run(): Promise { + const {flags} = await this.parse(DataPgDocs) + const {browser} = flags + const url = DataPgDocs.defaultUrl + + await hux.openUrl(url, browser, 'view the documentation') + } +} From 1c5a2a85841a3b7c6ece5f56a1affae386413e51 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Tue, 3 Feb 2026 16:42:14 -0300 Subject: [PATCH 04/11] Create missing tests for 'data:pg:docs' --- src/commands/data/pg/docs.ts | 6 +++- test/unit/commands/data/pg/docs.unit.test.ts | 37 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 test/unit/commands/data/pg/docs.unit.test.ts diff --git a/src/commands/data/pg/docs.ts b/src/commands/data/pg/docs.ts index 47e71053cd..1d010cf049 100644 --- a/src/commands/data/pg/docs.ts +++ b/src/commands/data/pg/docs.ts @@ -10,11 +10,15 @@ export default class DataPgDocs extends BaseCommand { browser: flags.string({description: 'browser to open docs with (example: "firefox", "safari")'}), } + public async openUrl(url: string, browser: string, description: string): Promise { + await hux.openUrl(url, browser, description) + } + public async run(): Promise { const {flags} = await this.parse(DataPgDocs) const {browser} = flags const url = DataPgDocs.defaultUrl - await hux.openUrl(url, browser, 'view the documentation') + await this.openUrl(url, browser, 'view the documentation') } } diff --git a/test/unit/commands/data/pg/docs.unit.test.ts b/test/unit/commands/data/pg/docs.unit.test.ts new file mode 100644 index 0000000000..cf08daa3ef --- /dev/null +++ b/test/unit/commands/data/pg/docs.unit.test.ts @@ -0,0 +1,37 @@ +import {expect} from 'chai' +import sinon from 'sinon' + +import DataPgDocs from '../../../../../src/commands/data/pg/docs.js' +import runCommand from '../../../../../test/helpers/runCommand.js' + +describe('data:pg:docs', function () { + let urlOpenerStub: sinon.SinonStub + + beforeEach(function () { + urlOpenerStub = sinon.stub(DataPgDocs.prototype, 'openUrl').resolves() + }) + + afterEach(function () { + sinon.restore() + }) + + it('opens the default documentation URL', async function () { + await runCommand(DataPgDocs, []) + + expect(urlOpenerStub.calledOnceWith( + 'https://devcenter.heroku.com/categories/heroku-postgres', + undefined, + 'view the documentation', + )).to.be.true + }) + + it('respects the browser flag', async function () { + await runCommand(DataPgDocs, ['--browser', 'firefox']) + + expect(urlOpenerStub.calledOnceWith( + 'https://devcenter.heroku.com/categories/heroku-postgres', + 'firefox', + 'view the documentation', + )).to.be.true + }) +}) From e87cf3b12bbed05b3b717b32f3ee0296297c6421 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Tue, 3 Feb 2026 18:42:55 -0300 Subject: [PATCH 05/11] Migrating command 'data:pg:psql' and tests --- cspell-dictionary.txt | 2 + src/commands/data/pg/psql.ts | 132 ++++++++ test/unit/commands/data/pg/psql.unit.test.ts | 324 +++++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 src/commands/data/pg/psql.ts create mode 100644 test/unit/commands/data/pg/psql.unit.test.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index dfa743fc58..8d2ba08ac2 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -134,6 +134,7 @@ killall Klass kolkrabbi libexec +libpq localdb localspace logfile @@ -248,6 +249,7 @@ pgappname pgbackups pgbouncer pgbouncers +PGCHANNELBINDING pgdatabase pgdiagnose pgdg diff --git a/src/commands/data/pg/psql.ts b/src/commands/data/pg/psql.ts new file mode 100644 index 0000000000..4e6884226c --- /dev/null +++ b/src/commands/data/pg/psql.ts @@ -0,0 +1,132 @@ +import {color, pg, utils} from '@heroku/heroku-cli-util' +import {Command, flags as Flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import Dyno, {DynoOpts} from '../../../lib/run/dyno.js' + +const heredoc = tsheredoc.default + +export default class DataPgPsql extends Command { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'open a psql shell to the database' + + static examples = ['<%= config.bin %> <%= command.id %> database_name -a example-app'] + + static flags = { + app: Flags.app({required: true}), + // prevent MITM attacks. + 'channel-binding': Flags.string({ + default: 'require', + description: heredoc( + 'override the default channel binding behavior (required). ' + + 'Can be "disable" to disable channel binding if you run into compatibility issues with your libpq version ' + + 'or if it was compiled without SSL support.', + ), + hidden: true, + options: ['disable', 'require'], + }), + command: Flags.string({char: 'c', description: 'SQL command to run'}), + credential: Flags.string({description: 'credential to use'}), + file: Flags.string({char: 'f', description: 'SQL file to run'}), + // If channel-binding is set it will override the default channel binding + // behavior (required). Customers can set this to "disable" to disable channel + // binding if they run into compatibility issues with their libpq version or if + // it was compiled without SSL support. + // + // Ideally we should work with customers to upgrade their libpq versions and + // enable SSL support as channel-binding is a more secure option and helps to + remote: Flags.remote(), + } + + public async run(): Promise { + const {args, flags} = await this.parse(DataPgPsql) + const {database: databaseArg} = args + const {app, 'channel-binding': channelBinding, command, credential, file} = flags + const namespace = credential ? `role:${credential}` : undefined + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + let db: pg.ConnectionDetails + + try { + db = await dbResolver.getDatabase(app, databaseArg, namespace) + } catch (error) { + if (namespace && error instanceof Error && error.message === "Couldn't find that addon.") { + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(databaseArg, app, utils.pg.addonService()) + const credCommand = utils.pg.isAdvancedDatabase(addon) ? 'data:pg:credentials' : 'pg:credentials' + throw new Error(`The credential ${credential} doesn't exist on the database ${databaseArg}. Run ${color.code(`heroku ${credCommand} ${addon.name}`)} to list the credentials on the database.`) + } + + throw error + } + + if (utils.pg.isAdvancedPrivateDatabase(db.attachment!.addon)) { + if (file) + ux.error('You can\'t use the --file flag on private networked Advanced databases.', {exit: 1}) + + let psqlCommand: string + + if (command) { + psqlCommand = `psql -c "${command.replaceAll('"', '\\"')}" --set sslmode=require ` + + `--set channel_binding=${channelBinding} $${db.attachment!.name}_URL` + } else { + const prompt = `${db.attachment!.app.name}::${db.attachment!.name}%R%# ` + psqlCommand = `psql --set PROMPT1="${prompt}" --set PROMPT2="${prompt}" --set sslmode=require ` + + `--set channel_binding=${channelBinding} $${db.attachment!.name}_URL` + } + + const opts = { + app, + attach: true, + command: psqlCommand, + env: `PGAPPNAME='psql ${command ? 'non-' : ''}interactive';PGSSLMODE=require;PGCHANNELBINDING=${channelBinding}`, + 'exit-code': true, + heroku: this.heroku, + 'no-tty': false, + notificationSubtitle: 'heroku data:pg:psql', + notify: false, + showStatus: false, + } + + return this.runThroughOneOffDyno(opts) + } + + const psqlService = new utils.pg.PsqlService(db) + + console.error(`--> Connecting to ${color.yellow(db.attachment!.addon.name)}`) + + const cmdArgs = [ + '--set', + `channel_binding=${channelBinding}`, + ] + + if (command) { + const output = await psqlService.execQuery(command, cmdArgs) + process.stdout.write(output) + } else if (file) { + const output = await psqlService.execFile(file, cmdArgs) + process.stdout.write(output) + } else + await psqlService.interactiveSession(cmdArgs) + } + + public async runThroughOneOffDyno(opts: DynoOpts): Promise { + const dyno = new Dyno(opts) + try { + await dyno.start() + } catch (error: unknown) { + const dynoError = error as {exitCode?: number} & Error + if (dynoError.exitCode) { + ux.error(dynoError.message, {code: String(dynoError.exitCode), exit: dynoError.exitCode}) + } else { + throw error + } + } + } +} diff --git a/test/unit/commands/data/pg/psql.unit.test.ts b/test/unit/commands/data/pg/psql.unit.test.ts new file mode 100644 index 0000000000..98298c5329 --- /dev/null +++ b/test/unit/commands/data/pg/psql.unit.test.ts @@ -0,0 +1,324 @@ +import {pg, utils} from '@heroku/heroku-cli-util' +import {CLIError} from '@oclif/core/errors' +import {expect} from 'chai' +import sinon from 'sinon' +import {stderr, stdout} from 'stdout-stderr' + +import type {DynoOpts} from '../../../../../src/lib/run/dyno.js' + +import DataPgPsql from '../../../../../src/commands/data/pg/psql.js' +import runCommand from '../../../../helpers/runCommand.js' + +describe('data:pg:psql', function () { + let psqlServiceExecQueryStub: sinon.SinonSpy + let psqlServiceExecFileStub: sinon.SinonSpy + let psqlServiceInteractiveStub: sinon.SinonSpy + let runThroughOneOffDynoSpy: sinon.SinonSpy + + afterEach(function () { + sinon.restore() + }) + + describe('non-advanced tiers', function () { + beforeEach(function () { + sinon.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(db) + psqlServiceExecQueryStub = sinon.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves('') + psqlServiceExecFileStub = sinon.stub(utils.pg.PsqlService.prototype, 'execFile').resolves('') + psqlServiceInteractiveStub = sinon.stub(utils.pg.PsqlService.prototype, 'interactiveSession').resolves('') + }) + + const db = { + attachment: { + addon: { + name: 'postgres-1', + plan: { + name: 'heroku-postgresql:essential-1', + }, + }, app: {name: 'myapp'}, config_vars: ['DATABASE_URL'], + name: 'DATABASE', + }, database: 'mydb', host: 'localhost', password: 'pass', port: 5432, user: 'jeff', + } as unknown as pg.ConnectionDetails + + it('runs psql', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--command', + 'SELECT 1', + ]) + + expect(stdout.output).to.equal('') + expect(stderr.output).to.equal('--> Connecting to postgres-1\n') + }) + + it('passes cmdArgs to execQuery with --command', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--command', + 'SELECT 1', + ]) + + expect(psqlServiceExecQueryStub.calledOnce).to.be.true + expect(psqlServiceExecQueryStub.args[0][0]).to.equal('SELECT 1') + expect(psqlServiceExecQueryStub.args[0][1]).to.deep.equal([ + '--set', + 'channel_binding=require', + ]) + }) + + it('runs psql with file', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--file', + 'test.sql', + ]) + + expect(stdout.output).to.equal('') + expect(stderr.output).to.equal('--> Connecting to postgres-1\n') + }) + + it('passes cmdArgs to execFile with --file', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--file', + 'test.sql', + ]) + + expect(psqlServiceExecFileStub.calledOnce).to.be.true + expect(psqlServiceExecFileStub.args[0][0]).to.equal('test.sql') + expect(psqlServiceExecFileStub.args[0][1]).to.deep.equal([ + '--set', + 'channel_binding=require', + ]) + }) + + it('passes cmdArgs to interactiveSession when running interactively', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + ]) + + expect(psqlServiceInteractiveStub.calledOnce).to.be.true + expect(psqlServiceInteractiveStub.args[0][0]).to.deep.equal([ + '--set', + 'channel_binding=require', + ]) + }) + + it('passes cmdArgs with custom channel_binding when --channel-binding is set', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--command', + 'SELECT 1', + '--channel-binding', + 'disable', + ]) + + expect(psqlServiceExecQueryStub.calledOnce).to.be.true + expect(psqlServiceExecQueryStub.args[0][1]).to.deep.equal([ + '--set', + 'channel_binding=disable', + ]) + }) + }) + + describe('advanced tier (publicly networked)', function () { + beforeEach(function () { + sinon.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(db) + psqlServiceExecQueryStub = sinon.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves('') + psqlServiceExecFileStub = sinon.stub(utils.pg.PsqlService.prototype, 'execFile').resolves('') + psqlServiceInteractiveStub = sinon.stub(utils.pg.PsqlService.prototype, 'interactiveSession').resolves('') + }) + + const db = { + attachment: { + addon: { + name: 'postgres-1', + plan: { + name: 'heroku-postgresql:advanced', + }, + }, + app: {name: 'myapp'}, config_vars: [ + 'DATABASE_URL', + 'DATABASE_ANALYTICS_URL', + ], + }, database: 'mydb', host: 'localhost', name: 'DATABASE', password: 'pass', port: 5432, + user: 'jeff', + } as unknown as pg.ConnectionDetails + + it('passes cmdArgs to execQuery with --command for advanced tier', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--command', + 'SELECT 1', + ]) + + expect(psqlServiceExecQueryStub.calledOnce).to.be.true + expect(psqlServiceExecQueryStub.args[0][0]).to.equal('SELECT 1') + expect(psqlServiceExecQueryStub.args[0][1]).to.deep.equal([ + '--set', + 'channel_binding=require', + ]) + }) + + it('runs psql with file', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--file', + 'test.sql', + ]) + + expect(stdout.output).to.equal('') + expect(stderr.output).to.equal('--> Connecting to postgres-1\n') + }) + + it('passes cmdArgs to execFile with --file for advanced tier', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--file', + 'test.sql', + ]) + + expect(psqlServiceExecFileStub.calledOnce).to.be.true + expect(psqlServiceExecFileStub.args[0][0]).to.equal('test.sql') + expect(psqlServiceExecFileStub.args[0][1]).to.deep.equal([ + '--set', + 'channel_binding=require', + ]) + }) + + it('passes cmdArgs with custom channel_binding when --channel-binding is set for advanced tier', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--command', + 'SELECT 1', + '--channel-binding', + 'disable', + ]) + + expect(psqlServiceExecQueryStub.calledOnce).to.be.true + expect(psqlServiceExecQueryStub.args[0][1]).to.deep.equal([ + '--set', + 'channel_binding=disable', + ]) + }) + + it('passes cmdArgs to interactiveSession when running interactively for advanced tier', async function () { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + ]) + + expect(psqlServiceInteractiveStub.calledOnce).to.be.true + expect(psqlServiceInteractiveStub.args[0][0]).to.deep.equal([ + '--set', + 'channel_binding=require', + ]) + }) + }) + + describe('advanced tier (privately networked)', function () { + beforeEach(function () { + sinon.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(db) + psqlServiceExecQueryStub = sinon.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves('') + psqlServiceExecFileStub = sinon.stub(utils.pg.PsqlService.prototype, 'execFile').resolves('') + psqlServiceInteractiveStub = sinon.stub(utils.pg.PsqlService.prototype, 'interactiveSession').resolves('') + runThroughOneOffDynoSpy = sinon.stub(DataPgPsql.prototype, 'runThroughOneOffDyno').resolves() + }) + + const db = { + attachment: { + addon: { + name: 'postgres-1', + plan: { + name: 'heroku-postgresql:advanced-private', + }, + }, + app: {name: 'myapp'}, config_vars: [ + 'DATABASE_URL', + 'DATABASE_ANALYTICS_URL', + ], + name: 'DATABASE', + }, connStringVar: 'DATABASE_URL', database: 'mydb', host: 'localhost', password: 'pass', port: 5432, + user: 'jeff', + } as unknown as pg.ConnectionDetails + + it('errors out with ‘--file’ option', async function () { + try { + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--file', + 'test.sql', + ]) + } catch (error: unknown) { + const {message, oclif} = error as CLIError + expect(message).to.eq("You can't use the --file flag on private networked Advanced databases.") + expect(oclif.exit).to.eq(1) + } + + expect(psqlServiceExecFileStub.called).to.be.false + expect(stdout.output).to.equal('') + }) + + it('runs psql command on a one-off dyno', async function () { + const expectedOptions: Partial = { + app: 'myapp', + attach: true, + command: 'psql -c "SELECT 1" --set sslmode=require --set channel_binding=require $DATABASE_URL', + env: "PGAPPNAME='psql non-interactive';PGSSLMODE=require;PGCHANNELBINDING=require", + 'exit-code': true, + } + + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + '--command', + 'SELECT 1', + ]) + + expect(stdout.output).to.equal('') + expect(runThroughOneOffDynoSpy.args[0][0]).to.include(expectedOptions) + }) + + it('runs an interactive psql session on a one-off dyno', async function () { + const expectedOptions: Partial = { + app: 'myapp', + attach: true, + command: 'psql --set PROMPT1="myapp::DATABASE%R%# " --set PROMPT2="myapp::DATABASE%R%# " --set sslmode=require --set channel_binding=require $DATABASE_URL', + env: "PGAPPNAME='psql interactive';PGSSLMODE=require;PGCHANNELBINDING=require", + 'exit-code': true, + } + + await runCommand(DataPgPsql, [ + 'DATABASE', + '--app', + 'myapp', + ]) + + expect(stdout.output).to.equal('') + expect(runThroughOneOffDynoSpy.args[0][0]).to.include(expectedOptions) + }) + }) +}) From c6c62cbc2833ecb7fe8e7ff46083ad1313e853a2 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Thu, 5 Feb 2026 16:16:22 -0300 Subject: [PATCH 06/11] Migrating command 'data:pg:fork' and tests --- cspell-dictionary.txt | 1 + package-lock.json | 33 +- package.json | 1 + src/commands/data/pg/fork.ts | 202 +++++++++ test/unit/commands/data/pg/fork.unit.test.ts | 439 +++++++++++++++++++ 5 files changed, 653 insertions(+), 23 deletions(-) create mode 100644 src/commands/data/pg/fork.ts create mode 100644 test/unit/commands/data/pg/fork.unit.test.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 8d2ba08ac2..d47ad59ece 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -42,6 +42,7 @@ buildpath cachecompl CEDARPRIVATESPACES CERTFILE +chrono ckey clearsign clientsecret diff --git a/package-lock.json b/package-lock.json index c0648cd22a..bfed757aee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "ansi-escapes": "^4.3.2", "ansis": "^4", "bytes": "^3.1.2", + "chrono-node": "^2.7.6", "cli-progress": "^3.12.0", "commander": "^2.15.1", "date-fns": "^2.30.0", @@ -12388,6 +12389,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chrono-node": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz", + "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -14465,17 +14475,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "license": "MIT", @@ -17186,18 +17185,6 @@ "node": ">=4" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "funding": [ diff --git a/package.json b/package.json index 8fb2d26ab6..f016918e9e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "ansi-escapes": "^4.3.2", "ansis": "^4", "bytes": "^3.1.2", + "chrono-node": "^2.7.6", "cli-progress": "^3.12.0", "commander": "^2.15.1", "date-fns": "^2.30.0", diff --git a/src/commands/data/pg/fork.ts b/src/commands/data/pg/fork.ts new file mode 100644 index 0000000000..f801690bd7 --- /dev/null +++ b/src/commands/data/pg/fork.ts @@ -0,0 +1,202 @@ +import {color, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import * as chrono from 'chrono-node' +import tsheredoc from 'tsheredoc' + +import createAddon from '../../../lib/addons/create_addon.js' +import BaseCommand from '../../../lib/data/baseCommand.js' +import {parseProvisionOpts} from '../../../lib/data/parseProvisionOpts.js' +import {InfoResponse} from '../../../lib/data/types.js' +import notify from '../../../lib/notify.js' + +const heredoc = tsheredoc.default + +export default class Fork extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'fork or rollback a Postgres Advanced database' + + static examples = [ + heredoc` + # Create a fork for an existing database + <%= config.bin %> <%= command.id %> DATABASE --app my-app --as DATABASE_COPY + `, + heredoc` + # Create a point-in-time recovery fork with a timestamp: + <%= config.bin %> <%= command.id %> DATABASE --app my-app --as RESTORED --rollback-to '2025-08-11T12:35:00' + `, + heredoc` + # Create a point-in-time recovery fork with a time interval: + <%= config.bin %> <%= command.id %> DATABASE --app my-app --as RESTORED --rollback-by '1 day 3 hours 20 minutes' + `, + ] + + static flags = { + app: Flags.app({required: true}), + as: Flags.string({description: 'name for the initial database attachment'}), + confirm: Flags.string({hidden: true}), + level: Flags.string({description: 'set compute scale'}), + name: Flags.string({char: 'n', description: 'name for the database'}), + 'provision-option': Flags.string({ + description: 'additional options for provisioning in KEY:VALUE or KEY format, and VALUE defaults to "true" (example: \'foo:bar\' or \'foo\')', + multiple: true, + }), + remote: Flags.remote(), + 'rollback-by': Flags.string({ + description: 'time interval to rollback (example: \'3 days\', \'2 hours\', \'3 days 7 hours 22 minutes\')', + }), + 'rollback-to': Flags.string({ + description: 'explicit timestamp for rollback database with the format \'2025-11-17T15:20:00\'', + exclusive: ['rollback-by'], + }), + wait: Flags.boolean({description: 'watch database fork creation status and exit when complete'}), + } + + public async notify(message: string, success = true): Promise { + notify('heroku data:pg:fork', message, success) + } + + /** + * Parses a time interval string for rollback operations. + * Automatically appends "ago" to make chrono parsing work with simple intervals. + * + * @param interval - Time interval like '3 days', '2 hours', or '3 days 7 hours' + * @returns Date object representing the point in time for recovery + * @throws Error if interval cannot be parsed + * + * @example + * parseRollbackInterval('3 days') // 3 days ago + * parseRollbackInterval('2 days 5 hours') // 2 days 5 hours ago + * parseRollbackInterval('1 day ago') // 1 day ago (doesn't double-add) + */ + public parseRollbackInterval(interval: string): Date { + const normalized = interval.trim().toLowerCase() + + const timeString = normalized.endsWith('ago') + ? interval + : `${interval} ago` + + const parsedDate = chrono.parseDate(timeString) + + if (!parsedDate) { + ux.error( + `${interval} isn't a supported time interval. Use a format like '1 day', '3 hours', '2 days 5 hours' for example. ` + + 'See https://devcenter.heroku.com/articles/heroku-postgres-rollback.', + ) + } + + return parsedDate + } + + public async run(): Promise { + const {args, flags} = await this.parse(Fork) + const {database} = args + const {app, as, confirm, name, 'provision-option': provisionOpts, 'rollback-by': rollbackBy, 'rollback-to': rollbackTo, wait} = flags + let {level} = flags + + // Parse provision options + let provisionConfig: Record = {} + if (provisionOpts) { + try { + provisionConfig = parseProvisionOpts(provisionOpts) + } catch (error) { + ux.error(error instanceof Error ? error.message : String(error)) + } + } + + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(database, app, utils.pg.addonService()) + + const renderLegacyCommand = (): string => `heroku addons:create ${addon.plan.name}` + + ` -a ${app}` + + `${name ? ` --name ${name}` : ''}` + + `${as ? ` --as ${as}` : ''}` + + `${wait ? ' --wait' : ''}` + + ` -- ${rollbackTo || rollbackBy ? '--rollback' : '--fork'} ${addon.name}` + + `${rollbackTo ? ` --to '${rollbackTo}'` : ''}` + + `${rollbackBy ? ` --by '${rollbackBy}'` : ''}` + + if (!utils.pg.isAdvancedDatabase(addon)) { + ux.error( + 'You can only use this command on Advanced-tier databases.\n' + + `Use ${color.code(renderLegacyCommand())} instead.`, + ) + } + + if (!level) { + const {body: databaseInfo} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/info`) + level = databaseInfo.pools.find(p => p.name === 'leader')?.expected_level + } + + let recoveryTime: string | undefined + + if (rollbackTo) { + recoveryTime = rollbackTo + } else if (rollbackBy) { + const parsedDate = this.parseRollbackInterval(rollbackBy) + recoveryTime = this.formatRecoveryTime(parsedDate) + } + + const config = recoveryTime + ? { + level, + 'recovery-time': recoveryTime, + rollback: database, + ...provisionConfig, + } + : { + fork: database, + level, + ...provisionConfig, + } + + try { + const rollbackMessage = `with a rollback ${rollbackTo ? `to ${rollbackTo}` : `by ${rollbackBy}`}` + const actionStartMessage = recoveryTime + ? `Creating a fork for ${color.addon(addon.name)} on ${color.app(app)} ${rollbackMessage}` + : `Creating a fork for ${color.addon(addon.name)} on ${color.app(app)}` + await createAddon(this.heroku, app, addon.plan.name!, confirm, wait, { + actionStartMessage, actionStopMessage: 'done', as, config, name, + }) + + if (wait) { + this.notify('We successfully provisioned the database fork') + } + } catch (error) { + ux.action.stop() + + if (wait) { + this.notify( + 'We can\'t provision the database fork. Try again or open a ticket with Heroku Support: https://help.heroku.com/.', + false, + ) + } + + throw error + } + } + + /** + * Formats a Date object to the backend-expected timestamp format. + * Format: YYYY-MM-DDTHH:MM:SS (e.g., '2025-11-17T15:20:00') + * + * @param date - Date object to format + * @returns Formatted timestamp string + */ + private formatRecoveryTime(date: Date): string { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + const hours = String(date.getUTCHours()).padStart(2, '0') + const minutes = String(date.getUTCMinutes()).padStart(2, '0') + const seconds = String(date.getUTCSeconds()).padStart(2, '0') + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` + } +} diff --git a/test/unit/commands/data/pg/fork.unit.test.ts b/test/unit/commands/data/pg/fork.unit.test.ts new file mode 100644 index 0000000000..6c3190cd92 --- /dev/null +++ b/test/unit/commands/data/pg/fork.unit.test.ts @@ -0,0 +1,439 @@ +/* eslint-disable mocha/no-setup-in-describe */ +/* eslint-disable max-nested-callbacks */ +import {Config} from '@oclif/core' +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import sinon from 'sinon' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import Fork from '../../../../../src/commands/data/pg/fork.js' +import { + addon, + createForkResponse, + nonAdvancedAddon, + pgInfo, +} from '../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../helpers/runCommand.js' + +const stubbedDate = new Date('2025-01-31T00:00:00+00:00') +const heredoc = tsheredoc.default + +describe('data:pg:fork', function () { + beforeEach(function () { + sinon.useFakeTimers({ + now: stubbedDate, + shouldAdvanceTime: false, + toFake: ['Date'], + }) + sinon.stub(Fork.prototype, 'notify').resolves() + }) + + afterEach(function () { + sinon.restore() + }) + + describe('basic fork functionality', function () { + it('creates a fork from an existing database', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .post('/apps/myapp/addons', { + attachment: {}, + config: { + fork: 'advanced-horizontal-01234', + level: '4G-Performance', + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createForkResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp... done + `) + expect(stdout.output).to.equal( + heredoc(` + Your forked database is being provisioned + advanced-oblique-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-oblique-01234 -a myapp to check creation progress. + `), + ) + }) + + it('creates a fork with custom name and attachment', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .post('/apps/myapp/addons', { + attachment: {name: 'DATABASE_COPY'}, + config: { + fork: 'advanced-horizontal-01234', + level: '4G-Performance', + }, + name: 'my-forked-db', + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createForkResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--name=my-forked-db', + '--as=DATABASE_COPY', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp... done + `) + expect(stdout.output).to.equal( + heredoc(` + Your forked database is being provisioned + advanced-oblique-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-oblique-01234 -a myapp to check creation progress. + `), + ) + }) + + it('creates a fork with custom level', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .post('/apps/myapp/addons', { + attachment: {}, + config: { + fork: 'advanced-horizontal-01234', + level: '8G-Performance', + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createForkResponse) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--level=8G-Performance', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp... done + `) + expect(stdout.output).to.equal( + heredoc(` + Your forked database is being provisioned + advanced-oblique-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-oblique-01234 -a myapp to check creation progress. + `), + ) + }) + + it('creates a fork with provision options', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .post('/apps/myapp/addons', (body: any) => body.config.level === '4G-Performance' + && body.config.fork === 'advanced-horizontal-01234' + && body.config.foo === 'bar' + && body.config.baz === 'true' + && body.config.key === 'value:with:colons') + .reply(200, createForkResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--provision-option=foo:bar', + '--provision-option=baz', + '--provision-option=key:value:with:colons', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp... done + `) + }) + }) + + describe('rollback functionality', function () { + it('creates a rollback fork with rollback-to timestamp', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .post('/apps/myapp/addons', { + attachment: {}, + config: { + level: '4G-Performance', + 'recovery-time': '2025-01-11T12:35:00', + rollback: 'advanced-horizontal-01234', + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createForkResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + forked_from: { + id: addon.id, + name: addon.name, + }, + }) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--rollback-to=2025-01-11T12:35:00', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp with a rollback to 2025-01-11T12:35:00... done + `) + expect(stdout.output).to.equal( + heredoc(` + Your forked database is being provisioned + advanced-oblique-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-oblique-01234 -a myapp to check creation progress. + `), + ) + }) + + it('creates a rollback fork with rollback-by interval', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .post('/apps/myapp/addons', { + attachment: {}, + config: { + level: '4G-Performance', + 'recovery-time': '2025-01-30T00:00:00', + rollback: 'advanced-horizontal-01234', + }, + plan: {name: 'heroku-postgresql:advanced-beta'}, + }) + .reply(200, createForkResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + forked_from: { + id: addon.id, + name: addon.name, + }, + }) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--rollback-by=1 day', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp with a rollback by 1 day... done + `) + expect(stdout.output).to.equal( + heredoc(` + Your forked database is being provisioned + advanced-oblique-01234 is being created in the background. The app will restart when complete... + Run heroku data:pg:info advanced-oblique-01234 -a myapp to check creation progress. + `), + ) + }) + + it('creates a rollback fork with provision options', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .post('/apps/myapp/addons', (body: any) => body.config.level === '4G-Performance' + && body.config.rollback === 'advanced-horizontal-01234' + && body.config['recovery-time'] === '2025-01-11T12:35:00' + && body.config.foo === 'bar' + && body.config.baz === 'true') + .reply(200, createForkResponse) + + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + forked_from: { + id: addon.id, + name: addon.name, + }, + }) + + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--rollback-to=2025-01-11T12:35:00', + '--provision-option=foo:bar', + '--provision-option=baz', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Creating a fork for advanced-horizontal-01234 on ⬢ myapp with a rollback to 2025-01-11T12:35:00... done + `) + }) + }) + + describe('error handling', function () { + it('shows error for non-Advanced databases', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + ]) + } catch (error) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + You can only use this command on Advanced-tier databases. + Use heroku addons:create heroku-postgresql:standard-0 -a myapp -- --fork standard-database instead.`, + ) + } + + herokuApi.done() + }) + + it('shows error for non-Advanced databases with rollback to', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--rollback-to=2025-08-11T12:35:00', + ]) + } catch (error) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + You can only use this command on Advanced-tier databases. + Use heroku addons:create heroku-postgresql:standard-0 -a myapp -- --rollback standard-database --to '2025-08-11T12:35:00' instead.`, + ) + } + + herokuApi.done() + }) + + it('shows error for non-Advanced databases with rollback by', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(Fork, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--rollback-by=3 days 7 hours 22 minutes', + ]) + } catch (error) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + You can only use this command on Advanced-tier databases. + Use heroku addons:create heroku-postgresql:standard-0 -a myapp -- --rollback standard-database --by '3 days 7 hours 22 minutes' instead.`, + ) + } + + herokuApi.done() + }) + }) + + describe('parseRollbackInterval', function () { + let fork: InstanceType + + beforeEach(function () { + fork = new Fork([], {} as Config) + }) + + describe('valid intervals', function () { + const testCases: Array<{description: string; expected: string; input: string}> = [ + // Simple time intervals + {description: 'parses days', expected: '2025-01-28T00:00:00.000Z', input: '3 days'}, + {description: 'parses single day', expected: '2025-01-30T00:00:00.000Z', input: '1 day'}, + {description: 'parses hours', expected: '2025-01-30T18:00:00.000Z', input: '6 hours'}, + {description: 'parses minutes', expected: '2025-01-30T23:30:00.000Z', input: '30 minutes'}, + {description: 'parses weeks', expected: '2025-01-17T00:00:00.000Z', input: '2 weeks'}, + + // Complex intervals + {description: 'parses days and hours', expected: '2025-01-27T17:00:00.000Z', input: '3 days 7 hours'}, + {description: 'parses days, hours, and minutes', expected: '2025-01-28T18:30:00.000Z', input: '2 days 5 hours 30 minutes'}, + {description: 'parses hours and minutes', expected: '2025-01-30T11:15:00.000Z', input: '12 hours 45 minutes'}, + {description: 'handles example from flag description', expected: '2025-01-27T16:38:00.000Z', input: '3 days 7 hours 22 minutes'}, + + // With "ago" suffix + {description: 'handles "ago" suffix without doubling', expected: '2025-01-28T00:00:00.000Z', input: '3 days ago'}, + {description: 'handles complex interval with "ago"', expected: '2025-01-28T19:00:00.000Z', input: '2 days 5 hours ago'}, + {description: 'handles "1 day ago"', expected: '2025-01-30T00:00:00.000Z', input: '1 day ago'}, + + // Edge cases + {description: 'handles singular forms', expected: '2025-01-30T23:00:00.000Z', input: '1 hour'}, + {description: 'handles extra spacing', expected: '2025-01-28T00:00:00.000Z', input: ' 3 days '}, + {description: 'handles mixed case', expected: '2025-01-28T00:00:00.000Z', input: '3 Days'}, + {description: 'handles 24 hours', expected: '2025-01-30T00:00:00.000Z', input: '24 hours'}, + ] + + testCases.forEach(({description, expected, input}) => { + it(description, function () { + const result = fork.parseRollbackInterval(input) + expect(result.toISOString()).to.equal(expected) + }) + }) + }) + + describe('invalid intervals', function () { + const errorCases: Array<{description: string; input: string}> = [ + {description: 'rejects invalid text', input: 'invalid input'}, + {description: 'rejects empty string', input: ''}, + {description: 'rejects just numbers', input: '5'}, + {description: 'rejects invalid numbers', input: 'xyz days'}, + ] + + errorCases.forEach(({description, input}) => { + it(description, function () { + try { + fork.parseRollbackInterval(input) + expect.fail('Should have thrown an error') + } catch (error) { + const err = error as Error + expect(ansis.strip(err.message)).to.include("isn't a supported time interval") + } + }) + }) + }) + }) +}) From f441f003b608a248c6aad604fc85e886003601b5 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Thu, 5 Feb 2026 16:49:34 -0300 Subject: [PATCH 07/11] Migrating command 'data:pg:settings' and tests --- src/commands/data/pg/settings.ts | 108 +++++++++++++++ .../commands/data/pg/settings.unit.test.ts | 131 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/commands/data/pg/settings.ts create mode 100644 test/unit/commands/data/pg/settings.unit.test.ts diff --git a/src/commands/data/pg/settings.ts b/src/commands/data/pg/settings.ts new file mode 100644 index 0000000000..e55df168ab --- /dev/null +++ b/src/commands/data/pg/settings.ts @@ -0,0 +1,108 @@ +import {color, hux, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import BaseCommand from '../../../lib/data/baseCommand.js' +import {SettingsChangeResponse, SettingsResponse} from '../../../lib/data/types.js' + +const heredoc = tsheredoc.default + +const settingsChangeHeaders = { + Settings: {}, + // eslint-disable-next-line perfectionist/sort-objects + From: {}, + To: {}, +} + +const settingsHeaders = { + Setting: {}, + Value: {}, +} + +const settingsChangeTableData = (response: SettingsChangeResponse) => response.changes.map(change => ({ + From: color.yellow(change.previous), + Settings: change.name, + To: color.cyan(change.current), +})) + +const settingsTableData = (response: SettingsResponse) => { + const settingsArray = response.items.map(item => ({Setting: item.name, Value: item.current})) + + return settingsArray +} + +export default class DataPgSettings extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'get or update the settings of a Postgres Advanced database' + + static examples = [ + '# Get database settings\n' + + '<%= config.bin %> <%= command.id %> database_name -a app_name', + '# Change ‘log_min_duration_statement’ and ‘log_statement’ settings for database\n' + + '<%= config.bin %> <%= command.id %> database_name --set=log_min_duration_statement:2000 --set=log_statement:ddl -a app_name', + ] + + static flags = { + app: Flags.app({ + required: true, + }), + remote: Flags.remote(), + set: Flags.string({ + description: 'Postgres setting to change in SETTING_NAME:VALUE format (example: \'track_functions:pl\' or \'log_lock_waits:1\')', + multiple: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgSettings) + const {database: databaseArg} = args + const {app, set} = flags + const settings: string = set?.map((setting: string) => setting.trim()).join(',') + + const addonResolver = new utils.AddonResolver(this.heroku) + const database = await addonResolver.resolve(databaseArg, app, utils.pg.addonService()) + + if (!utils.pg.isAdvancedDatabase(database)) { + ux.error(heredoc(` + You can only use this command to configure settings on Advanced-tier databases. + See https://devcenter.heroku.com/articles/heroku-postgres-settings to configure settings on non-Advanced-tier databases. + `)) + } + + if (settings) { + const response = await this.dataApi.put(`/data/postgres/v1/${database.id}/settings`, { + body: {settings}, + }) + + const {body} = response + + if (body.changes.length === 0) { + ux.stdout( + `\nThose settings are already applied to ${color.addon(database.name)}. ` + + `Use ${color.code(`heroku data:pg:settings ${database.name} -a ${app}`)} to see the current settings on the database.`, + ) + } else { + const tableData = settingsChangeTableData(body) + ux.stdout('Updating these settings...') + hux.table(tableData, settingsChangeHeaders) + ux.stdout(`Updating your database ${color.addon(database.name)} shortly. You can use ${color.code( + `data:pg:info ${database.name} -a ${app}`, + )} to track progress`, + ) + } + } else { + const response = await this.dataApi.get(`/data/postgres/v1/${database.id}/settings`) + const {body} = response + const tableData = settingsTableData(body) + ux.stdout(`=== ${color.addon(database.name)}`) + hux.table(tableData, settingsHeaders) + } + } +} diff --git a/test/unit/commands/data/pg/settings.unit.test.ts b/test/unit/commands/data/pg/settings.unit.test.ts new file mode 100644 index 0000000000..9c148bf4f8 --- /dev/null +++ b/test/unit/commands/data/pg/settings.unit.test.ts @@ -0,0 +1,131 @@ +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgSettings from '../../../../../src/commands/data/pg/settings.js' +import { + addon, + emptySettingsChangeResponse, + nonAdvancedAddon, + settingsGetResponse, + settingsPutResponse, +} from '../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:settings', function () { + it('exits with error if it isn\'t a Advanced-tier database', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(DataPgSettings, [ + 'my-addon', + '--app=myapp', + ]) + } catch (error: unknown) { + const err = error as Error + herokuApi.done() + expect(err.message).to.equal(heredoc(` + You can only use this command to configure settings on Advanced-tier databases. + See https://devcenter.heroku.com/articles/heroku-postgres-settings to configure settings on non-Advanced-tier databases. + `), + ) + } + }) + + context('put', function () { + it('shows no changes applied when no changes received', async function () { + const herokuApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) + const dataApi = nock('https://test.data.heroku.com') + .put(`/data/postgres/v1/${addon.id}/settings`) + .reply(200, emptySettingsChangeResponse) + + await runCommand(DataPgSettings, [ + 'my-addon', + '--app=myapp', + '--set=log_min_duration_statement:500', + ]) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + expect(stdout.output.trim()).to.include('Those settings are already applied to advanced-horizontal-01234.') + }) + + it('shows received changes', async function () { + const herokuApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) + const dataApi = nock('https://test.data.heroku.com') + .put( + `/data/postgres/v1/${addon.id}/settings`, + {settings: 'log_min_duration_statement:500,idle_in_transaction_session_timeout:864000'}, + ) + .reply(200, settingsPutResponse) + + await runCommand(DataPgSettings, [ + 'my-addon', + '--app=myapp', + '--set=log_min_duration_statement:500', + '--set=idle_in_transaction_session_timeout:864000', + ]) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + expect(stdout.output).to.equal( + heredoc` + Updating these settings... + Settings From To + ────────────────────────────────────────────────────── + log_min_duration_statement 550 500 + idle_in_transaction_session_timeout 80000 864000 + + Updating your database advanced-horizontal-01234 shortly. You can use data:pg:info advanced-horizontal-01234 -a myapp to track progress + `, + ) + }) + }) + + context('get', function () { + it('shows settings', async function () { + const herokuApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) + const dataApi = nock('https://test.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/settings`) + .reply(200, settingsGetResponse) + + await runCommand(DataPgSettings, [ + 'my-addon', + '--app=myapp', + ]) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + + expect(stdout.output).to.equal( + heredoc` + === advanced-horizontal-01234 + Setting Value + ──────────────────────────────────────────── + log_connections true + log_lock_waits true + log_min_duration_statement 500 + log_min_error_statement info + log_statement ddl + track_functions pl + auto_explain.log_analyze + auto_explain.log_buffers + auto_explain.log_format + auto_explain.log_min_duration + auto_explain.log_nested_statements + auto_explain.log_triggers + auto_explain.log_verbose + + `, + ) + }) + }) +}) From eb0e3cb5a2c30c44b741792d0c8659641264549c Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Thu, 5 Feb 2026 18:14:42 -0300 Subject: [PATCH 08/11] Migrating command 'data:pg:update' and tests --- package-lock.json | 7 + package.json | 1 + src/commands/data/pg/update.ts | 511 ++++++++++++++++ .../unit/commands/data/pg/update.unit.test.ts | 545 ++++++++++++++++++ 4 files changed, 1064 insertions(+) create mode 100644 src/commands/data/pg/update.ts create mode 100644 test/unit/commands/data/pg/update.unit.test.ts diff --git a/package-lock.json b/package-lock.json index bfed757aee..653b3adae3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "https-proxy-agent": "^7.0.6", "inquirer": "^8.2.6", "lodash": "^4.17.21", + "mock-stdin": "^1", "natural-orderby": "^5.0.0", "netrc-parser": "3.1.6", "node-fetch": "^2.6.7", @@ -19184,6 +19185,12 @@ "node": ">=10" } }, + "node_modules/mock-stdin": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mock-stdin/-/mock-stdin-1.0.0.tgz", + "integrity": "sha512-tukRdb9Beu27t6dN+XztSRHq9J0B/CoAOySGzHfn8UTfmqipA5yNT/sDUEyYdAV3Hpka6Wx6kOMxuObdOex60Q==", + "license": "MIT" + }, "node_modules/modify-values": { "version": "1.0.1", "dev": true, diff --git a/package.json b/package.json index f016918e9e..fca730b6f3 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "https-proxy-agent": "^7.0.6", "inquirer": "^8.2.6", "lodash": "^4.17.21", + "mock-stdin": "^1", "natural-orderby": "^5.0.0", "netrc-parser": "3.1.6", "node-fetch": "^2.6.7", diff --git a/src/commands/data/pg/update.ts b/src/commands/data/pg/update.ts new file mode 100644 index 0000000000..efb946ea78 --- /dev/null +++ b/src/commands/data/pg/update.ts @@ -0,0 +1,511 @@ +import type {Answers, DistinctChoice, ListChoiceMap} from 'inquirer' + +import { + color, hux, pg, utils, +} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' +import {Args, ux} from '@oclif/core' +import inquirer from 'inquirer' +import tsheredoc from 'tsheredoc' + +import BaseCommand from '../../../lib/data/baseCommand.js' +import createPool from '../../../lib/data/createPool.js' +import PoolConfig from '../../../lib/data/poolConfig.js' +import { + DeepRequired, + ExtendedPostgresLevelInfo, + InfoResponse, + PoolInfoResponse, +} from '../../../lib/data/types.js' +import {fetchLevelsAndPricing, renderPricingInfo} from '../../../lib/data/utils.js' + +const heredoc = tsheredoc.default +// eslint-disable-next-line import/no-named-as-default-member +const {Separator, prompt} = inquirer + +export default class DataPgUpdate extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + }), + } + + static description = 'update a Postgres Advanced database through interactive prompts' + + static flags = { + app: Flags.app({ + required: true, + }), + remote: Flags.remote(), + } + + private database: DeepRequired | pg.ExtendedAddonAttachment['addon'] | undefined + private extendedLevelsInfo: ExtendedPostgresLevelInfo[] | undefined + private followerInstanceCount: number = 0 + private pool: PoolInfoResponse | undefined + private selectedPoolOption: string | undefined + + public async confirmCommand(comparison: string): Promise { + await hux.confirmCommand({comparison}) + } + + public async followerPoolActionStep(pool: PoolInfoResponse): Promise { + const choices: Array>> = [ + {name: 'Change pool level', value: '__change_level'}, + {name: 'Update number of instances', value: '__update_count'}, + {name: 'Destroy pool', value: '__destroy_pool'}, + new Separator(), + {name: 'Go back', value: '__go_back'}, + ] + + const {action} = await this.prompt<{action: string}>({ + choices, + message: 'What do you want to do?:', + name: 'action', + type: 'list', + }) + + let newLevel: string | undefined + let newCount: string | undefined + const poolConfig = new PoolConfig(this.extendedLevelsInfo!, this.followerInstanceCount) + switch (action) { + case '__change_level': { + newLevel = await poolConfig.levelStep('Follower', pool, true) + if (newLevel !== '__go_back') { + ux.action.start('Changing follower pool level') + + try { + await this.dataApi.patch(`/data/postgres/v1/${this.database!.id}/pools/${pool.id}`, { + body: {level: newLevel}, + }) + ux.action.stop() + ux.stdout(heredoc` + ${color.green('✓ Success:')} Level changed from ${pool.expected_level} to ${newLevel} for follower pool ${pool.name}. + `) + pool.expected_level = newLevel + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } + + break + } + + case '__update_count': { + newCount = await poolConfig.instanceCountStep(pool) + if (newCount !== '__go_back') { + ux.action.start('Updating follower pool instances count') + + try { + await this.dataApi.patch(`/data/postgres/v1/${this.database!.id}/pools/${pool.id}`, { + body: {count: Number(newCount)}, + }) + ux.action.stop() + ux.stdout(heredoc` + ${color.success('✓ Success:')} The ${color.name(pool.name)} follower pool now has ${newCount} instance${Number(newCount) === 1 ? '' : 's'}. + `) + this.followerInstanceCount = this.followerInstanceCount - pool.expected_count + Number(newCount) + pool.expected_count = Number(newCount) + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } + + break + } + + case '__destroy_pool': { + await this.confirmCommand(this.database!.app.name) + ux.action.start(`Destroying follower pool ${color.name(`${pool.name}`)} on ${color.datastore(`${this.database!.name}`)}`) + + try { + await this.dataApi.delete(`/data/postgres/v1/${this.database!.id}/pools/${pool.id}`) + ux.action.stop() + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + + break + } + + case '__go_back': { + break + } + } + + process.stderr.write('\n') + return action + } + + public async leaderPoolActionStep(pool: PoolInfoResponse): Promise { + const leaderPricing = this.extendedLevelsInfo!.find(level => level.name === pool.expected_level)?.pricing + const choices: Array>> = [ + {name: 'Change pool level', value: '__change_level'}, + ] + + if (pool.expected_count > 1) { + choices.push({ + name: 'Remove high availability' + ( + renderPricingInfo(leaderPricing) === 'free' + ? '' + : ` ${color.yellowBright(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}` + ), + value: '__remove_ha', + }) + } else { + choices.push( + { + name: ( + 'Add a high availability (HA) standby instance' + + ` ${color.green(renderPricingInfo(leaderPricing))}` + ), + value: '__add_ha', + }, + ) + } + + choices.push( + new Separator(), + {name: 'Go back', value: '__go_back'}, + ) + + const {action} = await this.prompt<{action: string}>({ + choices, + message: 'What do you want to do?:', + name: 'action', + type: 'list', + }) + + let newLevel: string | undefined + const poolConfig = new PoolConfig(this.extendedLevelsInfo!, this.followerInstanceCount) + switch (action) { + case '__change_level': { + newLevel = await poolConfig.levelStep('Leader', pool, true) + if (newLevel !== '__go_back') { + ux.action.start('Changing leader pool level') + + try { + await this.dataApi.patch(`/data/postgres/v1/${this.database!.id}/pools/${pool.id}`, { + body: {level: newLevel}, + }) + ux.action.stop() + ux.stdout(heredoc` + ${color.green('✓ Success:')} Level changed from ${pool.expected_level} to ${newLevel} for leader pool. + `) + pool.expected_level = newLevel + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } + + break + } + + case '__remove_ha': { + ux.action.start(`Removing the high availability (HA) standby instance from ${color.addon(`${this.database!.name}`)}`) + + try { + await this.dataApi.patch(`/data/postgres/v1/${this.database!.id}/pools/${pool.id}`, { + body: {count: 1}, + }) + ux.action.stop() + pool.expected_count = 1 + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + + break + } + + case '__add_ha': { + ux.action.start(`Adding a high availability (HA) standby instance for ${color.addon(`${this.database!.name}`)}`) + + try { + await this.dataApi.patch(`/data/postgres/v1/${this.database!.id}/pools/${pool.id}`, { + body: {count: 2}, + }) + ux.action.stop() + pool.expected_count = 2 + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + + break + } + + case '__go_back': { + break + } + } + + process.stderr.write('\n') + return action + } + + public async prompt(...args: Parameters>): Promise { + return prompt(...args) + } + + public async run(): Promise { + const {args, flags} = await this.parse(DataPgUpdate) + const {app} = flags + const {database: dbIdentifier} = args + + // Database selection stage + if (dbIdentifier) { + const addonResolver = new utils.AddonResolver(this.heroku) + this.database = await addonResolver.resolve(dbIdentifier, app, utils.pg.addonService()) + if (this.database && !utils.pg.isAdvancedDatabase(this.database)) { + ux.error(heredoc` + You can only use this command on Advanced-tier databases. + Use ${color.code(`heroku addons:upgrade ${this.database.name} -a ${app}`)} instead.`, + ) + } + } else { + const databases = await this.getAllAdvancedDatabases(app) + if (databases.length === 0) { + ux.error('No Heroku Postgres Advanced-tier databases found on the app.') + } + + const selectedDatabase = ( + await this.prompt<{database: string}>({ + choices: this.renderDatabaseChoices(databases), + message: 'Select the Heroku Postgres Advanced database to update:', + name: 'database', + pageSize: 12, + type: 'list', + }) + ).database + + if (selectedDatabase !== '__exit') { + this.database = databases.find(db => db.name === selectedDatabase) + } + } + + if (this.database) { + process.stderr.write(heredoc` + + Update ${color.addon(this.database!.name)} on ${color.app(app)} + ${color.dim('Press Ctrl+C to cancel')} + + `) + + // Fetch the information on levels and pricing for rendering choices + const [, planName] = this.database!.plan!.name.split(':', 2) + const {extendedLevelsInfo} = await fetchLevelsAndPricing(planName, this.dataApi) + this.extendedLevelsInfo = extendedLevelsInfo + + // Pool selection stage + await this.poolSelectionLoopStage() + } + } + + private async addFollowerPoolStage(): Promise { + const poolConfig = new PoolConfig(this.extendedLevelsInfo!, this.followerInstanceCount) + const {count, level, name} = await poolConfig.followerInteractiveConfig() + try { + ux.action.start('Configuring follower pool') + const poolInfo = await createPool(this.dataApi, this.database!, {count, level, name}) + ux.action.stop() + ux.stdout(heredoc` + ${color.green('✓ Success:')} we're provisioning ${color.bold(poolInfo.name)} follower pool on ${color.addon(this.database!.name)}. + Run ${color.code(`heroku data:pg:info ${this.database!.name} -a ${this.database!.app?.name}`)} to check creation progress. + `) + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + + process.stderr.write('\n') + this.followerInstanceCount += count + } + + /** + * Helper function that attempts to find all Heroku Postgres Advanced-tier attachments on a given app. + * + * @param app - The name of the app to get the attachments for + * @returns Promise resolving to an array of all Heroku Postgres Advanced-tier attachments on the app + */ + private async allAdvancedDatabaseAttachments(app: string) { + const {body: attachments} = await this.heroku.get( + `/apps/${app}/addon-attachments`, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Inclusion': 'addon:plan,config_vars', + }, + }, + ) + return attachments.filter(a => utils.pg.isAdvancedDatabase(a.addon)) + } + + /** + * Return all Heroku Postgres databases on the Advanced-tier for a given app. + * + * @param app - The name of the app to get the databases for + * @returns Promise resolving to all Heroku Postgres databases + * @throws {Error} When no legacy database add-on exists on the app + */ + private async getAllAdvancedDatabases(app: string): Promise> { + const allAttachments = await this.allAdvancedDatabaseAttachments(app) + const addons: Array<{attachment_names?: string[]} & pg.ExtendedAddonAttachment['addon']> = [] + for (const attachment of allAttachments) { + if (!addons.some(a => a.id === attachment.addon.id)) { + addons.push(attachment.addon) + } + } + + const attachmentNamesByAddon = this.getAttachmentNamesByAddon(allAttachments) + for (const addon of addons) { + addon.attachment_names = attachmentNamesByAddon[addon.id] + } + + return addons + } + + /** + * Helper function that groups attachment names by addon. + * + * @param attachments - The attachments to group by addon + * @returns A record of addon IDs with their attachment names + */ + private getAttachmentNamesByAddon(attachments: pg.ExtendedAddonAttachment[]): Record { + const addons: Record = {} + for (const attachment of attachments) { + addons[attachment.addon.id] = [...(addons[attachment.addon.id] || []), attachment.name] + } + + return addons + } + + private async poolSelectionLoopStage(): Promise { + let currentStep = 'poolSelectionStep' + + do { + let action: string | undefined + switch (currentStep) { + case 'poolSelectionStep': { + await this.poolSelectionStep() + if (this.selectedPoolOption === '__exit') { + currentStep = '__exit' + } else if (this.selectedPoolOption === '__add_follower_pool') { + currentStep = 'addFollowerPoolStage' + } else { + currentStep = 'poolActionStage' + } + + break + } + + case 'poolActionStage': { + if (this.pool!.name === 'leader') { + action = await this.leaderPoolActionStep(this.pool!) + } else { + action = await this.followerPoolActionStep(this.pool!) + } + + if (action === '__go_back' || action === '__destroy_pool') { + currentStep = 'poolSelectionStep' + } + + break + } + + case 'addFollowerPoolStage': { + await this.addFollowerPoolStage() + currentStep = 'poolSelectionStep' + break + } + } + } while (currentStep !== '__exit') + } + + private async poolSelectionStep(): Promise { + // Fetch the info each time we render this menu to show the updated database configuration + const {body: databaseInfo} = await this.dataApi.get(`/data/postgres/v1/${this.database!.id}/info`) + const {pools} = databaseInfo + if (pools.length === 0) { + ux.error('No pools found on the database.') // It should never happen, but just for the sake of safety + } + + this.followerInstanceCount = pools.filter(pool => pool.name !== 'leader').reduce((acc, pool) => acc + pool.expected_count, 0) + + const selectedPoolOption = ( + await this.prompt<{pool: string}>({ + choices: await this.renderPoolChoices(pools), + message: 'Select the pool to update, or add a follower pool:', + name: 'pool', + type: 'list', + }) + ).pool + process.stderr.write('\n') + this.selectedPoolOption = selectedPoolOption + this.pool = pools.find(pool => pool.name === this.selectedPoolOption) + } + + private renderDatabaseChoices(databases: Array<{attachment_names?: string[]} & pg.ExtendedAddonAttachment['addon']>) { + const choices: Array>> = [] + + databases.forEach(database => { + choices.push( + {name: `${database.name} (${database.attachment_names?.join(', ')})`, value: database.name}, + ) + }) + choices.push( + new Separator(), + {name: 'Exit', value: '__exit'}, + ) + return choices + } + + private async renderPoolChoices(pools: PoolInfoResponse[]): Promise>>> { + const leaderPool = pools.find(pool => pool.name === 'leader') + const followerPools = pools.filter(pool => pool.name !== 'leader').sort((a, b) => a.name.localeCompare(b.name)) + const choices: Array>> = [] + + if (leaderPool) { + const levelInfo = this.extendedLevelsInfo!.find(level => level.name === leaderPool.expected_level) + choices.push({ + name: `Leader: ${levelInfo!.name}` + + ` ${`${levelInfo!.vcpu} ${color.inverse('vCPU')}`}` + + ` ${`${levelInfo!.memory_in_gb} GB ${color.inverse('MEM')}`}` + + color.green( + ` ${leaderPool.expected_count} instance${leaderPool.expected_count === 1 ? '' : 's'}` + + ` ${`starting at ${renderPricingInfo(levelInfo!.pricing)}`}` + + `${leaderPool.expected_count === 1 ? '' : ' each'}`, + ), + value: leaderPool.name, + }) + } + + followerPools.forEach(pool => { + const levelInfo = this.extendedLevelsInfo!.find(level => level.name === pool.expected_level) + choices.push({ + name: `Follower ${color.bold(pool.name)}: ${levelInfo!.name}` + + ` ${`${levelInfo!.vcpu} ${color.inverse('vCPU')}`}` + + ` ${`${levelInfo!.memory_in_gb} GB ${color.inverse('MEM')}`}` + + color.green( + ` ${pool.expected_count} instance${pool.expected_count === 1 ? '' : 's'}` + + ` ${`starting at ${renderPricingInfo(levelInfo!.pricing)}`}` + + `${pool.expected_count === 1 ? '' : ' each'}`, + ), + value: pool.name, + }) + }) + + choices.push( + new Separator(), + {name: 'Add a follower pool', value: '__add_follower_pool'}, + {name: 'Exit', value: '__exit'}, + ) + + return choices + } +} diff --git a/test/unit/commands/data/pg/update.unit.test.ts b/test/unit/commands/data/pg/update.unit.test.ts new file mode 100644 index 0000000000..069b709c55 --- /dev/null +++ b/test/unit/commands/data/pg/update.unit.test.ts @@ -0,0 +1,545 @@ +/* eslint-disable import/no-named-as-default-member */ +import ansis from 'ansis' +import {expect} from 'chai' +import inquirer from 'inquirer' +import mockStdin from 'mock-stdin' +import nock from 'nock' +import sinon from 'sinon' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgUpdate from '../../../../../src/commands/data/pg/update.js' +import PoolConfig from '../../../../../src/lib/data/poolConfig.js' +import {clearLevelsAndPricingCache} from '../../../../../src/lib/data/utils.js' +import { + addon, + advancedAddonAttachment, + createPoolResponse, + levelsResponse, + nonAdvancedAddon, + nonAdvancedAddonAttachment, + nonPostgresAddonAttachment, + pgInfo, + pricingResponse, +} from '../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default +const {prompt} = inquirer + +describe('data:pg:update', function () { + let stdin: mockStdin.MockSTDIN + let mockedStdinInput: string[] = [] + let poolConfigLevelStepStub: sinon.SinonStub + let poolConfigInstanceCountStepStub: sinon.SinonStub + let poolConfigFollowerInteractiveConfigStub: sinon.SinonStub + + beforeEach(function () { + // Create stubs for PoolConfig methods + stdin = mockStdin.stdin() + poolConfigLevelStepStub = sinon.stub(PoolConfig.prototype, 'levelStep') + poolConfigInstanceCountStepStub = sinon.stub(PoolConfig.prototype, 'instanceCountStep') + poolConfigFollowerInteractiveConfigStub = sinon.stub(PoolConfig.prototype, 'followerInteractiveConfig') + sinon.stub(DataPgUpdate.prototype, 'prompt').callsFake(async (...args: Parameters) => { + process.nextTick(() => { + const input = mockedStdinInput.shift() + if (input) { + stdin.send(input) + } else { + stdin.end() + } + }) + return prompt(...args) + }) + }) + + afterEach(function () { + clearLevelsAndPricingCache() + sinon.restore() + stdin.restore() + }) + + describe('interactive database selection (no DATABASE argument)', function () { + it('allows the user to select a database from a list of Advanced-tier databases only', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + advancedAddonAttachment, + nonAdvancedAddonAttachment, + nonPostgresAddonAttachment, + ]) + + // Simulate the user selecting the 'Exit' option by pressing the up arrow and then Enter + mockedStdinInput = ['\u001B[A\n'] + + await runCommand(DataPgUpdate, ['--app=myapp']) + + herokuApi.done() + expect(stdout.output).to.contain('advanced-horizontal-01234 (DATABASE)') + expect(stdout.output).not.to.contain('standard-database (STANDARD_DATABASE)') + expect(stdout.output).not.to.contain('redis-database (REDIS)') + }) + + it('errors out when no Advanced-tier databases are found', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + nonAdvancedAddonAttachment, + nonPostgresAddonAttachment, + ]) + + try { + await runCommand(DataPgUpdate, ['--app=myapp']) + } catch (error: unknown) { + const err = error as Error + herokuApi.done() + expect(err.message).to.equal('No Heroku Postgres Advanced-tier databases found on the app.') + } + }) + }) + + describe('non-interactive database selection (DATABASE argument provided)', function () { + it('shows the pool list selection prompt', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + + // Simulate the user selecting the 'Exit' option by pressing the up arrow and then Enter + mockedStdinInput = ['\u001B[A\n'] + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(stdout.output).to.contain('Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + expect(stdout.output).to.contain('Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + expect(stdout.output).to.contain('Add a follower pool') + expect(stdout.output).to.contain('Exit') + }) + + it('errors out when the database isn\'t an Advanced-tier one', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(DataPgUpdate, ['STANDARD_DATABASE', '--app=myapp']) + } catch (error: unknown) { + const err = error as Error + herokuApi.done() + expect(ansis.strip(err.message)).to.equal(heredoc` + You can only use this command on Advanced-tier databases. + Use heroku addons:upgrade standard-database -a myapp instead.`, + ) + } + }) + }) + + describe('leader pool actions', function () { + it('allows the user to change the leader pool level', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + .patch( + `/data/postgres/v1/${addon.id}/pools/${pgInfo.pools[0].id}`, + {level: levelsResponse.items[1].name}, + ) + .reply(200, { + ...pgInfo.pools[0], + expected_level: '8G-Performance', + status: 'modifying', + }) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + { + ...pgInfo.pools[0], + expected_level: '8G-Performance', + status: 'modifying', + }, + pgInfo.pools[1], + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\n', // Pool selection prompt: selects 'Leader' pool + '\n', // Pool action prompt: selects 'Change pool level' + // 8G-Performance level selected and applied, returns to the action prompt + '\u001B[A\n', // Pool action prompt: selects 'Go back' + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + // Simulate the user selecting the 8G-Performance level on the level selection prompt + poolConfigLevelStepStub.resolves(levelsResponse.items[1].name) + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain('Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + expect(ansis.strip(stdout.output)).to.contain('Success: Level changed from 4G-Performance to 8G-Performance for leader pool.') + expect(ansis.strip(stdout.output)).to.contain('Leader: 8G-Performance 4 vCPU 8 GB MEM 2 instances starting at ~$0.278/hour ($200/month) each') + }) + + it('allows the user to remove the high availability (HA) standby instance from the leader pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + .patch( + `/data/postgres/v1/${addon.id}/pools/${pgInfo.pools[0].id}`, + {count: 1}, + ) + .reply(200, { + ...pgInfo.pools[0], + expected_count: 1, + status: 'modifying', + }) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + { + ...pgInfo.pools[0], + expected_count: 1, + status: 'modifying', + }, + pgInfo.pools[1], + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\n', // Pool selection prompt: selects 'Leader' pool + '\u001B[B\n', // Pool action prompt: selects 'Remove high availability' + // HA removed, returns to the action prompt + '\u001B[A\n', // Pool action prompt: selects 'Go back' + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain('Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + expect(ansis.strip(stderr.output)).to.contain('Removing the high availability (HA) standby instance from advanced-horizontal-01234... done') + expect(ansis.strip(stdout.output)).to.contain('Leader: 4G-Performance 2 vCPU 4 GB MEM 1 instance starting at ~$0.083/hour ($60/month)') + }) + + it('allows the user to add a high availability (HA) standby instance to the leader pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + { + ...pgInfo.pools[0], + compute_instances: [ + pgInfo.pools[0].compute_instances[0], + ], + expected_count: 1, + }, + pgInfo.pools[1], + ], + }) + .patch( + `/data/postgres/v1/${addon.id}/pools/${pgInfo.pools[0].id}`, + {count: 2}, + ) + .reply(200, { + ...pgInfo.pools[0], + expected_count: 2, + status: 'modifying', + }) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + { + ...pgInfo.pools[0], + expected_count: 2, + status: 'modifying', + }, + pgInfo.pools[1], + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\n', // Pool selection prompt: selects 'Leader' pool + '\u001B[B\n', // Pool action prompt: selects 'Add high availability' + // HA added, returns to the action prompt + '\u001B[A\n', // Pool action prompt: selects 'Go back' + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain('Leader: 4G-Performance 2 vCPU 4 GB MEM 1 instance starting at ~$0.083/hour ($60/month)') + expect(ansis.strip(stderr.output)).to.contain('Adding a high availability (HA) standby instance for advanced-horizontal-01234... done') + expect(ansis.strip(stdout.output)).to.contain('Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + }) + }) + + describe('follower pool actions', function () { + it('allows the user to change the follower pool level', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + .patch( + `/data/postgres/v1/${addon.id}/pools/${pgInfo.pools[1].id}`, + {level: levelsResponse.items[1].name}, + ) + .reply(200, { + ...pgInfo.pools[1], + expected_level: '8G-Performance', + status: 'modifying', + }) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + pgInfo.pools[0], + { + ...pgInfo.pools[1], + expected_level: '8G-Performance', + status: 'modifying', + }, + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\u001B[B\n', // Pool selection prompt: selects 'Follower analytics' pool + '\n', // Pool action prompt: selects 'Change pool level' + // 8G-Performance level selected and applied, returns to the action prompt + '\u001B[A\n', // Pool action prompt: selects 'Go back' + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + // Simulate the user selecting the 8G-Performance level on the level selection prompt + poolConfigLevelStepStub.resolves(levelsResponse.items[1].name) + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain('Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + expect(ansis.strip(stdout.output)).to.contain('Success: Level changed from 4G-Performance to 8G-Performance for follower pool analytics.') + expect(ansis.strip(stdout.output)).to.contain('Follower analytics: 8G-Performance 4 vCPU 8 GB MEM 2 instances starting at ~$0.278/hour ($200/month) each') + }) + + it('allows the user to update the number of instances in the follower pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + .patch( + `/data/postgres/v1/${addon.id}/pools/${pgInfo.pools[1].id}`, + {count: 1}, + ) + .reply(200, { + ...pgInfo.pools[1], + expected_count: 1, + status: 'modifying', + }) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + pgInfo.pools[0], + { + ...pgInfo.pools[1], + expected_count: 1, + status: 'modifying', + }, + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\u001B[B\n', // Pool selection prompt: selects 'Follower analytics' pool + '\u001B[B\n', // Pool action prompt: selects 'Update number of instances' + // 1 instance selected and applied, returns to the action prompt + '\u001B[A\n', // Pool action prompt: selects 'Go back' + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + // Simulate the user selecting the 1 instance on the instance count selection prompt + poolConfigInstanceCountStepStub.resolves('1') + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain('Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each') + expect(ansis.strip(stderr.output)).to.contain('Updating follower pool instances count... done') + expect(ansis.strip(stdout.output)).to.contain('Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 1 instance starting at ~$0.083/hour ($60/month)') + }) + + it('allows the user to destroy the follower pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + .delete( + `/data/postgres/v1/${addon.id}/pools/${pgInfo.pools[1].id}`, + ) + .reply(204) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + pgInfo.pools[0], + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\u001B[B\n', // Pool selection prompt: selects 'Follower analytics' pool + '\u001B[B\u001B[B\n', // Pool action prompt: selects 'Destroy pool' + // Pool destroyed, returns to the action prompt + '\u001B[A\n', // Pool action prompt: selects 'Go back' + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + // Simulate the user selecting the 1 instance on the instance count selection prompt + poolConfigInstanceCountStepStub.resolves('1') + + // Simulate the user confirming the pool destruction + sinon.stub(DataPgUpdate.prototype, 'confirmCommand').resolves() + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain(heredoc` + Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + `) + expect(ansis.strip(stderr.output)).to.contain('Destroying follower pool analytics on ⛁ advanced-horizontal-01234... done') + expect(ansis.strip(stdout.output)).to.contain(heredoc` + Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + ────────────── + `) + }) + }) + + describe('adding another follower pool', function () { + it('allows the user to configure a new follower pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + + const dataApi = nock('https://test.data.heroku.com') + .get('/data/postgres/v1/levels/advanced') + .reply(200, levelsResponse) + .get('/data/postgres/v1/pricing') + .reply(200, pricingResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, pgInfo) + .post(`/data/postgres/v1/${addon.id}/pools`, { + count: 2, + level: levelsResponse.items[0].name, + name: 'readers', + }) + .reply(200, createPoolResponse) + .get(`/data/postgres/v1/${addon.id}/info`) + .reply(200, { + ...pgInfo, + pools: [ + ...pgInfo.pools, + createPoolResponse, + ], + }) + + // Simulate the user selections + mockedStdinInput = [ + '\u001B[B\u001B[B\n', // Pool selection prompt: selects 'Add a follower pool' + // A new follower pool is configured, returns to the pool selection prompt + '\u001B[A\n', // Pool selection prompt: selects 'Exit' + ] + + // Simulate the user configuring a 4G-Performance 'readers' follower pool with 2 instances + poolConfigFollowerInteractiveConfigStub.resolves({ + count: 2, + level: levelsResponse.items[0].name, + name: 'readers', + }) + + await runCommand(DataPgUpdate, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout.output)).to.contain(heredoc` + Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + ────────────── + `) + expect(ansis.strip(stderr.output)).to.contain('Configuring follower pool... done') + expect(ansis.strip(stdout.output)).to.contain(heredoc` + Leader: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + Follower analytics: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + Follower readers: 4G-Performance 2 vCPU 4 GB MEM 2 instances starting at ~$0.083/hour ($60/month) each + ────────────── + `) + }) + }) +}) From 372648e92204b1b21f9020018f58209393e01d75 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Thu, 5 Feb 2026 18:18:36 -0300 Subject: [PATCH 09/11] Normalizing HEROKU_DATA_HOST to the default value on tests --- test/helpers/init.mjs | 14 ++++++------- .../unit/commands/data/pg/create.unit.test.ts | 20 +++++++++---------- test/unit/commands/data/pg/fork.unit.test.ts | 12 +++++------ .../commands/data/pg/settings.unit.test.ts | 6 +++--- .../unit/commands/data/pg/update.unit.test.ts | 16 +++++++-------- test/unit/lib/data/baseCommand.unit.test.ts | 6 +++--- 6 files changed, 36 insertions(+), 38 deletions(-) diff --git a/test/helpers/init.mjs b/test/helpers/init.mjs index 8f8eb9b706..ac35abb911 100644 --- a/test/helpers/init.mjs +++ b/test/helpers/init.mjs @@ -1,14 +1,13 @@ -import path from 'path' -import {fileURLToPath} from 'url' -import nock from 'nock' +/* eslint-disable import/no-named-as-default-member */ import chai from 'chai' import chaiAsPromised from 'chai-as-promised' +import nock from 'nock' +import path from 'path' +import {fileURLToPath} from 'url' -globalThis.setInterval = () => ({unref: () => {}}) +globalThis.setInterval = () => ({unref() {}}) const tm = globalThis.setTimeout -globalThis.setTimeout = cb => { - return tm(cb) -} +globalThis.setTimeout = cb => tm(cb) const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '../..') @@ -21,7 +20,6 @@ process.env.IS_HEROKU_TEST_ENV = 'true' process.env.HEROKU_SKIP_NEW_VERSION_CHECK = 'true' -process.env.HEROKU_DATA_HOST = 'test.data.heroku.com' process.env.HEROKU_DATA_CONTROL_PLANE = 'test-control-plane' nock.disableNetConnect() diff --git a/test/unit/commands/data/pg/create.unit.test.ts b/test/unit/commands/data/pg/create.unit.test.ts index 512ac4e5bf..376cacac62 100644 --- a/test/unit/commands/data/pg/create.unit.test.ts +++ b/test/unit/commands/data/pg/create.unit.test.ts @@ -76,7 +76,7 @@ describe('data:pg:create', function () { plan: {name: 'heroku-postgresql:advanced-beta'}, }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .post(`/data/postgres/v1/${createAddonResponse.id}/pools`, { count: 2, level: '4G-Performance', @@ -232,7 +232,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -317,7 +317,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -356,7 +356,7 @@ describe('data:pg:create', function () { && body.config.foo === 'true') .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -391,7 +391,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -432,7 +432,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -476,7 +476,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -519,7 +519,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -575,7 +575,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -653,7 +653,7 @@ describe('data:pg:create', function () { }) .reply(200, createAddonResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') diff --git a/test/unit/commands/data/pg/fork.unit.test.ts b/test/unit/commands/data/pg/fork.unit.test.ts index 6c3190cd92..69ca714492 100644 --- a/test/unit/commands/data/pg/fork.unit.test.ts +++ b/test/unit/commands/data/pg/fork.unit.test.ts @@ -49,7 +49,7 @@ describe('data:pg:fork', function () { }) .reply(200, createForkResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/info`) .reply(200, pgInfo) @@ -87,7 +87,7 @@ describe('data:pg:fork', function () { }) .reply(200, createForkResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/info`) .reply(200, pgInfo) @@ -157,7 +157,7 @@ describe('data:pg:fork', function () { && body.config.key === 'value:with:colons') .reply(200, createForkResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/info`) .reply(200, pgInfo) @@ -193,7 +193,7 @@ describe('data:pg:fork', function () { }) .reply(200, createForkResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/info`) .reply(200, { ...pgInfo, @@ -238,7 +238,7 @@ describe('data:pg:fork', function () { }) .reply(200, createForkResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/info`) .reply(200, { ...pgInfo, @@ -280,7 +280,7 @@ describe('data:pg:fork', function () { && body.config.baz === 'true') .reply(200, createForkResponse) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/info`) .reply(200, { ...pgInfo, diff --git a/test/unit/commands/data/pg/settings.unit.test.ts b/test/unit/commands/data/pg/settings.unit.test.ts index 9c148bf4f8..2a2ca6088c 100644 --- a/test/unit/commands/data/pg/settings.unit.test.ts +++ b/test/unit/commands/data/pg/settings.unit.test.ts @@ -40,7 +40,7 @@ describe('data:pg:settings', function () { context('put', function () { it('shows no changes applied when no changes received', async function () { const herokuApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .put(`/data/postgres/v1/${addon.id}/settings`) .reply(200, emptySettingsChangeResponse) @@ -58,7 +58,7 @@ describe('data:pg:settings', function () { it('shows received changes', async function () { const herokuApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .put( `/data/postgres/v1/${addon.id}/settings`, {settings: 'log_min_duration_statement:500,idle_in_transaction_session_timeout:864000'}, @@ -92,7 +92,7 @@ describe('data:pg:settings', function () { context('get', function () { it('shows settings', async function () { const herokuApi = nock('https://api.heroku.com').post('/actions/addons/resolve').reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/settings`) .reply(200, settingsGetResponse) diff --git a/test/unit/commands/data/pg/update.unit.test.ts b/test/unit/commands/data/pg/update.unit.test.ts index 069b709c55..49d4bae73d 100644 --- a/test/unit/commands/data/pg/update.unit.test.ts +++ b/test/unit/commands/data/pg/update.unit.test.ts @@ -104,7 +104,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -149,7 +149,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -204,7 +204,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -256,7 +256,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -322,7 +322,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -377,7 +377,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -432,7 +432,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') @@ -488,7 +488,7 @@ describe('data:pg:update', function () { .post('/actions/addons/resolve') .reply(200, [addon]) - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, levelsResponse) .get('/data/postgres/v1/pricing') diff --git a/test/unit/lib/data/baseCommand.unit.test.ts b/test/unit/lib/data/baseCommand.unit.test.ts index 300aeefe11..2fc99340fc 100644 --- a/test/unit/lib/data/baseCommand.unit.test.ts +++ b/test/unit/lib/data/baseCommand.unit.test.ts @@ -16,7 +16,7 @@ describe('BaseCommand', function () { beforeEach(function () { originalEnv = {...process.env} - process.env.HEROKU_DATA_HOST = 'test.data.heroku.com' + process.env.HEROKU_DATA_HOST = 'api.data.heroku.com' process.env.HEROKU_DATA_CONTROL_PLANE = 'test-control-plane' }) @@ -26,7 +26,7 @@ describe('BaseCommand', function () { context('get dataApi', function () { it('respects the value of HEROKU_DATA_HOST', async function () { - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .reply(200, []) @@ -38,7 +38,7 @@ describe('BaseCommand', function () { }) it('respects the value of HEROKU_DATA_CONTROL_PLANE', async function () { - const dataApi = nock('https://test.data.heroku.com') + const dataApi = nock('https://api.data.heroku.com') .get('/data/postgres/v1/levels/advanced') .matchHeader('X-Data-Control-Plane', 'test-control-plane') .reply(200, []) From 03b39a59e6c25fbc7216164b6b72f7a3e897148a Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Thu, 5 Feb 2026 18:41:00 -0300 Subject: [PATCH 10/11] Fixing test with problems on table output checks --- .../commands/data/pg/settings.unit.test.ts | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/test/unit/commands/data/pg/settings.unit.test.ts b/test/unit/commands/data/pg/settings.unit.test.ts index 2a2ca6088c..9b1bbf208b 100644 --- a/test/unit/commands/data/pg/settings.unit.test.ts +++ b/test/unit/commands/data/pg/settings.unit.test.ts @@ -12,10 +12,11 @@ import { settingsPutResponse, } from '../../../../fixtures/data/pg/fixtures.js' import runCommand from '../../../../helpers/runCommand.js' +import removeAllWhitespace from '../../../../helpers/utils/remove-whitespaces.js' const heredoc = tsheredoc.default -describe('data:pg:settings', function () { +describe.only('data:pg:settings', function () { it('exits with error if it isn\'t a Advanced-tier database', async function () { const herokuApi = nock('https://api.heroku.com') .post('/actions/addons/resolve') @@ -75,17 +76,12 @@ describe('data:pg:settings', function () { herokuApi.done() dataApi.done() expect(stderr.output).to.equal('') - expect(stdout.output).to.equal( - heredoc` - Updating these settings... - Settings From To - ────────────────────────────────────────────────────── - log_min_duration_statement 550 500 - idle_in_transaction_session_timeout 80000 864000 - Updating your database advanced-horizontal-01234 shortly. You can use data:pg:info advanced-horizontal-01234 -a myapp to track progress - `, - ) + const actual = removeAllWhitespace(stdout.output) + expect(actual).to.include(removeAllWhitespace('Updating these settings...')) + expect(actual).to.include(removeAllWhitespace('log_min_duration_statement 550 500')) + expect(actual).to.include(removeAllWhitespace('idle_in_transaction_session_timeout 80000 864000')) + expect(actual).to.include(removeAllWhitespace('Updating your database advanced-horizontal-01234 shortly. You can use data:pg:info advanced-horizontal-01234 -a myapp to track progress')) }) }) @@ -105,27 +101,22 @@ describe('data:pg:settings', function () { dataApi.done() expect(stderr.output).to.equal('') - expect(stdout.output).to.equal( - heredoc` - === advanced-horizontal-01234 - Setting Value - ──────────────────────────────────────────── - log_connections true - log_lock_waits true - log_min_duration_statement 500 - log_min_error_statement info - log_statement ddl - track_functions pl - auto_explain.log_analyze - auto_explain.log_buffers - auto_explain.log_format - auto_explain.log_min_duration - auto_explain.log_nested_statements - auto_explain.log_triggers - auto_explain.log_verbose - - `, - ) + const actual = removeAllWhitespace(stdout.output) + expect(actual).to.include(removeAllWhitespace('=== advanced-horizontal-01234')) + expect(actual).to.include(removeAllWhitespace('Setting Value')) + expect(actual).to.include(removeAllWhitespace('log_connections true')) + expect(actual).to.include(removeAllWhitespace('log_lock_waits true')) + expect(actual).to.include(removeAllWhitespace('log_min_duration_statement 500')) + expect(actual).to.include(removeAllWhitespace('log_min_error_statement info')) + expect(actual).to.include(removeAllWhitespace('log_statement ddl')) + expect(actual).to.include(removeAllWhitespace('track_functions pl')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_analyze')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_buffers')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_format')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_min_duration')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_nested_statements')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_triggers')) + expect(actual).to.include(removeAllWhitespace('auto_explain.log_verbose')) }) }) }) From a348988399c8ff415ac4115b86615868cc20a680 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Thu, 5 Feb 2026 18:45:38 -0300 Subject: [PATCH 11/11] Configuring terminal size on tests to avoid line breaks in output checks --- test/helpers/init.mjs | 8 ++++++++ test/unit/commands/data/pg/settings.unit.test.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/helpers/init.mjs b/test/helpers/init.mjs index ac35abb911..f756d55051 100644 --- a/test/helpers/init.mjs +++ b/test/helpers/init.mjs @@ -22,6 +22,14 @@ process.env.HEROKU_SKIP_NEW_VERSION_CHECK = 'true' process.env.HEROKU_DATA_CONTROL_PLANE = 'test-control-plane' +// Set terminal size for tests to 200x50 +process.env.COLUMNS = '200' +process.env.LINES = '50' +process.stdout.columns = 200 +process.stdout.rows = 50 +process.stderr.columns = 200 +process.stderr.rows = 50 + nock.disableNetConnect() if (process.env.ENABLE_NET_CONNECT === 'true') { nock.enableNetConnect() diff --git a/test/unit/commands/data/pg/settings.unit.test.ts b/test/unit/commands/data/pg/settings.unit.test.ts index 9b1bbf208b..8a9365dd88 100644 --- a/test/unit/commands/data/pg/settings.unit.test.ts +++ b/test/unit/commands/data/pg/settings.unit.test.ts @@ -16,7 +16,7 @@ import removeAllWhitespace from '../../../../helpers/utils/remove-whitespaces.js const heredoc = tsheredoc.default -describe.only('data:pg:settings', function () { +describe('data:pg:settings', function () { it('exits with error if it isn\'t a Advanced-tier database', async function () { const herokuApi = nock('https://api.heroku.com') .post('/actions/addons/resolve')