diff --git a/package-lock.json b/package-lock.json index 9e2ce3b..004554e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@types/lodash": "^4.14.123", "@types/mocha": "^9.1.0", "@types/nconf": "^0.10.0", + "@types/sinon": "^10.0.16", "@types/table": "^4.0.5", "@types/traverse": "^0.6.32", "@types/watch": "^1.0.1", @@ -53,6 +54,7 @@ "mocha": "^9.1.4", "nconf": "^0.11.4", "pre-commit": "^1.2.2", + "sinon": "^15.2.0", "ts-node": "^8.0.3", "typescript": "^4.5.5" } @@ -927,6 +929,50 @@ "node": ">= 8" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@types/bluebird": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.38.tgz", @@ -1098,6 +1144,21 @@ "@types/node": "*" } }, + "node_modules/@types/sinon": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.16.tgz", + "integrity": "sha512-j2Du5SYpXZjJVJtXBokASpPRj+e2z+VUhCPHmM6WMfe3dpHu6iVKJMU6AiBcMp/XTAYnEj6Wc1trJUWwZ0QaAQ==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", + "dev": true + }, "node_modules/@types/table": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/@types/table/-/table-4.0.7.tgz", @@ -3739,6 +3800,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3772,6 +3839,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4339,6 +4412,43 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/nise": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.4.tgz", + "integrity": "sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5137,6 +5247,54 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/sinon": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz", + "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^10.3.0", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.4", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index 346a953..66702bb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/lodash": "^4.14.123", "@types/mocha": "^9.1.0", "@types/nconf": "^0.10.0", + "@types/sinon": "^10.0.16", "@types/table": "^4.0.5", "@types/traverse": "^0.6.32", "@types/watch": "^1.0.1", @@ -45,6 +46,7 @@ "mocha": "^9.1.4", "nconf": "^0.11.4", "pre-commit": "^1.2.2", + "sinon": "^15.2.0", "ts-node": "^8.0.3", "typescript": "^4.5.5" }, diff --git a/src/commands/templates/push.ts b/src/commands/templates/push.ts index 389b57d..360dd1c 100644 --- a/src/commands/templates/push.ts +++ b/src/commands/templates/push.ts @@ -156,7 +156,29 @@ async function processTemplates({ newList, manifest, all, force, spinner, client if (force || await confirmation()) { spinner.text = 'Pushing templates to Postmark...' spinner.start() - return pushTemplates(spinner, client, pushManifest) + await pushTemplates( + client, + pushManifest, + function handleBeforePush(template) { + spinner.color = "yellow"; + spinner.text = `Pushing template: ${template.Alias}`; + }, + function handleError(template, error) { + spinner.stop() + logError(`\n${template.Alias || template.Name}: ${error}`) + spinner.start() + }, + function handleComplete(failed){ + spinner.stop() + log('✅ All finished!', { color: 'green' }) + + if (failed > 0) { + logError( + `⚠️ Failed to push ${failed} ${pluralize(failed, 'template', 'templates')}. Please see the output above for more details.` + ) + } + } + ) } else { log('Canceling push. Have a good day!') } @@ -312,43 +334,43 @@ function printReview({ templates, layouts }: TemplatePushReview) { /** * Push all local templates */ -async function pushTemplates(spinner: ora.Ora, client: ServerClient, templates: TemplateManifest[]) { - let failed = 0 - - for (const template of templates) { - spinner.color = 'yellow' - spinner.text = `Pushing template: ${template.Alias}` - if (template.New) { +type OnPushTemplateError = (template: TemplateManifest, error: unknown) => void +type OnPushTemplatesComplete = (failed: number) => void +type OnBeforePushTemplate = (template: TemplateManifest) => void +export async function pushTemplates( + client: ServerClient, + localTemplates: TemplateManifest[], + onBeforePush: OnBeforePushTemplate, + onError: OnPushTemplateError, + onComplete: OnPushTemplatesComplete +) { + let failed = 0; + + // Push first layouts, then standard templates. We're iterating the list twice which is not super efficient, + // but it's easier to read and maintain. + // We need to push layouts first because they can be used by standard templates. + for (const templateType of [TemplateTypes.Layout, TemplateTypes.Standard]) { + for (const template of localTemplates) { + if (template.TemplateType !== templateType) continue; + + onBeforePush(template); try { - await client.createTemplate(template) + await pushTemplate(template); } catch (error) { - handleError(error, template) - failed++ - } - } else { - invariant(template.Alias, 'Template alias is required') - try { - await client.editTemplate(template.Alias, template) - } catch (error) { - handleError(error, template) - failed++ + onError(template, error); + failed++; } } } - spinner.stop() + onComplete(failed); - log('✅ All finished!', { color: 'green' }) - - if (failed > 0) { - logError( - `⚠️ Failed to push ${failed} ${pluralize(failed, 'template', 'templates')}. Please see the output above for more details.` - ) - } - - function handleError(error: unknown, template: TemplateManifest) { - spinner.stop() - logError(`\n${template.Alias || template.Name}: ${error}`) - spinner.start() + async function pushTemplate(template: TemplateManifest): Promise { + invariant(template.Alias, "Template alias is required"); + if (template.New) { + await client.createTemplate(template); + } else { + await client.editTemplate(template.Alias, template); + } } } \ No newline at end of file diff --git a/test/unit/commands/templates/push.test.ts b/test/unit/commands/templates/push.test.ts new file mode 100644 index 0000000..0cf3e7d --- /dev/null +++ b/test/unit/commands/templates/push.test.ts @@ -0,0 +1,117 @@ +import 'mocha' +import sinon from 'sinon' +import { expect } from 'chai' +import { ServerClient } from 'postmark' +import { TemplateTypes } from 'postmark/dist/client/models' +import { pushTemplates } from '../../../../src/commands/templates/push' +import { TemplateManifest } from '../../../../src/types' + +describe("pushing templates", () => { + it("pushes layouts before standard templates", async () => { + const tm1 = makeStandardTemplateManifest({ Alias: "t1" }) + const tm2 = makeLayoutTemplateManifest({ Alias: "l1" }) + const tm3 = makeStandardTemplateManifest({ Alias: "t2" }) + + const client = { + editTemplate: sinon.stub(), + } + + await pushTemplates(client as unknown as ServerClient, [tm1, tm2, tm3], sinon.stub(), handleError, sinon.stub()) + + expect(client.editTemplate.callCount).to.eql(3) + expect(client.editTemplate.getCall(0).args[0]).to.eql("l1") + expect(client.editTemplate.getCall(1).args[0]).to.eql("t1") + expect(client.editTemplate.getCall(2).args[0]).to.eql("t2") + }) + + it("notifies before pushing each template", async () => { + const tm1 = makeStandardTemplateManifest({ Alias: "t1" }) + const tm2 = makeLayoutTemplateManifest({ Alias: "l1" }) + const tm3 = makeStandardTemplateManifest({ Alias: "t2" }) + + const client = { + editTemplate: sinon.stub(), + } + const beforePush = sinon.stub() + + await pushTemplates(client as unknown as ServerClient, [tm1, tm2, tm3], beforePush, handleError, sinon.stub()) + + expect(beforePush.callCount).to.eql(3) + }) + + it("notifies once after pushing all templates", async () => { + const tm1 = makeStandardTemplateManifest({ Alias: "t1" }) + const tm2 = makeLayoutTemplateManifest({ Alias: "l1" }) + const tm3 = makeStandardTemplateManifest({ Alias: "t2" }) + + const client = { + editTemplate: sinon.stub(), + } + const completePush = sinon.stub() + + await pushTemplates(client as unknown as ServerClient, [tm1, tm2, tm3], sinon.stub(), handleError, completePush) + + expect(completePush.callCount).to.eql(1) + expect(completePush.getCall(0).args[0]).to.eql(0) // 0 failures + }) + + it("gracefully handles push errors", async () => { + const tm1 = makeStandardTemplateManifest({ Alias: "t1" }) + const tm2 = makeLayoutTemplateManifest({ Alias: "l1" }) + const tm3 = makeStandardTemplateManifest({ Alias: "t2" }) + + let callCount = 0 + + const client = { + editTemplate: sinon.stub().callsFake(() => { + callCount++ + if (callCount > 1) { + return Promise.reject(new Error("boom")) + } else { + return Promise.resolve() + } + }), + } + const completePush = sinon.stub() + const handleError = sinon.stub() + + await pushTemplates(client as unknown as ServerClient, [tm1, tm2, tm3], sinon.stub(), handleError, completePush) + + expect(handleError.callCount).to.eql(2) + + expect(completePush.callCount).to.eql(1) + expect(completePush.getCall(0).args[0]).to.eql(2) // 2 failures + }) + +}) + +function handleError(template: TemplateManifest, error: unknown): void { + console.error(`Error pushing template ${template.Alias}: ${error}`) +} + +function makeTemplateManifest(): TemplateManifest { + return { + TemplateType: TemplateTypes.Standard, + HtmlBody: undefined, + TextBody: undefined, + Subject: undefined, + Name: undefined, + LayoutTemplate: undefined, + } +} + +function makeStandardTemplateManifest(props: Partial): TemplateManifest { + return { + ...makeTemplateManifest(), + ...props, + TemplateType: TemplateTypes.Standard, + } +} + +function makeLayoutTemplateManifest(props: Partial): TemplateManifest { + return { + ...makeTemplateManifest(), + ...props, + TemplateType: TemplateTypes.Layout, + } +}