Skip to content

Commit 5d7cb72

Browse files
Merge pull request #212 from richardthe3rd/claude/cloudflare-workers-testing-ZSpKL
Add unit tests for Cloudflare Worker (Phase 0)
2 parents 89b1e06 + e1952a8 commit 5d7cb72

9 files changed

Lines changed: 4020 additions & 606 deletions

File tree

.github/workflows/deploy-worker.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,36 @@ jobs:
6868
- name: Validate festivals.json
6969
run: node scripts/validate-festivals.js
7070

71+
# Run Cloudflare Worker unit tests
72+
test-worker:
73+
needs: changes
74+
runs-on: ubuntu-latest
75+
if: |
76+
github.event_name == 'workflow_dispatch' ||
77+
needs.changes.outputs.worker == 'true' ||
78+
needs.changes.outputs.festivals == 'true'
79+
steps:
80+
- name: Checkout
81+
uses: actions/checkout@v4
82+
83+
- name: Setup Node.js
84+
uses: actions/setup-node@v4
85+
with:
86+
node-version: '20'
87+
cache: 'npm'
88+
cache-dependency-path: cloudflare-worker/package-lock.json
89+
90+
- name: Install worker dependencies
91+
working-directory: cloudflare-worker
92+
run: npm ci
93+
94+
- name: Run worker tests
95+
working-directory: cloudflare-worker
96+
run: npm test
97+
7198
# Validate Cloudflare Worker on PRs (dry-run)
7299
validate-worker:
73-
needs: changes
100+
needs: [changes, test-worker]
74101
runs-on: ubuntu-latest
75102
if: |
76103
github.event_name == 'pull_request' &&
@@ -103,7 +130,7 @@ jobs:
103130

104131
# Deploy Cloudflare Worker to production
105132
deploy-worker:
106-
needs: changes
133+
needs: [changes, test-worker]
107134
runs-on: ubuntu-latest
108135
if: |
109136
github.ref == 'refs/heads/main' &&

cloudflare-worker/package-lock.json

Lines changed: 3388 additions & 603 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cloudflare-worker/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
"private": true,
55
"scripts": {
66
"deploy": "wrangler deploy",
7-
"dev": "wrangler dev"
7+
"dev": "wrangler dev",
8+
"pretest": "cp ../data/festivals.json ./festivals.json",
9+
"test": "vitest run"
810
},
911
"devDependencies": {
12+
"@cloudflare/vitest-pool-workers": "^0.8.0",
13+
"vitest": "^3.0.0",
1014
"wrangler": "^4.59.1"
1115
}
1216
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { env, createExecutionContext, waitOnExecutionContext, fetchMock } from 'cloudflare:test';
3+
import worker from '../worker.js';
4+
5+
const UPSTREAM = 'https://data.cambridgebeerfestival.com';
6+
7+
/**
8+
* Helper to make a request to the worker.
9+
*/
10+
async function fetchWorker(path, origin = 'https://cambeerfestival.app') {
11+
const request = new Request(`https://worker.example.com${path}`, {
12+
headers: { Origin: origin },
13+
});
14+
const ctx = createExecutionContext();
15+
const response = await worker.fetch(request, env, ctx);
16+
await waitOnExecutionContext(ctx);
17+
return response;
18+
}
19+
20+
/**
21+
* Sample Apache-style directory listing HTML.
22+
*/
23+
function makeDirectoryHtml(files) {
24+
const links = files.map((f) => `<a href="${f}">${f}</a>`).join('\n');
25+
return `
26+
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
27+
<html><head><title>Index of /cbf2025</title></head>
28+
<body><h1>Index of /cbf2025</h1>
29+
<pre>Name Last modified Size Description
30+
<hr>
31+
<a href="/">Parent Directory</a> -
32+
${links}
33+
<hr></pre></body></html>`;
34+
}
35+
36+
describe('available_beverage_types endpoint', () => {
37+
beforeEach(() => {
38+
fetchMock.activate();
39+
fetchMock.disableNetConnect();
40+
});
41+
42+
afterEach(() => {
43+
fetchMock.deactivate();
44+
});
45+
46+
it('parses directory listing into beverage types', async () => {
47+
fetchMock.get(UPSTREAM)
48+
.intercept({ path: '/cbf2025/' })
49+
.reply(200, makeDirectoryHtml([
50+
'beer.json', 'cider.json', 'perry.json', 'mead.json',
51+
]));
52+
53+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
54+
expect(response.status).toBe(200);
55+
56+
const data = await response.json();
57+
expect(data.festival_id).toBe('cbf2025');
58+
expect(data.available_beverage_types).toEqual(['beer', 'cider', 'mead', 'perry']);
59+
});
60+
61+
it('returns types sorted alphabetically', async () => {
62+
fetchMock.get(UPSTREAM)
63+
.intercept({ path: '/cbf2025/' })
64+
.reply(200, makeDirectoryHtml([
65+
'wine.json', 'beer.json', 'apple-juice.json',
66+
]));
67+
68+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
69+
const data = await response.json();
70+
expect(data.available_beverage_types).toEqual(['apple-juice', 'beer', 'wine']);
71+
});
72+
73+
it('filters out available_beverage_types.json from results', async () => {
74+
fetchMock.get(UPSTREAM)
75+
.intercept({ path: '/cbf2025/' })
76+
.reply(200, makeDirectoryHtml([
77+
'beer.json', 'available_beverage_types.json', 'cider.json',
78+
]));
79+
80+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
81+
const data = await response.json();
82+
expect(data.available_beverage_types).toEqual(['beer', 'cider']);
83+
expect(data.available_beverage_types).not.toContain('available_beverage_types');
84+
});
85+
86+
it('returns empty array when no JSON files found', async () => {
87+
fetchMock.get(UPSTREAM)
88+
.intercept({ path: '/cbf2025/' })
89+
.reply(200, makeDirectoryHtml([]));
90+
91+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
92+
const data = await response.json();
93+
expect(data.available_beverage_types).toEqual([]);
94+
});
95+
96+
it('returns 404 when festival not found upstream', async () => {
97+
fetchMock.get(UPSTREAM)
98+
.intercept({ path: '/nonexistent/' })
99+
.reply(404, 'Not Found');
100+
101+
const response = await fetchWorker('/nonexistent/available_beverage_types.json');
102+
expect(response.status).toBe(404);
103+
104+
const data = await response.json();
105+
expect(data.error).toBe('Festival not found');
106+
expect(data.festival_id).toBe('nonexistent');
107+
});
108+
109+
it('returns 500 when upstream fetch fails', async () => {
110+
fetchMock.get(UPSTREAM)
111+
.intercept({ path: '/cbf2025/' })
112+
.replyWithError(new Error('Connection refused'));
113+
114+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
115+
expect(response.status).toBe(500);
116+
117+
const data = await response.json();
118+
expect(data.error).toBe('Failed to fetch beverage types');
119+
});
120+
121+
it('includes CORS headers on 500 error', async () => {
122+
fetchMock.get(UPSTREAM)
123+
.intercept({ path: '/cbf2025/' })
124+
.replyWithError(new Error('Connection refused'));
125+
126+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
127+
expect(response.headers.get('Access-Control-Allow-Origin'))
128+
.toBe('https://cambeerfestival.app');
129+
});
130+
131+
it('includes CORS headers on success', async () => {
132+
fetchMock.get(UPSTREAM)
133+
.intercept({ path: '/cbf2025/' })
134+
.reply(200, makeDirectoryHtml(['beer.json']));
135+
136+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
137+
expect(response.headers.get('Access-Control-Allow-Origin'))
138+
.toBe('https://cambeerfestival.app');
139+
});
140+
141+
it('includes CORS headers on 404', async () => {
142+
fetchMock.get(UPSTREAM)
143+
.intercept({ path: '/nonexistent/' })
144+
.reply(404, 'Not Found');
145+
146+
const response = await fetchWorker('/nonexistent/available_beverage_types.json');
147+
expect(response.headers.get('Access-Control-Allow-Origin'))
148+
.toBe('https://cambeerfestival.app');
149+
});
150+
151+
it('sets Cache-Control to 1 hour on success', async () => {
152+
fetchMock.get(UPSTREAM)
153+
.intercept({ path: '/cbf2025/' })
154+
.reply(200, makeDirectoryHtml(['beer.json']));
155+
156+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
157+
expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600');
158+
});
159+
160+
it('includes timestamp in response', async () => {
161+
fetchMock.get(UPSTREAM)
162+
.intercept({ path: '/cbf2025/' })
163+
.reply(200, makeDirectoryHtml(['beer.json']));
164+
165+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
166+
const data = await response.json();
167+
expect(data.timestamp).toBeDefined();
168+
// Verify it's a valid ISO date
169+
expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp);
170+
});
171+
172+
it('handles hyphenated beverage type names', async () => {
173+
fetchMock.get(UPSTREAM)
174+
.intercept({ path: '/cbf2025/' })
175+
.reply(200, makeDirectoryHtml([
176+
'international-beer.json', 'low-no.json', 'apple-juice.json',
177+
]));
178+
179+
const response = await fetchWorker('/cbf2025/available_beverage_types.json');
180+
const data = await response.json();
181+
expect(data.available_beverage_types).toEqual([
182+
'apple-juice', 'international-beer', 'low-no',
183+
]);
184+
});
185+
});

0 commit comments

Comments
 (0)