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