@20syldev/api 4.3.0 → 4.4.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 (80) hide show
  1. package/README.md +3 -3
  2. package/dist/config/versions.js +10 -0
  3. package/dist/config/versions.js.map +1 -1
  4. package/dist/constants.js +0 -2
  5. package/dist/constants.js.map +1 -1
  6. package/dist/modules/v3/chat.js +1 -1
  7. package/dist/modules/v3/chat.js.map +1 -1
  8. package/dist/modules/v3/domain.js +1 -1
  9. package/dist/modules/v3/domain.js.map +1 -1
  10. package/dist/modules/v3/hyperplanning.js +1 -1
  11. package/dist/modules/v3/hyperplanning.js.map +1 -1
  12. package/dist/modules/v3/personal.js +1 -1
  13. package/dist/modules/v3/personal.js.map +1 -1
  14. package/dist/modules/v3/username.js +1 -1
  15. package/dist/modules/v3/username.js.map +1 -1
  16. package/dist/modules/v4/captcha.js +60 -23
  17. package/dist/modules/v4/captcha.js.map +1 -1
  18. package/dist/modules/v4/chat.js +1 -1
  19. package/dist/modules/v4/chat.js.map +1 -1
  20. package/dist/modules/v4/color.js +44 -35
  21. package/dist/modules/v4/color.js.map +1 -1
  22. package/dist/modules/v4/colors.js +54 -0
  23. package/dist/modules/v4/colors.js.map +1 -0
  24. package/dist/modules/v4/convert.js +93 -19
  25. package/dist/modules/v4/convert.js.map +1 -1
  26. package/dist/modules/v4/domain.js +1 -1
  27. package/dist/modules/v4/domain.js.map +1 -1
  28. package/dist/modules/v4/hash.js +11 -4
  29. package/dist/modules/v4/hash.js.map +1 -1
  30. package/dist/modules/v4/hyperplanning.js +1 -1
  31. package/dist/modules/v4/hyperplanning.js.map +1 -1
  32. package/dist/modules/v4/palette.js +2 -43
  33. package/dist/modules/v4/palette.js.map +1 -1
  34. package/dist/modules/v4/personal.js +1 -1
  35. package/dist/modules/v4/personal.js.map +1 -1
  36. package/dist/modules/v4/placeholder.js +5 -10
  37. package/dist/modules/v4/placeholder.js.map +1 -1
  38. package/dist/modules/v4/qrcode.js +79 -6
  39. package/dist/modules/v4/qrcode.js.map +1 -1
  40. package/dist/modules/v4/username.js +1 -1
  41. package/dist/modules/v4/username.js.map +1 -1
  42. package/dist/routes/delete.js +14 -14
  43. package/dist/routes/delete.js.map +1 -1
  44. package/dist/routes/get.js +68 -13
  45. package/dist/routes/get.js.map +1 -1
  46. package/dist/routes/post.js +10 -3
  47. package/dist/routes/post.js.map +1 -1
  48. package/dist/utils/colors.js +54 -0
  49. package/dist/utils/colors.js.map +1 -0
  50. package/dist/utils/helpers.js +26 -0
  51. package/dist/utils/helpers.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/config/versions.ts +10 -0
  54. package/src/constants.ts +0 -2
  55. package/src/modules/v4/captcha.ts +84 -27
  56. package/src/modules/v4/chat.ts +1 -1
  57. package/src/modules/v4/color.ts +56 -52
  58. package/src/modules/v4/convert.ts +100 -20
  59. package/src/modules/v4/domain.ts +1 -1
  60. package/src/modules/v4/hash.ts +18 -4
  61. package/src/modules/v4/hyperplanning.ts +1 -1
  62. package/src/modules/v4/palette.ts +3 -43
  63. package/src/modules/v4/personal.ts +1 -1
  64. package/src/modules/v4/placeholder.ts +6 -9
  65. package/src/modules/v4/qrcode.ts +108 -6
  66. package/src/modules/v4/username.ts +1 -1
  67. package/src/routes/get.ts +71 -15
  68. package/src/routes/post.ts +11 -3
  69. package/src/utils/colors.ts +91 -0
  70. package/src/utils/helpers.ts +90 -0
  71. package/src/utils/response.ts +8 -0
  72. package/tests/integration/api.test.ts +51 -7
  73. package/tests/unit/captcha.test.ts +50 -12
  74. package/tests/unit/chat.test.ts +4 -1
  75. package/tests/unit/color.test.ts +24 -4
  76. package/tests/unit/convert.test.ts +26 -1
  77. package/tests/unit/hash.test.ts +17 -10
  78. package/tests/unit/qrcode.test.ts +72 -4
  79. package/tests/unit/tic_tac_toe.test.ts +4 -1
  80. package/src/modules/v4/utils.ts +0 -38
@@ -2,6 +2,8 @@ import { Router, type Request, type Response } from 'express';
2
2
  import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
3
  import { MIN_TOKEN_LENGTH, MAX_TOKEN_LENGTH } from '../constants.js';
4
4
  import { error } from '../utils/response.js';
5
+ import { since } from '../utils/helpers.js';
6
+ import type { HashResult } from '../modules/v4/hash.js';
5
7
 
6
8
  const router = Router();
7
9
 
@@ -64,7 +66,7 @@ router.post('/:version/chat/private', (req: Request, res: Response) => {
64
66
 
65
67
  // Generate hash
66
68
  router.post('/:version/hash', (req: Request, res: Response) => {
67
- const { text, method } = (req.body as Record<string, string>) || {};
69
+ const { text, method, encoding } = (req.body as Record<string, string>) || {};
68
70
 
69
71
  if (!text) {
70
72
  error(res, 400, 'Please provide a text (?text={text})', `${req.version}/hash`);
@@ -76,8 +78,14 @@ router.post('/:version/hash', (req: Request, res: Response) => {
76
78
  }
77
79
 
78
80
  try {
79
- const result = req.module.hash(text, method);
80
- res.jsonResponse(result);
81
+ if (since(req.version, 4)) {
82
+ const hashFn = req.module.hash as (t: string, m: string, e?: string) => HashResult;
83
+ const result = hashFn(text, method, encoding);
84
+ res.jsonResponse(result);
85
+ } else {
86
+ const result = (req.module.hash as (t: string, m: string) => Record<string, string>)(text, method);
87
+ res.jsonResponse(result);
88
+ }
81
89
  } catch (err) {
82
90
  error(res, 400, (err as Error).message, `${req.version}/hash`);
83
91
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Parses a hex color string into an RGB tuple.
3
+ *
4
+ * @param hex - Hex color string (#RRGGBB or RRGGBB)
5
+ * @returns RGB tuple [r, g, b] with values 0-255
6
+ * @throws Error if the hex string is invalid
7
+ */
8
+ export function hexToRgb(hex: string): [number, number, number] {
9
+ const clean = hex.replace('#', '');
10
+ if (!/^[0-9a-fA-F]{6}$/.test(clean)) throw new Error('Invalid HEX color (use #RRGGBB)');
11
+ return [parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16)];
12
+ }
13
+
14
+ /**
15
+ * Converts HSL values to an RGB tuple.
16
+ *
17
+ * @param h - Hue in degrees (0-360)
18
+ * @param s - Saturation (0-1)
19
+ * @param l - Lightness (0-1)
20
+ * @returns RGB tuple [r, g, b] with values 0-255
21
+ */
22
+ export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
23
+ const c = (1 - Math.abs(2 * l - 1)) * s;
24
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
25
+ const m = l - c / 2;
26
+ let r1 = 0,
27
+ g1 = 0,
28
+ b1 = 0;
29
+ if (h < 60) [r1, g1, b1] = [c, x, 0];
30
+ else if (h < 120) [r1, g1, b1] = [x, c, 0];
31
+ else if (h < 180) [r1, g1, b1] = [0, c, x];
32
+ else if (h < 240) [r1, g1, b1] = [0, x, c];
33
+ else if (h < 300) [r1, g1, b1] = [x, 0, c];
34
+ else [r1, g1, b1] = [c, 0, x];
35
+ return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
36
+ }
37
+
38
+ /**
39
+ * Validates and normalizes a hex color string, adding # if missing.
40
+ *
41
+ * @param value - Raw color value (with or without #)
42
+ * @param def - Default color if value is empty
43
+ * @returns Normalized hex color string with #
44
+ * @throws Error if the color format is invalid
45
+ */
46
+ export function normalizeColor(value: string | undefined, def: string): string {
47
+ if (!value) return def;
48
+ const clean = value.startsWith('#') ? value : `#${value}`;
49
+ if (!/^#([0-9a-fA-F]{3}){1,2}$/.test(clean)) throw new Error('Invalid color (use hex like ff6600)');
50
+ return clean;
51
+ }
52
+
53
+ /**
54
+ * Formats an RGB tuple as a hex color string.
55
+ *
56
+ * @param r - Red channel (0-255)
57
+ * @param g - Green channel (0-255)
58
+ * @param b - Blue channel (0-255)
59
+ * @returns Hex color string (#rrggbb)
60
+ */
61
+ export function rgbToHex(r: number, g: number, b: number): string {
62
+ return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
63
+ }
64
+
65
+ /**
66
+ * Converts RGB values to an HSL tuple.
67
+ *
68
+ * @param r - Red channel (0-255)
69
+ * @param g - Green channel (0-255)
70
+ * @param b - Blue channel (0-255)
71
+ * @returns HSL tuple [h (0-360), s (0-1), l (0-1)]
72
+ */
73
+ export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
74
+ const r1 = r / 255,
75
+ g1 = g / 255,
76
+ b1 = b / 255;
77
+ const max = Math.max(r1, g1, b1),
78
+ min = Math.min(r1, g1, b1);
79
+ const l = (max + min) / 2;
80
+
81
+ if (max === min) return [0, 0, l];
82
+
83
+ const d = max - min;
84
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
85
+ let h = 0;
86
+ if (max === r1) h = (g1 - b1) / d + (g1 < b1 ? 6 : 0);
87
+ else if (max === g1) h = (b1 - r1) / d + 2;
88
+ else h = (r1 - g1) / d + 4;
89
+
90
+ return [(h * 60 + 360) % 360, s, l];
91
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tracks requests per user and throws if the rate limit is exceeded.
3
+ *
4
+ * @param rateLimits - Store of timestamps per user
5
+ * @param userId - Identifier of the requesting user
6
+ * @param timestamp - Current timestamp in milliseconds
7
+ * @param window - Time window in milliseconds
8
+ * @param limit - Maximum requests allowed in the window
9
+ * @returns false if within limits
10
+ * @throws Error if rate limit is exceeded
11
+ */
12
+ export function checkRateLimit(
13
+ rateLimits: Record<string, number[]>,
14
+ userId: string,
15
+ timestamp: number,
16
+ window = 10000,
17
+ limit = 50,
18
+ ): boolean {
19
+ rateLimits[userId] = (rateLimits[userId] ?? []).filter((ts) => timestamp - ts < window);
20
+
21
+ if (rateLimits[userId]!.length > limit) {
22
+ const remainingTime = Math.ceil((rateLimits[userId]![0]! + window - timestamp) / 1000);
23
+ throw new Error(`Rate limit exceeded. Try again in ${remainingTime} seconds.`);
24
+ }
25
+
26
+ rateLimits[userId]!.push(timestamp);
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Parses a space-separated environment variable into an array.
32
+ *
33
+ * @param key - The environment variable name
34
+ * @returns The parsed array, or null if not set
35
+ */
36
+ export function envList(key: string): string[] | null {
37
+ const v = process.env[key];
38
+ return v && v !== 'undefined' ? v.split(' ') : null;
39
+ }
40
+
41
+ /**
42
+ * Formats a Date to ISO string without timezone suffix.
43
+ *
44
+ * @param date - The date to format
45
+ * @returns ISO-formatted string without trailing Z
46
+ */
47
+ export function formatDate(date: Date): string {
48
+ return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().replace('Z', '');
49
+ }
50
+
51
+ /**
52
+ * Generates a random IPv4 address.
53
+ *
54
+ * @returns A string in the format "X.X.X.X"
55
+ */
56
+ export function genIP(): string {
57
+ return `${randomNumber(0, 255)}.${randomNumber(0, 255)}.${randomNumber(0, 255)}.${randomNumber(0, 255)}`;
58
+ }
59
+
60
+ /**
61
+ * Returns a random element from an array.
62
+ *
63
+ * @param arr - The source array
64
+ * @returns A random element
65
+ */
66
+ export function random<T>(arr: T[]): T {
67
+ return arr[Math.floor(Math.random() * arr.length)]!;
68
+ }
69
+
70
+ /**
71
+ * Returns a random integer between min and max (inclusive).
72
+ *
73
+ * @param min - Lower bound
74
+ * @param max - Upper bound
75
+ * @returns A random integer in [min, max]
76
+ */
77
+ export function randomNumber(min: number, max: number): number {
78
+ return Math.floor(Math.random() * (max - min + 1)) + min;
79
+ }
80
+
81
+ /**
82
+ * Checks if a version string is at least the given minimum.
83
+ *
84
+ * @param version - Version string (e.g. "v4")
85
+ * @param min - Minimum version number
86
+ * @returns true if the version is >= min
87
+ */
88
+ export function since(version: string, min: number): boolean {
89
+ return parseInt(version.replace('v', '')) >= min;
90
+ }
@@ -1,6 +1,14 @@
1
1
  import type { Response } from 'express';
2
2
  import { DOCS_URL, STATUS_MESSAGES } from '../constants.js';
3
3
 
4
+ /**
5
+ * Sends a standardized JSON error response.
6
+ *
7
+ * @param res - Express response object
8
+ * @param status - HTTP status code
9
+ * @param message - Error description
10
+ * @param docPath - Optional documentation path appended to the base URL
11
+ */
4
12
  export function error(res: Response, status: number, message: string, docPath?: string): void {
5
13
  res.status(status).jsonResponse({
6
14
  message: STATUS_MESSAGES[status] ?? 'Error',
@@ -90,15 +90,23 @@ describe('GET /v4/algorithms', () => {
90
90
  });
91
91
 
92
92
  describe('GET /v4/captcha', () => {
93
- test('returns PNG', async () => {
93
+ test('returns PNG with provided text', async () => {
94
94
  const res = await fetch(`${baseUrl}/v4/captcha?text=hello`);
95
95
  assert.equal(res.status, 200);
96
96
  assert.match(res.headers.get('content-type') ?? '', /image\/png/);
97
+ assert.equal(res.headers.get('x-captcha-text'), 'hello');
97
98
  });
98
99
 
99
- test('without text returns 400', async () => {
100
- const { status } = await getJson('/v4/captcha');
101
- assert.equal(status, 400);
100
+ test('auto-generates text when none provided', async () => {
101
+ const res = await fetch(`${baseUrl}/v4/captcha`);
102
+ assert.equal(res.status, 200);
103
+ assert.match(res.headers.get('content-type') ?? '', /image\/png/);
104
+ assert.ok(res.headers.get('x-captcha-text')!.length > 0);
105
+ });
106
+
107
+ test('accepts noise and dimensions', async () => {
108
+ const res = await fetch(`${baseUrl}/v4/captcha?text=AB&noise=high&width=200&height=80`);
109
+ assert.equal(res.status, 200);
102
110
  });
103
111
  });
104
112
 
@@ -117,7 +125,7 @@ describe('GET /v4/chat', () => {
117
125
  });
118
126
 
119
127
  describe('GET /v4/color', () => {
120
- test('returns color formats', async () => {
128
+ test('returns random color formats', async () => {
121
129
  const { status, body } = await getJson('/v4/color');
122
130
  assert.equal(status, 200);
123
131
  assert.match(body.hex as string, /^#[0-9a-f]{6}$/);
@@ -125,6 +133,12 @@ describe('GET /v4/color', () => {
125
133
  assert.ok('hsl' in body);
126
134
  assert.ok('cmyk' in body);
127
135
  });
136
+
137
+ test('accepts hex input', async () => {
138
+ const { status, body } = await getJson('/v4/color?hex=ff6600');
139
+ assert.equal(status, 200);
140
+ assert.equal(body.hex, '#ff6600');
141
+ });
128
142
  });
129
143
 
130
144
  describe('GET /v4/convert', () => {
@@ -134,6 +148,12 @@ describe('GET /v4/convert', () => {
134
148
  assert.equal(body.result, 212);
135
149
  });
136
150
 
151
+ test('km to mi', async () => {
152
+ const { status, body } = await getJson('/v4/convert?value=1&from=km&to=mi');
153
+ assert.equal(status, 200);
154
+ assert.ok(Math.abs((body.result as number) - 0.621371) < 0.001);
155
+ });
156
+
137
157
  test('missing value returns 400', async () => {
138
158
  const { status } = await getJson('/v4/convert?from=celsius&to=fahrenheit');
139
159
  assert.equal(status, 400);
@@ -249,8 +269,16 @@ describe('GET /v4/placeholder', () => {
249
269
  });
250
270
 
251
271
  describe('GET /v4/qrcode', () => {
252
- test('returns data URL', async () => {
253
- const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com');
272
+ test('returns PNG image by default', async () => {
273
+ const res = await fetch(`${baseUrl}/v4/qrcode?url=https%3A%2F%2Fexample.com`);
274
+ assert.equal(res.status, 200);
275
+ assert.match(res.headers.get('content-type') ?? '', /image\/png/);
276
+ const buf = Buffer.from(await res.arrayBuffer());
277
+ assert.ok(buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])));
278
+ });
279
+
280
+ test('returns data URL with format=base64', async () => {
281
+ const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com&format=base64');
254
282
  assert.equal(status, 200);
255
283
  assert.match(body as unknown as string, /^data:image\/png;base64,/);
256
284
  });
@@ -425,6 +453,7 @@ describe('POST /v4/hash', () => {
425
453
  const { status, body } = await sendJson('POST', '/v4/hash', { text: 'hello', method: 'sha256' });
426
454
  assert.equal(status, 200);
427
455
  assert.equal(body.hash, '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
456
+ assert.equal(body.encoding, 'hex');
428
457
  });
429
458
 
430
459
  test('md5', async () => {
@@ -432,6 +461,21 @@ describe('POST /v4/hash', () => {
432
461
  assert.equal(body.hash, '5d41402abc4b2a76b9719d911017c592');
433
462
  });
434
463
 
464
+ test('base64 encoding', async () => {
465
+ const { status, body } = await sendJson('POST', '/v4/hash', {
466
+ text: 'hello',
467
+ method: 'sha256',
468
+ encoding: 'base64',
469
+ });
470
+ assert.equal(status, 200);
471
+ assert.equal(body.encoding, 'base64');
472
+ });
473
+
474
+ test('unsupported method returns 400', async () => {
475
+ const { status } = await sendJson('POST', '/v4/hash', { text: 'hello', method: 'fakehash' });
476
+ assert.equal(status, 400);
477
+ });
478
+
435
479
  test('missing text returns 400', async () => {
436
480
  const { status } = await sendJson('POST', '/v4/hash', { method: 'sha256' });
437
481
  assert.equal(status, 400);
@@ -2,22 +2,60 @@ import { test, describe } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import captcha from '../../src/modules/v4/captcha.js';
4
4
 
5
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
6
+
5
7
  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);
8
+ test('returns PNG buffer with provided text', () => {
9
+ const result = captcha({ text: 'hello' });
10
+ assert.equal(result.contentType, 'image/png');
11
+ assert.ok(Buffer.isBuffer(result.body));
12
+ assert.ok(result.body.subarray(0, 4).equals(PNG_MAGIC));
13
+ assert.equal(result.text, 'hello');
14
+ });
15
+
16
+ test('auto-generates text when none provided', () => {
17
+ const result = captcha({});
18
+ assert.equal(result.text.length, 6);
19
+ assert.ok(Buffer.isBuffer(result.body));
20
+ });
21
+
22
+ test('respects custom length', () => {
23
+ const result = captcha({ length: 10 });
24
+ assert.equal(result.text.length, 10);
25
+ });
26
+
27
+ test('throws on invalid length', () => {
28
+ assert.throws(() => captcha({ length: 0 }), /length/);
29
+ assert.throws(() => captcha({ length: 99 }), /length/);
30
+ });
31
+
32
+ test('accepts custom dimensions', () => {
33
+ const result = captcha({ text: 'AB', width: 200, height: 80 });
34
+ assert.ok(Buffer.isBuffer(result.body));
35
+ });
36
+
37
+ test('throws on invalid dimensions', () => {
38
+ assert.throws(() => captcha({ text: 'AB', width: 10 }), /width/);
39
+ assert.throws(() => captcha({ text: 'AB', height: 500 }), /height/);
40
+ });
41
+
42
+ test('accepts noise levels', () => {
43
+ for (const noise of ['low', 'medium', 'high'] as const) {
44
+ const result = captcha({ text: 'test', noise });
45
+ assert.ok(Buffer.isBuffer(result.body));
46
+ }
47
+ });
48
+
49
+ test('throws on invalid noise', () => {
50
+ assert.throws(() => captcha({ text: 'test', noise: 'extreme' as 'low' }), /noise/);
10
51
  });
11
52
 
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
53
+ test('accepts custom colors', () => {
54
+ const result = captcha({ text: 'test', bg: 'f0f0f0', color: '333333' });
55
+ assert.ok(Buffer.isBuffer(result.body));
18
56
  });
19
57
 
20
- test('throws on missing text', () => {
21
- assert.throws(() => captcha(''));
58
+ test('throws on invalid color', () => {
59
+ assert.throws(() => captcha({ text: 'test', bg: 'xyz' }), /color/i);
22
60
  });
23
61
  });
@@ -37,7 +37,10 @@ describe('chat', () => {
37
37
  });
38
38
 
39
39
  test('throws on missing username', () => {
40
- assert.throws(() => chat('message', { username: '', message: 'hi', session: 's', storage: makeStorage() }), /username/);
40
+ assert.throws(
41
+ () => chat('message', { username: '', message: 'hi', session: 's', storage: makeStorage() }),
42
+ /username/,
43
+ );
41
44
  });
42
45
 
43
46
  test('throws on invalid action', () => {
@@ -15,19 +15,39 @@ describe('color', () => {
15
15
 
16
16
  test('hex matches #RRGGBB', () => {
17
17
  for (let i = 0; i < 20; i++) {
18
- assert.match(color().hex!, /^#[0-9a-f]{6}$/);
18
+ assert.match(color().hex, /^#[0-9a-f]{6}$/);
19
19
  }
20
20
  });
21
21
 
22
22
  test('rgb format', () => {
23
- assert.match(color().rgb!, /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
23
+ assert.match(color().rgb, /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
24
24
  });
25
25
 
26
26
  test('hsl format', () => {
27
- assert.match(color().hsl!, /^hsl\(\d+(\.\d+)?, \d+(\.\d+)?%, \d+(\.\d+)?%\)$/);
27
+ assert.match(color().hsl, /^hsl\(\d+(\.\d+)?, \d+(\.\d+)?%, \d+(\.\d+)?%\)$/);
28
28
  });
29
29
 
30
30
  test('cmyk format with 4 channels', () => {
31
- assert.match(color().cmyk!, /^cmyk\(/);
31
+ assert.match(color().cmyk, /^cmyk\(/);
32
+ });
33
+
34
+ test('accepts hex input', () => {
35
+ const result = color('#ff6600');
36
+ assert.equal(result.hex, '#ff6600');
37
+ assert.equal(result.rgb, 'rgb(255, 102, 0)');
38
+ });
39
+
40
+ test('accepts hex without #', () => {
41
+ const result = color('ff6600');
42
+ assert.equal(result.hex, '#ff6600');
43
+ });
44
+
45
+ test('throws on invalid hex', () => {
46
+ assert.throws(() => color('xyz'), /Invalid HEX/);
47
+ });
48
+
49
+ test('HSV correct for equal channels', () => {
50
+ const result = color('#808000');
51
+ assert.match(result.hsv, /^hsv\(60\.0,/);
32
52
  });
33
53
  });
@@ -36,7 +36,32 @@ describe('convert', () => {
36
36
  assert.throws(() => convert(-300, 'celsius', 'kelvin'), /absolute zero/);
37
37
  });
38
38
 
39
+ test('km to mi', () => {
40
+ const result = convert(1, 'km', 'mi');
41
+ assert.ok(Math.abs(result.result - 0.621371) < 0.001);
42
+ });
43
+
44
+ test('kg to lb', () => {
45
+ const result = convert(1, 'kg', 'lb');
46
+ assert.ok(Math.abs(result.result - 2.20462) < 0.001);
47
+ });
48
+
49
+ test('kb to mb', () => {
50
+ const result = convert(1024, 'kb', 'mb');
51
+ assert.equal(result.result, 1);
52
+ });
53
+
54
+ test('km/h to mph', () => {
55
+ const result = convert(100, 'km/h', 'mph');
56
+ assert.ok(Math.abs(result.result - 62.1371) < 0.01);
57
+ });
58
+
59
+ test('negative values allowed for non-temperature', () => {
60
+ const result = convert(-5, 'km', 'mi');
61
+ assert.ok(result.result < 0);
62
+ });
63
+
39
64
  test('throws on non-numeric value', () => {
40
- assert.throws(() => convert('abc', 'celsius', 'kelvin'), /must be a number/);
65
+ assert.throws(() => convert(NaN, 'celsius', 'kelvin'), /must be a number/);
41
66
  });
42
67
  });
@@ -8,25 +8,32 @@ describe('hash', () => {
8
8
  assert.deepEqual(result, {
9
9
  method: 'sha256',
10
10
  hash: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
11
+ encoding: 'hex',
11
12
  });
12
13
  });
13
14
 
14
15
  test('md5 of "hello"', () => {
15
16
  const result = hash('hello', 'md5');
16
- assert.deepEqual(result, {
17
- method: 'md5',
18
- hash: '5d41402abc4b2a76b9719d911017c592',
19
- });
17
+ assert.equal(result.hash, '5d41402abc4b2a76b9719d911017c592');
18
+ assert.equal(result.encoding, 'hex');
19
+ });
20
+
21
+ test('base64 encoding', () => {
22
+ const result = hash('hello', 'sha256', 'base64');
23
+ assert.equal(result.encoding, 'base64');
24
+ assert.ok(result.hash.length > 0);
25
+ });
26
+
27
+ test('throws on empty text', () => {
28
+ assert.throws(() => hash('', 'sha256'), /Text is required/);
20
29
  });
21
30
 
22
- test('sha1 of empty string', () => {
23
- const result = hash('', 'sha1');
24
- assert.equal((result as { hash: string }).hash, 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
31
+ test('throws on unsupported method', () => {
32
+ assert.throws(() => hash('hello', 'fakehash'), /Unsupported method/);
25
33
  });
26
34
 
27
- test('returns error object on unsupported method', () => {
28
- const result = hash('hello', 'fakehash');
29
- assert.ok('error' in result);
35
+ test('throws on invalid encoding', () => {
36
+ assert.throws(() => hash('hello', 'sha256', 'utf8'), /Encoding must be/);
30
37
  });
31
38
 
32
39
  test('same input + method = same hash (deterministic)', () => {
@@ -2,13 +2,81 @@ import { test, describe } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import qrcode from '../../src/modules/v4/qrcode.js';
4
4
 
5
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
6
+
5
7
  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,/);
8
+ test('returns PNG buffer by default', async () => {
9
+ const result = await qrcode({ url: 'https://example.com' });
10
+ assert.equal(result.contentType, 'image/png');
11
+ assert.ok(Buffer.isBuffer(result.body));
12
+ assert.ok((result.body as Buffer).subarray(0, 4).equals(PNG_MAGIC));
13
+ });
14
+
15
+ test('returns data URL with format=base64', async () => {
16
+ const result = await qrcode({ url: 'https://example.com', format: 'base64' });
17
+ assert.equal(result.contentType, 'application/json');
18
+ assert.match(result.body as string, /^data:image\/png;base64,/);
19
+ });
20
+
21
+ test('throws on invalid format', async () => {
22
+ await assert.rejects(() => qrcode({ url: 'https://example.com', format: 'jpg' as 'png' }), /format/);
9
23
  });
10
24
 
11
25
  test('throws on missing url', async () => {
12
- await assert.rejects(() => qrcode(''));
26
+ await assert.rejects(() => qrcode({ url: '' }));
27
+ });
28
+
29
+ test('accepts custom size', async () => {
30
+ const result = await qrcode({ url: 'https://example.com', size: 400 });
31
+ assert.equal(result.contentType, 'image/png');
32
+ });
33
+
34
+ test('throws on invalid size', async () => {
35
+ await assert.rejects(() => qrcode({ url: 'https://example.com', size: 9999 }), /size/);
36
+ });
37
+
38
+ test('accepts custom colors', async () => {
39
+ const result = await qrcode({ url: 'https://example.com', dark: 'ff0000', light: 'ffffff' });
40
+ assert.equal(result.contentType, 'image/png');
41
+ });
42
+
43
+ test('throws on invalid dark color', async () => {
44
+ await assert.rejects(() => qrcode({ url: 'https://example.com', dark: 'xyz' }), /color/i);
45
+ });
46
+
47
+ test('throws on invalid light color', async () => {
48
+ await assert.rejects(() => qrcode({ url: 'https://example.com', light: 'nope' }), /color/i);
49
+ });
50
+
51
+ test('accepts correction level', async () => {
52
+ const result = await qrcode({ url: 'https://example.com', correction: 'H' });
53
+ assert.equal(result.contentType, 'image/png');
54
+ });
55
+
56
+ test('throws on invalid correction level', async () => {
57
+ await assert.rejects(() => qrcode({ url: 'https://example.com', correction: 'X' as 'L' }), /correction/);
58
+ });
59
+
60
+ test('accepts custom margin', async () => {
61
+ const result = await qrcode({ url: 'https://example.com', margin: 0 });
62
+ assert.equal(result.contentType, 'image/png');
63
+ });
64
+
65
+ test('throws on invalid margin', async () => {
66
+ await assert.rejects(() => qrcode({ url: 'https://example.com', margin: 99 }), /margin/);
67
+ });
68
+
69
+ test('throws on non-HTTPS icon URL', async () => {
70
+ await assert.rejects(
71
+ () => qrcode({ url: 'https://example.com', icon: 'http://example.com/icon.png' }),
72
+ /HTTPS/,
73
+ );
74
+ });
75
+
76
+ test('throws on unreachable icon URL', async () => {
77
+ await assert.rejects(
78
+ () => qrcode({ url: 'https://example.com', icon: 'https://localhost:1/nope.png' }),
79
+ /Failed to fetch icon|fetch/i,
80
+ );
13
81
  });
14
82
  });
@@ -36,7 +36,10 @@ describe('tic_tac_toe', () => {
36
36
  });
37
37
 
38
38
  test('throws on missing username', () => {
39
- assert.throws(() => tic_tac_toe('play', { move: '1-1', session: 's', game: 'G', storage: makeStorage() }), /username/);
39
+ assert.throws(
40
+ () => tic_tac_toe('play', { move: '1-1', session: 's', game: 'G', storage: makeStorage() }),
41
+ /username/,
42
+ );
40
43
  });
41
44
 
42
45
  test('throws on missing move', () => {