diff --git a/src/commands/apps/diff.ts b/src/commands/apps/diff.ts index 221f7977b5..5fcbacec83 100644 --- a/src/commands/apps/diff.ts +++ b/src/commands/apps/diff.ts @@ -45,9 +45,8 @@ async function checksum(heroku: APIClient, app: string): Promise } async function diffFiles(heroku: APIClient, app1: string, app2: string): Promise { - const sum1 = await checksum(heroku, app1) - const sum2 = await checksum(heroku, app2) - return sum1 === sum2 ? [] : [{prop: 'slug (checksum)', app1: sum1 ?? undefined, app2: sum2 ?? undefined}] + const sums = await Promise.all([checksum(heroku, app1), checksum(heroku, app2)]) + return sums[0] === sums[1] ? [] : [{prop: 'slug (checksum)', app1: sums[0] ?? undefined, app2: sums[1] ?? undefined}] } async function diffEnv(heroku: APIClient, app1: string, app2: string): Promise { diff --git a/test/unit/commands/apps/diff.unit.test.ts b/test/unit/commands/apps/diff.unit.test.ts index 41002a2c44..39bf811d06 100644 --- a/test/unit/commands/apps/diff.unit.test.ts +++ b/test/unit/commands/apps/diff.unit.test.ts @@ -1,5 +1,6 @@ import {expect} from 'chai' -import nock from 'nock' +import sinon from 'sinon' +import {APIClient} from '@heroku-cli/command' import AppsDiff from '../../../../src/commands/apps/diff.js' import {runCommand} from '../../../helpers/run-command.js' @@ -17,55 +18,67 @@ describe('apps:diff', function () { const emptyAddons: Array<{addon_service: {name: string}}> = [] const emptyFeatures: Array<{name: string; enabled: boolean}> = [] - let api: nock.Scope - - function mockNoDiffs(app1: string, app2: string, slug1: string, slug2: string) { - const releases1 = releasesWithSlug(slug1) - const releases2 = releasesWithSlug(slug2) - api - .get(`/apps/${app1}/releases`) - .matchHeader('range', /version/) - .reply(200, releases1) - .get(`/apps/${app1}/slugs/${slug1}`) - .reply(200, slugBody(sameChecksum)) - .get(`/apps/${app2}/releases`) - .matchHeader('range', /version/) - .reply(200, releases2) - .get(`/apps/${app2}/slugs/${slug2}`) - .reply(200, slugBody(sameChecksum)) - .get(`/apps/${app1}/config-vars`) - .reply(200, {}) - .get(`/apps/${app2}/config-vars`) - .reply(200, {}) - .get(`/apps/${app1}`) - .reply(200, appStack('heroku-22')) - .get(`/apps/${app2}`) - .reply(200, appStack('heroku-22')) - .get(`/apps/${app1}/buildpack-installations`) - .reply(200, emptyBuildpacks) - .get(`/apps/${app2}/buildpack-installations`) - .reply(200, emptyBuildpacks) - .get(`/apps/${app1}/addons`) - .reply(200, emptyAddons) - .get(`/apps/${app2}/addons`) - .reply(200, emptyAddons) - .get(`/apps/${app1}/features`) - .reply(200, emptyFeatures) - .get(`/apps/${app2}/features`) - .reply(200, emptyFeatures) + let sandbox: sinon.SinonSandbox + let requestStub: sinon.SinonStub + let getStub: sinon.SinonStub + + function httpStatusError(statusCode: number): Error & {http: {statusCode: number}} { + const e = new Error(`HTTP ${statusCode}`) as Error & {http: {statusCode: number}} + e.http = {statusCode} + return e } beforeEach(function () { - api = nock('https://api.heroku.com') + sandbox = sinon.createSandbox() + requestStub = sandbox.stub(APIClient.prototype, 'request') + getStub = sandbox.stub(APIClient.prototype, 'get') }) afterEach(function () { - api.done() - nock.cleanAll() + sandbox.restore() }) it('prints table with no diff rows when both apps are identical', async function () { - mockNoDiffs(app1Name, app2Name, slugId1, slugId2) + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + const slugId = url.includes(app1Name) ? slugId1 : slugId2 + return {body: releasesWithSlug(slugId)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + return {body: slugBody(sameChecksum)} + } + + if (url.includes('/config-vars')) { + return {body: {}} + } + + if (url.includes('/buildpack-installations')) { + return {body: emptyBuildpacks} + } + + if (url.includes('/addons')) { + return {body: emptyAddons} + } + + if (url.includes('/features')) { + return {body: emptyFeatures} + } + + if (new RegExp(`/apps/${app1Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + if (new RegExp(`/apps/${app2Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + throw new Error(`unexpected GET ${url}`) + }) const {stdout, error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -79,21 +92,48 @@ describe('apps:diff', function () { it('includes slug (checksum) row when checksums differ', async function () { const checksum1 = 'SHA256:aaaaaaaa' const checksum2 = 'SHA256:bbbbbbbb' - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId1)) - .get(`/apps/${app1Name}/slugs/${slugId1}`).reply(200, slugBody(checksum1)) - .get(`/apps/${app2Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId2)) - .get(`/apps/${app2Name}/slugs/${slugId2}`).reply(200, slugBody(checksum2)) - .get(`/apps/${app1Name}/config-vars`).reply(200, {}) - .get(`/apps/${app2Name}/config-vars`).reply(200, {}) - .get(`/apps/${app1Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app2Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app1Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app2Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app1Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app2Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app1Name}/features`).reply(200, emptyFeatures) - .get(`/apps/${app2Name}/features`).reply(200, emptyFeatures) + + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + const slugId = url.includes(app1Name) ? slugId1 : slugId2 + return {body: releasesWithSlug(slugId)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + const checksum = url.includes(app1Name) ? checksum1 : checksum2 + return {body: slugBody(checksum)} + } + + if (url.includes('/config-vars')) { + return {body: {}} + } + + if (url.includes('/buildpack-installations')) { + return {body: emptyBuildpacks} + } + + if (url.includes('/addons')) { + return {body: emptyAddons} + } + + if (url.includes('/features')) { + return {body: emptyFeatures} + } + + if (new RegExp(`/apps/${app1Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + if (new RegExp(`/apps/${app2Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + throw new Error(`unexpected GET ${url}`) + }) const {stdout, error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -104,21 +144,50 @@ describe('apps:diff', function () { }) it('includes config and stack diff rows when they differ', async function () { - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId1)) - .get(`/apps/${app1Name}/slugs/${slugId1}`).reply(200, slugBody(sameChecksum)) - .get(`/apps/${app2Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId2)) - .get(`/apps/${app2Name}/slugs/${slugId2}`).reply(200, slugBody(sameChecksum)) - .get(`/apps/${app1Name}/config-vars`).reply(200, {FOO: 'a', BAR: 'same'}) - .get(`/apps/${app2Name}/config-vars`).reply(200, {FOO: 'b', BAR: 'same'}) - .get(`/apps/${app1Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app2Name}`).reply(200, appStack('heroku-24')) - .get(`/apps/${app1Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app2Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app1Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app2Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app1Name}/features`).reply(200, emptyFeatures) - .get(`/apps/${app2Name}/features`).reply(200, emptyFeatures) + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + const slugId = url.includes(app1Name) ? slugId1 : slugId2 + return {body: releasesWithSlug(slugId)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + return {body: slugBody(sameChecksum)} + } + + if (url.includes('/config-vars')) { + return { + body: url.includes(app1Name) + ? {FOO: 'a', BAR: 'same'} + : {FOO: 'b', BAR: 'same'}, + } + } + + if (url.includes('/buildpack-installations')) { + return {body: emptyBuildpacks} + } + + if (url.includes('/addons')) { + return {body: emptyAddons} + } + + if (url.includes('/features')) { + return {body: emptyFeatures} + } + + if (new RegExp(`/apps/${app1Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + if (new RegExp(`/apps/${app2Name}$`).test(url)) { + return {body: appStack('heroku-24')} + } + + throw new Error(`unexpected GET ${url}`) + }) const {stdout, error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -130,8 +199,25 @@ describe('apps:diff', function () { }) it('throws App not found when one app returns 404 on releases', async function () { - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(404, {id: 'not_found', message: 'Couldn\'t find that app.'}) + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + if (url.includes(app1Name)) { + throw httpStatusError(404) + } + + return {body: releasesWithSlug(slugId2)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + return {body: slugBody(sameChecksum)} + } + + throw new Error(`unexpected GET ${url}`) + }) const {error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -141,9 +227,26 @@ describe('apps:diff', function () { }) it('throws App not found when slug returns 404', async function () { - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId1)) - .get(`/apps/${app1Name}/slugs/${slugId1}`).reply(404, {id: 'not_found', message: 'Not found'}) + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + const slugId = url.includes(app1Name) ? slugId1 : slugId2 + return {body: releasesWithSlug(slugId)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/') && url.includes(app1Name)) { + throw httpStatusError(404) + } + + if (url.includes('/slugs/')) { + return {body: slugBody(sameChecksum)} + } + + throw new Error(`unexpected GET ${url}`) + }) const {error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -154,21 +257,48 @@ describe('apps:diff', function () { it('truncates long values to 56 chars with ellipsis', async function () { const longChecksum = 'SHA256:' + 'a'.repeat(60) - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId1)) - .get(`/apps/${app1Name}/slugs/${slugId1}`).reply(200, slugBody(longChecksum)) - .get(`/apps/${app2Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId2)) - .get(`/apps/${app2Name}/slugs/${slugId2}`).reply(200, slugBody(sameChecksum)) - .get(`/apps/${app1Name}/config-vars`).reply(200, {}) - .get(`/apps/${app2Name}/config-vars`).reply(200, {}) - .get(`/apps/${app1Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app2Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app1Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app2Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app1Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app2Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app1Name}/features`).reply(200, emptyFeatures) - .get(`/apps/${app2Name}/features`).reply(200, emptyFeatures) + + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + const slugId = url.includes(app1Name) ? slugId1 : slugId2 + return {body: releasesWithSlug(slugId)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + const checksum = url.includes(app1Name) ? longChecksum : sameChecksum + return {body: slugBody(checksum)} + } + + if (url.includes('/config-vars')) { + return {body: {}} + } + + if (url.includes('/buildpack-installations')) { + return {body: emptyBuildpacks} + } + + if (url.includes('/addons')) { + return {body: emptyAddons} + } + + if (url.includes('/features')) { + return {body: emptyFeatures} + } + + if (new RegExp(`/apps/${app1Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + if (new RegExp(`/apps/${app2Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + throw new Error(`unexpected GET ${url}`) + }) const {stdout, error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -179,23 +309,48 @@ describe('apps:diff', function () { }) it('shows add-on only on second app', async function () { - const addons1 = emptyAddons const addons2 = [{addon_service: {name: 'heroku-postgresql'}}] - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId1)) - .get(`/apps/${app1Name}/slugs/${slugId1}`).reply(200, slugBody(sameChecksum)) - .get(`/apps/${app2Name}/releases`).matchHeader('range', /version/).reply(200, releasesWithSlug(slugId2)) - .get(`/apps/${app2Name}/slugs/${slugId2}`).reply(200, slugBody(sameChecksum)) - .get(`/apps/${app1Name}/config-vars`).reply(200, {}) - .get(`/apps/${app2Name}/config-vars`).reply(200, {}) - .get(`/apps/${app1Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app2Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app1Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app2Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app1Name}/addons`).reply(200, addons1) - .get(`/apps/${app2Name}/addons`).reply(200, addons2) - .get(`/apps/${app1Name}/features`).reply(200, emptyFeatures) - .get(`/apps/${app2Name}/features`).reply(200, emptyFeatures) + + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + const slugId = url.includes(app1Name) ? slugId1 : slugId2 + return {body: releasesWithSlug(slugId)} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + return {body: slugBody(sameChecksum)} + } + + if (url.includes('/config-vars')) { + return {body: {}} + } + + if (url.includes('/buildpack-installations')) { + return {body: emptyBuildpacks} + } + + if (url.includes('/addons')) { + return {body: url.includes(app1Name) ? emptyAddons : addons2} + } + + if (url.includes('/features')) { + return {body: emptyFeatures} + } + + if (new RegExp(`/apps/${app1Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + if (new RegExp(`/apps/${app2Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + throw new Error(`unexpected GET ${url}`) + }) const {stdout, error} = await runCommand(AppsDiff, [app1Name, app2Name]) @@ -207,19 +362,46 @@ describe('apps:diff', function () { it('shows no slug row when both apps have no release slug', async function () { const releasesNoSlug = [{status: 'succeeded'}] - api - .get(`/apps/${app1Name}/releases`).matchHeader('range', /version/).reply(200, releasesNoSlug) - .get(`/apps/${app2Name}/releases`).matchHeader('range', /version/).reply(200, releasesNoSlug) - .get(`/apps/${app1Name}/config-vars`).reply(200, {}) - .get(`/apps/${app2Name}/config-vars`).reply(200, {}) - .get(`/apps/${app1Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app2Name}`).reply(200, appStack('heroku-22')) - .get(`/apps/${app1Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app2Name}/buildpack-installations`).reply(200, emptyBuildpacks) - .get(`/apps/${app1Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app2Name}/addons`).reply(200, emptyAddons) - .get(`/apps/${app1Name}/features`).reply(200, emptyFeatures) - .get(`/apps/${app2Name}/features`).reply(200, emptyFeatures) + + requestStub.callsFake(async (url: string) => { + if (!url.includes('/releases')) { + throw new Error(`unexpected request ${url}`) + } + + return {body: releasesNoSlug} + }) + + getStub.callsFake(async (url: string) => { + if (url.includes('/slugs/')) { + throw new Error(`unexpected slug GET ${url}`) + } + + if (url.includes('/config-vars')) { + return {body: {}} + } + + if (url.includes('/buildpack-installations')) { + return {body: emptyBuildpacks} + } + + if (url.includes('/addons')) { + return {body: emptyAddons} + } + + if (url.includes('/features')) { + return {body: emptyFeatures} + } + + if (new RegExp(`/apps/${app1Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + if (new RegExp(`/apps/${app2Name}$`).test(url)) { + return {body: appStack('heroku-22')} + } + + throw new Error(`unexpected GET ${url}`) + }) const {stdout, error} = await runCommand(AppsDiff, [app1Name, app2Name])