Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion functions/_lib/drink-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
75 changes: 73 additions & 2 deletions functions/test/drink-preview.test.js
Original file line number Diff line number Diff line change
@@ -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 = [
{
Expand Down Expand Up @@ -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',
);
});
});

155 changes: 155 additions & 0 deletions functions/test/handler.test.js
Original file line number Diff line number Diff line change
@@ -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 </head>.
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('</head>', injected + '</head>'), {
status: response.status,
headers: response.headers,
});
}
}

const SPA_HTML = '<!DOCTYPE html><html><head><title>CBF</title></head><body></body></html>';

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('<title>CBF</title>');
expect(text).toContain('<body></body>');
});
});

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');
});
});
Loading