From c7168308bfbf9aa0cf746509c9b3e256f98a04cd Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 2 Sep 2026 19:40:37 +0100 Subject: [PATCH 1/3] fix(firestore-send-email): map DATABASE_REGION to a Cloud Run region for function placement Stop passing the raw DATABASE_REGION value as the function region. Firestore multi-region locations (nam5, nam7, eur3) are not Cloud Run regions, so any multi-region database made every deploy fail. Map them to a region inside the multi-region (nam5/nam7 -> us-central1, eur3 -> europe-west1); regional locations pass through unchanged. With the parameter unset or empty the function declares no region and the CLI resolves one at deploy time. Fixes #3069. --- kits/firestore-send-email/CHANGELOG.md | 1 + kits/firestore-send-email/README.md | 82 +++++++++++-------- kits/firestore-send-email/src/config.ts | 10 ++- .../firestore-send-email/src/export-config.ts | 3 +- kits/firestore-send-email/src/index.ts | 2 +- kits/firestore-send-email/src/region.ts | 37 +++++++++ .../tests/deploy-options.test.ts | 55 +++++++++++-- .../firestore-send-email/tests/region.test.ts | 44 ++++++++++ 8 files changed, 188 insertions(+), 46 deletions(-) create mode 100644 kits/firestore-send-email/src/region.ts create mode 100644 kits/firestore-send-email/tests/region.test.ts diff --git a/kits/firestore-send-email/CHANGELOG.md b/kits/firestore-send-email/CHANGELOG.md index 711eb60d36..46b907430b 100644 --- a/kits/firestore-send-email/CHANGELOG.md +++ b/kits/firestore-send-email/CHANGELOG.md @@ -1 +1,2 @@ +- fix: map `DATABASE_REGION` to a valid Cloud Run region before using it as the function region. Firestore multi-region locations (`nam5`, `nam7`) now deploy the function to `us-central1` and `eur3` to `europe-west1` instead of failing the deploy; regional locations pass through unchanged. With the parameter unset or empty the function declares no region and the Firebase CLI resolves one at deploy time. - Initial release of kit, see README for differences between the legacy extension and this kit diff --git a/kits/firestore-send-email/README.md b/kits/firestore-send-email/README.md index 722146cd36..13ac8ddfd5 100644 --- a/kits/firestore-send-email/README.md +++ b/kits/firestore-send-email/README.md @@ -22,11 +22,11 @@ Firebase CLI 15.23.0 or later creates that account, grants the roles below, and attaches it to every function in this kit. Do not set a custom runtime service account for this codebase — it conflicts with that automatic setup. -| Role | Why | -|---|---| -| `roles/datastore.user` | read mail documents and write delivery status | -| `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | -| `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| Role | Why | +| ------------------------------ | --------------------------------------------------- | +| `roles/datastore.user` | read mail documents and write delivery status | +| `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | +| `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | ## Usage @@ -80,28 +80,28 @@ loads them at deploy time and prompts for any required values that are missing. Rows marked `secret` live in Secret Manager. You can reuse existing secrets; the CLI connects them to the function at deploy time. -| Field | Env var | Required | Default | Description | -|---|---|---|---|---| -| `mailCollection` | `MAIL_COLLECTION` | no | `mail` | Firestore collection of outbound mail docs | -| `defaultFrom` | `DEFAULT_FROM` | yes | — | Default From address | -| `defaultReplyTo` | `DEFAULT_REPLY_TO` | no | (empty) | Default Reply-To address | -| `databaseRegion` | `DATABASE_REGION` | yes | — | Region for the trigger | -| `databaseId` | `DATABASE` | no | `(default)` | Firestore database id | -| `authType` | `AUTH_TYPE` | no | `UsernamePassword` | `UsernamePassword` or `OAuth2` | -| `smtpConnectionUri` | `SMTP_CONNECTION_URI` | no | (empty) | SMTP connection URI (username/password auth) | -| `smtpPassword` | `SMTP_PASSWORD` | secret | — | SMTP password | -| `host` | `HOST` | no | (empty) | SMTP host (OAuth2) | -| `oauthPort` | `OAUTH_PORT` | no | `465` | SMTP port (OAuth2) | -| `oauthSecure` | `OAUTH_SECURE` | no | `true` | Use TLS (OAuth2) | -| `user` | `USER` | no | (empty) | SMTP username (OAuth2) | -| `clientId` | `CLIENT_ID` | secret | — | OAuth2 client id | -| `clientSecret` | `CLIENT_SECRET` | secret | — | OAuth2 client secret | -| `refreshToken` | `REFRESH_TOKEN` | secret | — | OAuth2 refresh token | -| `templatesCollection` | `TEMPLATES_COLLECTION` | no | (empty) | Optional Handlebars templates collection | -| `usersCollection` | `USERS_COLLECTION` | no | (empty) | Optional users collection for recipient lookup | -| `ttlExpireType` | `TTL_EXPIRE_TYPE` | no | `never` | TTL policy for processed docs | -| `ttlExpireValue` | `TTL_EXPIRE_VALUE` | no | `1` | TTL amount when expire type is set | -| `tlsOptions` | `TLS_OPTIONS` | no | `{}` | JSON TLS options for the SMTP transport | +| Field | Env var | Required | Default | Description | +| --------------------- | ---------------------- | -------- | ------------------ | ----------------------------------------------------- | +| `mailCollection` | `MAIL_COLLECTION` | no | `mail` | Firestore collection of outbound mail docs | +| `defaultFrom` | `DEFAULT_FROM` | yes | — | Default From address | +| `defaultReplyTo` | `DEFAULT_REPLY_TO` | no | (empty) | Default Reply-To address | +| `databaseRegion` | `DATABASE_REGION` | yes | (prompted) | Firestore database location; also places the function | +| `databaseId` | `DATABASE` | no | `(default)` | Firestore database id | +| `authType` | `AUTH_TYPE` | no | `UsernamePassword` | `UsernamePassword` or `OAuth2` | +| `smtpConnectionUri` | `SMTP_CONNECTION_URI` | no | (empty) | SMTP connection URI (username/password auth) | +| `smtpPassword` | `SMTP_PASSWORD` | secret | — | SMTP password | +| `host` | `HOST` | no | (empty) | SMTP host (OAuth2) | +| `oauthPort` | `OAUTH_PORT` | no | `465` | SMTP port (OAuth2) | +| `oauthSecure` | `OAUTH_SECURE` | no | `true` | Use TLS (OAuth2) | +| `user` | `USER` | no | (empty) | SMTP username (OAuth2) | +| `clientId` | `CLIENT_ID` | secret | — | OAuth2 client id | +| `clientSecret` | `CLIENT_SECRET` | secret | — | OAuth2 client secret | +| `refreshToken` | `REFRESH_TOKEN` | secret | — | OAuth2 refresh token | +| `templatesCollection` | `TEMPLATES_COLLECTION` | no | (empty) | Optional Handlebars templates collection | +| `usersCollection` | `USERS_COLLECTION` | no | (empty) | Optional users collection for recipient lookup | +| `ttlExpireType` | `TTL_EXPIRE_TYPE` | no | `never` | TTL policy for processed docs | +| `ttlExpireValue` | `TTL_EXPIRE_VALUE` | no | `1` | TTL amount when expire type is set | +| `tlsOptions` | `TLS_OPTIONS` | no | `{}` | JSON TLS options for the SMTP transport | ## Multiple instances @@ -158,15 +158,25 @@ value, and on OAuth2 auth do the same for `SMTP_PASSWORD`. ### DATABASE_REGION now decides where the function runs -In the extension it only told the trigger where your database lived; the function -itself ran in the Cloud Functions location you picked at install. The kit passes -`DATABASE_REGION` straight through as the function's region, so the function -moves to your database's region and the install-time location setting has no -replacement. If your Firestore is multi-region or dual-region (`nam5`, `nam7`, -`eur3`), that value is not a Cloud Functions region and the deploy fails; deploy -the trigger yourself from the package's `./lib` entry point with a real region -such as `us-central1` or `europe-west1`. This was not exercised against a live -deploy. +In the extension it only told the trigger where your database lived; the +function itself ran in the Cloud Functions location you picked at install. The +kit deploys the function to the region derived from `DATABASE_REGION`, so the +function moves next to your database and the install-time location setting has +no replacement. Regional Firestore locations (`europe-west2`, `us-east1`, ...) +are used as-is; the multi-region locations map to a Cloud Run region inside +them - `nam5` and `nam7` to `us-central1`, `eur3` to `europe-west1` - because +they are not Cloud Run regions themselves and would fail the deploy. The +Firestore trigger always fires in the database's own region, whatever region +the function runs in. + +With `DATABASE_REGION` unset or empty, the function declares no region and the +Firebase CLI resolves one at deploy time: it keeps the region it is already +deployed in, and on a first deploy lands in `us-central1` unless you set the +`FIREBASE_FUNCTIONS_DEFAULT_REGION` environment variable when running +`firebase deploy`. Careful with that variable: it applies to every no-region +function in the deploy, not just this kit. Note that changing an existing +install's function region deletes and recreates the function in the new +region. ### Create the Eventarc channel yourself for events diff --git a/kits/firestore-send-email/src/config.ts b/kits/firestore-send-email/src/config.ts index 77059e6e53..a096607f5c 100644 --- a/kits/firestore-send-email/src/config.ts +++ b/kits/firestore-send-email/src/config.ts @@ -27,6 +27,7 @@ import type { SecretValue, SendEmailConfig, } from "./export-config"; +import { firestoreLocationToFunctionRegion } from "./region"; import { AuthenticatonType } from "./types"; const DATABASE_REGION_OPTIONS = [ @@ -96,7 +97,7 @@ const params = { databaseRegion: defineString("DATABASE_REGION", { label: "Firestore Instance Location", description: - "Where is the Firestore database located? You can check your current database location at [https://console.cloud.google.com/firestore/databases](https://console.cloud.google.com/firestore/databases).", + "Where is the Firestore database located? You can check your current database location at [https://console.cloud.google.com/firestore/databases](https://console.cloud.google.com/firestore/databases). The function in this kit deploys to the Cloud Run region closest to this location.", input: select({ "Multi-region (Europe - Belgium and Netherlands)": "eur3", @@ -369,9 +370,14 @@ export function configFromEnv(): SendEmailConfig { } export function envDeployOptions(): DeployTimeOptions { + // The region option cannot be a param expression, so the value is read from + // `process.env` (populated from `.env` during CLI discovery) instead of via + // the param declared above. + const region = firestoreLocationToFunctionRegion(process.env.DATABASE_REGION); + return { document: expr`${params.mailCollection}/{documentId}`, database: params.databaseId, - region: params.databaseRegion, + ...(region ? { region } : {}), }; } diff --git a/kits/firestore-send-email/src/export-config.ts b/kits/firestore-send-email/src/export-config.ts index b1a6346e63..90ecf22b62 100644 --- a/kits/firestore-send-email/src/export-config.ts +++ b/kits/firestore-send-email/src/export-config.ts @@ -57,7 +57,8 @@ export interface SendEmailConfig { export interface DeployTimeOptions { document: string | Expression; database: string | Expression; - region: string | Expression; + /** Cloud Run region for the function; omitted when `DATABASE_REGION` is unset. */ + region?: string; } export interface ResolvedSendEmailConfig { diff --git a/kits/firestore-send-email/src/index.ts b/kits/firestore-send-email/src/index.ts index de44f6f815..9c97957651 100644 --- a/kits/firestore-send-email/src/index.ts +++ b/kits/firestore-send-email/src/index.ts @@ -81,7 +81,7 @@ function ensureInitialized(): Promise { export const processQueue = onDocumentWritten( { - region: deploy.region, + ...(deploy.region ? { region: deploy.region } : {}), document: deploy.document, database: deploy.database, timeoutSeconds: 120, diff --git a/kits/firestore-send-email/src/region.ts b/kits/firestore-send-email/src/region.ts new file mode 100644 index 0000000000..51f07e1f05 --- /dev/null +++ b/kits/firestore-send-email/src/region.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Firestore multi-region locations are not Cloud Run regions; deploying a +// function to one hard-fails, so they map to a region inside the multi-region. +const MULTI_REGION_TO_FUNCTION_REGION: Record = { + nam5: "us-central1", + nam7: "us-central1", + eur3: "europe-west1", +}; + +/** + * Maps a Firestore database location to the Cloud Run region the functions + * should deploy to. Regional locations pass through unchanged; an unset or + * empty location returns `undefined`, meaning the functions declare no region. + */ +export function firestoreLocationToFunctionRegion( + location: string | undefined +): string | undefined { + if (!location) { + return undefined; + } + return MULTI_REGION_TO_FUNCTION_REGION[location] ?? location; +} diff --git a/kits/firestore-send-email/tests/deploy-options.test.ts b/kits/firestore-send-email/tests/deploy-options.test.ts index e44cecec44..52f2e8748a 100644 --- a/kits/firestore-send-email/tests/deploy-options.test.ts +++ b/kits/firestore-send-email/tests/deploy-options.test.ts @@ -15,32 +15,75 @@ */ import { Expression } from "firebase-functions/params"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { envDeployOptions } from "../src/config"; const cel = (value: unknown): string => value instanceof Expression ? value.toCEL() : String(value); +const originalDatabaseRegion = process.env.DATABASE_REGION; + +function setDatabaseRegion(value?: string): void { + if (value === undefined) { + delete process.env.DATABASE_REGION; + } else { + process.env.DATABASE_REGION = value; + } +} + +afterEach(() => { + setDatabaseRegion(originalDatabaseRegion); +}); + describe("envDeployOptions", () => { - const options = envDeployOptions(); + test("emits CEL for document and database", () => { + const options = envDeployOptions(); - test("emits CEL for document, database, and region", () => { expect(options.document).toBeInstanceOf(Expression); expect(options.database).toBeInstanceOf(Expression); - expect(options.region).toBeInstanceOf(Expression); expect(cel(options.document)).toBe( "{{ params.MAIL_COLLECTION }}/{documentId}" ); expect(cel(options.database)).toBe("{{ params.DATABASE }}"); - expect(cel(options.region)).toBe("{{ params.DATABASE_REGION }}"); + }); + + test.each([ + ["nam5", "us-central1"], + ["nam7", "us-central1"], + ["eur3", "europe-west1"], + ])( + "multi-region DATABASE_REGION %s maps the function region to %s", + (databaseRegion, expectedRegion) => { + setDatabaseRegion(databaseRegion); + expect(envDeployOptions().region).toBe(expectedRegion); + } + ); + + test("regional DATABASE_REGION passes through as the function region", () => { + setDatabaseRegion("europe-west1"); + expect(envDeployOptions().region).toBe("europe-west1"); + }); + + test("unset DATABASE_REGION omits the region option", () => { + setDatabaseRegion(undefined); + expect(envDeployOptions()).not.toHaveProperty("region"); + }); + + test("empty DATABASE_REGION omits the region option", () => { + setDatabaseRegion(""); + expect(envDeployOptions()).not.toHaveProperty("region"); }); test("serialized deploy-time options do not contain undefined", () => { + setDatabaseRegion(undefined); const serialized = JSON.stringify( Object.fromEntries( - Object.entries(options).map(([key, value]) => [key, cel(value)]) + Object.entries(envDeployOptions()).map(([key, value]) => [ + key, + cel(value), + ]) ) ); diff --git a/kits/firestore-send-email/tests/region.test.ts b/kits/firestore-send-email/tests/region.test.ts new file mode 100644 index 0000000000..7411fecc52 --- /dev/null +++ b/kits/firestore-send-email/tests/region.test.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; + +import { firestoreLocationToFunctionRegion } from "../src/region"; + +describe("firestoreLocationToFunctionRegion", () => { + test.each([ + ["nam5", "us-central1"], + ["nam7", "us-central1"], + ["eur3", "europe-west1"], + ])("maps the multi-region location %s to %s", (location, region) => { + expect(firestoreLocationToFunctionRegion(location)).toBe(region); + }); + + test.each(["us-central1", "europe-west1", "asia-northeast1", "us-east1"])( + "passes the regional location %s through unchanged", + (location) => { + expect(firestoreLocationToFunctionRegion(location)).toBe(location); + } + ); + + test("returns undefined for an unset location", () => { + expect(firestoreLocationToFunctionRegion(undefined)).toBeUndefined(); + }); + + test("returns undefined for an empty location", () => { + expect(firestoreLocationToFunctionRegion("")).toBeUndefined(); + }); +}); From cfe7199291882b9219496d04a66b027ae01e6a46 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 8 Sep 2026 14:28:03 +0100 Subject: [PATCH 2/3] fix(firestore-send-email): normalize DATABASE_REGION and pin the function options Lowercase and trim the value before the multi-region lookup so a hand-edited `.env` carrying `NAM5` still lands in `us-central1` instead of failing the deploy. Add an index test that asserts the region option on `processQueue` for set and unset values, document the firebase-tools 15.28.0 floor and the second-deploy caveat, and warn that mail written while the function is being moved between regions is never delivered. --- kits/firestore-send-email/CHANGELOG.md | 2 +- kits/firestore-send-email/README.md | 36 ++++++-- kits/firestore-send-email/src/config.ts | 6 +- kits/firestore-send-email/src/region.ts | 11 ++- kits/firestore-send-email/tests/index.test.ts | 91 +++++++++++++++++++ .../firestore-send-email/tests/region.test.ts | 18 ++++ 6 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 kits/firestore-send-email/tests/index.test.ts diff --git a/kits/firestore-send-email/CHANGELOG.md b/kits/firestore-send-email/CHANGELOG.md index 46b907430b..4f8bbba21a 100644 --- a/kits/firestore-send-email/CHANGELOG.md +++ b/kits/firestore-send-email/CHANGELOG.md @@ -1,2 +1,2 @@ -- fix: map `DATABASE_REGION` to a valid Cloud Run region before using it as the function region. Firestore multi-region locations (`nam5`, `nam7`) now deploy the function to `us-central1` and `eur3` to `europe-west1` instead of failing the deploy; regional locations pass through unchanged. With the parameter unset or empty the function declares no region and the Firebase CLI resolves one at deploy time. +- fix: map `DATABASE_REGION` to a valid Cloud Run region before using it as the function region. Firestore multi-region locations (`nam5`, `nam7`) now deploy the function to `us-central1` and `eur3` to `europe-west1` instead of failing the deploy; regional locations pass through unchanged. The value is matched case-insensitively. With the parameter unset or empty the function declares no region and the Firebase CLI resolves one at deploy time. - Initial release of kit, see README for differences between the legacy extension and this kit diff --git a/kits/firestore-send-email/README.md b/kits/firestore-send-email/README.md index 13ac8ddfd5..551121ece3 100644 --- a/kits/firestore-send-email/README.md +++ b/kits/firestore-send-email/README.md @@ -159,15 +159,28 @@ value, and on OAuth2 auth do the same for `SMTP_PASSWORD`. ### DATABASE_REGION now decides where the function runs In the extension it only told the trigger where your database lived; the -function itself ran in the Cloud Functions location you picked at install. The -kit deploys the function to the region derived from `DATABASE_REGION`, so the -function moves next to your database and the install-time location setting has -no replacement. Regional Firestore locations (`europe-west2`, `us-east1`, ...) -are used as-is; the multi-region locations map to a Cloud Run region inside -them - `nam5` and `nam7` to `us-central1`, `eur3` to `europe-west1` - because -they are not Cloud Run regions themselves and would fail the deploy. The -Firestore trigger always fires in the database's own region, whatever region -the function runs in. +function itself always ran in `us-central1`, as the extension offered no +location setting. The kit deploys the function to the region derived from +`DATABASE_REGION`, so the function moves next to your database. Regional +Firestore locations (`europe-west2`, `us-east1`, ...) are used as-is; the +multi-region locations map to a Cloud Run region inside them - `nam5` and +`nam7` to `us-central1`, `eur3` to `europe-west1` - because they are not Cloud +Run regions themselves and would fail the deploy. The value is matched +case-insensitively. The Firestore trigger always fires in the database's own +region, whatever region the function runs in. + +If you copied `DATABASE_REGION` into your `.env` from an extension install, it +is honored: the function deploys near your database. + +Placement needs firebase-tools 15.28.0 or later - older CLIs do not load +`.env` values during deploy discovery, so the function silently falls back to +the no-region behavior below. Two consequences worth knowing before you +deploy. Upgrading the CLI (or this kit, if your `.env` already carried +`DATABASE_REGION`) can itself trigger the region move described below on your +next deploy. And on a fresh interactive install the value you enter at the +prompt only takes effect from the second deploy: the first deploy computes the +region before the prompt runs, so it lands in `us-central1` and the next deploy +moves the function. With `DATABASE_REGION` unset or empty, the function declares no region and the Firebase CLI resolves one at deploy time: it keeps the region it is already @@ -176,7 +189,10 @@ deployed in, and on a first deploy lands in `us-central1` unless you set the `firebase deploy`. Careful with that variable: it applies to every no-region function in the deploy, not just this kit. Note that changing an existing install's function region deletes and recreates the function in the new -region. +region. `processQueue` is the kit's only function and nothing reconciles the +mail collection afterwards, so any document written while the function is gone +is never delivered. Stop writers and let the collection drain before a deploy +that moves the region. ### Create the Eventarc channel yourself for events diff --git a/kits/firestore-send-email/src/config.ts b/kits/firestore-send-email/src/config.ts index a096607f5c..754979ef54 100644 --- a/kits/firestore-send-email/src/config.ts +++ b/kits/firestore-send-email/src/config.ts @@ -370,9 +370,9 @@ export function configFromEnv(): SendEmailConfig { } export function envDeployOptions(): DeployTimeOptions { - // The region option cannot be a param expression, so the value is read from - // `process.env` (populated from `.env` during CLI discovery) instead of via - // the param declared above. + // The multi-region to Cloud Run region lookup cannot be expressed in CEL, so + // the value is read from `process.env` (populated from `.env` during CLI + // discovery) instead of via the param expression. const region = firestoreLocationToFunctionRegion(process.env.DATABASE_REGION); return { diff --git a/kits/firestore-send-email/src/region.ts b/kits/firestore-send-email/src/region.ts index 51f07e1f05..0ca47d4b25 100644 --- a/kits/firestore-send-email/src/region.ts +++ b/kits/firestore-send-email/src/region.ts @@ -24,14 +24,17 @@ const MULTI_REGION_TO_FUNCTION_REGION: Record = { /** * Maps a Firestore database location to the Cloud Run region the functions - * should deploy to. Regional locations pass through unchanged; an unset or - * empty location returns `undefined`, meaning the functions declare no region. + * should deploy to. The lookup is case-insensitive and ignores surrounding + * whitespace, as the CLI's own region handling is. Regional locations pass + * through lowercased; an unset or blank location returns `undefined`, meaning + * the functions declare no region. */ export function firestoreLocationToFunctionRegion( location: string | undefined ): string | undefined { - if (!location) { + const normalized = location?.trim().toLowerCase(); + if (!normalized) { return undefined; } - return MULTI_REGION_TO_FUNCTION_REGION[location] ?? location; + return MULTI_REGION_TO_FUNCTION_REGION[normalized] ?? normalized; } diff --git a/kits/firestore-send-email/tests/index.test.ts b/kits/firestore-send-email/tests/index.test.ts new file mode 100644 index 0000000000..c0e6a647e9 --- /dev/null +++ b/kits/firestore-send-email/tests/index.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("firebase-functions/firestore", () => ({ + onDocumentWritten: vi.fn(() => ({})), +})); +vi.mock("firebase-functions/v2", () => ({ + requiresRole: vi.fn(), +})); + +type FunctionOptions = Record; + +const originalDatabaseRegion = process.env.DATABASE_REGION; + +afterEach(() => { + if (originalDatabaseRegion === undefined) { + delete process.env.DATABASE_REGION; + } else { + process.env.DATABASE_REGION = originalDatabaseRegion; + } +}); + +async function loadTriggerOptions( + databaseRegion?: string +): Promise { + vi.resetModules(); + if (databaseRegion === undefined) { + delete process.env.DATABASE_REGION; + } else { + process.env.DATABASE_REGION = databaseRegion; + } + + await import("../src/index"); + const { onDocumentWritten } = await import("firebase-functions/firestore"); + + const calls = vi.mocked(onDocumentWritten).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0] as FunctionOptions; +} + +describe("processQueue options", () => { + test.each([ + ["nam5", "us-central1"], + ["nam7", "us-central1"], + ["eur3", "europe-west1"], + ["NAM5", "us-central1"], + ])( + "multi-region DATABASE_REGION %s deploys the function to %s", + async (databaseRegion, expectedRegion) => { + const options = await loadTriggerOptions(databaseRegion); + expect(options.region).toBe(expectedRegion); + } + ); + + test("regional DATABASE_REGION passes through as the function region", async () => { + const options = await loadTriggerOptions("europe-west1"); + expect(options.region).toBe("europe-west1"); + }); + + test("unset DATABASE_REGION leaves the function without a region", async () => { + const options = await loadTriggerOptions(); + expect(options).not.toHaveProperty("region"); + }); + + test("empty DATABASE_REGION leaves the function without a region", async () => { + const options = await loadTriggerOptions(""); + expect(options).not.toHaveProperty("region"); + }); + + test("the trigger binds to the configured database and collection", async () => { + const options = await loadTriggerOptions(); + expect(String(options.database)).toBe("params.DATABASE"); + const document = options.document as { toCEL(): string }; + expect(document.toCEL()).toContain("params.MAIL_COLLECTION"); + }); +}); diff --git a/kits/firestore-send-email/tests/region.test.ts b/kits/firestore-send-email/tests/region.test.ts index 7411fecc52..5d983bf0cd 100644 --- a/kits/firestore-send-email/tests/region.test.ts +++ b/kits/firestore-send-email/tests/region.test.ts @@ -41,4 +41,22 @@ describe("firestoreLocationToFunctionRegion", () => { test("returns undefined for an empty location", () => { expect(firestoreLocationToFunctionRegion("")).toBeUndefined(); }); + + test("returns undefined for a whitespace-only location", () => { + expect(firestoreLocationToFunctionRegion(" ")).toBeUndefined(); + }); + + test.each([ + ["NAM5", "us-central1"], + ["Eur3", "europe-west1"], + [" nam7 ", "us-central1"], + ])("normalizes %s before the multi-region lookup", (location, region) => { + expect(firestoreLocationToFunctionRegion(location)).toBe(region); + }); + + test("lowercases and trims a regional location", () => { + expect(firestoreLocationToFunctionRegion(" Europe-West2 ")).toBe( + "europe-west2" + ); + }); }); From bd616ac5b11026593d1c9f35303c757af912ea1e Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 8 Sep 2026 14:30:37 +0100 Subject: [PATCH 3/3] test(firestore-send-email): mock requiresAPI in the index test --- kits/firestore-send-email/tests/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/kits/firestore-send-email/tests/index.test.ts b/kits/firestore-send-email/tests/index.test.ts index c0e6a647e9..ee37425b57 100644 --- a/kits/firestore-send-email/tests/index.test.ts +++ b/kits/firestore-send-email/tests/index.test.ts @@ -20,6 +20,7 @@ vi.mock("firebase-functions/firestore", () => ({ onDocumentWritten: vi.fn(() => ({})), })); vi.mock("firebase-functions/v2", () => ({ + requiresAPI: vi.fn(), requiresRole: vi.fn(), }));