diff --git a/package-lock.json b/package-lock.json index 5bf7ec97b5..595d2fe8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@heroku/heroku-fetch": "^0.1.1-beta", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.5", - "@heroku/sdk": "^0.6.4", + "@heroku/sdk": "^0.7.0", "@heroku/socksv5": "^0.0.9", "@heroku/types": "^5.2.0", "@inquirer/prompts": "^8.7", @@ -3457,9 +3457,9 @@ } }, "node_modules/@heroku/sdk": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@heroku/sdk/-/sdk-0.6.4.tgz", - "integrity": "sha512-1y7zfPPRI6tS4VsLdBOC6YlnY5jtdaaQRg4e1reisnCDvhMQCuLYUmXeRDXl0BiX99Et9ZBal+hF4eG2nhD+jQ==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@heroku/sdk/-/sdk-0.7.0.tgz", + "integrity": "sha512-nN4F9dxMzMd+aZMBsaiW4SnCchYDdFNPKyaqBhxvSHTww6hFq6X+6ywxDPR1WqpSHh8aNlrFsDR1m6bKwTwlYA==", "license": "Apache-2.0", "dependencies": { "@heroku/heroku-fetch": "^0.1.4", diff --git a/package.json b/package.json index f6cb5d80dc..319f0a59f7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@heroku/heroku-fetch": "^0.1.1-beta", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.5", - "@heroku/sdk": "^0.6.4", + "@heroku/sdk": "^0.7.0", "@heroku/socksv5": "^0.0.9", "@heroku/types": "^5.2.0", "@inquirer/prompts": "^8.7", diff --git a/src/commands/pg/backups/cancel.ts b/src/commands/pg/backups/cancel.ts index a376ead69f..c07a627a7e 100644 --- a/src/commands/pg/backups/cancel.ts +++ b/src/commands/pg/backups/cancel.ts @@ -1,9 +1,8 @@ import {Command, flags} from '@heroku-cli/command' -import {utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' -import backupsFactory from '../../../lib/pg/backups.js' -import {BackupTransfer} from '../../../lib/pg/types.js' +import * as pgBackups from '../../../lib/pg/backups.js' export default class Cancel extends Command { static args = { @@ -20,27 +19,27 @@ export default class Cancel extends Command { const {args, flags} = await this.parse(Cancel) const {app} = flags const {backup_id} = args - const pgbackups = backupsFactory(app, this.heroku) + const {data} = new HerokuSDK() - let transfer: BackupTransfer | undefined + let transfer if (backup_id) { - const num = await pgbackups.num(backup_id) + const num = await pgBackups.num(backup_id, app, data) if (!num) { ux.error(`Invalid Backup: ${backup_id}`) } - ({body: transfer} = await this.heroku.get(`/client/v11/apps/${app}/transfers/${num}`, {hostname: utils.pg.host()})) + transfer = await data.transfer.infoByApp(app, String(num), {}) } else { - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + const transfers = await data.transfer.listByApp(app) transfer = transfers .sort((a, b) => b.created_at.localeCompare(a.created_at)) .find(t => !t.finished_at) } if (transfer) { - ux.action.start(`Cancelling ${pgbackups.name(transfer)}`) - await this.heroku.post(`/client/v11/apps/${app}/transfers/${transfer.uuid}/actions/cancel`, {hostname: utils.pg.host()}) + ux.action.start(`Cancelling ${pgBackups.name(transfer)}`) + await data.transfer.cancel(app, transfer.uuid) ux.action.stop() } else { ux.error('No active backups/transfers') diff --git a/src/commands/pg/backups/delete.ts b/src/commands/pg/backups/delete.ts index e2c239e6db..703edc8936 100644 --- a/src/commands/pg/backups/delete.ts +++ b/src/commands/pg/backups/delete.ts @@ -1,9 +1,10 @@ import {Command, flags} from '@heroku-cli/command' -import {color, utils} from '@heroku/heroku-cli-util' +import {color} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import ConfirmCommand from '../../../lib/confirm-command.js' -import backupsFactory from '../../../lib/pg/backups.js' +import * as pgBackups from '../../../lib/pg/backups.js' export default class Delete extends Command { static args = { @@ -24,17 +25,17 @@ export default class Delete extends Command { const {args, flags} = await this.parse(Delete) const {app, confirm} = flags const {backup_id} = args - const pgbackups = backupsFactory(app, this.heroku) + const {data} = new HerokuSDK() await new ConfirmCommand().confirm(app, confirm) ux.action.start(`Deleting backup ${color.cyan(backup_id)} on ${color.app(app)}`) - const num = await pgbackups.num(backup_id) + const num = await pgBackups.num(backup_id, app, data) if (!num) { throw new Error(`Invalid Backup: ${backup_id}`) } - await this.heroku.delete(`/client/v11/apps/${app}/transfers/${num}`, {hostname: utils.pg.host()}) + await data.transfer.deleteByApp(app, String(num)) ux.action.stop() } } diff --git a/src/commands/pg/backups/download.ts b/src/commands/pg/backups/download.ts index 2c88bd172a..86a9e9c7fb 100644 --- a/src/commands/pg/backups/download.ts +++ b/src/commands/pg/backups/download.ts @@ -1,11 +1,10 @@ import {Command, flags} from '@heroku-cli/command' -import {color, utils} from '@heroku/heroku-cli-util' +import {color} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import fs from 'fs-extra' -import type {BackupTransfer, PublicUrlResponse} from '../../../lib/pg/types.js' - -import pgBackupsApi from '../../../lib/pg/backups.js' +import * as pgBackups from '../../../lib/pg/backups.js' import download from '../../../lib/pg/download.js' export default class Download extends Command { @@ -25,14 +24,15 @@ export default class Download extends Command { const {backup_id} = args const {app} = flags const output = flags.output || defaultFilename() + const {data} = new HerokuSDK() let num ux.action.start(`Getting backup from ${color.app(app)}`) if (backup_id) { - num = await pgBackupsApi(app, this.heroku).num(backup_id) + num = await pgBackups.num(backup_id, app, data) if (!num) throw new Error(`Invalid Backup: ${backup_id}`) } else { - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + const transfers = await data.transfer.listByApp(app) const lastBackup = transfers .filter(t => t.succeeded && t.to_type === 'gof3r') .sort((a, b) => b.created_at.localeCompare(a.created_at))[0] @@ -42,7 +42,7 @@ export default class Download extends Command { } ux.action.status = `fetching url of #${num}` - const {body: info} = await this.heroku.post(`/client/v11/apps/${app}/transfers/${num}/actions/public-url`, {hostname: utils.pg.host()}) + const info = await data.transfer.publicUrl(app, String(num), {}) ux.action.stop(`done, #${num}`) await download(info.url, output, {progress: true}) diff --git a/src/commands/pg/backups/index.ts b/src/commands/pg/backups/index.ts index f4d4982b75..4cf7d8a5a7 100644 --- a/src/commands/pg/backups/index.ts +++ b/src/commands/pg/backups/index.ts @@ -1,12 +1,15 @@ +import type {TransferInfoByAppResult, TransferListByAppResult} from '@heroku/types/data' + import {Command, flags} from '@heroku-cli/command' -import {color, hux, utils} from '@heroku/heroku-cli-util' +import {color, hux} from '@heroku/heroku-cli-util' import {HerokuSDK} from '@heroku/sdk' import {transferExtensions} from '@heroku/sdk/extensions/data' import {ux} from '@oclif/core/ux' -import type {BackupTransfer} from '../../../lib/pg/types.js' +import * as pgBackups from '../../../lib/pg/backups.js' -import backupsFactory from '../../../lib/pg/backups.js' +// hux.table needs Record; the SDK's interface-based TransferInfoByAppResult has no implicit index signature. +type TransferRow = Record & TransferInfoByAppResult export default class Index extends Command { static description = 'list database backups' @@ -27,38 +30,37 @@ export default class Index extends Command { const {flags: {app}} = await this.parse(Index) const {data} = new HerokuSDK({extensions: [transferExtensions]}) - const transfers = await data.transfer.listByApp(app) as BackupTransfer[] + const transfers = await data.transfer.listByApp(app) // NOTE that the sort order is descending transfers.sort((transferA, transferB) => transferB.created_at.localeCompare(transferA.created_at)) - this.displayBackups(transfers, app) - this.displayRestores(transfers, app) - this.displayCopies(transfers, app) + this.displayBackups(transfers) + this.displayRestores(transfers) + this.displayCopies(transfers) } - private displayBackups(transfers: BackupTransfer[], app: string) { + private displayBackups(transfers: TransferListByAppResult) { const backups = transfers.filter(backupTransfer => backupTransfer.from_type === 'pg_dump' && backupTransfer.to_type === 'gof3r') - const pgbackups = backupsFactory(app, this.heroku) hux.styledHeader('Backups') if (backups.length === 0) { ux.stdout(`No backups. Capture one with ${color.code('heroku pg:backups:capture')}`) } else { /* eslint-disable perfectionist/sort-objects */ - hux.table(backups, { + hux.table(backups as TransferRow[], { ID: { - get: (transfer: BackupTransfer) => color.name(pgbackups.name(transfer)), + get: (transfer: TransferInfoByAppResult) => color.name(pgBackups.name(transfer)), }, 'Created at': { - get: (transfer: BackupTransfer) => transfer.created_at, + get: (transfer: TransferInfoByAppResult) => transfer.created_at, }, Status: { - get: (transfer: BackupTransfer) => pgbackups.status(transfer), + get: (transfer: TransferInfoByAppResult) => pgBackups.status(transfer), }, Size: { - get: (transfer: BackupTransfer) => pgbackups.filesize(transfer.processed_bytes), + get: (transfer: TransferInfoByAppResult) => pgBackups.filesize(transfer.processed_bytes), }, Database: { - get: (transfer: BackupTransfer) => color.datastore(transfer.from_name) || 'UNKNOWN', + get: (transfer: TransferInfoByAppResult) => transfer.from_name ? color.datastore(transfer.from_name) : 'UNKNOWN', }, }) /* eslint-enable perfectionist/sort-objects */ @@ -67,32 +69,31 @@ export default class Index extends Command { ux.stdout() } - private displayCopies(transfers: BackupTransfer[], app: string) { - const pgbackups = backupsFactory(app, this.heroku) + private displayCopies(transfers: TransferListByAppResult) { const copies = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'pg_restore').slice(0, 10) hux.styledHeader('Copies') if (copies.length === 0) { ux.stdout(`No copies found. Use ${color.code('heroku pg:copy')} to copy a database to another`) } else { /* eslint-disable perfectionist/sort-objects */ - hux.table(copies, { + hux.table(copies as TransferRow[], { ID: { - get: (transfer: BackupTransfer) => color.name(pgbackups.name(transfer)), + get: (transfer: TransferInfoByAppResult) => color.name(pgBackups.name(transfer)), }, 'Started at': { - get: (transfer: BackupTransfer) => transfer.created_at, + get: (transfer: TransferInfoByAppResult) => transfer.created_at, }, Status: { - get: (transfer: BackupTransfer) => pgbackups.status(transfer), + get: (transfer: TransferInfoByAppResult) => pgBackups.status(transfer), }, Size: { - get: (transfer: BackupTransfer) => pgbackups.filesize(transfer.processed_bytes), + get: (transfer: TransferInfoByAppResult) => pgBackups.filesize(transfer.processed_bytes), }, From: { - get: (transfer: BackupTransfer) => color.datastore(transfer.from_name) || color.inactive('UNKNOWN'), + get: (transfer: TransferInfoByAppResult) => transfer.from_name ? color.datastore(transfer.from_name) : color.inactive('UNKNOWN'), }, To: { - get: (transfer: BackupTransfer) => color.datastore(transfer.to_name) || color.inactive('UNKNOWN'), + get: (transfer: TransferInfoByAppResult) => transfer.to_name ? color.datastore(transfer.to_name) : color.inactive('UNKNOWN'), }, }) } @@ -101,31 +102,30 @@ export default class Index extends Command { ux.stdout() } - private displayRestores(transfers: BackupTransfer[], app: string) { + private displayRestores(transfers: TransferListByAppResult) { const restores = transfers .filter(t => t.from_type !== 'pg_dump' && t.to_type === 'pg_restore') .slice(0, 10) // first 10 only - const pgbackups = backupsFactory(app, this.heroku) hux.styledHeader('Restores') if (restores.length === 0) { ux.stdout(`No restores found. Use ${color.code('heroku pg:backups:restore')} to restore a backup`) } else { /* eslint-disable perfectionist/sort-objects */ - hux.table(restores, { + hux.table(restores as TransferRow[], { ID: { - get: (transfer: BackupTransfer) => color.name(pgbackups.name(transfer)), + get: (transfer: TransferInfoByAppResult) => color.name(pgBackups.name(transfer)), }, 'Started at': { - get: (transfer: BackupTransfer) => transfer.created_at, + get: (transfer: TransferInfoByAppResult) => transfer.created_at, }, Status: { - get: (transfer: BackupTransfer) => pgbackups.status(transfer), + get: (transfer: TransferInfoByAppResult) => pgBackups.status(transfer), }, Size: { - get: (transfer: BackupTransfer) => pgbackups.filesize(transfer.processed_bytes), + get: (transfer: TransferInfoByAppResult) => pgBackups.filesize(transfer.processed_bytes), }, Database: { - get: (transfer: BackupTransfer) => color.datastore(transfer.to_name) || 'UNKNOWN', + get: (transfer: TransferInfoByAppResult) => transfer.to_name ? color.datastore(transfer.to_name) : 'UNKNOWN', }, }) /* eslint-enable perfectionist/sort-objects */ diff --git a/src/commands/pg/backups/info.ts b/src/commands/pg/backups/info.ts index a3cc70c64d..0601cf67b3 100644 --- a/src/commands/pg/backups/info.ts +++ b/src/commands/pg/backups/info.ts @@ -1,13 +1,15 @@ +import type {TransferInfoByAppResult} from '@heroku/types/data' import {Command, flags} from '@heroku-cli/command' -import {color, hux, utils} from '@heroku/heroku-cli-util' +import {color, hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' -import type {BackupTransfer} from '../../../lib/pg/types.js' +import * as pgBackups from '../../../lib/pg/backups.js' -import pgBackupsApi from '../../../lib/pg/backups.js' +type Data = HerokuSDK['data'] -function status(backup: BackupTransfer) { +function status(backup: TransferInfoByAppResult) { if (backup.succeeded) { if (backup.warnings > 0) return `Finished with ${backup.warnings} warnings` @@ -23,7 +25,9 @@ function status(backup: BackupTransfer) { return 'Pending' } -function compression(compressed: number, total: number) { +function compression(compressed: null | number, total: null | number) { + if (compressed === null || total === null) return '' + let pct = 0 if (compressed > 0) { pct = Math.round((total - compressed) / total * 100) @@ -43,36 +47,34 @@ export default class Info extends Command { remote: flags.remote(), } static topic = 'pg' - displayBackup = (backup: BackupTransfer, app: string) => { - const pgbackups = pgBackupsApi(app, this.heroku) - hux.styledHeader(`Backup ${color.name(pgbackups.name(backup))}`) + displayBackup = (backup: TransferInfoByAppResult) => { + hux.styledHeader(`Backup ${color.name(pgBackups.name(backup))}`) /* eslint-disable perfectionist/sort-objects */ hux.styledObject({ - Database: color.datastore(backup.from_name), + Database: backup.from_name ? color.datastore(backup.from_name) : 'UNKNOWN', 'Started at': backup.started_at, 'Finished at': backup.finished_at, Status: status(backup), - Type: backup.schedule ? 'Scheduled' : 'Manual', 'Original DB Size': pgbackups.filesize(backup.source_bytes), - 'Backup Size': `${pgbackups.filesize(backup.processed_bytes)}${backup.finished_at ? compression(backup.processed_bytes, backup.source_bytes) : ''}`, + Type: backup.schedule ? 'Scheduled' : 'Manual', 'Original DB Size': pgBackups.filesize(backup.source_bytes ?? null), + 'Backup Size': `${pgBackups.filesize(backup.processed_bytes)}${backup.finished_at ? compression(backup.processed_bytes, backup.source_bytes ?? null) : ''}`, }, ['Database', 'Started at', 'Finished at', 'Status', 'Type', 'Original DB Size', 'Backup Size']) /* eslint-enable perfectionist/sort-objects */ ux.stdout('\n') } - displayLogs = (backup: BackupTransfer) => { + displayLogs = (backup: TransferInfoByAppResult) => { hux.styledHeader('Backup Logs') - for (const log of backup.logs) + for (const log of backup.logs || []) ux.stdout(`${log.created_at} ${log.message}\n`) ux.stdout('\n') } - getBackup = async (id: string | undefined, app: string) => { + getBackup = async (id: string | undefined, app: string, data: Data) : Promise => { let backupID if (id) { - const pgbackups = pgBackupsApi(app, this.heroku) - backupID = await pgbackups.num(id) + backupID = await pgBackups.num(id, app, data) if (!backupID) throw new Error(`Invalid ID: ${id}`) } else { - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + const transfers = await data.transfer.listByApp(app) transfers.sort((a, b) => a.created_at.localeCompare(b.created_at)) const backups = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'gof3r') const lastBackup = backups.pop() @@ -81,7 +83,7 @@ export default class Info extends Command { backupID = lastBackup.num } - const {body: backup} = await this.heroku.get(`/client/v11/apps/${app}/transfers/${backupID}?verbose=true`, {hostname: utils.pg.host()}) + const backup = await data.transfer.infoByApp(app, String(backupID), {verbose: true}) return backup } @@ -89,9 +91,10 @@ export default class Info extends Command { const {args, flags} = await this.parse(Info) const {app} = flags const {backup_id} = args + const {data} = new HerokuSDK() - const backup = await this.getBackup(backup_id, app) - this.displayBackup(backup, app) + const backup = await this.getBackup(backup_id, app, data) + this.displayBackup(backup) this.displayLogs(backup) } } diff --git a/src/commands/pg/backups/url.ts b/src/commands/pg/backups/url.ts index e9fe3fe271..936b2a29a6 100644 --- a/src/commands/pg/backups/url.ts +++ b/src/commands/pg/backups/url.ts @@ -1,10 +1,9 @@ import {Command, flags} from '@heroku-cli/command' -import {color, utils} from '@heroku/heroku-cli-util' +import {color} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' -import type {BackupTransfer, PublicUrlResponse} from '../../../lib/pg/types.js' - -import pgBackupsApi from '../../../lib/pg/backups.js' +import * as pgBackups from '../../../lib/pg/backups.js' export default class Url extends Command { static args = { @@ -21,14 +20,15 @@ export default class Url extends Command { const {args, flags} = await this.parse(Url) const {backup_id} = args const {app} = flags + const {data} = new HerokuSDK() let num if (backup_id) { - num = await pgBackupsApi(app, this.heroku).num(backup_id) + num = await pgBackups.num(backup_id, app, data) if (!num) throw new Error(`Invalid Backup: ${backup_id}`) } else { - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + const transfers = await data.transfer.listByApp(app) const succeededBackups = transfers.filter(t => t.succeeded && t.to_type === 'gof3r') succeededBackups.sort((a, b) => a.created_at.localeCompare(b.created_at)) const lastBackup = succeededBackups.pop() @@ -37,7 +37,7 @@ export default class Url extends Command { num = lastBackup.num } - const {body: info} = await this.heroku.post(`/client/v11/apps/${app}/transfers/${num}/actions/public-url`, {hostname: utils.pg.host()}) + const info = await data.transfer.publicUrl(app, String(num), {}) ux.stdout(info.url + '\n') } } diff --git a/src/lib/pg/backups.ts b/src/lib/pg/backups.ts index 3331435182..d3c4632b1f 100644 --- a/src/lib/pg/backups.ts +++ b/src/lib/pg/backups.ts @@ -1,24 +1,15 @@ -import {APIClient} from '@heroku-cli/command' -import {color, utils} from '@heroku/heroku-cli-util' +import type {HerokuSDK} from '@heroku/sdk' +import type {TransferInfoByAppResult} from '@heroku/types/data' + import {ux} from '@oclif/core/ux' import bytes from 'bytes' -import tsheredoc from 'tsheredoc' -import type {BackupTransfer} from './types.js' +type Data = HerokuSDK['data'] -const heredoc = tsheredoc.default +export class LogDisplay { + private readonly logsAlreadyShown = new Set() -class Backups { - protected app: string - protected heroku: APIClient - protected logsAlreadyShown = new Set() - - constructor(app: string, heroku: APIClient) { - this.app = app - this.heroku = heroku - } - - protected displayLogs(logs: BackupTransfer['logs'] | undefined) { + displayLogs(logs: TransferInfoByAppResult['logs'] | undefined) { for (const log of (logs ?? [])) { if (this.logsAlreadyShown.has(log.created_at + log.message)) { continue @@ -28,57 +19,107 @@ class Backups { ux.stdout(`${log.created_at} ${log.message}`) } } +} + +export function filesize(size: null | number, opts = {}): string { + if (size === null) return '' + Object.assign(opts, { + decimalPlaces: 2, + fixedDecimals: true, + }) + return bytes(size, opts) || '' +} + +export function name(transfer: TransferInfoByAppResult): string { + const oldPGBName = transfer.options?.pgbackups_name + if (oldPGBName) return `o${oldPGBName}` + return `${prefix(transfer)}${(transfer.num || '').toString().padStart(3, '0')}` +} + +export async function num(transferName: string, app: string, data: Data): Promise { + let m = transferName.match(/^[abcr](\d+)$/) + if (m) return Number.parseInt(m[1], 10) + m = transferName.match(/^o[ab]\d+$/) + if (m) { + const transfers = await data.transfer.listByApp(app) + const transfer = transfers.find(t => name(t) === transferName) + if (transfer) return transfer.num + } +} + +export function status(transfer: TransferInfoByAppResult): string { + const {finished_at, processed_bytes, started_at, succeeded, warnings} = transfer + if (finished_at && succeeded) { + if (warnings > 0) { + return `Finished with ${warnings} warnings` + } + + return `Completed ${finished_at}` + } - public filesize(size: number, opts = {}): string { - Object.assign(opts, { - decimalPlaces: 2, - fixedDecimals: true, - }) - return bytes(size, opts) || '' + if (finished_at) { + return `Failed ${finished_at}` } - public name(transfer: BackupTransfer): string { - const oldPGBName = transfer.options?.pgbackups_name - if (oldPGBName) return `o${oldPGBName}` - return `${prefix(transfer)}${(transfer.num || '').toString().padStart(3, '0')}` + if (started_at) { + return `Running (processed ${filesize(processed_bytes)})` } - public async num(name: string): Promise { - let m = name.match(/^[abcr](\d+)$/) - if (m) return Number.parseInt(m[1], 10) - m = name.match(/^o[ab]\d+$/) - if (m) { - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${this.app}/transfers`, {hostname: utils.pg.host()}) - const transfer = transfers.find(t => this.name(t) === name) - if (transfer) return transfer.num + return 'Pending' +} + +function prefix(transfer: TransferInfoByAppResult) { + if (transfer.from_type === 'pg_dump') { + if (transfer.to_type === 'pg_restore') { + return 'c' + } + + return transfer.schedule ? 'a' : 'b' + + // eslint-disable-next-line no-else-return + } else { + if (transfer.to_type === 'pg_restore') { + return 'r' } + + return 'b' } +} + +// TODO: Temporary default export so pg:backups:capture, pg:backups:restore, and pg:copy still compile. Remove it when those commands move to @heroku/sdk. +import {APIClient} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import tsheredoc from 'tsheredoc' - protected async * poll(transferID: string, interval: number, verbose: boolean, appId: string) { +import type {BackupTransfer} from './types.js' + +const heredoc = tsheredoc.default + +export default function backupsFactory(app: string, heroku: APIClient) { + const logs = new LogDisplay() + + async function * poll(transferID: string, interval: number, verbose: boolean, appId: string) { const tty = process.env.TERM !== 'dumb' && process.stderr.isTTY - let backup: BackupTransfer = {} as BackupTransfer + let backup = {} as BackupTransfer let failures = 0 - const quietUrl = `/client/v11/apps/${appId ?? this.app}/transfers/${transferID}` + const quietUrl = `/client/v11/apps/${appId}/transfers/${transferID}` const verboseUrl = quietUrl + '?verbose=true' - const url = verbose ? verboseUrl : quietUrl while (failures < 21) { try { - ({body: backup} = await this.heroku.get(url, {hostname: utils.pg.host()})) + ({body: backup} = await heroku.get(url, {hostname: utils.pg.host()})) } catch (error) { - if (failures++ > 20) { - throw error - } + if (failures++ > 20) throw error } if (verbose) { - this.displayLogs(backup.logs) + logs.displayLogs(backup.logs as TransferInfoByAppResult['logs']) } else if (tty) { - const msg = backup.started_at ? this.filesize(backup.processed_bytes) : 'pending' + const msg = backup.started_at ? filesize(backup.processed_bytes) : 'pending' const log = backup.logs?.pop() - ux.action.status = log ? `${msg}\n${log.created_at + ' ' + log.message}` : msg + ux.action.status = log ? `${msg}\n${log.created_at} ${log.message}` : msg } if (backup?.finished_at) { @@ -87,15 +128,14 @@ class Backups { break } - // logs is undefined unless verbose=true is passed - ({body: backup} = await this.heroku.get(verboseUrl, {hostname: utils.pg.host()})) + ({body: backup} = await heroku.get(verboseUrl, {hostname: utils.pg.host()})) throw new Error(heredoc(` An error occurred and the backup did not finish. ${backup.logs.slice(-5).map(l => l.message).join('\n')} - Run ${color.code('heroku pg:backups:info ' + this.name(backup))} for more details.`)) + Run ${color.code('heroku pg:backups:info ' + name(backup as unknown as TransferInfoByAppResult))} for more details.`)) } yield new Promise(resolve => { @@ -104,67 +144,26 @@ class Backups { } } - public status(transfer: BackupTransfer): string { - const {finished_at, processed_bytes, started_at, succeeded, warnings} = transfer - if (finished_at && succeeded) { - if (warnings > 0) { - return `Finished with ${warnings} warnings` - } + return { + name(transfer: BackupTransfer) { + return name(transfer as unknown as TransferInfoByAppResult) + }, - return `Completed ${finished_at}` - } - - if (finished_at) { - return `Failed ${finished_at}` - } + async wait(action: string, transferID: string, interval: number, verbose: boolean, appId: string) { + if (verbose) ux.stdout(`${action}...`) - if (started_at) { - return `Running (processed ${this.filesize(processed_bytes)})` - } - - return 'Pending' - } - - public async wait(action: string, transferID: string, interval: number, verbose: boolean, app: string): Promise { - if (verbose) { - ux.stdout(`${action}...`) - } - - ux.action.start(action) - try { - for await (const backupSucceeded of this.poll(transferID, interval, verbose, app || this.app)) { - if (backupSucceeded) { - ux.action.stop() - break + ux.action.start(action) + try { + for await (const backupSucceeded of poll(transferID, interval, verbose, appId || app)) { + if (backupSucceeded) { + ux.action.stop() + break + } } + } catch (error) { + ux.action.stop('!') + ux.error(error as Error) } - } catch (error) { - ux.action.stop('!') - ux.error(error as Error) - } + }, } } - -function factory(app: string, heroku: APIClient) { - return new Backups(app, heroku) -} - -function prefix(transfer: BackupTransfer) { - if (transfer.from_type === 'pg_dump') { - if (transfer.to_type === 'pg_restore') { - return 'c' - } - - return transfer.schedule ? 'a' : 'b' - - // eslint-disable-next-line no-else-return - } else { - if (transfer.to_type === 'pg_restore') { - return 'r' - } - - return 'b' - } -} - -export default factory diff --git a/test/unit/commands/pg/backups/cancel.unit.test.ts b/test/unit/commands/pg/backups/cancel.unit.test.ts index 03ac9a166b..4359f92810 100644 --- a/test/unit/commands/pg/backups/cancel.unit.test.ts +++ b/test/unit/commands/pg/backups/cancel.unit.test.ts @@ -1,35 +1,38 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {restore, type SinonStub, stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/backups/cancel.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default describe('pg:backups:cancel', function () { - let pg: nock.Scope + let cancelStub: SinonStub + let sdkMock: MockSDK beforeEach(function () { - pg = nock('https://api.data.heroku.com') - .post('/client/v11/apps/myapp/transfers/100-001/actions/cancel').reply(200, {}) + cancelStub = stub().resolves({}) }) afterEach(function () { - pg.done() - nock.cleanAll() + restore() }) context('with no id', function () { + let listByAppStub: SinonStub beforeEach(function () { - pg.get('/client/v11/apps/myapp/transfers').reply(200, [ + listByAppStub = stub().resolves([ { - num: '3', succeeded: true, to_type: 'gof3r', uuid: '100-001', + num: 3, succeeded: true, to_type: 'gof3r', uuid: '100-001', }, ]) }) it('cancels backup', async function () { + sdkMock = mockSDKData({transfer: {cancel: cancelStub, listByApp: listByAppStub}}) + const {stderr} = await runCommand(Cmd, [ '--app', 'myapp', @@ -38,17 +41,22 @@ describe('pg:backups:cancel', function () { expect(stderr).to.equal(heredoc` Cancelling b003... done `) + expect(cancelStub.calledWith('myapp', '100-001')).to.be.true }) }) context('with id', function () { + let infoByAppStub: SinonStub + beforeEach(function () { - pg.get('/client/v11/apps/myapp/transfers/3').reply(200, { - num: '3', succeeded: true, to_type: 'gof3r', uuid: '100-001', + infoByAppStub = stub().resolves({ + num: 3, succeeded: true, to_type: 'gof3r', uuid: '100-001', }) }) it('cancels backup', async function () { + sdkMock = mockSDKData({transfer: {cancel: cancelStub, infoByApp: infoByAppStub}}) + const {stderr} = await runCommand(Cmd, [ '--app', 'myapp', @@ -58,6 +66,7 @@ describe('pg:backups:cancel', function () { expect(stderr).to.equal(heredoc` Cancelling b003... done `) + expect(cancelStub.calledWith('myapp', '100-001')).to.be.true }) }) }) diff --git a/test/unit/commands/pg/backups/delete.unit.test.ts b/test/unit/commands/pg/backups/delete.unit.test.ts index a2e626bfc1..17d55a37fd 100644 --- a/test/unit/commands/pg/backups/delete.unit.test.ts +++ b/test/unit/commands/pg/backups/delete.unit.test.ts @@ -1,26 +1,31 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' -import nock from 'nock' +import {restore, type SinonStub, stub} from 'sinon' import Cmd from '../../../../../src/commands/pg/backups/delete.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' describe('pg:backups:delete', function () { - let pg: nock.Scope + let deleteStub: SinonStub + let sdkMock: MockSDK beforeEach(function () { - pg = nock('https://api.data.heroku.com') - .delete('/client/v11/apps/myapp/transfers/3') - .reply(200, { - url: 'https://dburl', - }) + deleteStub = stub().resolves({url: 'https://dburl'}) }) afterEach(function () { - nock.cleanAll() - pg.done() + restore() }) - it('shows URL', async function () { + it('deletes the backup', async function () { + sdkMock = mockSDKData({ + transfer: { + deleteByApp: deleteStub, + listByApp: stub().resolves([ + {num: 3, succeeded: true, to_type: 'gof3r'}, + ]), + }, + }) const {stderr} = await runCommand(Cmd, [ '--app', 'myapp', @@ -29,5 +34,6 @@ describe('pg:backups:delete', function () { 'b003', ]) expect(stderr).to.equal('Deleting backup b003 on ⬢ myapp... done\n') + expect(deleteStub.calledWith('myapp', '3')).to.be.true }) }) diff --git a/test/unit/commands/pg/backups/download.unit.test.ts b/test/unit/commands/pg/backups/download.unit.test.ts index f806c80808..b2ad712bac 100644 --- a/test/unit/commands/pg/backups/download.unit.test.ts +++ b/test/unit/commands/pg/backups/download.unit.test.ts @@ -2,31 +2,37 @@ import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import fs from 'fs-extra' import nock from 'nock' +import {restore, type SinonStub, stub} from 'sinon' import Cmd from '../../../../../src/commands/pg/backups/download.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' describe('pg:backups:download', function () { + let publicUrlStub: SinonStub + let sdkMock: MockSDK + beforeEach(function () { + publicUrlStub = stub().resolves({url: 'https://api.data.heroku.com/db'}) nock('https://api.data.heroku.com') - .post('/client/v11/apps/myapp/transfers/3/actions/public-url') - .reply(200, { - url: 'https://api.data.heroku.com/db', - }) .get('/db') .reply(200, {}) }) afterEach(function () { nock.cleanAll() + restore() }) context('with no id', function () { beforeEach(function () { - nock('https://api.data.heroku.com') - .get('/client/v11/apps/myapp/transfers') - .reply(200, [ - {num: 3, succeeded: true, to_type: 'gof3r'}, - ]) + sdkMock = mockSDKData({ + transfer: { + listByApp: stub().resolves([ + {num: 3, succeeded: true, to_type: 'gof3r'}, + ]), + publicUrl: publicUrlStub, + }, + }) }) it('downloads to latest.dump', async function () { @@ -37,11 +43,14 @@ describe('pg:backups:download', function () { './tmp/latest.dump', ]) expect(fs.readFileSync('./tmp/latest.dump', 'utf8')).to.equal('{}') + expect(publicUrlStub.calledOnceWithExactly('myapp', '3', {})).to.equal(true) }) }) context('with id', function () { it('downloads to latest.dump', async function () { + sdkMock = mockSDKData({transfer: {publicUrl: publicUrlStub}}) + await runCommand(Cmd, [ '--app', 'myapp', @@ -50,6 +59,7 @@ describe('pg:backups:download', function () { 'b003', ]) expect(fs.readFileSync('./tmp/latest.dump', 'utf8')).to.equal('{}') + expect(publicUrlStub.calledOnceWithExactly('myapp', '3', {})).to.equal(true) }) }) }) diff --git a/test/unit/commands/pg/backups/index.unit.test.ts b/test/unit/commands/pg/backups/index.unit.test.ts index 4f776bb611..245611a9db 100644 --- a/test/unit/commands/pg/backups/index.unit.test.ts +++ b/test/unit/commands/pg/backups/index.unit.test.ts @@ -1,10 +1,10 @@ +import type {TransferInfoByAppResult, TransferListByAppResult} from '@heroku/types/data' + import {runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import {restore, stub} from 'sinon' import tsheredoc from 'tsheredoc' -import type {BackupTransfer} from '../../../../../src/lib/pg/types.js' - import Cmd from '../../../../../src/commands/pg/backups/index.js' import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' import normalizeTableOutput from '../../../../helpers/utils/normalize-table-output.js' @@ -13,7 +13,7 @@ const heredoc = tsheredoc.default describe('pg:backups', function () { let sdkMock: MockSDK - let transfers: BackupTransfer[] + let transfers: TransferListByAppResult afterEach(function () { sdkMock.restore() @@ -62,7 +62,7 @@ describe('pg:backups', function () { to_type: 'gof3r', updated_at: '2016-10-08 00:43:04 +0000', warnings: 2, - } as BackupTransfer, { + } as TransferInfoByAppResult, { created_at: '2016-10-02 00:42:54 +0000', finished_at: '2016-10-08 00:43:00 +0000', from_name: 'DATABASE', @@ -73,7 +73,7 @@ describe('pg:backups', function () { succeeded: true, to_type: 'gof3r', warnings: 0, - } as BackupTransfer, { + } as TransferInfoByAppResult, { created_at: '2016-10-03 00:42:54 +0000', finished_at: '2016-10-08 00:43:00 +0000', from_name: 'DATABASE', @@ -81,14 +81,14 @@ describe('pg:backups', function () { num: 4, processed_bytes: 1437, to_type: 'gof3r', - } as BackupTransfer, { + } as TransferInfoByAppResult, { created_at: '2016-10-04 00:42:54 +0000', from_name: 'DATABASE', from_type: 'pg_dump', num: 5, processed_bytes: 1437, to_type: 'gof3r', - } as BackupTransfer, { + } as TransferInfoByAppResult, { created_at: '2016-10-05 00:42:54 +0000', from_name: 'DATABASE', from_type: 'pg_dump', @@ -96,7 +96,7 @@ describe('pg:backups', function () { processed_bytes: 1437, started_at: '2016-10-08 00:42:54 +0000', to_type: 'gof3r', - } as BackupTransfer, + } as TransferInfoByAppResult, ] }) @@ -141,7 +141,7 @@ No copies found. Use heroku pg:copy to copy a database to another to_type: 'pg_restore', updated_at: '2016-10-08 00:43:04 +0000', warnings: 0, - } as BackupTransfer, + } as TransferInfoByAppResult, ] }) @@ -184,7 +184,7 @@ No copies found. Use heroku pg:copy to copy a database to another to_type: 'pg_restore', updated_at: '2016-10-08 00:43:04 +0000', warnings: 0, - } as BackupTransfer, + } as TransferInfoByAppResult, ] }) diff --git a/test/unit/commands/pg/backups/info.unit.test.ts b/test/unit/commands/pg/backups/info.unit.test.ts index 4d33513b77..582590b2de 100644 --- a/test/unit/commands/pg/backups/info.unit.test.ts +++ b/test/unit/commands/pg/backups/info.unit.test.ts @@ -1,41 +1,48 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' -import nock from 'nock' +import {restore, stub} from 'sinon' import tsheredoc from 'tsheredoc' import Cmd from '../../../../../src/commands/pg/backups/info.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' const heredoc = tsheredoc.default -const shouldInfo = function (cmdRun: (args: string[]) => Promise) { + +describe('pg:backups:info', function () { + let sdkMock: MockSDK + afterEach(function () { - nock.cleanAll() + restore() }) context('without specifying a backup and no backups', function () { beforeEach(function () { - nock('https://api.data.heroku.com') - .get('/client/v11/apps/myapp/transfers') - .reply(200, []) + sdkMock = mockSDKData({ + transfer: {listByApp: stub().resolves([])}, + }) }) it('shows error message', async function () { - const {error} = await cmdRun(['--app', 'myapp']) - expect(ansis.strip(error.message)).to.equal('No backups. Capture one with heroku pg:backups:capture') + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + const errorMessage = error?.message ?? '' + expect(ansis.strip(errorMessage)).to.equal('No backups. Capture one with heroku pg:backups:capture') }) }) context('with specifying a backup', function () { beforeEach(function () { - nock('https://api.data.heroku.com') - .get('/client/v11/apps/myapp/transfers/3?verbose=true') - .reply(200, { - from_name: 'RED', logs: [{created_at: '100', message: 'foo'}], num: 3, processed_bytes: 100_000, source_bytes: 1_000_000, - }) + sdkMock = mockSDKData({ + transfer: { + infoByApp: stub().resolves({ + from_name: 'RED', logs: [{created_at: '100', message: 'foo'}], num: 3, processed_bytes: 100_000, source_bytes: 1_000_000, + }), + }, + }) }) it('shows the backup', async function () { - const {stdout} = await cmdRun(['--app', 'myapp', 'b003']) + const {stdout} = await runCommand(Cmd, ['--app', 'myapp', 'b003']) expectOutput(stdout, heredoc(` === Backup b003 Database: ⛁ RED @@ -43,7 +50,7 @@ const shouldInfo = function (cmdRun: (args: string[]) => Promise) { Type: Manual Original DB Size: 976.56KB Backup Size: 97.66KB - + === Backup Logs 100 foo @@ -53,21 +60,22 @@ const shouldInfo = function (cmdRun: (args: string[]) => Promise) { context('with specifying a legacy backup', function () { beforeEach(function () { - nock('https://api.data.heroku.com') - .get('/client/v11/apps/myapp/transfers') - .reply(200, [ - { - from_type: 'pg_dump', name: 'ob001', num: 1, options: {pgbackups_name: 'b001'}, to_type: 'gof3r', - }, - ]) - .get('/client/v11/apps/myapp/transfers/1?verbose=true') - .reply(200, { - from_name: 'RED', logs: [{created_at: '100', message: 'foo'}], name: 'ob001', num: 1, options: {pgbackups_name: 'b001'}, processed_bytes: 100_000, source_bytes: 1_000_000, - }) + sdkMock = mockSDKData({ + transfer: { + infoByApp: stub().resolves({ + from_name: 'RED', logs: [{created_at: '100', message: 'foo'}], name: 'ob001', num: 1, options: {pgbackups_name: 'b001'}, processed_bytes: 100_000, source_bytes: 1_000_000, + }), + listByApp: stub().resolves([ + { + from_type: 'pg_dump', name: 'ob001', num: 1, options: {pgbackups_name: 'b001'}, to_type: 'gof3r', + }, + ]), + }, + }) }) it('shows the backup', async function () { - const {stdout} = await cmdRun(['--app', 'myapp', 'ob001']) + const {stdout} = await runCommand(Cmd, ['--app', 'myapp', 'ob001']) expectOutput(stdout, heredoc(` === Backup ob001 Database: ⛁ RED @@ -85,19 +93,20 @@ const shouldInfo = function (cmdRun: (args: string[]) => Promise) { context('without specifying a backup', function () { beforeEach(function () { - nock('https://api.data.heroku.com') - .get('/client/v11/apps/myapp/transfers') - .reply(200, [ - {from_type: 'pg_dump', num: 3, to_type: 'gof3r'}, - ]) - .get('/client/v11/apps/myapp/transfers/3?verbose=true') - .reply(200, { - finished_at: '100', from_name: 'RED', logs: [{created_at: '100', message: 'foo'}], num: 3, processed_bytes: 100_000, source_bytes: 1_000_000, succeeded: true, - }) + sdkMock = mockSDKData({ + transfer: { + infoByApp: stub().resolves({ + finished_at: '100', from_name: 'RED', logs: [{created_at: '100', message: 'foo'}], num: 3, processed_bytes: 100_000, source_bytes: 1_000_000, succeeded: true, + }), + listByApp: stub().resolves([ + {from_type: 'pg_dump', num: 3, to_type: 'gof3r'}, + ]), + }, + }) }) it('shows the latest backup', async function () { - const {stdout} = await cmdRun(['--app', 'myapp']) + const {stdout} = await runCommand(Cmd, ['--app', 'myapp']) expectOutput(stdout, heredoc(` === Backup b003 Database: ⛁ RED @@ -113,8 +122,4 @@ const shouldInfo = function (cmdRun: (args: string[]) => Promise) { `)) }) }) -} - -describe('pg:backups:info', function () { - shouldInfo((args: string[]) => runCommand(Cmd, args)) }) diff --git a/test/unit/commands/pg/backups/url.unit.test.ts b/test/unit/commands/pg/backups/url.unit.test.ts index baf4259f96..8e8334cd29 100644 --- a/test/unit/commands/pg/backups/url.unit.test.ts +++ b/test/unit/commands/pg/backups/url.unit.test.ts @@ -1,44 +1,48 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' -import nock from 'nock' +import {expect} from 'chai' +import {restore, type SinonStub, stub} from 'sinon' import Cmd from '../../../../../src/commands/pg/backups/url.js' +import {type MockSDK, mockSDKData} from '../../../../helpers/mock-sdk.js' + +describe('pg:backups:url', function () { + let publicUrlStub: SinonStub + let sdkMock: MockSDK -const shouldUrl = function (cmdRun: (args: string[]) => Promise) { beforeEach(function () { - nock('https://api.data.heroku.com') - .post('/client/v11/apps/myapp/transfers/3/actions/public-url') - .reply(200, { - url: 'https://dburl', - }) + publicUrlStub = stub().resolves({url: 'https://dburl'}) }) afterEach(function () { - nock.cleanAll() + restore() }) context('with no id', function () { beforeEach(function () { - nock('https://api.data.heroku.com') - .get('/client/v11/apps/myapp/transfers') - .reply(200, [ - {num: 3, succeeded: true, to_type: 'gof3r'}, - ]) + sdkMock = mockSDKData({ + transfer: { + listByApp: stub().resolves([ + {num: 3, succeeded: true, to_type: 'gof3r'}, + ]), + publicUrl: publicUrlStub, + }, + }) }) it('shows URL', async function () { - const {stdout} = await cmdRun(['--app', 'myapp']) + const {stdout} = await runCommand(Cmd, ['--app', 'myapp']) expectOutput(stdout, 'https://dburl') + expect(publicUrlStub.calledOnceWithExactly('myapp', '3', {})).to.equal(true) }) }) context('with id', function () { it('shows URL', async function () { - const {stdout} = await cmdRun(['--app', 'myapp', 'b003']) + sdkMock = mockSDKData({transfer: {publicUrl: publicUrlStub}}) + + const {stdout} = await runCommand(Cmd, ['--app', 'myapp', 'b003']) expectOutput(stdout, 'https://dburl') + expect(publicUrlStub.calledOnceWithExactly('myapp', '3', {})).to.equal(true) }) }) -} - -describe('pg:backups:url', function () { - shouldUrl((args: string[]) => runCommand(Cmd, args)) }) diff --git a/test/unit/lib/pg/backups.unit.test.ts b/test/unit/lib/pg/backups.unit.test.ts index cdb0d53b77..a58e874a71 100644 --- a/test/unit/lib/pg/backups.unit.test.ts +++ b/test/unit/lib/pg/backups.unit.test.ts @@ -1,56 +1,37 @@ -import {APIClient} from '@heroku-cli/command' -import {ux} from '@oclif/core/ux' +import type {HerokuSDK} from '@heroku/sdk' +import type {TransferInfoByAppResult} from '@heroku/types/data' + import {expect} from 'chai' -import {SinonStub, stub} from 'sinon' +import {stub} from 'sinon' -import type {BackupTransfer} from '../../../../src/lib/pg/types.js' +import { + filesize, name, num, status, +} from '../../../../src/lib/pg/backups.js' -import backupsFactory from '../../../../src/lib/pg/backups.js' +type Data = HerokuSDK['data'] describe('Backups', function () { - describe('constructor', function () { - it('requires an `app` string and a `heroku` API client', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - expect(backups).to.be.an('object') - expect(backups).to.have.property('filesize').that.is.a('function') - expect(backups).to.have.property('status').that.is.a('function') - expect(backups).to.have.property('num').that.is.a('function') - expect(backups).to.have.property('name').that.is.a('function') - expect(backups).to.have.property('wait').that.is.a('function') - }) - }) - describe('filesize', function () { it('displays 2 decimal places when the `decimalPlaces` option is not provided', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const result = backups.filesize(1536) + const result = filesize(1536) expect(result).to.equal('1.50KB') }) it('displays 2 decimal places when the `decimalPlaces` option is provided and is a value other than 2', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const result = backups.filesize(1536, {decimalPlaces: 0}) + const result = filesize(1536, {decimalPlaces: 0}) expect(result).to.equal('1.50KB') }) it('displays 2 decimal places when the `fixedDecimals` option is not provided', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const result = backups.filesize(1536) + const result = filesize(1536) expect(result).to.equal('1.50KB') }) it('displays 2 decimal places when the `fixedDecimals` option is provided and is set to `false`', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const result = backups.filesize(1536, {fixedDecimals: false}) + const result = filesize(1536, {fixedDecimals: false}) expect(result).to.equal('1.50KB') }) @@ -58,322 +39,157 @@ describe('Backups', function () { describe('status', function () { it('returns warnings when the backup transfer successfully completed, but warnings are present', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { finished_at: '2025-01-01T00:00:00Z', succeeded: true, warnings: 3, - } as BackupTransfer + } as TransferInfoByAppResult - const result = backups.status(transfer) + const result = status(transfer) expect(result).to.equal('Finished with 3 warnings') }) it('returns the time the transfer completed when the backup transfer successfully completes without warning', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { finished_at: '2025-01-01T00:00:00Z', succeeded: true, warnings: 0, - } as BackupTransfer + } as TransferInfoByAppResult - const result = backups.status(transfer) + const result = status(transfer) expect(result).to.equal('Completed 2025-01-01T00:00:00Z') }) it('returns a failure message when the transfer completes, but is not marked as having succeeded.', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { finished_at: '2025-01-01T00:00:00Z', succeeded: false, - } as BackupTransfer + } as TransferInfoByAppResult - const result = backups.status(transfer) + const result = status(transfer) expect(result).to.equal('Failed 2025-01-01T00:00:00Z') }) it('returns a running message when the transfer has been started, but is not yet finished', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { finished_at: '', processed_bytes: 1536, started_at: '2025-01-01T00:00:00Z', - } as BackupTransfer + } as TransferInfoByAppResult - const result = backups.status(transfer) + const result = status(transfer) expect(result).to.equal('Running (processed 1.50KB)') }) it('returns a pending message when the transfer has neither started nor finished', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { finished_at: '', started_at: '', - } as BackupTransfer + } as TransferInfoByAppResult - const result = backups.status(transfer) + const result = status(transfer) expect(result).to.equal('Pending') }) }) describe('num', function () { - it('resolves to the numerical portion of the `name` when the `name` begins with `a`, `b`, `c`, or `r` and is followed by one or more digits upto the end of the `name`', async function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) + const app = 'my-app' - expect(await backups.num('a123')).to.equal(123) - expect(await backups.num('b456')).to.equal(456) - expect(await backups.num('c789')).to.equal(789) - expect(await backups.num('r012')).to.equal(12) + it('resolves to the numerical portion of the `name` when the `name` begins with `a`, `b`, `c`, or `r` and is followed by one or more digits upto the end of the `name`', async function () { + const mockSDK = {} as Data + expect(await num('a123', app, mockSDK)).to.equal(123) + expect(await num('b456', app, mockSDK)).to.equal(456) + expect(await num('c789', app, mockSDK)).to.equal(789) + expect(await num('r012', app, mockSDK)).to.equal(12) }) it('resolves to the `num` value of the transfer having a name that matches the provided `name`, when `name` begins with either `oa` or `ob` and is followed by one or more digits upto the end of the `name`', async function () { - const mockHeroku = { - get: stub().resolves({ - body: [ + const mockSDK = { + transfer: { + listByApp: stub().resolves([ {num: 42, options: {pgbackups_name: 'a123'}}, {num: 99, options: {pgbackups_name: 'b456'}}, - ], - }), - } as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) + ]), + }, + } as unknown as Data - const result = await backups.num('oa123') + const result = await num('oa123', app, mockSDK) expect(result).to.equal(42) }) it('resolves to undefined when the name does not match any known pattern', async function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) + const mockSDK = {} as Data - expect(await backups.num('xyz123')).to.be.undefined - expect(await backups.num('123')).to.be.undefined - expect(await backups.num('a')).to.be.undefined + expect(await num('xyz123', app, mockSDK)).to.be.undefined + expect(await num('123', app, mockSDK)).to.be.undefined + expect(await num('a', app, mockSDK)).to.be.undefined }) }) describe('name', function () { it('returns the old PG backup name prefixed with an `o`, when it is present on the provided `transfer`', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { options: {pgbackups_name: 'a123'}, - } as unknown as BackupTransfer + } as unknown as TransferInfoByAppResult - const result = backups.name(transfer) + const result = name(transfer) expect(result).to.equal('oa123') }) it('returns the name composed of a prefix of `c` and a suffix of the transfer number when the transfer is from `pg_dump` to `pg_restore`', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { from_type: 'pg_dump', num: 5, to_type: 'pg_restore', - } as unknown as BackupTransfer + } as unknown as TransferInfoByAppResult - const result = backups.name(transfer) + const result = name(transfer) expect(result).to.equal('c005') }) it('returns the name composed of a prefix of `a` and a suffix of the transfer number when the transfer is from `pg_dump` and the transfer has a schedule property present', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { from_type: 'pg_dump', num: 7, schedule: {uuid: 'some-schedule-id'}, to_type: 'xxxxxxxxxx', - } as unknown as BackupTransfer + } as unknown as TransferInfoByAppResult - const result = backups.name(transfer) + const result = name(transfer) expect(result).to.equal('a007') }) it('returns the name composed of a prefix of `b` and a suffix of the transfer number when the transfer is from `pg_dump` and the transfer lacks a schedule property', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { from_type: 'pg_dump', num: 3, to_type: 'door_number_three', - } as unknown as BackupTransfer + } as unknown as TransferInfoByAppResult - const result = backups.name(transfer) + const result = name(transfer) expect(result).to.equal('b003') }) it('returns the name composed of a prefix of `r` and a suffix of the transfer number when the transfer is to `pg_restore`', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { from_type: 'cow', num: 12, to_type: 'pg_restore', - } as unknown as BackupTransfer + } as unknown as TransferInfoByAppResult - const result = backups.name(transfer) + const result = name(transfer) expect(result).to.equal('r012') }) it('returns the name composed of a prefix of `b` is not from `pg_dump` and is not to `pg_restore`', function () { - const mockHeroku = {} as APIClient - const backups = backupsFactory('my-app', mockHeroku) - const transfer = { from_type: 'cats', num: 8, to_type: 'kittens', - } as unknown as BackupTransfer + } as unknown as TransferInfoByAppResult - const result = backups.name(transfer) + const result = name(transfer) expect(result).to.equal('b008') }) }) - - describe('wait', function () { - let stdoutStub: SinonStub - let actionStartStub: SinonStub - let actionStopStub: SinonStub - let errorStub: SinonStub - - beforeEach(function () { - stdoutStub = stub(ux, 'stdout') - actionStartStub = stub(ux.action, 'start') - actionStopStub = stub(ux.action, 'stop') - errorStub = stub(ux, 'error') - }) - - afterEach(function () { - stdoutStub.restore() - actionStartStub.restore() - actionStopStub.restore() - errorStub.restore() - }) - - it('writes the action to stdout with trailing ellipsis when verbose is true', async function () { - const mockHeroku = { - get: stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }), - } as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, true, 'my-app') - - expect(stdoutStub.calledWith('Backing up...')).to.be.true - }) - - it('does not write to stdout when verbose is false', async function () { - const mockHeroku = { - get: stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }), - } as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, false, 'my-app') - - expect(stdoutStub.called).to.be.false - }) - - it('calls the start action with the provided action name', async function () { - const mockHeroku = { - get: stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }), - } as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, false, 'my-app') - - expect(actionStartStub.calledWith('Backing up')).to.be.true - }) - - it('calls the stop action when the poll yields a successful backup', async function () { - const mockHeroku = { - get: stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }), - } as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, false, 'my-app') - - expect(actionStopStub.calledOnce).to.be.true - expect(actionStopStub.calledWith()).to.be.true - }) - - it('calls the stop action with "!" and calls ux.error when poll throws an error', async function () { - const mockHeroku = { - get: stub().resolves({ - body: { - finished_at: '2025-01-01T00:00:00Z', - logs: [{created_at: '2025-01-01', message: 'Backup failed'}], - succeeded: false, - }, - }), - } as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, false, 'my-app') - - expect(actionStopStub.calledWith('!')).to.be.true - expect(errorStub.called).to.be.true - }) - - it('uses the provided app parameter when polling', async function () { - const getStub = stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }) - const mockHeroku = {get: getStub} as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, false, 'other-app') - - expect(getStub.firstCall.args[0]).to.include('other-app') - }) - - it('falls back to the instance app when the app parameter is falsy', async function () { - const getStub = stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }) - const mockHeroku = {get: getStub} as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-123', 1, false, '') - - expect(getStub.firstCall.args[0]).to.include('my-app') - }) - - it('passes transferID, interval, and verbose to poll', async function () { - const getStub = stub().resolves({ - body: {finished_at: '2025-01-01T00:00:00Z', logs: [], succeeded: true}, - }) - const mockHeroku = {get: getStub} as unknown as APIClient - const backups = backupsFactory('my-app', mockHeroku) - - await backups.wait('Backing up', 'transfer-456', 1, true, 'my-app') - - // When verbose is true, the URL should include ?verbose=true - expect(getStub.firstCall.args[0]).to.include('transfer-456') - expect(getStub.firstCall.args[0]).to.include('verbose=true') - }) - }) })