@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.
Files changed (40) hide show
  1. package/README.md +3 -3
  2. package/dist/app.js +4 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/config/versions.js +9 -0
  5. package/dist/config/versions.js.map +1 -1
  6. package/dist/constants.js +2 -0
  7. package/dist/constants.js.map +1 -1
  8. package/dist/modules/v4/placeholder.js +107 -46
  9. package/dist/modules/v4/placeholder.js.map +1 -1
  10. package/dist/modules/v4.js +6 -0
  11. package/dist/modules/v4.js.map +1 -1
  12. package/dist/routes/delete.js +4 -5
  13. package/dist/routes/delete.js.map +1 -1
  14. package/dist/routes/get.js +144 -0
  15. package/dist/routes/get.js.map +1 -1
  16. package/dist/routes/index.js +2 -2
  17. package/dist/routes/index.js.map +1 -1
  18. package/dist/routes/patch.js +2 -3
  19. package/dist/routes/patch.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/app.ts +4 -0
  22. package/src/config/versions.ts +6 -0
  23. package/src/middleware/cors.ts +1 -1
  24. package/src/modules/v4/chat.ts +24 -1
  25. package/src/modules/v4/geo.ts +53 -0
  26. package/src/modules/v4/palette.ts +103 -0
  27. package/src/modules/v4/placeholder.ts +192 -0
  28. package/src/modules/v4/tic_tac_toe.ts +33 -1
  29. package/src/modules/v4.ts +3 -0
  30. package/src/routes/delete.ts +71 -0
  31. package/src/routes/get.ts +76 -0
  32. package/src/routes/patch.ts +44 -0
  33. package/tests/integration/api.test.ts +62 -18
  34. package/tests/unit/captcha.test.ts +23 -0
  35. package/tests/unit/chat.test.ts +50 -0
  36. package/tests/unit/geo.test.ts +45 -0
  37. package/tests/unit/palette.test.ts +53 -0
  38. package/tests/unit/placeholder.test.ts +57 -0
  39. package/tests/unit/qrcode.test.ts +14 -0
  40. 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/domain', () => {
173
- test('has TLD', async () => {
174
- const { status, body } = await getJson('/v4/domain');
175
- assert.equal(status, 200);
176
- assert.match(body.domain as string, /\./);
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=stats.5 returns sub-key', async () => {
331
- const { status, body } = await getJson('/v4/website?key=stats.5');
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('stats.5' in body);
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
+ });