diff --git a/kits/firestore-send-email/package.json b/kits/firestore-send-email/package.json index abf679e55..fd8ee5a2f 100644 --- a/kits/firestore-send-email/package.json +++ b/kits/firestore-send-email/package.json @@ -26,6 +26,8 @@ "scripts": { "build": "tsc -b", "clean": "tsc -b --clean", + "//pretest": "Only this kit has a test that requires lib/. The release workflow's test job runs npm test without building, so the hook has to live here rather than in CI.", + "pretest": "npm run build", "test": "vitest run", "deploy": "pnpm build && firebase deploy --only functions", "serve": "firebase emulators:start --only functions" diff --git a/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts b/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts index 0f83a0f73..29d7bce41 100644 --- a/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts +++ b/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import sgMail from "@sendgrid/mail"; +import { MailService } from "@sendgrid/mail"; import type { Address, MailSource, @@ -29,9 +29,13 @@ export class SendGridTransport { public readonly name = "firebase-extensions-nodemailer-sendgrid"; public readonly version = "0.0.1"; + // @sendgrid/mail's default export is a shared MailService singleton, so + // setApiKey on it would reconfigure every other transport in the process. + private readonly client = new MailService(); + constructor(options: SendGridTransportOptions = {}) { if (options.apiKey) { - sgMail.setApiKey(options.apiKey); + this.client.setApiKey(options.apiKey); } } @@ -164,8 +168,8 @@ export class SendGridTransport { } } - sgMail - .send(msg as Parameters[0]) + this.client + .send(msg as Parameters[0]) .then(([response]) => { const rawQueueId = (response.headers["x-message-id"] || response.headers["X-Message-Id"]) as string | undefined; diff --git a/kits/firestore-send-email/tests/build-interop.test.ts b/kits/firestore-send-email/tests/build-interop.test.ts index d9e7be082..d2014be53 100644 --- a/kits/firestore-send-email/tests/build-interop.test.ts +++ b/kits/firestore-send-email/tests/build-interop.test.ts @@ -17,10 +17,11 @@ import { createRequire } from "node:module"; import { describe, expect, test } from "vitest"; -// The subject is the compiled output, not src: only tsc's esModuleInterop -// helper drops the prototype methods off the instance @sendgrid/mail exports, -// and vitest's own transform does not reproduce that. Needs `npm run build`, -// which CI runs before `npm test`. +// The subject is the compiled output, not src: tsc's esModuleInterop helper +// strips the prototype methods off the instance @sendgrid/mail exports, and +// vitest's own transform does not reproduce that. The named import in +// nodemailer-sendgrid emits no helper; this guards a revert to a default one. +// The pretest hook builds lib/ before this runs. const requireBuilt = createRequire(import.meta.url); describe("built SendGridTransport", () => { diff --git a/kits/firestore-send-email/tests/helpers.test.ts b/kits/firestore-send-email/tests/helpers.test.ts index 035538d34..f2207d758 100644 --- a/kits/firestore-send-email/tests/helpers.test.ts +++ b/kits/firestore-send-email/tests/helpers.test.ts @@ -18,11 +18,16 @@ import { logger } from "firebase-functions"; import Mail from "nodemailer/lib/mailer"; import { beforeEach, describe, expect, test, vi } from "vitest"; -// @sendgrid/mail exports a single MailService instance, so the mock has to be -// reachable as both the default and the named exports. +// @sendgrid/mail exports a MailService singleton whose methods are also the +// module's own exports, plus the MailService class. Constructing the class +// hands back the same spies as the singleton, so the module-level ones see +// whatever the transport's own client was configured with. vi.mock("@sendgrid/mail", () => { const mail = { setApiKey: vi.fn(), send: vi.fn() }; - return { ...mail, default: mail }; + const MailService = vi.fn(function () { + return mail; + }); + return { ...mail, MailService }; }); import * as sgMail from "@sendgrid/mail"; diff --git a/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts b/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts index 12824eecf..d60857aae 100644 --- a/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts +++ b/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts @@ -16,8 +16,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -// @sendgrid/mail exports a single MailService instance, so the mock has to be -// reachable as both the default and the named exports. +// @sendgrid/mail exports a MailService singleton whose methods are also the +// module's own exports, plus the MailService class. Constructing the class +// hands back the same spies as the singleton, so most tests can assert against +// the module-level ones; the isolation tests override the implementation to +// tell the two apart. vi.mock("@sendgrid/mail", () => { const mail = { setApiKey: vi.fn(), @@ -29,7 +32,10 @@ vi.mock("@sendgrid/mail", () => { {}, ]), }; - return { ...mail, default: mail }; + const MailService = vi.fn(function () { + return mail; + }); + return { ...mail, MailService }; }); import * as sgMail from "@sendgrid/mail"; @@ -43,6 +49,7 @@ import type { const setApiKey = vi.mocked(sgMail.setApiKey); const send = vi.mocked(sgMail.send); +const MailServiceMock = vi.mocked(sgMail.MailService); /** Wraps a normalized mail source in the minimal MailSource shape the transport consumes. */ function mailFrom(source: Partial): MailSource { @@ -84,6 +91,29 @@ describe("SendGridTransport", () => { expect(setApiKey).not.toHaveBeenCalled(); }); + test("builds its own MailService rather than using the module singleton", () => { + new SendGridTransport({ apiKey: "API-KEY-123" }); + expect(MailServiceMock).toHaveBeenCalledTimes(1); + }); + + test("keeps API keys separate across transports", () => { + const first = { setApiKey: vi.fn(), send: vi.fn() }; + const second = { setApiKey: vi.fn(), send: vi.fn() }; + MailServiceMock.mockImplementationOnce(function () { + return first as never; + }).mockImplementationOnce(function () { + return second as never; + }); + + new SendGridTransport({ apiKey: "FIRST" }); + new SendGridTransport({ apiKey: "SECOND" }); + + expect(first.setApiKey.mock.calls).toEqual([["FIRST"]]); + expect(second.setApiKey.mock.calls).toEqual([["SECOND"]]); + // The shared singleton the module exports is never reconfigured. + expect(setApiKey).not.toHaveBeenCalled(); + }); + test("calls back with the normalize error and never sends", async () => { const transport = new SendGridTransport({ apiKey: "X" }); const normalizeError = new Error("normalize failed");