@20syldev/api 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/app.js +4 -0
- package/dist/app.js.map +1 -1
- package/dist/config/versions.js +9 -0
- package/dist/config/versions.js.map +1 -1
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/modules/v4/placeholder.js +107 -46
- package/dist/modules/v4/placeholder.js.map +1 -1
- package/dist/modules/v4.js +6 -0
- package/dist/modules/v4.js.map +1 -1
- package/dist/routes/delete.js +4 -5
- package/dist/routes/delete.js.map +1 -1
- package/dist/routes/get.js +144 -0
- package/dist/routes/get.js.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/patch.js +2 -3
- package/dist/routes/patch.js.map +1 -1
- package/package.json +1 -1
- package/src/app.ts +4 -0
- package/src/config/versions.ts +6 -0
- package/src/middleware/cors.ts +1 -1
- package/src/modules/v4/chat.ts +24 -1
- package/src/modules/v4/geo.ts +53 -0
- package/src/modules/v4/palette.ts +103 -0
- package/src/modules/v4/placeholder.ts +192 -0
- package/src/modules/v4/tic_tac_toe.ts +33 -1
- package/src/modules/v4.ts +3 -0
- package/src/routes/delete.ts +71 -0
- package/src/routes/get.ts +76 -0
- package/src/routes/patch.ts +44 -0
- package/tests/integration/api.test.ts +62 -18
- package/tests/unit/captcha.test.ts +23 -0
- package/tests/unit/chat.test.ts +50 -0
- package/tests/unit/geo.test.ts +45 -0
- package/tests/unit/palette.test.ts +53 -0
- package/tests/unit/placeholder.test.ts +57 -0
- package/tests/unit/qrcode.test.ts +14 -0
- package/tests/unit/tic_tac_toe.test.ts +52 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import { chatStorage, ticTacToeStorage } from '../storage/index.js';
|
|
3
|
+
import { error } from '../utils/response.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
// Clear a private chat
|
|
8
|
+
router.delete('/:version/chat/:token', (req: Request, res: Response) => {
|
|
9
|
+
if (req.version !== 'v4') {
|
|
10
|
+
error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/chat`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const token = req.params.token as string;
|
|
15
|
+
const { username, session } = (req.body as Record<string, string>) || {};
|
|
16
|
+
|
|
17
|
+
if (!username) {
|
|
18
|
+
error(res, 400, 'Please provide a username (?username={username})');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!session) {
|
|
22
|
+
error(res, 400, 'Please provide a valid session ID (&session={ID})');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const result = req.module.chat('clear', {
|
|
28
|
+
username,
|
|
29
|
+
token,
|
|
30
|
+
session,
|
|
31
|
+
storage: chatStorage,
|
|
32
|
+
});
|
|
33
|
+
res.jsonResponse(result);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
error(res, 400, (err as Error).message);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Forfeit a tic-tac-toe game
|
|
40
|
+
router.delete('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
|
|
41
|
+
if (req.version !== 'v4') {
|
|
42
|
+
error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/tic-tac-toe`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const game = req.params.game as string;
|
|
47
|
+
const { username, session } = (req.body as Record<string, string>) || {};
|
|
48
|
+
|
|
49
|
+
if (!username) {
|
|
50
|
+
error(res, 400, 'Please provide a username (?username={username})');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!session) {
|
|
54
|
+
error(res, 400, 'Please provide a valid session ID (&session={ID})');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const result = req.module.tic_tac_toe('forfeit', {
|
|
60
|
+
username,
|
|
61
|
+
session,
|
|
62
|
+
game,
|
|
63
|
+
storage: ticTacToeStorage,
|
|
64
|
+
});
|
|
65
|
+
res.jsonResponse(result);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
error(res, 400, (err as Error).message);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export default router;
|
package/src/routes/get.ts
CHANGED
|
@@ -196,6 +196,29 @@ router.get('/:version/encode', (req: Request, res: Response) => {
|
|
|
196
196
|
}
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
// Geographic distance and bearing between two coordinates
|
|
200
|
+
router.get('/:version/geo', (req: Request, res: Response) => {
|
|
201
|
+
const { lat1, lon1, lat2, lon2 } = req.query;
|
|
202
|
+
const { version } = req.params;
|
|
203
|
+
|
|
204
|
+
const geo = (req.module as { geo?: (a: string, b: string, c: string, d: string) => unknown }).geo;
|
|
205
|
+
if (!geo) {
|
|
206
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/geo`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (lat1 === undefined || lon1 === undefined || lat2 === undefined || lon2 === undefined) {
|
|
210
|
+
error(res, 400, 'Please provide lat1, lon1, lat2 and lon2', `${version}/geo`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const result = geo(lat1 as string, lon1 as string, lat2 as string, lon2 as string);
|
|
216
|
+
res.jsonResponse(result);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
error(res, 400, (err as Error).message, `${req.version}/geo`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
199
222
|
// GET planning error
|
|
200
223
|
router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
|
|
201
224
|
error(res, 405, 'This endpoint only supports POST requests.');
|
|
@@ -242,6 +265,33 @@ router.get('/:version/levenshtein', (req: Request, res: Response) => {
|
|
|
242
265
|
}
|
|
243
266
|
});
|
|
244
267
|
|
|
268
|
+
// Generate a color palette from a base color
|
|
269
|
+
router.get('/:version/palette', (req: Request, res: Response) => {
|
|
270
|
+
const { color, type } = req.query;
|
|
271
|
+
const { version } = req.params;
|
|
272
|
+
|
|
273
|
+
const palette = (req.module as { palette?: (c: string, t: string) => unknown }).palette;
|
|
274
|
+
if (!palette) {
|
|
275
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/palette`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!color) {
|
|
279
|
+
error(res, 400, 'Please provide a base color (?color=#ff6600)', `${version}/palette`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (!type) {
|
|
283
|
+
error(res, 400, 'Please provide a palette type (&type=complementary)', `${version}/palette`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const result = palette(color as string, type as string);
|
|
289
|
+
res.jsonResponse(result);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
error(res, 400, (err as Error).message, `${req.version}/palette`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
245
295
|
// Generate personal data
|
|
246
296
|
router.get('/:version/personal', (req: Request, res: Response) => {
|
|
247
297
|
try {
|
|
@@ -252,6 +302,32 @@ router.get('/:version/personal', (req: Request, res: Response) => {
|
|
|
252
302
|
}
|
|
253
303
|
});
|
|
254
304
|
|
|
305
|
+
// Generate a placeholder image or skeleton
|
|
306
|
+
router.get('/:version/placeholder', (req: Request, res: Response) => {
|
|
307
|
+
const { type = 'image' } = req.query;
|
|
308
|
+
const { version } = req.params;
|
|
309
|
+
|
|
310
|
+
const placeholder = (
|
|
311
|
+
req.module as {
|
|
312
|
+
placeholder?: (
|
|
313
|
+
t: string,
|
|
314
|
+
q: Record<string, string | undefined>,
|
|
315
|
+
) => { type: string; contentType: string; body: Buffer | string };
|
|
316
|
+
}
|
|
317
|
+
).placeholder;
|
|
318
|
+
if (!placeholder) {
|
|
319
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/placeholder`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const result = placeholder(type as string, req.query as Record<string, string | undefined>);
|
|
325
|
+
res.type(result.contentType).send(result.body);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
error(res, 400, (err as Error).message, `${req.version}/placeholder`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
255
331
|
// Generate QR Code
|
|
256
332
|
router.get('/:version/qrcode', async (req: Request, res: Response) => {
|
|
257
333
|
const { url } = req.query;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import { ticTacToeStorage } from '../storage/index.js';
|
|
3
|
+
import { error } from '../utils/response.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
// Play a tic-tac-toe move
|
|
8
|
+
router.patch('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
|
|
9
|
+
if (req.version !== 'v4') {
|
|
10
|
+
error(res, 405, 'PATCH is only supported in v4+.', `${req.version}/tic-tac-toe`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const game = req.params.game as string;
|
|
15
|
+
const { username, move, session } = (req.body as Record<string, string>) || {};
|
|
16
|
+
|
|
17
|
+
if (!username) {
|
|
18
|
+
error(res, 400, 'Please provide a username (?username={username})');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!move) {
|
|
22
|
+
error(res, 400, 'Please provide a valid move (&move={move})');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!session) {
|
|
26
|
+
error(res, 400, 'Please provide a valid session ID (&session={ID})');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = req.module.tic_tac_toe('play', {
|
|
32
|
+
username,
|
|
33
|
+
move,
|
|
34
|
+
session,
|
|
35
|
+
game,
|
|
36
|
+
storage: ticTacToeStorage,
|
|
37
|
+
});
|
|
38
|
+
res.jsonResponse(result);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
error(res, 400, (err as Error).message);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default router;
|
|
@@ -52,6 +52,9 @@ describe('GET / (version listing)', () => {
|
|
|
52
52
|
const endpoints = body.endpoints as Record<string, Record<string, string>>;
|
|
53
53
|
assert.ok('dice' in endpoints.get!);
|
|
54
54
|
assert.ok('encode' in endpoints.get!);
|
|
55
|
+
assert.ok('geo' in endpoints.get!);
|
|
56
|
+
assert.ok('palette' in endpoints.get!);
|
|
57
|
+
assert.ok('placeholder' in endpoints.get!);
|
|
55
58
|
assert.ok('statistics' in endpoints.get!);
|
|
56
59
|
assert.ok('text' in endpoints.get!);
|
|
57
60
|
assert.ok('validate' in endpoints.get!);
|
|
@@ -151,6 +154,14 @@ describe('GET /v4/dice', () => {
|
|
|
151
154
|
});
|
|
152
155
|
});
|
|
153
156
|
|
|
157
|
+
describe('GET /v4/domain', () => {
|
|
158
|
+
test('has TLD', async () => {
|
|
159
|
+
const { status, body } = await getJson('/v4/domain');
|
|
160
|
+
assert.equal(status, 200);
|
|
161
|
+
assert.match(body.domain as string, /\./);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
154
165
|
describe('GET /v4/encode', () => {
|
|
155
166
|
test('base64 encode', async () => {
|
|
156
167
|
const { status, body } = await getJson('/v4/encode?method=base64encode&text=hello');
|
|
@@ -169,11 +180,16 @@ describe('GET /v4/encode', () => {
|
|
|
169
180
|
});
|
|
170
181
|
});
|
|
171
182
|
|
|
172
|
-
describe('GET /v4/
|
|
173
|
-
test('
|
|
174
|
-
const {
|
|
175
|
-
|
|
176
|
-
assert.
|
|
183
|
+
describe('GET /v4/geo', () => {
|
|
184
|
+
test('Paris-NYC distance', async () => {
|
|
185
|
+
const { body } = await getJson('/v4/geo?lat1=48.8566&lon1=2.3522&lat2=40.7128&lon2=-74.006');
|
|
186
|
+
const distance = body.distance as { km: number };
|
|
187
|
+
assert.ok(Math.abs(distance.km - 5837) < 50);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('missing param returns 400', async () => {
|
|
191
|
+
const { status } = await getJson('/v4/geo?lat1=0&lon1=0');
|
|
192
|
+
assert.equal(status, 400);
|
|
177
193
|
});
|
|
178
194
|
});
|
|
179
195
|
|
|
@@ -194,6 +210,18 @@ describe('GET /v4/levenshtein', () => {
|
|
|
194
210
|
});
|
|
195
211
|
});
|
|
196
212
|
|
|
213
|
+
describe('GET /v4/palette', () => {
|
|
214
|
+
test('triadic returns 3 colors', async () => {
|
|
215
|
+
const { body } = await getJson('/v4/palette?color=%23ff6600&type=triadic');
|
|
216
|
+
assert.equal((body.colors as unknown[]).length, 3);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('invalid type returns 400', async () => {
|
|
220
|
+
const { status } = await getJson('/v4/palette?color=%23ff6600&type=rainbow');
|
|
221
|
+
assert.equal(status, 400);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
197
225
|
describe('GET /v4/personal', () => {
|
|
198
226
|
test('returns generated identity', async () => {
|
|
199
227
|
const { status, body } = await getJson('/v4/personal');
|
|
@@ -204,6 +232,22 @@ describe('GET /v4/personal', () => {
|
|
|
204
232
|
});
|
|
205
233
|
});
|
|
206
234
|
|
|
235
|
+
describe('GET /v4/placeholder', () => {
|
|
236
|
+
test('image returns SVG', async () => {
|
|
237
|
+
const res = await fetch(`${baseUrl}/v4/placeholder?type=image&width=100&height=50`);
|
|
238
|
+
assert.equal(res.status, 200);
|
|
239
|
+
assert.match(res.headers.get('content-type') ?? '', /image\/svg\+xml/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('skeleton returns SVG', async () => {
|
|
243
|
+
const res = await fetch(`${baseUrl}/v4/placeholder?type=skeleton&width=200&height=100`);
|
|
244
|
+
assert.equal(res.status, 200);
|
|
245
|
+
assert.match(res.headers.get('content-type') ?? '', /image\/svg\+xml/);
|
|
246
|
+
const body = await res.text();
|
|
247
|
+
assert.match(body, /<svg/);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
207
251
|
describe('GET /v4/qrcode', () => {
|
|
208
252
|
test('returns data URL', async () => {
|
|
209
253
|
const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com');
|
|
@@ -260,6 +304,16 @@ describe('GET /v4/time', () => {
|
|
|
260
304
|
});
|
|
261
305
|
});
|
|
262
306
|
|
|
307
|
+
describe('GET /v4/username', () => {
|
|
308
|
+
test('has fields', async () => {
|
|
309
|
+
const { status, body } = await getJson('/v4/username');
|
|
310
|
+
assert.equal(status, 200);
|
|
311
|
+
assert.ok('username' in body);
|
|
312
|
+
assert.ok('adjective' in body);
|
|
313
|
+
assert.ok('animal' in body);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
263
317
|
describe('GET /v4/validate', () => {
|
|
264
318
|
test('valid luhn', async () => {
|
|
265
319
|
const { body } = await getJson('/v4/validate?type=luhn&value=4111111111111111');
|
|
@@ -277,16 +331,6 @@ describe('GET /v4/validate', () => {
|
|
|
277
331
|
});
|
|
278
332
|
});
|
|
279
333
|
|
|
280
|
-
describe('GET /v4/username', () => {
|
|
281
|
-
test('has fields', async () => {
|
|
282
|
-
const { status, body } = await getJson('/v4/username');
|
|
283
|
-
assert.equal(status, 200);
|
|
284
|
-
assert.ok('username' in body);
|
|
285
|
-
assert.ok('adjective' in body);
|
|
286
|
-
assert.ok('animal' in body);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
334
|
describe('GET /v4/website', () => {
|
|
291
335
|
const originalFetch = globalThis.fetch;
|
|
292
336
|
|
|
@@ -327,10 +371,10 @@ describe('GET /v4/website', () => {
|
|
|
327
371
|
assert.ok('stats' in body);
|
|
328
372
|
});
|
|
329
373
|
|
|
330
|
-
test('?key=
|
|
331
|
-
const { status, body } = await getJson('/v4/website?key=
|
|
374
|
+
test('?key=active returns sub-key', async () => {
|
|
375
|
+
const { status, body } = await getJson('/v4/website?key=active');
|
|
332
376
|
assert.equal(status, 200);
|
|
333
|
-
assert.ok('
|
|
377
|
+
assert.ok('active' in body);
|
|
334
378
|
});
|
|
335
379
|
|
|
336
380
|
test('?key=invalid.path returns 404', async () => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import captcha from '../../src/modules/v4/captcha.js';
|
|
4
|
+
|
|
5
|
+
describe('captcha', () => {
|
|
6
|
+
test('returns a Buffer for valid text', () => {
|
|
7
|
+
const result = captcha('hello');
|
|
8
|
+
assert.ok(Buffer.isBuffer(result));
|
|
9
|
+
assert.ok(result.length > 0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('PNG starts with correct magic bytes', () => {
|
|
13
|
+
const result = captcha('test');
|
|
14
|
+
assert.equal(result[0], 0x89);
|
|
15
|
+
assert.equal(result[1], 0x50); // P
|
|
16
|
+
assert.equal(result[2], 0x4e); // N
|
|
17
|
+
assert.equal(result[3], 0x47); // G
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('throws on missing text', () => {
|
|
21
|
+
assert.throws(() => captcha(''));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import chat from '../../src/modules/v4/chat.js';
|
|
4
|
+
import type { ChatStorage } from '../../src/types/storage.js';
|
|
5
|
+
|
|
6
|
+
function makeStorage(): ChatStorage {
|
|
7
|
+
return { messages: [], privateChats: {}, sessions: {}, rateLimits: {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('chat', () => {
|
|
11
|
+
test('sends a public message', () => {
|
|
12
|
+
const storage = makeStorage();
|
|
13
|
+
const result = chat('message', { username: 'alice', message: 'hello', session: 'a1', storage });
|
|
14
|
+
assert.ok((result as { message: string }).message.includes('sent'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('fetches public messages', () => {
|
|
18
|
+
const storage = makeStorage();
|
|
19
|
+
chat('message', { username: 'alice', message: 'test', session: 'a1', storage });
|
|
20
|
+
const result = chat('fetch', { username: 'bob', storage });
|
|
21
|
+
assert.ok(Array.isArray(result));
|
|
22
|
+
assert.equal((result as { message: string }[])[0]!.message, 'test');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('sends and retrieves private messages', () => {
|
|
26
|
+
const storage = makeStorage();
|
|
27
|
+
chat('message', { username: 'alice', message: 'secret', session: 'a1', token: 'tok1', storage });
|
|
28
|
+
const result = chat('private', { username: 'bob', token: 'tok1', storage });
|
|
29
|
+
assert.ok(Array.isArray(result));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('clears a private chat', () => {
|
|
33
|
+
const storage = makeStorage();
|
|
34
|
+
chat('message', { username: 'alice', message: 'secret', session: 'a1', token: 'tok2', storage });
|
|
35
|
+
const result = chat('clear', { username: 'alice', session: 'a1', token: 'tok2', storage });
|
|
36
|
+
assert.ok((result as { message: string }).message.includes('cleared'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('throws on missing username', () => {
|
|
40
|
+
assert.throws(() => chat('message', { username: '', message: 'hi', session: 's', storage: makeStorage() }), /username/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('throws on invalid action', () => {
|
|
44
|
+
assert.throws(() => chat('delete', { username: 'alice', storage: makeStorage() }), /Invalid action/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('throws on fetch with no messages', () => {
|
|
48
|
+
assert.throws(() => chat('fetch', { username: 'alice', storage: makeStorage() }), /No messages/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import geo from '../../src/modules/v4/geo.js';
|
|
4
|
+
|
|
5
|
+
describe('geo', () => {
|
|
6
|
+
test('Paris to NYC distance ~5837 km', () => {
|
|
7
|
+
const result = geo('48.8566', '2.3522', '40.7128', '-74.006');
|
|
8
|
+
assert.ok(Math.abs(result.distance.km - 5837) < 50, `expected ~5837, got ${result.distance.km}`);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('same point returns 0 km', () => {
|
|
12
|
+
const result = geo('48.8566', '2.3522', '48.8566', '2.3522');
|
|
13
|
+
assert.equal(result.distance.km, 0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('miles and nautical miles match km conversion', () => {
|
|
17
|
+
const result = geo('0', '0', '0', '90');
|
|
18
|
+
assert.ok(Math.abs(result.distance.miles - result.distance.km * 0.621371) < 0.01);
|
|
19
|
+
assert.ok(Math.abs(result.distance.nauticalMiles - result.distance.km * 0.539957) < 0.01);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('bearing N for due north', () => {
|
|
23
|
+
const result = geo('0', '0', '10', '0');
|
|
24
|
+
assert.equal(result.bearing.degrees, 0);
|
|
25
|
+
assert.equal(result.bearing.cardinal, 'N');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('bearing E for due east', () => {
|
|
29
|
+
const result = geo('0', '0', '0', '10');
|
|
30
|
+
assert.equal(result.bearing.degrees, 90);
|
|
31
|
+
assert.equal(result.bearing.cardinal, 'E');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('throws on invalid latitude', () => {
|
|
35
|
+
assert.throws(() => geo('100', '0', '0', '0'), /lat1 must be between/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('throws on invalid longitude', () => {
|
|
39
|
+
assert.throws(() => geo('0', '200', '0', '0'), /lon1 must be between/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('throws on non-numeric coords', () => {
|
|
43
|
+
assert.throws(() => geo('abc', '0', '0', '0'), /must be a number/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import palette from '../../src/modules/v4/palette.js';
|
|
4
|
+
|
|
5
|
+
describe('palette', () => {
|
|
6
|
+
test('complementary returns 2 colors', () => {
|
|
7
|
+
const result = palette('#ff6600', 'complementary');
|
|
8
|
+
assert.equal(result.colors.length, 2);
|
|
9
|
+
assert.equal(result.type, 'complementary');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('triadic returns 3 colors', () => {
|
|
13
|
+
const result = palette('#ff6600', 'triadic');
|
|
14
|
+
assert.equal(result.colors.length, 3);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('analogous returns 5 colors', () => {
|
|
18
|
+
const result = palette('#ff6600', 'analogous');
|
|
19
|
+
assert.equal(result.colors.length, 5);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('tetradic returns 4 colors', () => {
|
|
23
|
+
const result = palette('#0066ff', 'tetradic');
|
|
24
|
+
assert.equal(result.colors.length, 4);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('split-complementary returns 3 colors', () => {
|
|
28
|
+
const result = palette('#0066ff', 'split-complementary');
|
|
29
|
+
assert.equal(result.colors.length, 3);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('each color has hex/rgb/hsl', () => {
|
|
33
|
+
const result = palette('#ff6600', 'triadic');
|
|
34
|
+
for (const c of result.colors) {
|
|
35
|
+
assert.match(c.hex, /^#[0-9a-f]{6}$/);
|
|
36
|
+
assert.match(c.rgb, /^rgb\(/);
|
|
37
|
+
assert.match(c.hsl, /^hsl\(/);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('base color is included', () => {
|
|
42
|
+
const result = palette('#ff6600', 'complementary');
|
|
43
|
+
assert.equal(result.base.hex, '#ff6600');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('throws on invalid hex', () => {
|
|
47
|
+
assert.throws(() => palette('#zzz', 'triadic'), /Invalid HEX/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('throws on unknown type', () => {
|
|
51
|
+
assert.throws(() => palette('#ff6600', 'rainbow'), /must be one of/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import placeholder from '../../src/modules/v4/placeholder.js';
|
|
4
|
+
|
|
5
|
+
describe('placeholder', () => {
|
|
6
|
+
test('image returns SVG with text', () => {
|
|
7
|
+
const result = placeholder('image', { width: '200', height: '100', text: 'Hello' });
|
|
8
|
+
assert.equal(result.type, 'image');
|
|
9
|
+
assert.equal(result.contentType, 'image/svg+xml');
|
|
10
|
+
assert.match(result.body, /<svg/);
|
|
11
|
+
assert.match(result.body, /Hello/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('skeleton returns SVG with animation', () => {
|
|
15
|
+
const result = placeholder('skeleton', { width: '300', height: '200' });
|
|
16
|
+
assert.equal(result.type, 'skeleton');
|
|
17
|
+
assert.equal(result.contentType, 'image/svg+xml');
|
|
18
|
+
assert.match(result.body, /<svg/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('default dimensions 800x600', () => {
|
|
22
|
+
const result = placeholder('image', {});
|
|
23
|
+
assert.match(result.body, /width="800"/);
|
|
24
|
+
assert.match(result.body, /height="600"/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('skeleton with avatar', () => {
|
|
28
|
+
const result = placeholder('skeleton', { avatar: 'circle' });
|
|
29
|
+
assert.match(result.body, /<circle/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('skeleton with rows', () => {
|
|
33
|
+
const result = placeholder('skeleton', { rows: '5' });
|
|
34
|
+
assert.match(result.body, /<rect/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('animate none removes animation', () => {
|
|
38
|
+
const result = placeholder('skeleton', { animate: 'none' });
|
|
39
|
+
assert.ok(!result.body.includes('<animate'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('throws on invalid type', () => {
|
|
43
|
+
assert.throws(() => placeholder('video', {}), /Type must be one of/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('throws on invalid width', () => {
|
|
47
|
+
assert.throws(() => placeholder('image', { width: '5000' }), /width/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('throws on invalid avatar shape', () => {
|
|
51
|
+
assert.throws(() => placeholder('skeleton', { avatar: 'hexagon' }), /avatar/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('throws on invalid speed', () => {
|
|
55
|
+
assert.throws(() => placeholder('skeleton', { speed: '99' }), /speed/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import qrcode from '../../src/modules/v4/qrcode.js';
|
|
4
|
+
|
|
5
|
+
describe('qrcode', () => {
|
|
6
|
+
test('returns data URL for valid url', async () => {
|
|
7
|
+
const result = await qrcode('https://example.com');
|
|
8
|
+
assert.match(result, /^data:image\/png;base64,/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('throws on missing url', async () => {
|
|
12
|
+
await assert.rejects(() => qrcode(''));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import tic_tac_toe from '../../src/modules/v4/tic_tac_toe.js';
|
|
4
|
+
import type { TicTacToeStorage } from '../../src/types/storage.js';
|
|
5
|
+
|
|
6
|
+
function makeStorage(): TicTacToeStorage {
|
|
7
|
+
return { games: {}, sessions: {}, rateLimits: {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('tic_tac_toe', () => {
|
|
11
|
+
test('list returns empty games array', () => {
|
|
12
|
+
const result = tic_tac_toe('list', { storage: makeStorage() });
|
|
13
|
+
assert.ok(Array.isArray(result.games));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('play creates a game and makes a move', () => {
|
|
17
|
+
const storage = makeStorage();
|
|
18
|
+
const result = tic_tac_toe('play', { username: 'alice', move: '1-1', session: 'a1', game: 'TEST1', storage });
|
|
19
|
+
assert.ok((result.message as string).includes('Move sent'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('fetch returns game state', () => {
|
|
23
|
+
const storage = makeStorage();
|
|
24
|
+
tic_tac_toe('play', { username: 'alice', move: '1-1', session: 'a1', game: 'TEST2', storage });
|
|
25
|
+
const result = tic_tac_toe('fetch', { username: 'alice', game: 'TEST2', storage });
|
|
26
|
+
assert.equal(result.id, 'TEST2');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('forfeit throws on non-player', () => {
|
|
30
|
+
const storage = makeStorage();
|
|
31
|
+
tic_tac_toe('play', { username: 'alice', move: '1-1', session: 'a1', game: 'TEST3', storage });
|
|
32
|
+
assert.throws(
|
|
33
|
+
() => tic_tac_toe('forfeit', { username: 'charlie', session: 'c1', game: 'TEST3', storage }),
|
|
34
|
+
/not a player/,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('throws on missing username', () => {
|
|
39
|
+
assert.throws(() => tic_tac_toe('play', { move: '1-1', session: 's', game: 'G', storage: makeStorage() }), /username/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('throws on missing move', () => {
|
|
43
|
+
assert.throws(
|
|
44
|
+
() => tic_tac_toe('play', { username: 'alice', session: 's', game: 'G', storage: makeStorage() }),
|
|
45
|
+
/move/i,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('throws on invalid action', () => {
|
|
50
|
+
assert.throws(() => tic_tac_toe('reset', { username: 'alice', storage: makeStorage() }), /Invalid action/);
|
|
51
|
+
});
|
|
52
|
+
});
|