From b519db512d2ad37cd75315c8c0a18c2cefd3f81b Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Wed, 23 Sep 2026 17:40:41 -0400 Subject: [PATCH 1/2] Fail configured builds on Firestore read failure (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getBeers(), getVenues(), and getBeerBySlug() used plain getDocs(), which silently resolves from the offline cache when a build-time backend read fails — indistinguishable from a legitimately empty collection and able to deploy an empty catalog, empty venue list, altered FAQ copy, or beer pages as 404s. Extend the #104 contract to all public build-time reads: unconfigured builds (CI, local without .env.local) return the empty fallback without initializing Firebase; configured builds read via getDocsFromServer so a legitimately empty result stays valid while a real failure throws and fails the build. Query semantics, ordering, and mapping are unchanged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 7 +- docs/TECHNICAL.md | 40 ++-- lib/beers.ts | 27 ++- lib/venues.ts | 23 ++- tests/lib/beers-static-params.test.ts | 123 ------------ tests/lib/firestore-build-reads.test.ts | 240 ++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 150 deletions(-) delete mode 100644 tests/lib/beers-static-params.test.ts create mode 100644 tests/lib/firestore-build-reads.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17b8fbe..5e29631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,9 +51,10 @@ jobs: - name: Build # Service clients initialize lazily, so `next build` needs no - # credentials or config placeholders. Firestore reads made during - # prerendering resolve empty (offline) when no project config is - # present, which is the intended CI behavior. + # credentials or config placeholders. Firestore reads during + # prerendering see no project config via hasFirebaseConfig() and + # return their empty fallbacks without touching Firebase — the + # intended CI behavior. run: npm run build - name: Install Playwright Chromium diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index aa85fe1..6e9f903 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -64,19 +64,22 @@ client SDK writes (content management) or through Admin-SDK-backed API routes with `dynamicParams = false`: only slugs known at build time exist, and unknown slugs 404 without a render. A newly published beer becomes reachable after the normal deploy-hook rebuild. -- **Static generation reads live Firestore.** `getBeers()` / `getVenues()` use - the Firebase *client* SDK, so `next build` performs real Firestore reads - against the configured project during prerendering. If reads fail (denied, - or no project configured at all — as in CI, which provides no env), the SDK - logs errors, falls back to offline mode, and the pages still build with - empty states — see `lib/beers.ts` and `lib/venues.ts` and the Firestore - `INVALID_ARGUMENT`/`permission-denied` warnings observed in build logs. - Beer detail pages add one safeguard (`getBeerStaticParams`): slug - enumeration uses `getDocsFromServer`, which never resolves from the - offline cache — a real backend failure (unavailable, permission-denied) - throws and fails the build rather than deploying zero beer pages, while a - genuinely empty public catalog is a valid successful result. Builds - without Firebase config skip the read entirely and generate no params. +- **Static generation reads live Firestore.** `getBeers()` / `getVenues()` / + `getBeerBySlug()` / `getBeerStaticParams()` (in `lib/beers.ts` and + `lib/venues.ts`) use the Firebase *client* SDK, so `next build` performs + real Firestore reads during prerendering. All of them share one read + contract, gated by `hasFirebaseConfig()` (`lib/firebase.ts`): + - **Unconfigured build** (CI, local without `.env.local`): the helper + returns its empty fallback (`[]` or `null`) without initializing + Firebase — credential-free builds are intentional. + - **Configured build:** the read uses `getDocsFromServer`, which never + resolves from the offline cache — plain `getDocs` can silently return + an empty cached result on backend failure, indistinguishable from a + legitimately empty collection. A genuinely empty public catalog is a + valid successful result, while a real backend failure (unavailable, + permission-denied) throws and fails the build rather than deploying an + empty catalog, an empty venue list, altered FAQ copy, or beer pages + that 404 until the next rebuild. - **Node runtime.** The repository is normalized on **Node 24** (active LTS): `.nvmrc` declares `24` and is the single source of truth — CI reads it via `actions/setup-node`'s `node-version-file`, `package.json` declares @@ -662,11 +665,12 @@ variables at all** — verified experimentally after the lazy-initialization refactor (issue #18). Resend is only constructed at send time via `getResendClient()` and Firebase client services only via `getFirebase*()` first-use getters, so page-data collection evaluates no service clients. -Static generation still invokes the Firestore reads in `getBeers()` / -`getVenues()`, which fail fast against an unconfigured project -(`INVALID_ARGUMENT` warnings), fall back to offline mode, and resolve empty — -pages build with fallback/empty states. Typecheck/lint/tests need no env -either. No secrets or placeholder values exist anywhere in CI. +Static generation's Firestore reads (`getBeers()` / `getVenues()` / +`getBeerBySlug()` / `getBeerStaticParams()`) see no config via +`hasFirebaseConfig()` and return their empty fallbacks without +initializing Firebase, so pages build with empty states and no SDK +warnings. Typecheck/lint/tests need no env either. No secrets or +placeholder values exist anywhere in CI. ## 14. Testing and verification strategy diff --git a/lib/beers.ts b/lib/beers.ts index b26bf41..6f9bd3a 100644 --- a/lib/beers.ts +++ b/lib/beers.ts @@ -1,7 +1,6 @@ import { cache } from "react"; import { collection, - getDocs, getDocsFromServer, query, where, @@ -18,22 +17,44 @@ function publicBeersQuery() { ); } +/** + * Public beer catalog for build-time renders (homepage, /beers, + * /where-to-buy, sitemap). Same read contract as `getBeerStaticParams`: + * an unconfigured build returns `[]` without initializing Firebase, while + * a configured build reads via `getDocsFromServer` — never `getDocs`, + * which silently resolves from the offline cache on backend failure, + * indistinguishable from a legitimately empty catalog. A server read + * either returns the true result (a valid empty catalog included) or + * throws, failing the build rather than deploying an empty one. + */ export async function getBeers(): Promise { - const snapshot = await getDocs(publicBeersQuery()); + if (!hasFirebaseConfig()) { + return []; + } + const snapshot = await getDocsFromServer(publicBeersQuery()); return snapshot.docs.map((doc) => doc.data() as Beer); } // React cache() dedupes the generateMetadata + page render reads for the // same slug within a single render, so each beer page costs one Firestore // read instead of two. +// +// Same read contract as `getBeers`: unconfigured builds return `null` +// without initializing Firebase; configured builds read from the server, +// so a backend failure throws (failing the build) instead of surfacing as +// a missing beer. A genuinely absent public slug still resolves `null`, +// which `notFound()` turns into a legitimate 404. export const getBeerBySlug = cache( async (slug: string): Promise => { + if (!hasFirebaseConfig()) { + return null; + } const q = query( collection(getFirebaseDb(), "beers"), where("slug", "==", slug), where("isPublic", "==", true) ); - const snapshot = await getDocs(q); + const snapshot = await getDocsFromServer(q); if (snapshot.empty) return null; return snapshot.docs[0].data() as Beer; } diff --git a/lib/venues.ts b/lib/venues.ts index 1e57d60..e6078a9 100644 --- a/lib/venues.ts +++ b/lib/venues.ts @@ -1,13 +1,30 @@ -import { collection, getDocs, query, where, orderBy } from "firebase/firestore"; -import { getFirebaseDb } from "@/lib/firebase"; +import { + collection, + getDocsFromServer, + query, + where, + orderBy, +} from "firebase/firestore"; +import { getFirebaseDb, hasFirebaseConfig } from "@/lib/firebase"; import type { Venue } from "@/lib/types"; +/** + * Public venue list for /where-to-buy. Same read contract as the beer + * helpers in `lib/beers.ts`: an unconfigured build returns `[]` without + * initializing Firebase, while a configured build reads via + * `getDocsFromServer` so a backend failure throws and fails the build + * rather than silently rendering "no partner locations". A legitimately + * empty public venue list remains a valid result. + */ export async function getVenues(): Promise { + if (!hasFirebaseConfig()) { + return []; + } const q = query( collection(getFirebaseDb(), "venues"), where("isPublic", "==", true), orderBy("sortOrder", "asc") ); - const snapshot = await getDocs(q); + const snapshot = await getDocsFromServer(q); return snapshot.docs.map((doc) => doc.data() as Venue); } diff --git a/tests/lib/beers-static-params.test.ts b/tests/lib/beers-static-params.test.ts deleted file mode 100644 index 85f76c5..0000000 --- a/tests/lib/beers-static-params.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { after, describe, it } from "node:test"; -import assert from "node:assert"; -import fs from "node:fs"; -import path from "node:path"; -import { deleteApp, getApps } from "firebase/app"; -import { terminate } from "firebase/firestore"; -import { getFirebaseDb, hasFirebaseConfig } from "@/lib/firebase"; - -// The configured-path test initializes a real Firestore client whose gRPC -// streams would otherwise keep the test process alive after completion. -after(async () => { - if (getApps().length === 0) return; - await terminate(getFirebaseDb()); - await Promise.all(getApps().map((app) => deleteApp(app))); -}); - -const FIREBASE_ENV_KEYS = [ - "NEXT_PUBLIC_FIREBASE_API_KEY", - "NEXT_PUBLIC_FIREBASE_PROJECT_ID", - "NEXT_PUBLIC_FIREBASE_APP_ID", -] as const; - -async function withEnv( - values: Record, - fn: () => Promise | T -): Promise { - const originals = FIREBASE_ENV_KEYS.map((k) => process.env[k]); - for (const k of FIREBASE_ENV_KEYS) delete process.env[k]; - Object.assign( - process.env, - Object.fromEntries( - Object.entries(values).filter(([, v]) => v !== undefined) - ) - ); - try { - return await fn(); - } finally { - FIREBASE_ENV_KEYS.forEach((k, i) => { - if (originals[i] === undefined) delete process.env[k]; - else process.env[k] = originals[i]; - }); - } -} - -describe("hasFirebaseConfig", () => { - it("is false when the public config is absent", async () => { - await withEnv({}, () => assert.equal(hasFirebaseConfig(), false)); - }); - - it("is true when the public config is present", async () => { - await withEnv( - { - NEXT_PUBLIC_FIREBASE_API_KEY: "key", - NEXT_PUBLIC_FIREBASE_PROJECT_ID: "project", - NEXT_PUBLIC_FIREBASE_APP_ID: "app", - }, - () => assert.equal(hasFirebaseConfig(), true) - ); - }); -}); - -describe("getBeerStaticParams", () => { - it("returns [] without initializing Firebase when unconfigured", async () => { - // Credential-free builds (CI, local without .env.local) intentionally - // produce no beer pages. Returning early also avoids the SDK's noisy - // offline-fallback errors during `next build`. - const { getBeerStaticParams } = await import("@/lib/beers"); - const params = await withEnv({}, () => getBeerStaticParams()); - assert.deepEqual(params, []); - }); - - it("rejects when configured but Firestore cannot be enumerated", async () => { - // A syntactically valid config pointing at a nonexistent project makes - // getDocsFromServer throw `unavailable` — the same failure class as a - // network outage or permission denial. Letting it reject (rather than - // swallowing to []) is what prevents a bad build from deploying zero - // beer pages. - const { getBeerStaticParams } = await import("@/lib/beers"); - await withEnv( - { - NEXT_PUBLIC_FIREBASE_API_KEY: "ci-dummy-api-key", - NEXT_PUBLIC_FIREBASE_PROJECT_ID: "nonexistent-proj-xq9", - NEXT_PUBLIC_FIREBASE_APP_ID: "1:0:web:x", - }, - () => - assert.rejects( - getBeerStaticParams(), - (e: unknown) => (e as { code?: string }).code === "unavailable" - ) - ); - }); -}); - -describe("beer static-params source guards", () => { - const beersSource = fs.readFileSync( - path.join(process.cwd(), "lib", "beers.ts"), - "utf8" - ); - const pageSource = fs.readFileSync( - path.join(process.cwd(), "app", "(pages)", "beers", "[slug]", "page.tsx"), - "utf8" - ); - - it("enumerates via getDocsFromServer, never the cache-fallback getDocs", () => { - const fn = beersSource.slice(beersSource.indexOf("getBeerStaticParams")); - assert.ok(fn.includes("getDocsFromServer(publicBeersQuery())")); - assert.ok(!fn.includes("getDocs(")); - }); - - it("returns params in the { slug } shape Next expects", () => { - assert.ok( - beersSource.includes("snapshot.docs.map((doc) => ({ slug:") || - beersSource.includes("{ slug: (doc.data()") - ); - }); - - it("page disables dynamic params and delegates to the canonical service", () => { - assert.ok(pageSource.includes("export const dynamicParams = false")); - assert.ok(pageSource.includes("getBeerStaticParams()")); - assert.ok(!pageSource.includes("firebase/firestore")); - assert.ok(!pageSource.includes("collection(")); - }); -}); diff --git a/tests/lib/firestore-build-reads.test.ts b/tests/lib/firestore-build-reads.test.ts new file mode 100644 index 0000000..87bd181 --- /dev/null +++ b/tests/lib/firestore-build-reads.test.ts @@ -0,0 +1,240 @@ +import { after, describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { deleteApp, getApps } from "firebase/app"; +import { terminate } from "firebase/firestore"; +import { getFirebaseDb, hasFirebaseConfig } from "@/lib/firebase"; + +// The configured-path tests initialize a real Firestore client whose gRPC +// streams would otherwise keep the test process alive after completion. +after(async () => { + if (getApps().length === 0) return; + await terminate(getFirebaseDb()); + await Promise.all(getApps().map((app) => deleteApp(app))); +}); + +const FIREBASE_ENV_KEYS = [ + "NEXT_PUBLIC_FIREBASE_API_KEY", + "NEXT_PUBLIC_FIREBASE_PROJECT_ID", + "NEXT_PUBLIC_FIREBASE_APP_ID", +] as const; + +// A syntactically valid config pointing at a nonexistent project. +const CONFIGURED_ENV = { + NEXT_PUBLIC_FIREBASE_API_KEY: "ci-dummy-api-key", + NEXT_PUBLIC_FIREBASE_PROJECT_ID: "nonexistent-proj-xq9", + NEXT_PUBLIC_FIREBASE_APP_ID: "1:0:web:x", +} as const; + +async function withEnv( + values: Record, + fn: () => Promise | T +): Promise { + const originals = FIREBASE_ENV_KEYS.map((k) => process.env[k]); + for (const k of FIREBASE_ENV_KEYS) delete process.env[k]; + Object.assign( + process.env, + Object.fromEntries( + Object.entries(values).filter(([, v]) => v !== undefined) + ) + ); + try { + return await fn(); + } finally { + FIREBASE_ENV_KEYS.forEach((k, i) => { + if (originals[i] === undefined) delete process.env[k]; + else process.env[k] = originals[i]; + }); + } +} + +// Credential-free builds (CI, local without .env.local) intentionally +// produce empty content. The helper must return the documented fallback +// without initializing Firebase or issuing a read — an initialized app +// would register in getApps(), and a Firestore read cannot happen without +// one. Returning early also avoids the SDK's noisy offline-fallback +// errors during `next build`. +async function assertUnconfiguredFallback( + fn: () => Promise, + expected: T +): Promise { + const appsBefore = getApps().length; + const result = await withEnv({}, fn); + assert.deepEqual(result, expected); + assert.equal(getApps().length, appsBefore); +} + +// A configured but unreachable Firestore makes `getDocsFromServer` reject +// (`unavailable` — the same failure class as a network outage or +// permission denial). Letting helpers reject rather than swallow to +// []/null is what prevents a bad build from deploying empty content or +// false beer 404s. Plain `getDocs` would instead resolve from the empty +// offline cache, so these tests also prove the authoritative API is in +// use; see scripts/diagnose-firestore-build-reads.ts. +async function assertServerFailureRejects( + fn: () => Promise +): Promise { + await withEnv(CONFIGURED_ENV, () => + assert.rejects( + fn(), + (e: unknown) => (e as { code?: string }).code === "unavailable" + ) + ); +} + +describe("hasFirebaseConfig", () => { + it("is false when the public config is absent", async () => { + await withEnv({}, () => assert.equal(hasFirebaseConfig(), false)); + }); + + it("is true when the public config is present", async () => { + await withEnv(CONFIGURED_ENV, () => assert.equal(hasFirebaseConfig(), true)); + }); +}); + +describe("getBeers", () => { + it("returns [] without initializing Firebase when unconfigured", async () => { + const { getBeers } = await import("@/lib/beers"); + await assertUnconfiguredFallback(() => getBeers(), []); + }); + + it("rejects when configured but Firestore cannot be read", async () => { + const { getBeers } = await import("@/lib/beers"); + await assertServerFailureRejects(() => getBeers()); + }); +}); + +describe("getVenues", () => { + it("returns [] without initializing Firebase when unconfigured", async () => { + const { getVenues } = await import("@/lib/venues"); + await assertUnconfiguredFallback(() => getVenues(), []); + }); + + it("rejects when configured but Firestore cannot be read", async () => { + const { getVenues } = await import("@/lib/venues"); + await assertServerFailureRejects(() => getVenues()); + }); +}); + +describe("getBeerBySlug", () => { + it("returns null without initializing Firebase when unconfigured", async () => { + const { getBeerBySlug } = await import("@/lib/beers"); + await assertUnconfiguredFallback(() => getBeerBySlug("any-slug"), null); + }); + + it("rejects when configured but Firestore cannot be read", async () => { + const { getBeerBySlug } = await import("@/lib/beers"); + await assertServerFailureRejects(() => getBeerBySlug("any-slug")); + }); +}); + +describe("getBeerStaticParams", () => { + it("returns [] without initializing Firebase when unconfigured", async () => { + const { getBeerStaticParams } = await import("@/lib/beers"); + await assertUnconfiguredFallback(() => getBeerStaticParams(), []); + }); + + it("rejects when configured but Firestore cannot be enumerated", async () => { + const { getBeerStaticParams } = await import("@/lib/beers"); + await assertServerFailureRejects(() => getBeerStaticParams()); + }); +}); + +describe("build-time read source guards", () => { + const beersSource = fs.readFileSync( + path.join(process.cwd(), "lib", "beers.ts"), + "utf8" + ); + const venuesSource = fs.readFileSync( + path.join(process.cwd(), "lib", "venues.ts"), + "utf8" + ); + const pageSource = fs.readFileSync( + path.join(process.cwd(), "app", "(pages)", "beers", "[slug]", "page.tsx"), + "utf8" + ); + + function section( + source: string, + startMarker: string, + endMarker?: string + ): string { + const start = source.indexOf(startMarker); + assert.notEqual(start, -1, `start marker not found: ${startMarker}`); + if (endMarker === undefined) return source.slice(start); + const end = source.indexOf(endMarker, start + startMarker.length); + assert.notEqual(end, -1, `end marker not found: ${endMarker}`); + return source.slice(start, end); + } + + it("no public build-time read uses the cache-fallback getDocs", () => { + // `getDocs(` — with the paren — matches a call site but not + // `getDocsFromServer(`. Comments must not write it with parens. + assert.ok(!beersSource.includes("getDocs(")); + assert.ok(!venuesSource.includes("getDocs(")); + }); + + it("getBeers skips unconfigured builds and reads authoritatively", () => { + const fn = section( + beersSource, + "export async function getBeers", + "export const getBeerBySlug" + ); + assert.ok(fn.includes("hasFirebaseConfig()")); + assert.ok(fn.includes("return [];")); + assert.ok(fn.includes("getDocsFromServer(publicBeersQuery())")); + // The mapping shape is part of the public contract. + assert.ok(fn.includes("snapshot.docs.map((doc) => doc.data() as Beer)")); + }); + + it("getBeerBySlug skips unconfigured builds and reads authoritatively", () => { + const fn = section( + beersSource, + "export const getBeerBySlug", + "export async function getBeerStaticParams" + ); + assert.ok(fn.includes("hasFirebaseConfig()")); + assert.ok(fn.includes("return null;")); + assert.ok(fn.includes('where("slug", "==", slug)')); + assert.ok(fn.includes('where("isPublic", "==", true)')); + assert.ok(fn.includes("getDocsFromServer(")); + // A genuinely empty result is still a legitimate `null` → 404, not an + // outage. + assert.ok(fn.includes("snapshot.empty")); + }); + + it("getBeerStaticParams skips unconfigured builds and reads authoritatively", () => { + const fn = section( + beersSource, + "export async function getBeerStaticParams" + ); + assert.ok(fn.includes("hasFirebaseConfig()")); + assert.ok(fn.includes("return [];")); + assert.ok(fn.includes("getDocsFromServer(publicBeersQuery())")); + }); + + it("getVenues skips unconfigured builds and reads authoritatively", () => { + const fn = section(venuesSource, "export async function getVenues"); + assert.ok(fn.includes("hasFirebaseConfig()")); + assert.ok(fn.includes("return [];")); + assert.ok(fn.includes('where("isPublic", "==", true)')); + assert.ok(fn.includes('orderBy("sortOrder", "asc")')); + assert.ok(fn.includes("getDocsFromServer(")); + assert.ok(fn.includes("snapshot.docs.map((doc) => doc.data() as Venue)")); + }); + + it("returns params in the { slug } shape Next expects", () => { + assert.ok( + beersSource.includes("snapshot.docs.map((doc) => ({ slug:") || + beersSource.includes("{ slug: (doc.data()") + ); + }); + + it("page disables dynamic params and delegates to the canonical service", () => { + assert.ok(pageSource.includes("export const dynamicParams = false")); + assert.ok(pageSource.includes("getBeerStaticParams()")); + assert.ok(!pageSource.includes("firebase/firestore")); + assert.ok(!pageSource.includes("collection(")); + }); +}); From fdfa247d7aaac69e219cf95c92c11ba158032615 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Wed, 23 Sep 2026 17:45:58 -0400 Subject: [PATCH 2/2] Assert strict zero-app baseline for unconfigured read fallbacks Group the unconfigured-build tests before the configured-failure tests so the getApps() assertion proves no Firebase app was ever initialized, rather than comparing against a nonzero baseline once a configured test has run. Addresses a Sourcery review nitpick on the PR. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/lib/firestore-build-reads.test.ts | 56 ++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/lib/firestore-build-reads.test.ts b/tests/lib/firestore-build-reads.test.ts index 87bd181..6636cb8 100644 --- a/tests/lib/firestore-build-reads.test.ts +++ b/tests/lib/firestore-build-reads.test.ts @@ -55,14 +55,18 @@ async function withEnv( // would register in getApps(), and a Firestore read cannot happen without // one. Returning early also avoids the SDK's noisy offline-fallback // errors during `next build`. +// +// The zero-app baseline is strict: these tests must precede the +// configured-failure tests, which initialize a real Firestore client for +// the rest of the process. node:test runs tests sequentially in +// declaration order. async function assertUnconfiguredFallback( fn: () => Promise, expected: T ): Promise { - const appsBefore = getApps().length; const result = await withEnv({}, fn); assert.deepEqual(result, expected); - assert.equal(getApps().length, appsBefore); + assert.equal(getApps().length, 0); } // A configured but unreachable Firestore makes `getDocsFromServer` reject @@ -93,49 +97,45 @@ describe("hasFirebaseConfig", () => { }); }); -describe("getBeers", () => { - it("returns [] without initializing Firebase when unconfigured", async () => { +describe("unconfigured builds (no Firebase config)", () => { + it("getBeers returns [] without initializing Firebase", async () => { const { getBeers } = await import("@/lib/beers"); await assertUnconfiguredFallback(() => getBeers(), []); }); - it("rejects when configured but Firestore cannot be read", async () => { - const { getBeers } = await import("@/lib/beers"); - await assertServerFailureRejects(() => getBeers()); - }); -}); - -describe("getVenues", () => { - it("returns [] without initializing Firebase when unconfigured", async () => { + it("getVenues returns [] without initializing Firebase", async () => { const { getVenues } = await import("@/lib/venues"); await assertUnconfiguredFallback(() => getVenues(), []); }); - it("rejects when configured but Firestore cannot be read", async () => { - const { getVenues } = await import("@/lib/venues"); - await assertServerFailureRejects(() => getVenues()); - }); -}); - -describe("getBeerBySlug", () => { - it("returns null without initializing Firebase when unconfigured", async () => { + it("getBeerBySlug returns null without initializing Firebase", async () => { const { getBeerBySlug } = await import("@/lib/beers"); await assertUnconfiguredFallback(() => getBeerBySlug("any-slug"), null); }); - it("rejects when configured but Firestore cannot be read", async () => { - const { getBeerBySlug } = await import("@/lib/beers"); - await assertServerFailureRejects(() => getBeerBySlug("any-slug")); + it("getBeerStaticParams returns [] without initializing Firebase", async () => { + const { getBeerStaticParams } = await import("@/lib/beers"); + await assertUnconfiguredFallback(() => getBeerStaticParams(), []); }); }); -describe("getBeerStaticParams", () => { - it("returns [] without initializing Firebase when unconfigured", async () => { - const { getBeerStaticParams } = await import("@/lib/beers"); - await assertUnconfiguredFallback(() => getBeerStaticParams(), []); +describe("configured builds with an unreachable Firestore", () => { + it("getBeers rejects instead of resolving an empty catalog", async () => { + const { getBeers } = await import("@/lib/beers"); + await assertServerFailureRejects(() => getBeers()); + }); + + it("getVenues rejects instead of resolving an empty list", async () => { + const { getVenues } = await import("@/lib/venues"); + await assertServerFailureRejects(() => getVenues()); + }); + + it("getBeerBySlug rejects instead of becoming a false 404", async () => { + const { getBeerBySlug } = await import("@/lib/beers"); + await assertServerFailureRejects(() => getBeerBySlug("any-slug")); }); - it("rejects when configured but Firestore cannot be enumerated", async () => { + it("getBeerStaticParams rejects instead of enumerating zero slugs", async () => { const { getBeerStaticParams } = await import("@/lib/beers"); await assertServerFailureRejects(() => getBeerStaticParams()); });