diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 731db8b1..cc1f6363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,12 @@ jobs: google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON }} generate-mocks: 'true' + - name: Check formatting + run: | + dart format --output=none --set-exit-if-changed . + npm ci + npx prettier --check "**/*.{js,ts,mjs}" + - name: Analyze code run: flutter analyze --no-fatal-infos diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..15c7c4f7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +build/ +android/ +ios/ diff --git a/AGENTS.md b/AGENTS.md index 817338d2..67b806e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,10 @@ MISE_ENV=dev ./bin/mise tasks ls # All tasks including build/serve | Task | Command | Notes | |------|---------|-------| | **Pre-commit gate** | `./bin/mise run check` | **Run before every commit** | +| **Format all code** | `./bin/mise run format` | Runs all three formatters below | +| Format Dart | `./bin/mise run --no-deps dart:format` | **Run after every Dart change** — `--no-deps` skips unnecessary `pub get` | +| Format JS/TS | `./bin/mise run prettier:format` | After JS/TS changes | +| Format mise.toml | `./bin/mise run mise:format` | After editing mise.toml | | Generate code (mocks) | `./bin/mise run generate` | After model changes | | Analyze code | `./bin/mise run analyze` | generate → analyze | | Run tests | `./bin/mise run test` | generate → test | diff --git a/cloudflare-worker/test/beverage-types.test.js b/cloudflare-worker/test/beverage-types.test.js index 7715fb53..f21479e7 100644 --- a/cloudflare-worker/test/beverage-types.test.js +++ b/cloudflare-worker/test/beverage-types.test.js @@ -1,27 +1,31 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; - -const UPSTREAM = 'https://data.cambridgebeerfestival.com'; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; + +const UPSTREAM = "https://data.cambridgebeerfestival.com"; const DIRECTORY_FETCH_INIT = { - headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', - }, + headers: { + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", + }, }; -async function fetchWorker(path, origin = 'https://cambeerfestival.app') { - const request = new Request(`https://worker.example.com${path}`, { - headers: { Origin: origin }, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWorker(path, origin = "https://cambeerfestival.app") { + const request = new Request(`https://worker.example.com${path}`, { + headers: { Origin: origin }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } function makeDirectoryHtml(files) { - const links = files.map((f) => `${f}`).join('\n'); - return ` + const links = files.map((f) => `${f}`).join("\n"); + return ` Index of /cbf2025

Index of /cbf2025

@@ -32,158 +36,251 @@ ${links}
`; } -describe('available_beverage_types endpoint', () => { - let mockFetch; - - beforeEach(() => { - mockFetch = vi.fn(); - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('parses directory listing into beverage types', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json', 'cider.json', 'perry.json', 'mead.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.festival_id).toBe('cbf2025'); - expect(data.available_beverage_types).toEqual(['beer', 'cider', 'mead', 'perry']); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('returns types sorted alphabetically', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['wine.json', 'beer.json', 'apple-juice.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual(['apple-juice', 'beer', 'wine']); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('filters out available_beverage_types.json from results', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json', 'available_beverage_types.json', 'cider.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual(['beer', 'cider']); - expect(data.available_beverage_types).not.toContain('available_beverage_types'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('returns empty array when no JSON files found', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml([]), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual([]); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('returns 404 when festival not found upstream', async () => { - mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); - - const response = await fetchWorker('/nonexistent/available_beverage_types.json'); - expect(response.status).toBe(404); - - const data = await response.json(); - expect(data.error).toBe('Festival not found'); - expect(data.festival_id).toBe('nonexistent'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/nonexistent/`, DIRECTORY_FETCH_INIT); - }); - - it('returns 500 when upstream fetch fails', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.status).toBe(500); - - const data = await response.json(); - expect(data.error).toBe('Failed to fetch beverage types'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes CORS headers on 500 error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes CORS headers on success', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes CORS headers on 404', async () => { - mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); - - const response = await fetchWorker('/nonexistent/available_beverage_types.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/nonexistent/`, DIRECTORY_FETCH_INIT); - }); - - it('sets Cache-Control to 1 hour on success', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes timestamp in response', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.timestamp).toBeDefined(); - expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('handles hyphenated beverage type names', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['international-beer.json', 'low-no.json', 'apple-juice.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual([ - 'apple-juice', 'international-beer', 'low-no', - ]); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); +describe("available_beverage_types endpoint", () => { + let mockFetch; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("parses directory listing into beverage types", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml([ + "beer.json", + "cider.json", + "perry.json", + "mead.json", + ]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.festival_id).toBe("cbf2025"); + expect(data.available_beverage_types).toEqual([ + "beer", + "cider", + "mead", + "perry", + ]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns types sorted alphabetically", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml(["wine.json", "beer.json", "apple-juice.json"]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual([ + "apple-juice", + "beer", + "wine", + ]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("filters out available_beverage_types.json from results", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml([ + "beer.json", + "available_beverage_types.json", + "cider.json", + ]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual(["beer", "cider"]); + expect(data.available_beverage_types).not.toContain( + "available_beverage_types", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns empty array when no JSON files found", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml([]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual([]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns 404 when festival not found upstream", async () => { + mockFetch.mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + const response = await fetchWorker( + "/nonexistent/available_beverage_types.json", + ); + expect(response.status).toBe(404); + + const data = await response.json(); + expect(data.error).toBe("Festival not found"); + expect(data.festival_id).toBe("nonexistent"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/nonexistent/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns 500 when upstream fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.status).toBe(500); + + const data = await response.json(); + expect(data.error).toBe("Failed to fetch beverage types"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes CORS headers on 500 error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes CORS headers on success", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml(["beer.json"]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes CORS headers on 404", async () => { + mockFetch.mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + const response = await fetchWorker( + "/nonexistent/available_beverage_types.json", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/nonexistent/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("sets Cache-Control to 1 hour on success", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml(["beer.json"]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=3600"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes timestamp in response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml(["beer.json"]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.timestamp).toBeDefined(); + expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("handles hyphenated beverage type names", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml([ + "international-beer.json", + "low-no.json", + "apple-juice.json", + ]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual([ + "apple-juice", + "international-beer", + "low-no", + ]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); }); diff --git a/cloudflare-worker/test/cors.test.js b/cloudflare-worker/test/cors.test.js index 69ea5b7b..4352f587 100644 --- a/cloudflare-worker/test/cors.test.js +++ b/cloudflare-worker/test/cors.test.js @@ -1,161 +1,252 @@ -import { describe, it, expect } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; +import { describe, it, expect } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; /** * Helper to make a request to the worker with a given origin. */ -async function fetchWithOrigin(path, origin, method = 'GET') { - const headers = {}; - if (origin) { - headers['Origin'] = origin; - } - const request = new Request(`https://worker.example.com${path}`, { - method, - headers, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWithOrigin(path, origin, method = "GET") { + const headers = {}; + if (origin) { + headers["Origin"] = origin; + } + const request = new Request(`https://worker.example.com${path}`, { + method, + headers, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } -describe('CORS origin matching', () => { - it('allows production origin (cambeerfestival.app)', async () => { - const response = await fetchWithOrigin('/health', 'https://cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true'); - expect(response.headers.get('Vary')).toBe('Origin'); - }); - - it('allows staging origin', async () => { - const response = await fetchWithOrigin('/health', 'https://staging.cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://staging.cambeerfestival.app'); - }); - - it('allows GitHub Pages origin', async () => { - const response = await fetchWithOrigin('/health', 'https://richardthe3rd.github.io'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://richardthe3rd.github.io'); - }); - - it('allows tunnel origin', async () => { - const response = await fetchWithOrigin('/health', 'https://tunnel.cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://tunnel.cambeerfestival.app'); - }); - - it('allows localhost:8080', async () => { - const response = await fetchWithOrigin('/health', 'http://localhost:8080'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('http://localhost:8080'); - }); - - it('allows localhost:3000', async () => { - const response = await fetchWithOrigin('/health', 'http://localhost:3000'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('http://localhost:3000'); - }); - - it('allows 127.0.0.1:8080', async () => { - const response = await fetchWithOrigin('/health', 'http://127.0.0.1:8080'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('http://127.0.0.1:8080'); - }); - - it('allows Cloudflare Pages preview URLs (*.cambeerfestival.pages.dev)', async () => { - const response = await fetchWithOrigin('/health', 'https://abc123.cambeerfestival.pages.dev'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://abc123.cambeerfestival.pages.dev'); - expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true'); - expect(response.headers.get('Vary')).toBe('Origin'); - }); - - it('allows staging Pages preview URLs (*.staging-cambeerfestival.pages.dev)', async () => { - const response = await fetchWithOrigin('/health', 'https://feature-branch.staging-cambeerfestival.pages.dev'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://feature-branch.staging-cambeerfestival.pages.dev'); - }); - - it('allows Cloudflare Tunnel URLs (*.trycloudflare.com)', async () => { - const response = await fetchWithOrigin('/health', 'https://my-tunnel.trycloudflare.com'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://my-tunnel.trycloudflare.com'); - }); - - it('rejects unknown origins', async () => { - const response = await fetchWithOrigin('/health', 'https://evil.example.com'); - expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull(); - expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull(); - expect(response.headers.get('Vary')).toBeNull(); - }); - - it('handles request with no Origin header', async () => { - const response = await fetchWithOrigin('/health', null); - expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull(); - expect(response.status).toBe(200); - }); +describe("CORS origin matching", () => { + it("allows production origin (cambeerfestival.app)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "true", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("allows staging origin", async () => { + const response = await fetchWithOrigin( + "/health", + "https://staging.cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://staging.cambeerfestival.app", + ); + }); + + it("allows GitHub Pages origin", async () => { + const response = await fetchWithOrigin( + "/health", + "https://richardthe3rd.github.io", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://richardthe3rd.github.io", + ); + }); + + it("allows tunnel origin", async () => { + const response = await fetchWithOrigin( + "/health", + "https://tunnel.cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://tunnel.cambeerfestival.app", + ); + }); + + it("allows localhost:8080", async () => { + const response = await fetchWithOrigin("/health", "http://localhost:8080"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:8080", + ); + }); + + it("allows localhost:3000", async () => { + const response = await fetchWithOrigin("/health", "http://localhost:3000"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:3000", + ); + }); + + it("allows 127.0.0.1:8080", async () => { + const response = await fetchWithOrigin("/health", "http://127.0.0.1:8080"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://127.0.0.1:8080", + ); + }); + + it("allows Cloudflare Pages preview URLs (*.cambeerfestival.pages.dev)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://abc123.cambeerfestival.pages.dev", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://abc123.cambeerfestival.pages.dev", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "true", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("allows staging Pages preview URLs (*.staging-cambeerfestival.pages.dev)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://feature-branch.staging-cambeerfestival.pages.dev", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://feature-branch.staging-cambeerfestival.pages.dev", + ); + }); + + it("allows Cloudflare Tunnel URLs (*.trycloudflare.com)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://my-tunnel.trycloudflare.com", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://my-tunnel.trycloudflare.com", + ); + }); + + it("rejects unknown origins", async () => { + const response = await fetchWithOrigin( + "/health", + "https://evil.example.com", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + expect(response.headers.get("Vary")).toBeNull(); + }); + + it("handles request with no Origin header", async () => { + const response = await fetchWithOrigin("/health", null); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.status).toBe(200); + }); }); -describe('CORS preflight (OPTIONS)', () => { - it('returns 204 with no body', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.status).toBe(204); - const body = await response.text(); - expect(body).toBe(''); - }); - - it('includes correct methods and headers', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type'); - }); - - it('returns 300s max-age for production origin', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('300'); - }); - - it('returns 10s max-age for staging origin', async () => { - const response = await fetchWithOrigin('/', 'https://staging.cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for Pages preview URLs', async () => { - const response = await fetchWithOrigin('/', 'https://abc123.cambeerfestival.pages.dev', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for staging Pages preview URLs', async () => { - const response = await fetchWithOrigin('/', 'https://feature.staging-cambeerfestival.pages.dev', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for localhost', async () => { - const response = await fetchWithOrigin('/', 'http://localhost:8080', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for 127.0.0.1', async () => { - const response = await fetchWithOrigin('/', 'http://127.0.0.1:8080', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for Cloudflare Tunnel', async () => { - const response = await fetchWithOrigin('/', 'https://my-tunnel.trycloudflare.com', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('includes CORS origin header in preflight response', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - }); - - it('does not include CORS origin for rejected origins in preflight', async () => { - const response = await fetchWithOrigin('/', 'https://evil.example.com', 'OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull(); - }); +describe("CORS preflight (OPTIONS)", () => { + it("returns 204 with no body", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.status).toBe(204); + const body = await response.text(); + expect(body).toBe(""); + }); + + it("includes correct methods and headers", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "GET, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + it("returns 300s max-age for production origin", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("300"); + }); + + it("returns 10s max-age for staging origin", async () => { + const response = await fetchWithOrigin( + "/", + "https://staging.cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for Pages preview URLs", async () => { + const response = await fetchWithOrigin( + "/", + "https://abc123.cambeerfestival.pages.dev", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for staging Pages preview URLs", async () => { + const response = await fetchWithOrigin( + "/", + "https://feature.staging-cambeerfestival.pages.dev", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for localhost", async () => { + const response = await fetchWithOrigin( + "/", + "http://localhost:8080", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for 127.0.0.1", async () => { + const response = await fetchWithOrigin( + "/", + "http://127.0.0.1:8080", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for Cloudflare Tunnel", async () => { + const response = await fetchWithOrigin( + "/", + "https://my-tunnel.trycloudflare.com", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("includes CORS origin header in preflight response", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + }); + + it("does not include CORS origin for rejected origins in preflight", async () => { + const response = await fetchWithOrigin( + "/", + "https://evil.example.com", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); }); diff --git a/cloudflare-worker/test/festivals.test.js b/cloudflare-worker/test/festivals.test.js index 91c79a69..1ce755cd 100644 --- a/cloudflare-worker/test/festivals.test.js +++ b/cloudflare-worker/test/festivals.test.js @@ -1,88 +1,95 @@ -import { describe, it, expect } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; +import { describe, it, expect } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; /** * Helper to make a request to the worker. */ -async function fetchWorker(path, origin = 'https://cambeerfestival.app') { - const request = new Request(`https://worker.example.com${path}`, { - headers: { Origin: origin }, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWorker(path, origin = "https://cambeerfestival.app") { + const request = new Request(`https://worker.example.com${path}`, { + headers: { Origin: origin }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } -describe('festivals.json endpoint', () => { - it('returns 200 for /festivals.json', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.status).toBe(200); - }); +describe("festivals.json endpoint", () => { + it("returns 200 for /festivals.json", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.status).toBe(200); + }); - it('returns 200 for /festivals (alias)', async () => { - const response = await fetchWorker('/festivals'); - expect(response.status).toBe(200); - }); + it("returns 200 for /festivals (alias)", async () => { + const response = await fetchWorker("/festivals"); + expect(response.status).toBe(200); + }); - it('returns valid JSON', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - expect(data).toBeDefined(); - expect(data.festivals).toBeInstanceOf(Array); - expect(data.festivals.length).toBeGreaterThan(0); - }); + it("returns valid JSON", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + expect(data).toBeDefined(); + expect(data.festivals).toBeInstanceOf(Array); + expect(data.festivals.length).toBeGreaterThan(0); + }); - it('contains required festival fields', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - const festival = data.festivals[0]; + it("contains required festival fields", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + const festival = data.festivals[0]; - expect(festival.id).toBeDefined(); - expect(festival.name).toBeDefined(); - expect(festival.start_date).toBeDefined(); - expect(festival.end_date).toBeDefined(); - expect(festival.data_base_url).toBeDefined(); - }); + expect(festival.id).toBeDefined(); + expect(festival.name).toBeDefined(); + expect(festival.start_date).toBeDefined(); + expect(festival.end_date).toBeDefined(); + expect(festival.data_base_url).toBeDefined(); + }); - it('contains default_festival_id', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - expect(data.default_festival_id).toBeDefined(); - expect(typeof data.default_festival_id).toBe('string'); - }); + it("contains default_festival_id", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + expect(data.default_festival_id).toBeDefined(); + expect(typeof data.default_festival_id).toBe("string"); + }); - it('default_festival_id references an existing festival', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - const ids = data.festivals.map((f) => f.id); - expect(ids).toContain(data.default_festival_id); - }); + it("default_festival_id references an existing festival", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + const ids = data.festivals.map((f) => f.id); + expect(ids).toContain(data.default_festival_id); + }); - it('sets Content-Type to application/json with charset', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - }); + it("sets Content-Type to application/json with charset", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + }); - it('sets Cache-Control to no-cache', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.headers.get('Cache-Control')) - .toBe('no-cache, must-revalidate'); - }); + it("sets Cache-Control to no-cache", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.headers.get("Cache-Control")).toBe( + "no-cache, must-revalidate", + ); + }); - it('includes CORS headers', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - }); + it("includes CORS headers", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + }); - it('/festivals and /festivals.json return the same data', async () => { - const response1 = await fetchWorker('/festivals.json'); - const response2 = await fetchWorker('/festivals'); - const data1 = await response1.json(); - const data2 = await response2.json(); - expect(data1).toEqual(data2); - }); + it("/festivals and /festivals.json return the same data", async () => { + const response1 = await fetchWorker("/festivals.json"); + const response2 = await fetchWorker("/festivals"); + const data1 = await response1.json(); + const data2 = await response2.json(); + expect(data1).toEqual(data2); + }); }); diff --git a/cloudflare-worker/test/proxy.test.js b/cloudflare-worker/test/proxy.test.js index 07b35d93..2f8e9247 100644 --- a/cloudflare-worker/test/proxy.test.js +++ b/cloudflare-worker/test/proxy.test.js @@ -1,152 +1,195 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; - -const UPSTREAM = 'https://data.cambridgebeerfestival.com'; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; + +const UPSTREAM = "https://data.cambridgebeerfestival.com"; const PROXY_FETCH_INIT = { - method: 'GET', - headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', - }, + method: "GET", + headers: { + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", + }, }; -async function fetchWorker(path, origin = 'https://cambeerfestival.app') { - const request = new Request(`https://worker.example.com${path}`, { - headers: { Origin: origin }, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWorker(path, origin = "https://cambeerfestival.app") { + const request = new Request(`https://worker.example.com${path}`, { + headers: { Origin: origin }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } -describe('health check', () => { - it('returns 200 with status ok', async () => { - const response = await fetchWorker('/health'); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ status: 'ok' }); - }); - - it('returns JSON content type', async () => { - const response = await fetchWorker('/health'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - }); - - it('includes CORS headers', async () => { - const response = await fetchWorker('/health'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - }); +describe("health check", () => { + it("returns 200 with status ok", async () => { + const response = await fetchWorker("/health"); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ status: "ok" }); + }); + + it("returns JSON content type", async () => { + const response = await fetchWorker("/health"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + }); + + it("includes CORS headers", async () => { + const response = await fetchWorker("/health"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + }); }); -describe('upstream proxy', () => { - let mockFetch; - - beforeEach(() => { - mockFetch = vi.fn(); - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('proxies requests to upstream and returns response', async () => { - const upstreamBody = JSON.stringify([{ name: 'Test Brewery', products: [] }]); - mockFetch.mockResolvedValueOnce(new Response(upstreamBody, { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual([{ name: 'Test Brewery', products: [] }]); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('adds charset=utf-8 to JSON responses missing it', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('preserves charset if already present in upstream response', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('includes CORS headers on proxied responses', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(response.headers.get('Vary')).toBe('Origin'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('passes through upstream error status codes', async () => { - mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); - - const response = await fetchWorker('/cbf2025/nonexistent.json'); - expect(response.status).toBe(404); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/nonexistent.json`, PROXY_FETCH_INIT); - }); - - it('returns 502 when upstream fetch fails', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.status).toBe(502); - - const data = await response.json(); - expect(data.error).toBe('Proxy error'); - expect(data.message).toBeDefined(); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('returns 502 with CORS headers on proxy error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.status).toBe(502); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('preserves query string when proxying', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json?v=2'); - expect(response.status).toBe(200); - expect(mockFetch).toHaveBeenCalledWith( - `${UPSTREAM}/cbf2025/beer.json?v=2`, - PROXY_FETCH_INIT, - ); - }); +describe("upstream proxy", () => { + let mockFetch; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("proxies requests to upstream and returns response", async () => { + const upstreamBody = JSON.stringify([ + { name: "Test Brewery", products: [] }, + ]); + mockFetch.mockResolvedValueOnce( + new Response(upstreamBody, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual([{ name: "Test Brewery", products: [] }]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("adds charset=utf-8 to JSON responses missing it", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("preserves charset if already present in upstream response", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("includes CORS headers on proxied responses", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("passes through upstream error status codes", async () => { + mockFetch.mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + const response = await fetchWorker("/cbf2025/nonexistent.json"); + expect(response.status).toBe(404); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/nonexistent.json`, + PROXY_FETCH_INIT, + ); + }); + + it("returns 502 when upstream fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.status).toBe(502); + + const data = await response.json(); + expect(data.error).toBe("Proxy error"); + expect(data.message).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("returns 502 with CORS headers on proxy error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.status).toBe(502); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("preserves query string when proxying", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json?v=2"); + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json?v=2`, + PROXY_FETCH_INIT, + ); + }); }); diff --git a/cloudflare-worker/vitest.config.js b/cloudflare-worker/vitest.config.js index 63fb751b..adce4cc5 100644 --- a/cloudflare-worker/vitest.config.js +++ b/cloudflare-worker/vitest.config.js @@ -1,9 +1,11 @@ -import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; -import { defineConfig } from 'vitest/config'; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [cloudflareTest({ - wrangler: { configPath: './wrangler.toml' }, - })], - test: {}, + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.toml" }, + }), + ], + test: {}, }); diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index dc82a962..34ae2d4d 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -14,52 +14,52 @@ */ // Import festivals data directly - copied from data/festivals.json during build -import festivalsData from './festivals.json'; +import festivalsData from "./festivals.json"; -const UPSTREAM_URL = 'https://data.cambridgebeerfestival.com'; +const UPSTREAM_URL = "https://data.cambridgebeerfestival.com"; // Cache control for festivals.json // Use no-cache to ensure browsers revalidate on each request while still caching // This ensures updates are visible immediately while allowing conditional requests -const FESTIVALS_CACHE_CONTROL = 'no-cache, must-revalidate'; +const FESTIVALS_CACHE_CONTROL = "no-cache, must-revalidate"; // Allowed origins for CORS const ALLOWED_ORIGINS = [ - 'https://richardthe3rd.github.io', - 'https://cambeerfestival.app', - 'https://staging.cambeerfestival.app', - 'https://tunnel.cambeerfestival.app', - 'http://localhost:8080', - 'http://localhost:3000', - 'http://127.0.0.1:8080', + "https://richardthe3rd.github.io", + "https://cambeerfestival.app", + "https://staging.cambeerfestival.app", + "https://tunnel.cambeerfestival.app", + "http://localhost:8080", + "http://localhost:3000", + "http://127.0.0.1:8080", ]; export default { async fetch(request, env, ctx) { // Handle CORS preflight requests - if (request.method === 'OPTIONS') { + if (request.method === "OPTIONS") { return handleCorsPreflight(request); } const url = new URL(request.url); - + // Health check endpoint - if (url.pathname === '/health') { - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + if (url.pathname === "/health") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { + "Content-Type": "application/json; charset=utf-8", ...getCorsHeaders(request), }, }); } // Serve festivals.json directly from embedded data - if (url.pathname === '/festivals.json' || url.pathname === '/festivals') { + if (url.pathname === "/festivals.json" || url.pathname === "/festivals") { return new Response(JSON.stringify(festivalsData), { status: 200, headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': FESTIVALS_CACHE_CONTROL, + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": FESTIVALS_CACHE_CONTROL, ...getCorsHeaders(request), }, }); @@ -67,19 +67,21 @@ export default { // Handle dynamic available_beverage_types.json endpoint // Pattern: /{festivalId}/available_beverage_types.json - const availableTypesMatch = url.pathname.match(/^\/([^\/]+)\/available_beverage_types\.json$/); + const availableTypesMatch = url.pathname.match( + /^\/([^\/]+)\/available_beverage_types\.json$/, + ); if (availableTypesMatch) { return handleAvailableBeverageTypes(availableTypesMatch[1], request); } // Proxy the request to the upstream API const upstreamUrl = UPSTREAM_URL + url.pathname + url.search; - + try { const response = await fetch(upstreamUrl, { method: request.method, headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", }, }); @@ -89,9 +91,13 @@ export default { // Ensure JSON responses explicitly declare UTF-8 encoding // This prevents mojibake when non-ASCII characters (é, ö, ä, ñ) are present - const contentType = newHeaders.get('Content-Type'); - if (contentType && contentType.includes('application/json') && !contentType.includes('charset')) { - newHeaders.set('Content-Type', 'application/json; charset=utf-8'); + const contentType = newHeaders.get("Content-Type"); + if ( + contentType && + contentType.includes("application/json") && + !contentType.includes("charset") + ) { + newHeaders.set("Content-Type", "application/json; charset=utf-8"); } return new Response(response.body, { @@ -100,13 +106,16 @@ export default { headers: newHeaders, }); } catch (error) { - return new Response(JSON.stringify({ error: 'Proxy error', message: error.message }), { - status: 502, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ error: "Proxy error", message: error.message }), + { + status: 502, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...getCorsHeaders(request), + }, }, - }); + ); } }, }; @@ -125,21 +134,24 @@ async function handleAvailableBeverageTypes(festivalId, request) { const upstreamUrl = `${UPSTREAM_URL}/${festivalId}/`; const response = await fetch(upstreamUrl, { headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", }, }); if (!response.ok) { - return new Response(JSON.stringify({ - error: 'Festival not found', - festival_id: festivalId, - }), { - status: 404, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ + error: "Festival not found", + festival_id: festivalId, + }), + { + status: 404, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...getCorsHeaders(request), + }, }, - }); + ); } // Parse the HTML directory listing to find .json files @@ -147,29 +159,35 @@ async function handleAvailableBeverageTypes(festivalId, request) { const beverageTypes = parseDirectoryListingForBeverageTypes(html); // Return the list of available beverage types - return new Response(JSON.stringify({ - festival_id: festivalId, - available_beverage_types: beverageTypes, - timestamp: new Date().toISOString(), - }), { - status: 200, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ + festival_id: festivalId, + available_beverage_types: beverageTypes, + timestamp: new Date().toISOString(), + }), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=3600", // Cache for 1 hour + ...getCorsHeaders(request), + }, }, - }); + ); } catch (error) { - return new Response(JSON.stringify({ - error: 'Failed to fetch beverage types', - message: error.message, - }), { - status: 500, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ + error: "Failed to fetch beverage types", + message: error.message, + }), + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...getCorsHeaders(request), + }, }, - }); + ); } } @@ -191,12 +209,12 @@ function parseDirectoryListingForBeverageTypes(html) { const filename = match[1]; // Skip the available_beverage_types.json itself to avoid recursion - if (filename === 'available_beverage_types.json') { + if (filename === "available_beverage_types.json") { continue; } // Remove .json extension to get the beverage type name - const beverageType = filename.replace(/\.json$/, ''); + const beverageType = filename.replace(/\.json$/, ""); beverageTypes.push(beverageType); } @@ -205,41 +223,43 @@ function parseDirectoryListingForBeverageTypes(html) { } function handleCorsPreflight(request) { - const origin = request.headers.get('Origin') || ''; + const origin = request.headers.get("Origin") || ""; // Set shorter CORS preflight cache for staging/preview environments // to prevent stale CORS responses after deployments - let maxAge = '300'; // 5 minutes for production - - if (origin.endsWith('.staging-cambeerfestival.pages.dev') || - origin.endsWith('.cambeerfestival.pages.dev') || - origin === 'https://staging.cambeerfestival.app' || - origin.endsWith('.trycloudflare.com') || - origin.startsWith('http://localhost') || - origin.startsWith('http://127.0.0.1')) { - maxAge = '10'; // 10 seconds for development/staging + let maxAge = "300"; // 5 minutes for production + + if ( + origin.endsWith(".staging-cambeerfestival.pages.dev") || + origin.endsWith(".cambeerfestival.pages.dev") || + origin === "https://staging.cambeerfestival.app" || + origin.endsWith(".trycloudflare.com") || + origin.startsWith("http://localhost") || + origin.startsWith("http://127.0.0.1") + ) { + maxAge = "10"; // 10 seconds for development/staging } return new Response(null, { status: 204, headers: { ...getCorsHeaders(request), - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': maxAge, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": maxAge, }, }); } function getCorsHeaders(request) { - const origin = request.headers.get('Origin') || ''; + const origin = request.headers.get("Origin") || ""; // Allow listed origins (exact match) if (ALLOWED_ORIGINS.includes(origin)) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } @@ -248,22 +268,22 @@ function getCorsHeaders(request) { // Security note: This wildcard is safe because Cloudflare controls the .pages.dev // namespace. Only our cambeerfestival project can create subdomains under // cambeerfestival.pages.dev, preventing malicious domains from matching this pattern. - if (origin.endsWith('.cambeerfestival.pages.dev')) { + if (origin.endsWith(".cambeerfestival.pages.dev")) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } // Allow Cloudflare Pages staging preview URLs (*.staging-cambeerfestival.pages.dev) // This includes branch-based staging deployments // Security note: Same as above - Cloudflare controls the .pages.dev namespace - if (origin.endsWith('.staging-cambeerfestival.pages.dev')) { + if (origin.endsWith(".staging-cambeerfestival.pages.dev")) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } @@ -271,11 +291,11 @@ function getCorsHeaders(request) { // Used for local development with cloudflared tunnel // Security note: These are temporary development tunnels controlled by Cloudflare. // Only enable this in development/staging workers, not production. - if (origin.endsWith('.trycloudflare.com')) { + if (origin.endsWith(".trycloudflare.com")) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } diff --git a/functions/[festivalId]/drink/[category]/[drinkId].js b/functions/[festivalId]/drink/[category]/[drinkId].js index 5de0f6c2..e968a8dc 100644 --- a/functions/[festivalId]/drink/[category]/[drinkId].js +++ b/functions/[festivalId]/drink/[category]/[drinkId].js @@ -1,12 +1,17 @@ -import { isCrawler, fetchDrinkData, findDrink, buildOgTags } from '../../../_lib/drink-preview.js'; +import { + isCrawler, + fetchDrinkData, + findDrink, + buildOgTags, +} from "../../../_lib/drink-preview.js"; -const SITE_URL = 'https://cambeerfestival.app'; +const SITE_URL = "https://cambeerfestival.app"; export async function onRequest(context) { const { request, env, params } = context; // Always serve the SPA for non-crawlers — no latency overhead. - const userAgent = request.headers.get('User-Agent') ?? ''; + const userAgent = request.headers.get("User-Agent") ?? ""; if (!isCrawler(userAgent)) { return env.ASSETS.fetch(request); } @@ -31,9 +36,12 @@ export async function onRequest(context) { // HTMLRewriter streams the response and appends OG tags inside // without buffering the body — no encoding header concerns, no string hacks. return new HTMLRewriter() - .on('head', { + .on("head", { element(element) { - element.append(buildOgTags(drink.product, drink.producer, canonicalUrl), { html: true }); + element.append( + buildOgTags(drink.product, drink.producer, canonicalUrl), + { html: true }, + ); }, }) .transform(spaResponse); diff --git a/functions/_lib/drink-preview.js b/functions/_lib/drink-preview.js index d80029c9..4ba8ed38 100644 --- a/functions/_lib/drink-preview.js +++ b/functions/_lib/drink-preview.js @@ -1,20 +1,20 @@ const CRAWLER_UA_PATTERNS = [ - 'facebookexternalhit', - 'twitterbot', - 'whatsapp', - 'slackbot', - 'linkedinbot', - 'discordbot', - 'googlebot', - 'telegrambot', + "facebookexternalhit", + "twitterbot", + "whatsapp", + "slackbot", + "linkedinbot", + "discordbot", + "googlebot", + "telegrambot", ]; -const DATA_BASE_URL = 'https://data.cambeerfestival.app'; -const OG_IMAGE_URL = 'https://cambeerfestival.app/icons/Icon-512.png'; +const DATA_BASE_URL = "https://data.cambeerfestival.app"; +const OG_IMAGE_URL = "https://cambeerfestival.app/icons/Icon-512.png"; // Product category field values don't always match their API endpoint names. const CATEGORY_TO_ENDPOINT = { - 'foreign beer': 'international-beer', + "foreign beer": "international-beer", }; export function isCrawler(userAgent) { @@ -25,7 +25,9 @@ export function isCrawler(userAgent) { export function findDrink(producers, drinkId) { for (const producer of producers) { - const product = (producer.products ?? []).find((p) => String(p.id) === drinkId); + const product = (producer.products ?? []).find( + (p) => String(p.id) === drinkId, + ); if (product) return { product, producer }; } return null; @@ -33,22 +35,26 @@ export function findDrink(producers, drinkId) { function escapeHtml(str) { return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } function formatAbv(abv) { - const num = typeof abv === 'number' ? abv : parseFloat(abv); + const num = typeof abv === "number" ? abv : parseFloat(abv); if (isNaN(num)) return null; return `${Number.isInteger(num) ? num : num.toFixed(1)}% ABV`; } export function buildOgTags(product, producer, canonicalUrl) { const title = `${product.name} — ${producer.name}`; - const descParts = [product.style, formatAbv(product.abv), 'Cambridge Beer Festival'].filter(Boolean); - const description = descParts.join(' · '); + const descParts = [ + product.style, + formatAbv(product.abv), + "Cambridge Beer Festival", + ].filter(Boolean); + const description = descParts.join(" · "); return [ ``, @@ -60,11 +66,13 @@ export function buildOgTags(product, producer, canonicalUrl) { ``, ``, ``, - ].join('\n'); + ].join("\n"); } export async function fetchDrinkData(festivalId, category) { - const endpoint = Object.hasOwn(CATEGORY_TO_ENDPOINT, category) ? CATEGORY_TO_ENDPOINT[category] : category; + const endpoint = Object.hasOwn(CATEGORY_TO_ENDPOINT, category) + ? CATEGORY_TO_ENDPOINT[category] + : category; const url = `${DATA_BASE_URL}/${encodeURIComponent(festivalId)}/${encodeURIComponent(endpoint)}.json`; const response = await fetch(url); if (!response.ok) return null; diff --git a/functions/test/drink-preview.test.js b/functions/test/drink-preview.test.js index 1e2c69e8..da297e93 100644 --- a/functions/test/drink-preview.test.js +++ b/functions/test/drink-preview.test.js @@ -1,266 +1,336 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { isCrawler, findDrink, buildOgTags, fetchDrinkData } 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 = [ { - id: 'adnams', - name: 'Adnams', - location: 'Southwold', + id: "adnams", + name: "Adnams", + location: "Southwold", products: [ - { id: 'broadside', name: 'Broadside', category: 'beer', style: 'Strong Bitter', abv: 6.3, dispense: 'cask' }, - { id: 'ghost-ship', name: 'Ghost Ship', category: 'beer', style: 'Pale Ale', abv: 5.0, dispense: 'cask' }, + { + id: "broadside", + name: "Broadside", + category: "beer", + style: "Strong Bitter", + abv: 6.3, + dispense: "cask", + }, + { + id: "ghost-ship", + name: "Ghost Ship", + category: "beer", + style: "Pale Ale", + abv: 5.0, + dispense: "cask", + }, ], }, { - id: 'aspall', - name: 'Aspall', - location: 'Suffolk', + id: "aspall", + name: "Aspall", + location: "Suffolk", products: [ - { id: 'premier-cru', name: 'Premier Cru', category: 'cider', style: null, abv: 7.0, dispense: 'draught' }, + { + id: "premier-cru", + name: "Premier Cru", + category: "cider", + style: null, + abv: 7.0, + dispense: "draught", + }, ], }, ]; -describe('isCrawler', () => { - it('detects facebookexternalhit', () => { - expect(isCrawler('facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)')).toBe(true); +describe("isCrawler", () => { + it("detects facebookexternalhit", () => { + expect( + isCrawler( + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", + ), + ).toBe(true); }); - it('detects Twitterbot (case-insensitive)', () => { - expect(isCrawler('Twitterbot/1.0')).toBe(true); + it("detects Twitterbot (case-insensitive)", () => { + expect(isCrawler("Twitterbot/1.0")).toBe(true); }); - it('detects WhatsApp', () => { - expect(isCrawler('WhatsApp/2.19.81 A')).toBe(true); + it("detects WhatsApp", () => { + expect(isCrawler("WhatsApp/2.19.81 A")).toBe(true); }); - it('detects Slackbot', () => { - expect(isCrawler('Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)')).toBe(true); + it("detects Slackbot", () => { + expect( + isCrawler("Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"), + ).toBe(true); }); - it('detects LinkedInBot', () => { - expect(isCrawler('LinkedInBot/1.0 (compatible; Mozilla/5.0)')).toBe(true); + it("detects LinkedInBot", () => { + expect(isCrawler("LinkedInBot/1.0 (compatible; Mozilla/5.0)")).toBe(true); }); - it('detects Discordbot', () => { - expect(isCrawler('Mozilla/5.0 (compatible; Discordbot/2.0)')).toBe(true); + it("detects Discordbot", () => { + expect(isCrawler("Mozilla/5.0 (compatible; Discordbot/2.0)")).toBe(true); }); - it('detects Googlebot', () => { - expect(isCrawler('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(true); + it("detects Googlebot", () => { + expect(isCrawler("Mozilla/5.0 (compatible; Googlebot/2.1)")).toBe(true); }); - it('detects TelegramBot', () => { - expect(isCrawler('TelegramBot (like TwitterBot)')).toBe(true); + it("detects TelegramBot", () => { + expect(isCrawler("TelegramBot (like TwitterBot)")).toBe(true); }); - it('returns false for Chrome on Android', () => { - expect(isCrawler('Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/91.0 Mobile Safari/537.36')).toBe(false); + it("returns false for Chrome on Android", () => { + expect( + isCrawler( + "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/91.0 Mobile Safari/537.36", + ), + ).toBe(false); }); - it('returns false for Safari on iPhone', () => { - expect(isCrawler('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0) AppleWebKit/605.1.15 Safari/604.1')).toBe(false); + it("returns false for Safari on iPhone", () => { + expect( + isCrawler( + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0) AppleWebKit/605.1.15 Safari/604.1", + ), + ).toBe(false); }); - it('returns false for empty string', () => { - expect(isCrawler('')).toBe(false); + it("returns false for empty string", () => { + expect(isCrawler("")).toBe(false); }); - it('returns false for null', () => { + it("returns false for null", () => { expect(isCrawler(null)).toBe(false); }); }); -describe('findDrink', () => { - it('finds a drink by product ID', () => { - const result = findDrink(TEST_PRODUCERS, 'broadside'); +describe("findDrink", () => { + it("finds a drink by product ID", () => { + const result = findDrink(TEST_PRODUCERS, "broadside"); expect(result).not.toBeNull(); - expect(result.product.name).toBe('Broadside'); - expect(result.producer.name).toBe('Adnams'); + expect(result.product.name).toBe("Broadside"); + expect(result.producer.name).toBe("Adnams"); }); - it('finds a drink in a later producer', () => { - const result = findDrink(TEST_PRODUCERS, 'premier-cru'); - expect(result.producer.name).toBe('Aspall'); - expect(result.product.name).toBe('Premier Cru'); + it("finds a drink in a later producer", () => { + const result = findDrink(TEST_PRODUCERS, "premier-cru"); + expect(result.producer.name).toBe("Aspall"); + expect(result.product.name).toBe("Premier Cru"); }); - it('finds a second product from the same producer', () => { - const result = findDrink(TEST_PRODUCERS, 'ghost-ship'); - expect(result.product.name).toBe('Ghost Ship'); + it("finds a second product from the same producer", () => { + const result = findDrink(TEST_PRODUCERS, "ghost-ship"); + expect(result.product.name).toBe("Ghost Ship"); }); - it('returns null when drink ID not found', () => { - expect(findDrink(TEST_PRODUCERS, 'nonexistent-id')).toBeNull(); + it("returns null when drink ID not found", () => { + expect(findDrink(TEST_PRODUCERS, "nonexistent-id")).toBeNull(); }); - it('returns null for empty producers list', () => { - expect(findDrink([], 'broadside')).toBeNull(); + it("returns null for empty producers list", () => { + expect(findDrink([], "broadside")).toBeNull(); }); - it('handles producers with empty products array', () => { - const producers = [{ id: 'empty', name: 'Empty', products: [] }]; - expect(findDrink(producers, 'any')).toBeNull(); + it("handles producers with empty products array", () => { + const producers = [{ id: "empty", name: "Empty", products: [] }]; + expect(findDrink(producers, "any")).toBeNull(); }); - it('handles producers with missing products field', () => { - const producers = [{ id: 'noproducts', name: 'No Products' }]; - expect(findDrink(producers, 'any')).toBeNull(); + it("handles producers with missing products field", () => { + const producers = [{ id: "noproducts", name: "No Products" }]; + expect(findDrink(producers, "any")).toBeNull(); }); - it('coerces numeric product IDs to string for comparison', () => { - const producers = [{ id: 'test', name: 'Test', products: [{ id: 42, name: 'Numeric ID', abv: 4.0 }] }]; - const result = findDrink(producers, '42'); - expect(result.product.name).toBe('Numeric ID'); + it("coerces numeric product IDs to string for comparison", () => { + const producers = [ + { + id: "test", + name: "Test", + products: [{ id: 42, name: "Numeric ID", abv: 4.0 }], + }, + ]; + const result = findDrink(producers, "42"); + expect(result.product.name).toBe("Numeric ID"); }); }); -describe('buildOgTags', () => { - const product = { name: 'Broadside', style: 'Strong Bitter', abv: 6.3 }; - const producer = { name: 'Adnams' }; - const url = 'https://cambeerfestival.app/cbf2025/drink/beer/broadside'; +describe("buildOgTags", () => { + const product = { name: "Broadside", style: "Strong Bitter", abv: 6.3 }; + const producer = { name: "Adnams" }; + const url = "https://cambeerfestival.app/cbf2025/drink/beer/broadside"; - it('includes og:title with drink name and brewery', () => { + it("includes og:title with drink name and brewery", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('og:title'); - expect(tags).toContain('Broadside — Adnams'); + expect(tags).toContain("og:title"); + expect(tags).toContain("Broadside — Adnams"); }); - it('includes style, ABV, and festival name in description', () => { + it("includes style, ABV, and festival name in description", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('Strong Bitter · 6.3% ABV · Cambridge Beer Festival'); + expect(tags).toContain( + "Strong Bitter · 6.3% ABV · Cambridge Beer Festival", + ); }); - it('includes og:url set to canonical URL', () => { + it("includes og:url set to canonical URL", () => { const tags = buildOgTags(product, producer, url); expect(tags).toContain(`og:url" content="${url}"`); }); - it('includes og:image pointing to festival icon', () => { + it("includes og:image pointing to festival icon", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('og:image'); - expect(tags).toContain('Icon-512.png'); + expect(tags).toContain("og:image"); + expect(tags).toContain("Icon-512.png"); }); - it('includes twitter:card set to summary', () => { + it("includes twitter:card set to summary", () => { const tags = buildOgTags(product, producer, url); expect(tags).toContain('twitter:card" content="summary"'); }); - it('omits style from description when null', () => { - const noStyle = { name: 'Premier Cru', style: null, abv: 7.0 }; + it("omits style from description when null", () => { + const noStyle = { name: "Premier Cru", style: null, abv: 7.0 }; const tags = buildOgTags(noStyle, producer, url); - expect(tags).toContain('7% ABV · Cambridge Beer Festival'); - expect(tags).not.toContain('null'); + expect(tags).toContain("7% ABV · Cambridge Beer Festival"); + expect(tags).not.toContain("null"); }); - it('handles string ABV values', () => { - const strAbv = { name: 'Test', style: 'IPA', abv: '6.3' }; + it("handles string ABV values", () => { + const strAbv = { name: "Test", style: "IPA", abv: "6.3" }; const tags = buildOgTags(strAbv, producer, url); - expect(tags).toContain('6.3% ABV'); + expect(tags).toContain("6.3% ABV"); }); - it('omits ABV from description when ABV is non-numeric', () => { - const badAbv = { name: 'Test', style: 'IPA', abv: 'TBC' }; + it("omits ABV from description when ABV is non-numeric", () => { + const badAbv = { name: "Test", style: "IPA", abv: "TBC" }; const tags = buildOgTags(badAbv, producer, url); - expect(tags).not.toContain('TBC'); - expect(tags).toContain('IPA · Cambridge Beer Festival'); + expect(tags).not.toContain("TBC"); + expect(tags).toContain("IPA · Cambridge Beer Festival"); }); - it('formats whole-number ABV without decimal', () => { - const wholeAbv = { name: 'Test', style: 'IPA', abv: 5 }; + it("formats whole-number ABV without decimal", () => { + const wholeAbv = { name: "Test", style: "IPA", abv: 5 }; const tags = buildOgTags(wholeAbv, producer, url); - expect(tags).toContain('5% ABV'); - expect(tags).not.toContain('5.0%'); + expect(tags).toContain("5% ABV"); + expect(tags).not.toContain("5.0%"); }); - it('formats decimal ABV to one decimal place', () => { + it("formats decimal ABV to one decimal place", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('6.3% ABV'); + expect(tags).toContain("6.3% ABV"); }); - it('escapes HTML special characters in drink name', () => { - const xss = { name: '', style: null, abv: 4.0 }; + it("escapes HTML special characters in drink name", () => { + const xss = { + name: '', + style: null, + abv: 4.0, + }; const tags = buildOgTags(xss, producer, url); - expect(tags).not.toContain('