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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/workflows/deploy-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,36 @@ jobs:
- name: Validate festivals.json
run: node scripts/validate-festivals.js

# Run Cloudflare Worker unit tests
test-worker:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch' ||
needs.changes.outputs.worker == 'true' ||
needs.changes.outputs.festivals == 'true'
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: cloudflare-worker/package-lock.json

- name: Install worker dependencies
working-directory: cloudflare-worker
run: npm ci

- name: Run worker tests
working-directory: cloudflare-worker
run: npm test

# Validate Cloudflare Worker on PRs (dry-run)
validate-worker:
needs: changes
needs: [changes, test-worker]
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request' &&
Expand Down Expand Up @@ -103,7 +130,7 @@ jobs:

# Deploy Cloudflare Worker to production
deploy-worker:
needs: changes
needs: [changes, test-worker]
runs-on: ubuntu-latest
if: |
github.ref == 'refs/heads/main' &&
Expand Down
3,991 changes: 3,388 additions & 603 deletions cloudflare-worker/package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion cloudflare-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev"
"dev": "wrangler dev",
"pretest": "cp ../data/festivals.json ./festivals.json",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.0",
"vitest": "^3.0.0",
"wrangler": "^4.59.1"
}
}
185 changes: 185 additions & 0 deletions cloudflare-worker/test/beverage-types.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { env, createExecutionContext, waitOnExecutionContext, fetchMock } from 'cloudflare:test';
import worker from '../worker.js';

const UPSTREAM = 'https://data.cambridgebeerfestival.com';

/**
* 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;
}

/**
* Sample Apache-style directory listing HTML.
*/
function makeDirectoryHtml(files) {
const links = files.map((f) => `<a href="${f}">${f}</a>`).join('\n');
return `
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html><head><title>Index of /cbf2025</title></head>
<body><h1>Index of /cbf2025</h1>
<pre>Name Last modified Size Description
<hr>
<a href="/">Parent Directory</a> -
${links}
<hr></pre></body></html>`;
}

describe('available_beverage_types endpoint', () => {
beforeEach(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});

afterEach(() => {
fetchMock.deactivate();
});

it('parses directory listing into beverage types', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml([
'beer.json', 'cider.json', 'perry.json', 'mead.json',
]));

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

it('returns types sorted alphabetically', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml([
'wine.json', 'beer.json', 'apple-juice.json',
]));

const response = await fetchWorker('/cbf2025/available_beverage_types.json');
const data = await response.json();
expect(data.available_beverage_types).toEqual(['apple-juice', 'beer', 'wine']);
});

it('filters out available_beverage_types.json from results', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml([
'beer.json', 'available_beverage_types.json', 'cider.json',
]));

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

it('returns empty array when no JSON files found', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml([]));

const response = await fetchWorker('/cbf2025/available_beverage_types.json');
const data = await response.json();
expect(data.available_beverage_types).toEqual([]);
});

it('returns 404 when festival not found upstream', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/nonexistent/' })
.reply(404, 'Not Found');

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

it('returns 500 when upstream fetch fails', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.replyWithError(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');
});

it('includes CORS headers on 500 error', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.replyWithError(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');
});

it('includes CORS headers on success', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml(['beer.json']));

const response = await fetchWorker('/cbf2025/available_beverage_types.json');
expect(response.headers.get('Access-Control-Allow-Origin'))
.toBe('https://cambeerfestival.app');
});

it('includes CORS headers on 404', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/nonexistent/' })
.reply(404, 'Not Found');

const response = await fetchWorker('/nonexistent/available_beverage_types.json');
expect(response.headers.get('Access-Control-Allow-Origin'))
.toBe('https://cambeerfestival.app');
});

it('sets Cache-Control to 1 hour on success', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml(['beer.json']));

const response = await fetchWorker('/cbf2025/available_beverage_types.json');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600');
});

it('includes timestamp in response', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml(['beer.json']));

const response = await fetchWorker('/cbf2025/available_beverage_types.json');
const data = await response.json();
expect(data.timestamp).toBeDefined();
// Verify it's a valid ISO date
expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp);
});

it('handles hyphenated beverage type names', async () => {
fetchMock.get(UPSTREAM)
.intercept({ path: '/cbf2025/' })
.reply(200, makeDirectoryHtml([
'international-beer.json', 'low-no.json', 'apple-juice.json',
]));

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