diff --git a/functions/_lib/drink-preview.js b/functions/_lib/drink-preview.js index cea56633..0266624c 100644 --- a/functions/_lib/drink-preview.js +++ b/functions/_lib/drink-preview.js @@ -62,5 +62,8 @@ export async function fetchDrinkData(festivalId, category) { const url = `${DATA_BASE_URL}/${encodeURIComponent(festivalId)}/${encodeURIComponent(category)}.json`; const response = await fetch(url); if (!response.ok) return null; - return response.json(); + const data = await response.json(); + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.producers)) return data.producers; + return null; } diff --git a/functions/test/drink-preview.test.js b/functions/test/drink-preview.test.js index 75d18e52..8a9004c3 100644 --- a/functions/test/drink-preview.test.js +++ b/functions/test/drink-preview.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { isCrawler, findDrink, buildOgTags } from '../_lib/drink-preview.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isCrawler, findDrink, buildOgTags, fetchDrinkData } from '../_lib/drink-preview.js'; const TEST_PRODUCERS = [ { @@ -194,3 +194,74 @@ describe('buildOgTags', () => { }); }); +describe('fetchDrinkData', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns producers array from wrapped API response', async () => { + const producers = [{ id: 'adnams', name: 'Adnams', products: [] }]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ producers, timestamp: '2025-01-01' }), + })); + const result = await fetchDrinkData('cbf2025', 'beer'); + expect(result).toEqual(producers); + }); + + it('returns array directly when API response is already an array', async () => { + const producers = [{ id: 'adnams', name: 'Adnams', products: [] }]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(producers), + })); + const result = await fetchDrinkData('cbf2025', 'beer'); + expect(result).toEqual(producers); + }); + + it('returns null when response is not ok', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); + const result = await fetchDrinkData('cbf2025', 'beer'); + expect(result).toBeNull(); + }); + + it('returns null when wrapped response has no producers key', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ timestamp: '2025-01-01' }), + })); + const result = await fetchDrinkData('cbf2025', 'beer'); + expect(result).toBeNull(); + }); + + it('returns null when response.json() resolves to null', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(null), + })); + const result = await fetchDrinkData('cbf2025', 'beer'); + expect(result).toBeNull(); + }); + + it('returns null when producers field exists but is not an array', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ producers: 'not-an-array' }), + })); + const result = await fetchDrinkData('cbf2025', 'beer'); + expect(result).toBeNull(); + }); + + it('fetches the correct URL for a given festival and category', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ producers: [] }), + }); + vi.stubGlobal('fetch', mockFetch); + await fetchDrinkData('cbf2025', 'beer'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://data.cambeerfestival.app/cbf2025/beer.json', + ); + }); +}); + diff --git a/functions/test/handler.test.js b/functions/test/handler.test.js new file mode 100644 index 00000000..3f72247b --- /dev/null +++ b/functions/test/handler.test.js @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { onRequest } from '../[festivalId]/drink/[category]/[drinkId].js'; + +// Minimal HTMLRewriter mock: collects appended HTML and inserts it before . +class MockHTMLRewriter { + constructor() { + this._headHandler = null; + } + on(selector, handler) { + if (selector === 'head') this._headHandler = handler; + return this; + } + async transform(response) { + const text = await response.text(); + let injected = ''; + if (this._headHandler) { + // Second arg ({ html: true }) is intentionally ignored in this mock. + const element = { append: (html) => { injected += html; } }; + this._headHandler.element(element); + } + return new Response(text.replace('', injected + ''), { + status: response.status, + headers: response.headers, + }); + } +} + +const SPA_HTML = 'CBF'; + +const TEST_PRODUCERS = [ + { + id: 'adnams', + name: 'Adnams', + location: 'Southwold', + products: [ + { id: 'broadside', name: 'Broadside', style: 'Strong Bitter', abv: 6.3, category: 'beer' }, + ], + }, +]; + +function makeSpaResponse() { + return new Response(SPA_HTML, { headers: { 'Content-Type': 'text/html' } }); +} + +function makeContext({ ua, festivalId = 'cbf2025', category = 'beer', drinkId = 'broadside' } = {}) { + const url = `https://cambeerfestival.app/${festivalId}/drink/${category}/${drinkId}`; + return { + request: new Request(url, { + headers: { 'User-Agent': ua ?? 'Mozilla/5.0 Chrome/120' }, + }), + env: { + ASSETS: { fetch: vi.fn().mockResolvedValue(makeSpaResponse()) }, + }, + params: { festivalId, category, drinkId }, + }; +} + +beforeEach(() => { + vi.stubGlobal('HTMLRewriter', MockHTMLRewriter); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ producers: TEST_PRODUCERS }), + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('onRequest — non-crawler passthrough', () => { + it('calls ASSETS.fetch and returns immediately for a regular browser', async () => { + const ctx = makeContext({ ua: 'Mozilla/5.0 Chrome/120' }); + await onRequest(ctx); + expect(ctx.env.ASSETS.fetch).toHaveBeenCalledWith(ctx.request); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('does not inject OG tags for a regular browser', async () => { + const ctx = makeContext({ ua: 'Mozilla/5.0 Chrome/120' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).not.toContain('og:title'); + }); +}); + +describe('onRequest — crawler OG injection', () => { + it('injects og:title with drink name and brewery for Googlebot', async () => { + const ctx = makeContext({ ua: 'Googlebot/2.1' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).toContain('og:title'); + expect(text).toContain('Broadside — Adnams'); + }); + + it('injects og:url with the canonical drink URL', async () => { + const ctx = makeContext({ ua: 'Googlebot/2.1', festivalId: 'cbf2025', category: 'beer', drinkId: 'broadside' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).toContain('https://cambeerfestival.app/cbf2025/drink/beer/broadside'); + }); + + it('injects OG tags for WhatsApp crawler', async () => { + const ctx = makeContext({ ua: 'WhatsApp/2.19.81 A' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).toContain('og:title'); + }); + + it('fetches drink data from the correct festival and category', async () => { + const ctx = makeContext({ ua: 'Googlebot/2.1', festivalId: 'cbf2024', category: 'cider', drinkId: 'broadside' }); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ producers: TEST_PRODUCERS }), + }); + vi.stubGlobal('fetch', mockFetch); + await onRequest(ctx); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('cbf2024/cider.json'), + ); + }); + + it('preserves the rest of the SPA HTML when injecting', async () => { + const ctx = makeContext({ ua: 'Googlebot/2.1' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).toContain('CBF'); + expect(text).toContain(''); + }); +}); + +describe('onRequest — crawler fallback paths', () => { + it('returns unmodified SPA when API responds with non-ok status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); + const ctx = makeContext({ ua: 'Googlebot/2.1' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).not.toContain('og:title'); + expect(text).toBe(SPA_HTML); + }); + + it('returns unmodified SPA when fetch throws a network error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure'))); + const ctx = makeContext({ ua: 'Googlebot/2.1' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).not.toContain('og:title'); + }); + + it('returns unmodified SPA when the drink ID is not found', async () => { + const ctx = makeContext({ ua: 'Googlebot/2.1', drinkId: 'nonexistent' }); + const response = await onRequest(ctx); + const text = await response.text(); + expect(text).not.toContain('og:title'); + }); +});