diff --git a/packages/addons-v5/test/unit/lib/resolve.unit.test.js b/packages/addons-v5/test/unit/lib/resolve.unit.test.js deleted file mode 100644 index 4f6ff0e8f0..0000000000 --- a/packages/addons-v5/test/unit/lib/resolve.unit.test.js +++ /dev/null @@ -1,296 +0,0 @@ -'use strict' -/* globals beforeEach afterEach cli nock */ - -let resolve = require('../../../lib/resolve') -const {expect} = require('chai') -let Heroku = require('heroku-client') - -describe('resolve', () => { - beforeEach(function () { - cli.mockConsole() - resolve.addon.cache.clear() - }) - - afterEach(() => nock.cleanAll()) - - describe('addon', () => { - it('finds a single matching addon', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: null, addon: 'myaddon-1'}).reply(200, [{name: 'myaddon-1'}]) - - return resolve.addon(new Heroku(), null, 'myaddon-1') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-1'})) - .then(() => api.done()) - }) - - it('finds a single matching addon for an app', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-2'}).reply(200, [{name: 'myaddon-2'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-2') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-2'})) - .then(() => api.done()) - }) - - it('fails if no addon found', () => { - nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-3'}).reply(404, {resource: 'add_on'}) - .post('/actions/addons/resolve', {app: null, addon: 'myaddon-3'}).reply(404, {resource: 'add_on'}) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-3') - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 404})) - }) - - it('fails if no addon found with addon-service', () => { - const api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-3', addon_service: 'slowdb'}).reply(404, {resource: 'add_on'}) - .post('/actions/addons/resolve', {app: null, addon: 'myaddon-3', addon_service: 'slowdb'}).reply(404, {resource: 'add_on'}) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-3', {addon_service: 'slowdb'}) - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 404})) - .then(() => api.done()) - }) - - it('fails if errored', () => { - nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-5'}).reply(401) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-5') - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 401})) - }) - - it('fails if ambiguous', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-5'}) - .reply(200, [{name: 'myaddon-5'}, {name: 'myaddon-6'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-5') - .then(() => { - throw new Error('unreachable') - }) - .catch(function (error) { - api.done() - expect(error, 'to satisfy', {message: 'Ambiguous identifier; multiple matching add-ons found: myaddon-5, myaddon-6.', type: 'addon'}) - }) - }) - - it('fails if no addon found', () => { - const api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-3', addon_service: 'slowdb'}).reply(404, {resource: 'add_on'}) - .post('/actions/addons/resolve', {app: null, addon: 'myaddon-3', addon_service: 'slowdb'}).reply(404, {resource: 'add_on'}) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-3', {addon_service: 'slowdb'}) - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 404})) - .then(() => { - api.done() - }) - }) - - it('fails if app not found', () => { - const api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-3', addon_service: 'slowdb'}).reply(404, {resource: 'app'}) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-3', {addon_service: 'slowdb'}) - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 404, body: {resource: 'app'}})) - .then(() => { - api.done() - }) - }) - - it('finds the addon with null namespace for an app if no namespace is specified', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-1'}) - .reply(200, [{name: 'myaddon-1', namespace: null}, {name: 'myaddon-1b', namespace: 'definitely-not-null'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-1') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-1'})) - .then(() => api.done()) - }) - - it('finds the addon with no namespace for an app if no namespace is specified', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-1'}) - .reply(200, [{name: 'myaddon-1'}, {name: 'myaddon-1b', namespace: 'definitely-not-null'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-1') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-1'})) - .then(() => api.done()) - }) - - it('finds the addon with the specified namespace for an app if there are multiple addons', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-1'}) - .reply(200, [{name: 'myaddon-1'}, {name: 'myaddon-1b', namespace: 'great-namespace'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-1', {namespace: 'great-namespace'}) - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-1b'})) - .then(() => api.done()) - }) - - it('finds the addon with the specified namespace for an app if there is only one addon', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-1'}) - .reply(200, [{name: 'myaddon-1b', namespace: 'great-namespace'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-1', {namespace: 'great-namespace'}) - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-1b'})) - .then(() => api.done()) - }) - - it('fails if there is no addon with the specified namespace for an app', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-1'}) - .reply(200, [{name: 'myaddon-1'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-1', {namespace: 'amazing-namespace'}) - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 404})) - .then(() => { - api.done() - }) - }) - - it('finds the addon with a namespace for an app if there is only match which happens to have a namespace', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-1'}) - .reply(200, [{name: 'myaddon-1', namespace: 'definitely-not-null'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-1') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-1'})) - .then(() => api.done()) - }) - - describe('memoization', () => { - it('memoizes an addon for an app', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-6'}).reply(200, [{name: 'myaddon-6'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-6') - .then(function (addon) { - expect(addon, 'to satisfy', {name: 'myaddon-6'}) - api.done() - }) - .then(function () { - nock.cleanAll() - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-6') - .then(function (memoizedAddon) { - expect(memoizedAddon, 'to satisfy', {name: 'myaddon-6'}) - }) - }) - .then(function () { - let diffId = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-7'}).reply(200, [{name: 'myaddon-7'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-7') - .then(function (diffIdAddon) { - expect(diffIdAddon, 'to satisfy', {name: 'myaddon-7'}) - diffId.done() - }) - }) - .then(function () { - let diffApp = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'fooapp', addon: 'myaddon-6'}).reply(200, [{name: 'myaddon-6'}]) - - return resolve.addon(new Heroku(), 'fooapp', 'myaddon-6') - .then(function (diffAppAddon) { - expect(diffAppAddon, 'to satisfy', {name: 'myaddon-6'}) - diffApp.done() - }) - }) - .then(function () { - let diffAddonService = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'fooapp', addon: 'myaddon-6', addon_service: 'slowdb'}).reply(200, [{name: 'myaddon-6'}]) - - return resolve.addon(new Heroku(), 'fooapp', 'myaddon-6', {addon_service: 'slowdb'}) - .then(function (diffAddonServiceAddon) { - expect(diffAddonServiceAddon, 'to satisfy', {name: 'myaddon-6'}) - diffAddonService.done() - }) - }) - }) - - it('does not memoize errors', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-8'}).reply(403, {id: 'two_factor'}) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-8') - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error.body, 'to satisfy', {id: 'two_factor'})) - .then(() => api.done()) - .then(function () { - nock.cleanAll() - - let apiRetry = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-8'}).reply(200, [{name: 'myaddon-8'}]) - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-8') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-8'})) - .then(() => apiRetry.done()) - }) - .then(function () { - nock.cleanAll() - - return resolve.addon(new Heroku(), 'myapp', 'myaddon-8') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-8'})) - }) - }) - }) - }) - - describe('appAddon', () => { - it('finds a single matching addon for an app', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-2'}).reply(200, [{name: 'myaddon-2'}]) - - return resolve.appAddon(new Heroku(), 'myapp', 'myaddon-2') - .then(addon => expect(addon, 'to satisfy', {name: 'myaddon-2'})) - .then(() => api.done()) - }) - - it('fails if not found', () => { - nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-5'}).reply(404) - - return resolve.appAddon(new Heroku(), 'myapp', 'myaddon-5') - .then(() => { - throw new Error('unreachable') - }) - .catch(error => expect(error, 'to satisfy', {statusCode: 404})) - }) - - it('fails if ambiguous', () => { - let api = nock('https://api.heroku.com:443') - .post('/actions/addons/resolve', {app: 'myapp', addon: 'myaddon-5'}) - .reply(200, [{name: 'myaddon-5'}, {name: 'myaddon-6'}]) - - return resolve.appAddon(new Heroku(), 'myapp', 'myaddon-5') - .then(() => { - throw new Error('unreachable') - }) - .catch(function (error) { - api.done() - expect(error, 'to satisfy', {message: 'Ambiguous identifier; multiple matching add-ons found: myaddon-5, myaddon-6.', type: 'addon'}) - }) - }) - }) -}) diff --git a/packages/cli/src/commands/addons/upgrade.ts b/packages/cli/src/commands/addons/upgrade.ts index a3ff61795d..e87a2b055d 100644 --- a/packages/cli/src/commands/addons/upgrade.ts +++ b/packages/cli/src/commands/addons/upgrade.ts @@ -8,9 +8,11 @@ import {HTTP} from 'http-call' import {HerokuAPIError} from '@heroku-cli/command/lib/api-client' export default class Upgrade extends Command { + static aliases = ['addons:downgrade']; static topic = 'addons' static description = 'change add-on plan' static help = 'See available plans with `heroku addons:plans SERVICE`.\n\nNote that `heroku addons:upgrade` and `heroku addons:downgrade` are the same.\nEither one can be used to change an add-on plan up or down.\n\nhttps://devcenter.heroku.com/articles/managing-add-ons' + static examples = ['Upgrade an add-on by service name:\n$ heroku addons:upgrade heroku-redis:premium-2\n\nUpgrade a specific add-on:\n$ heroku addons:upgrade swimming-briskly-123 heroku-redis:premium-2'] static flags = { app: flags.app(), } @@ -48,7 +50,7 @@ export default class Upgrade extends Command { try { const patchResult: HTTP> = await this.heroku.patch(`/apps/${app}/addons/${addonName}`, + }>> = await this.heroku.patch(`/apps/${appName}/addons/${addonName}`, { body: {plan: {name: updatedPlanName}}, headers: { @@ -70,6 +72,8 @@ ${plans.map(plan => plan.name).join('\n')}\n\nSee more plan information with ${c ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`) } + + throw error } } @@ -81,7 +85,7 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`) protected getAddonPartsFromArgs(args: { addon: string, plan: string | undefined }): { plan: string, addon: string } { let {addon, plan} = args - // called with just one argument in the form of `heroku addons:upgrade heroku-redis:hobby` + if (!plan && addon.includes(':')) { ([addon, plan] = addon.split(':')) } diff --git a/packages/cli/src/lib/addons/resolve.ts b/packages/cli/src/lib/addons/resolve.ts index 72372087c9..583f7e4b9c 100644 --- a/packages/cli/src/lib/addons/resolve.ts +++ b/packages/cli/src/lib/addons/resolve.ts @@ -17,7 +17,7 @@ export const appAddon = async function (heroku: APIClient, app: string, id: stri return singularize('addon', options.namespace)(response?.body) } -const handleNotFound = function (err: {statusCode: number, body?:{resource: string}}, resource: string) { +const handleNotFound = function (err: { statusCode: number, body?: { resource: string } }, resource: string) { if (err.statusCode === 404 && err.body && err.body.resource === resource) { return true } @@ -116,7 +116,7 @@ export const attachmentResolver = async (heroku: APIClient, app: string | undefi if (attachment) { return attachment } - } catch {} + } catch {} // if no attachment, look up an add-on that matches the id // If we were passed an add-on slug, there still could be an attachment @@ -138,10 +138,11 @@ export const attachmentResolver = async (heroku: APIClient, app: string | undefi // ----------------------------------------------------- const addonResolverMap = new Map>() + export async function resolveAddon(...args: Parameters): ReturnType { const [, app, id, options] = args const key = `${app}|${id}|${options?.addon_service ?? ''}` - const promise:ReturnType = addonResolverMap.get(key) || addonResolver(...args) + const promise: ReturnType = addonResolverMap.get(key) || addonResolver(...args) try { await promise addonResolverMap.has(key) || addonResolverMap.set(key, promise)