Skip to content
This repository was archived by the owner on Feb 22, 2026. It is now read-only.

Commit 9b3de46

Browse files
Update CodeQL workflow and add unit test suite
- Update CodeQL workflow: actions v1->v3, checkout v2->v4, branch refs master->main, add required permissions block - Add unit tests (node:test) for proxy/cache.mjs: parseCacheControl, generateKey, setFilteredHeaders, calculateHeuristicMaxAge, and cache state transitions (fresh/stale/miss) - Add unit tests for playlist-reader: mp3 filtering and integration - Add unit tests for radar-utils: coordinate conversion functions and removeDopplerRadarImageNoise color remapping with mock canvas Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 39bafae commit 9b3de46

File tree

4 files changed

+536
-43
lines changed

4 files changed

+536
-43
lines changed
Lines changed: 10 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,38 @@
1-
# For most projects, this workflow file will not need changing; you simply need
2-
# to commit it to your repository.
3-
#
4-
# You may wish to alter this file to override the set of languages analyzed,
5-
# or to provide custom queries or build logic.
61
name: "CodeQL"
72

83
on:
94
push:
10-
branches: [master]
5+
branches: [main]
116
pull_request:
12-
# The branches below must be a subset of the branches above
13-
branches: [master]
7+
branches: [main]
148
schedule:
159
- cron: '0 11 * * 6'
1610

1711
jobs:
1812
analyze:
1913
name: Analyze
2014
runs-on: ubuntu-latest
15+
permissions:
16+
security-events: write
17+
actions: read
18+
contents: read
2119

2220
strategy:
2321
fail-fast: false
2422
matrix:
25-
# Override automatic language detection by changing the below list
26-
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
2723
language: ['javascript']
28-
# Learn more...
29-
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
3024

3125
steps:
3226
- name: Checkout repository
33-
uses: actions/checkout@v2
34-
with:
35-
# We must fetch at least the immediate parents so that if this is
36-
# a pull request then we can checkout the head.
37-
fetch-depth: 2
38-
39-
# If this run was triggered by a pull request event, then checkout
40-
# the head of the pull request instead of the merge commit.
41-
- run: git checkout HEAD^2
42-
if: ${{ github.event_name == 'pull_request' }}
27+
uses: actions/checkout@v4
4328

44-
# Initializes the CodeQL tools for scanning.
4529
- name: Initialize CodeQL
46-
uses: github/codeql-action/init@v1
30+
uses: github/codeql-action/init@v3
4731
with:
4832
languages: ${{ matrix.language }}
49-
# If you wish to specify custom queries, you can do so here or in a config file.
50-
# By default, queries listed here will override any specified in a config file.
51-
# Prefix the list here with "+" to use these queries and those in the config file.
52-
# queries: ./path/to/local/query, your-org/your-repo/queries@main
5333

54-
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55-
# If this step fails, then you should remove it and run the build manually (see below)
5634
- name: Autobuild
57-
uses: github/codeql-action/autobuild@v1
58-
59-
# ℹ️ Command-line programs to run using the OS shell.
60-
# 📚 https://git.io/JvXDl
61-
62-
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63-
# and modify them (or add more) to build your code if your project
64-
# uses a compiled language
65-
66-
#- run: |
67-
# make bootstrap
68-
# make release
35+
uses: github/codeql-action/autobuild@v3
6936

7037
- name: Perform CodeQL Analysis
71-
uses: github/codeql-action/analyze@v1
38+
uses: github/codeql-action/analyze@v3

tests/unit/cache.test.mjs

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { describe, it, before, beforeEach, afterEach, after } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
// HttpCache is exported as a singleton, so we import the module and access
5+
// the class constructor through it for static method testing.
6+
import cache from '../../proxy/cache.mjs';
7+
8+
const HttpCache = cache.constructor;
9+
10+
// Tear down the singleton's cleanup interval so Node can exit cleanly
11+
after(() => {
12+
cache.destroy();
13+
});
14+
15+
describe('HttpCache.parseCacheControl', () => {
16+
it('returns 0 for null/undefined input', () => {
17+
assert.equal(HttpCache.parseCacheControl(null), 0);
18+
assert.equal(HttpCache.parseCacheControl(undefined), 0);
19+
assert.equal(HttpCache.parseCacheControl(''), 0);
20+
});
21+
22+
it('extracts max-age value', () => {
23+
assert.equal(HttpCache.parseCacheControl('max-age=300'), 300);
24+
assert.equal(HttpCache.parseCacheControl('public, max-age=600'), 600);
25+
});
26+
27+
it('prefers s-maxage over max-age', () => {
28+
assert.equal(HttpCache.parseCacheControl('s-maxage=120, max-age=600'), 120);
29+
});
30+
31+
it('extracts s-maxage when present alone', () => {
32+
assert.equal(HttpCache.parseCacheControl('s-maxage=3600'), 3600);
33+
});
34+
35+
it('returns 0 for no-cache directives', () => {
36+
assert.equal(HttpCache.parseCacheControl('no-cache'), 0);
37+
assert.equal(HttpCache.parseCacheControl('no-store'), 0);
38+
});
39+
40+
it('is case-insensitive', () => {
41+
assert.equal(HttpCache.parseCacheControl('Max-Age=120'), 120);
42+
assert.equal(HttpCache.parseCacheControl('S-MAXAGE=60'), 60);
43+
});
44+
});
45+
46+
describe('HttpCache.generateKey', () => {
47+
it('generates key from path when no query string', () => {
48+
const req = { path: '/api/points/42,-90', url: '/api/points/42,-90' };
49+
const key = HttpCache.generateKey(req);
50+
assert.equal(key, '/api/points/42,-90');
51+
});
52+
53+
it('includes query string in key', () => {
54+
const req = { path: '/api/points/42,-90', url: '/api/points/42,-90?units=us' };
55+
const key = HttpCache.generateKey(req);
56+
assert.equal(key, '/api/points/42,-90?units=us');
57+
});
58+
59+
it('uses path as fallback when url is missing', () => {
60+
const req = { path: '/api/forecast' };
61+
const key = HttpCache.generateKey(req);
62+
assert.equal(key, '/api/forecast');
63+
});
64+
65+
it('uses url as fallback when path is missing', () => {
66+
// When path is missing, url is used for both path and url,
67+
// so query string gets appended again
68+
const req = { url: '/api/forecast?a=1' };
69+
const key = HttpCache.generateKey(req);
70+
assert.equal(key, '/api/forecast?a=1?a=1');
71+
});
72+
73+
it('defaults to / when both are missing', () => {
74+
const req = {};
75+
const key = HttpCache.generateKey(req);
76+
assert.equal(key, '/');
77+
});
78+
});
79+
80+
describe('HttpCache.setFilteredHeaders', () => {
81+
it('strips cache-related headers and sets proxy cache policy', () => {
82+
const headersSet = {};
83+
const res = {
84+
header(name, value) { headersSet[name] = value; },
85+
};
86+
87+
HttpCache.setFilteredHeaders(res, {
88+
'content-type': 'application/json',
89+
'cache-control': 'max-age=600',
90+
etag: '"abc123"',
91+
'last-modified': 'Mon, 01 Jan 2024 00:00:00 GMT',
92+
expires: 'Thu, 01 Jan 2099 00:00:00 GMT',
93+
'x-custom': 'value',
94+
});
95+
96+
assert.equal(headersSet['content-type'], 'application/json');
97+
assert.equal(headersSet['x-custom'], 'value');
98+
assert.equal(headersSet['cache-control'], 'public, max-age=30');
99+
assert.equal(headersSet.etag, undefined);
100+
assert.equal(headersSet['last-modified'], undefined);
101+
assert.equal(headersSet.expires, undefined);
102+
});
103+
104+
it('handles null/undefined headers gracefully', () => {
105+
const headersSet = {};
106+
const res = { header(name, value) { headersSet[name] = value; } };
107+
108+
HttpCache.setFilteredHeaders(res, null);
109+
assert.equal(headersSet['cache-control'], 'public, max-age=30');
110+
});
111+
});
112+
113+
describe('HttpCache.calculateHeuristicMaxAge', () => {
114+
it('returns 0 for future dates', () => {
115+
const future = new Date(Date.now() + 3600 * 1000).toUTCString();
116+
assert.equal(HttpCache.calculateHeuristicMaxAge(future), 0);
117+
});
118+
119+
it('clamps to minimum of 1 hour for recent resources', () => {
120+
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toUTCString();
121+
assert.equal(HttpCache.calculateHeuristicMaxAge(tenMinutesAgo), 3600);
122+
});
123+
124+
it('uses 10% of age for mid-range resources', () => {
125+
const twentyHoursAgo = new Date(Date.now() - 20 * 3600 * 1000).toUTCString();
126+
const result = HttpCache.calculateHeuristicMaxAge(twentyHoursAgo);
127+
// 20 hours = 72000s, 10% = 7200s = 2 hours, within [1h, 4h]
128+
assert.equal(result, 7200);
129+
});
130+
131+
it('clamps to maximum of 4 hours for old resources', () => {
132+
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toUTCString();
133+
assert.equal(HttpCache.calculateHeuristicMaxAge(oneWeekAgo), 4 * 3600);
134+
});
135+
136+
it('returns NaN for invalid date strings (Date constructor does not throw)', () => {
137+
const result = HttpCache.calculateHeuristicMaxAge('not-a-date');
138+
assert.ok(Number.isNaN(result));
139+
});
140+
});
141+
142+
describe('HttpCache instance - cache state transitions', () => {
143+
let testCache;
144+
145+
beforeEach(() => {
146+
testCache = new HttpCache();
147+
// Stop the cleanup interval to avoid interference
148+
if (testCache.cleanupInterval) {
149+
clearInterval(testCache.cleanupInterval);
150+
testCache.cleanupInterval = null;
151+
}
152+
});
153+
154+
afterEach(() => {
155+
if (testCache.cleanupInterval) {
156+
clearInterval(testCache.cleanupInterval);
157+
}
158+
});
159+
160+
it('returns miss for unknown keys', () => {
161+
const req = { path: '/api/test', url: '/api/test' };
162+
const result = testCache.getCachedRequest(req);
163+
assert.equal(result.status, 'miss');
164+
assert.equal(result.data, null);
165+
});
166+
167+
it('returns fresh for non-expired entries', () => {
168+
const req = { path: '/api/test', url: '/api/test' };
169+
const key = HttpCache.generateKey(req);
170+
171+
testCache.cache.set(key, {
172+
statusCode: 200,
173+
headers: { 'content-type': 'application/json' },
174+
data: '{"ok":true}',
175+
expiry: Date.now() + 60000,
176+
timestamp: Date.now(),
177+
url: 'https://api.weather.gov/api/test',
178+
});
179+
180+
const result = testCache.getCachedRequest(req);
181+
assert.equal(result.status, 'fresh');
182+
assert.equal(result.data.statusCode, 200);
183+
});
184+
185+
it('returns stale for expired entries', () => {
186+
const req = { path: '/api/test', url: '/api/test' };
187+
const key = HttpCache.generateKey(req);
188+
189+
testCache.cache.set(key, {
190+
statusCode: 200,
191+
headers: { 'content-type': 'application/json' },
192+
data: '{"ok":true}',
193+
expiry: Date.now() - 1000,
194+
timestamp: Date.now() - 61000,
195+
url: 'https://api.weather.gov/api/test',
196+
});
197+
198+
const result = testCache.getCachedRequest(req);
199+
assert.equal(result.status, 'stale');
200+
assert.equal(result.data.statusCode, 200);
201+
});
202+
203+
it('storeCachedResponse stores entry with explicit TTL', () => {
204+
const req = { path: '/api/test', url: '/api/test' };
205+
const response = { statusCode: 200, headers: {}, data: '{"ok":true}' };
206+
const originalHeaders = { 'cache-control': 'max-age=300' };
207+
208+
testCache.storeCachedResponse(req, response, 'https://api.weather.gov/api/test', originalHeaders);
209+
210+
const key = HttpCache.generateKey(req);
211+
const cached = testCache.cache.get(key);
212+
assert.ok(cached);
213+
assert.equal(cached.statusCode, 200);
214+
assert.ok(cached.expiry > Date.now());
215+
assert.ok(cached.expiry <= Date.now() + 300 * 1000 + 100);
216+
});
217+
218+
it('storeCachedResponse does not cache when no cache directives', () => {
219+
const req = { path: '/api/nocache', url: '/api/nocache' };
220+
const response = { statusCode: 200, headers: {}, data: '{}' };
221+
222+
testCache.storeCachedResponse(req, response, 'https://api.weather.gov/api/nocache', {});
223+
224+
const key = HttpCache.generateKey(req);
225+
assert.equal(testCache.cache.has(key), false);
226+
});
227+
228+
it('getStats returns correct counts', () => {
229+
const now = Date.now();
230+
testCache.cache.set('valid', { expiry: now + 60000 });
231+
testCache.cache.set('expired', { expiry: now - 1000 });
232+
233+
const stats = testCache.getStats();
234+
assert.equal(stats.total, 2);
235+
assert.equal(stats.valid, 1);
236+
assert.equal(stats.expired, 1);
237+
assert.equal(stats.inFlight, 0);
238+
});
239+
240+
it('clearEntry removes a specific entry', () => {
241+
testCache.cache.set('/api/test', { data: 'test' });
242+
assert.equal(testCache.cache.size, 1);
243+
244+
const result = testCache.clearEntry('/api/test');
245+
assert.equal(result, true);
246+
assert.equal(testCache.cache.size, 0);
247+
});
248+
249+
it('clearEntry returns false for missing entry', () => {
250+
const result = testCache.clearEntry('/api/nonexistent');
251+
assert.equal(result, false);
252+
});
253+
});

tests/unit/playlist.test.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
describe('mp3 filter regex', () => {
5+
const mp3Filter = (file) => file.match(/\.mp3$/);
6+
7+
it('matches .mp3 files', () => {
8+
assert.ok(mp3Filter('song.mp3'));
9+
assert.ok(mp3Filter('Trammell Starks - After Midnight.mp3'));
10+
});
11+
12+
it('rejects non-mp3 files', () => {
13+
assert.equal(mp3Filter('readme.txt'), null);
14+
assert.equal(mp3Filter('image.png'), null);
15+
assert.equal(mp3Filter('.gitkeep'), null);
16+
});
17+
18+
it('rejects files with mp3 in the name but wrong extension', () => {
19+
assert.equal(mp3Filter('mp3-notes.txt'), null);
20+
assert.equal(mp3Filter('song.mp3.bak'), null);
21+
});
22+
23+
it('rejects directory names', () => {
24+
assert.equal(mp3Filter('default'), null);
25+
});
26+
});
27+
28+
describe('playlist-reader integration', () => {
29+
it('returns mp3 files from server/music directory', async () => {
30+
const reader = (await import('../../src/playlist-reader.mjs')).default;
31+
const files = await reader();
32+
33+
assert.ok(Array.isArray(files));
34+
assert.ok(files.length > 0, 'Expected at least one mp3 file');
35+
36+
for (const file of files) {
37+
assert.match(file, /\.mp3$/, `Expected mp3 file, got: ${file}`);
38+
}
39+
});
40+
41+
it('does not include non-mp3 files', async () => {
42+
const reader = (await import('../../src/playlist-reader.mjs')).default;
43+
const files = await reader();
44+
45+
const nonMp3 = files.filter((f) => !f.match(/\.mp3$/));
46+
assert.equal(nonMp3.length, 0, `Found non-mp3 files: ${nonMp3.join(', ')}`);
47+
});
48+
});

0 commit comments

Comments
 (0)