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 = '