@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.
- package/README.md +3 -3
- package/dist/config/versions.js +10 -0
- package/dist/config/versions.js.map +1 -1
- package/dist/constants.js +0 -2
- package/dist/constants.js.map +1 -1
- package/dist/modules/v3/chat.js +1 -1
- package/dist/modules/v3/chat.js.map +1 -1
- package/dist/modules/v3/domain.js +1 -1
- package/dist/modules/v3/domain.js.map +1 -1
- package/dist/modules/v3/hyperplanning.js +1 -1
- package/dist/modules/v3/hyperplanning.js.map +1 -1
- package/dist/modules/v3/personal.js +1 -1
- package/dist/modules/v3/personal.js.map +1 -1
- package/dist/modules/v3/username.js +1 -1
- package/dist/modules/v3/username.js.map +1 -1
- package/dist/modules/v4/captcha.js +60 -23
- package/dist/modules/v4/captcha.js.map +1 -1
- package/dist/modules/v4/chat.js +1 -1
- package/dist/modules/v4/chat.js.map +1 -1
- package/dist/modules/v4/color.js +44 -35
- package/dist/modules/v4/color.js.map +1 -1
- package/dist/modules/v4/colors.js +54 -0
- package/dist/modules/v4/colors.js.map +1 -0
- package/dist/modules/v4/convert.js +93 -19
- package/dist/modules/v4/convert.js.map +1 -1
- package/dist/modules/v4/domain.js +1 -1
- package/dist/modules/v4/domain.js.map +1 -1
- package/dist/modules/v4/hash.js +11 -4
- package/dist/modules/v4/hash.js.map +1 -1
- package/dist/modules/v4/hyperplanning.js +1 -1
- package/dist/modules/v4/hyperplanning.js.map +1 -1
- package/dist/modules/v4/palette.js +2 -43
- package/dist/modules/v4/palette.js.map +1 -1
- package/dist/modules/v4/personal.js +1 -1
- package/dist/modules/v4/personal.js.map +1 -1
- package/dist/modules/v4/placeholder.js +5 -10
- package/dist/modules/v4/placeholder.js.map +1 -1
- package/dist/modules/v4/qrcode.js +79 -6
- package/dist/modules/v4/qrcode.js.map +1 -1
- package/dist/modules/v4/username.js +1 -1
- package/dist/modules/v4/username.js.map +1 -1
- package/dist/routes/delete.js +14 -14
- package/dist/routes/delete.js.map +1 -1
- package/dist/routes/get.js +68 -13
- package/dist/routes/get.js.map +1 -1
- package/dist/routes/post.js +10 -3
- package/dist/routes/post.js.map +1 -1
- package/dist/utils/colors.js +54 -0
- package/dist/utils/colors.js.map +1 -0
- package/dist/utils/helpers.js +26 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +1 -1
- package/src/config/versions.ts +10 -0
- package/src/constants.ts +0 -2
- package/src/modules/v4/captcha.ts +84 -27
- package/src/modules/v4/chat.ts +1 -1
- package/src/modules/v4/color.ts +56 -52
- package/src/modules/v4/convert.ts +100 -20
- package/src/modules/v4/domain.ts +1 -1
- package/src/modules/v4/hash.ts +18 -4
- package/src/modules/v4/hyperplanning.ts +1 -1
- package/src/modules/v4/palette.ts +3 -43
- package/src/modules/v4/personal.ts +1 -1
- package/src/modules/v4/placeholder.ts +6 -9
- package/src/modules/v4/qrcode.ts +108 -6
- package/src/modules/v4/username.ts +1 -1
- package/src/routes/get.ts +71 -15
- package/src/routes/post.ts +11 -3
- package/src/utils/colors.ts +91 -0
- package/src/utils/helpers.ts +90 -0
- package/src/utils/response.ts +8 -0
- package/tests/integration/api.test.ts +51 -7
- package/tests/unit/captcha.test.ts +50 -12
- package/tests/unit/chat.test.ts +4 -1
- package/tests/unit/color.test.ts +24 -4
- package/tests/unit/convert.test.ts +26 -1
- package/tests/unit/hash.test.ts +17 -10
- package/tests/unit/qrcode.test.ts +72 -4
- package/tests/unit/tic_tac_toe.test.ts +4 -1
- package/src/modules/v4/utils.ts +0 -38
package/src/routes/post.ts
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/src/utils/response.ts
CHANGED
|
@@ -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('
|
|
100
|
-
const
|
|
101
|
-
assert.equal(status,
|
|
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
|
|
253
|
-
const
|
|
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
|
|
7
|
-
const result = captcha('hello');
|
|
8
|
-
assert.
|
|
9
|
-
assert.ok(result.
|
|
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('
|
|
13
|
-
const result = captcha('test');
|
|
14
|
-
assert.
|
|
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
|
|
21
|
-
assert.throws(() => captcha(''));
|
|
58
|
+
test('throws on invalid color', () => {
|
|
59
|
+
assert.throws(() => captcha({ text: 'test', bg: 'xyz' }), /color/i);
|
|
22
60
|
});
|
|
23
61
|
});
|
package/tests/unit/chat.test.ts
CHANGED
|
@@ -37,7 +37,10 @@ describe('chat', () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test('throws on missing username', () => {
|
|
40
|
-
assert.throws(
|
|
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', () => {
|
package/tests/unit/color.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
65
|
+
assert.throws(() => convert(NaN, 'celsius', 'kelvin'), /must be a number/);
|
|
41
66
|
});
|
|
42
67
|
});
|
package/tests/unit/hash.test.ts
CHANGED
|
@@ -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.
|
|
17
|
-
|
|
18
|
-
|
|
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('
|
|
23
|
-
|
|
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('
|
|
28
|
-
|
|
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
|
|
7
|
-
const result = await qrcode('https://example.com');
|
|
8
|
-
assert.
|
|
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(
|
|
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', () => {
|