@20syldev/api 4.6.0 → 4.8.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 (111) hide show
  1. package/README.md +5 -3
  2. package/dist/app.js +6 -6
  3. package/dist/app.js.map +1 -1
  4. package/dist/config/env.js.map +1 -1
  5. package/dist/config/versions.js +22 -3
  6. package/dist/config/versions.js.map +1 -1
  7. package/dist/constants.js +27 -12
  8. package/dist/constants.js.map +1 -1
  9. package/dist/middleware/error.js +2 -2
  10. package/dist/middleware/error.js.map +1 -1
  11. package/dist/middleware/logger.js.map +1 -1
  12. package/dist/middleware/ratelimit.js +2 -2
  13. package/dist/middleware/ratelimit.js.map +1 -1
  14. package/dist/middleware/version.js.map +1 -1
  15. package/dist/modules/v3.js +1 -1
  16. package/dist/modules/v3.js.map +1 -1
  17. package/dist/modules/v4/address.js +381 -0
  18. package/dist/modules/v4/address.js.map +1 -0
  19. package/dist/modules/v4/agent.js +92 -0
  20. package/dist/modules/v4/agent.js.map +1 -0
  21. package/dist/modules/v4/algorithms.js +79 -0
  22. package/dist/modules/v4/algorithms.js.map +1 -1
  23. package/dist/modules/v4/avatar.js +136 -0
  24. package/dist/modules/v4/avatar.js.map +1 -0
  25. package/dist/modules/v4/barcode.js +440 -0
  26. package/dist/modules/v4/barcode.js.map +1 -0
  27. package/dist/modules/v4/captcha.js +7 -0
  28. package/dist/modules/v4/captcha.js.map +1 -1
  29. package/dist/modules/v4/chat.js +9 -1
  30. package/dist/modules/v4/chat.js.map +1 -1
  31. package/dist/modules/v4/color.js +8 -1
  32. package/dist/modules/v4/color.js.map +1 -1
  33. package/dist/modules/v4/convert.js +9 -0
  34. package/dist/modules/v4/convert.js.map +1 -1
  35. package/dist/modules/v4/credit.js +104 -0
  36. package/dist/modules/v4/credit.js.map +1 -0
  37. package/dist/modules/v4/cron.js +202 -0
  38. package/dist/modules/v4/cron.js.map +1 -0
  39. package/dist/modules/v4/dice.js +7 -0
  40. package/dist/modules/v4/dice.js.map +1 -1
  41. package/dist/modules/v4/domain.js +6 -1
  42. package/dist/modules/v4/domain.js.map +1 -1
  43. package/dist/modules/v4/encode.js +71 -0
  44. package/dist/modules/v4/encode.js.map +1 -1
  45. package/dist/modules/v4/geo.js +11 -1
  46. package/dist/modules/v4/geo.js.map +1 -1
  47. package/dist/modules/v4/hash.js +10 -1
  48. package/dist/modules/v4/hash.js.map +1 -1
  49. package/dist/modules/v4/hyperplanning.js +9 -1
  50. package/dist/modules/v4/hyperplanning.js.map +1 -1
  51. package/dist/modules/v4/ip.js +132 -0
  52. package/dist/modules/v4/ip.js.map +1 -0
  53. package/dist/modules/v4/levenshtein.js +8 -0
  54. package/dist/modules/v4/levenshtein.js.map +1 -1
  55. package/dist/modules/v4/palette.js +9 -1
  56. package/dist/modules/v4/palette.js.map +1 -1
  57. package/dist/modules/v4/password.js +343 -0
  58. package/dist/modules/v4/password.js.map +1 -0
  59. package/dist/modules/v4/personal.js +14 -18
  60. package/dist/modules/v4/personal.js.map +1 -1
  61. package/dist/modules/v4/placeholder.js +8 -0
  62. package/dist/modules/v4/placeholder.js.map +1 -1
  63. package/dist/modules/v4/qrcode.js +10 -3
  64. package/dist/modules/v4/qrcode.js.map +1 -1
  65. package/dist/modules/v4/regex.js +48 -0
  66. package/dist/modules/v4/regex.js.map +1 -0
  67. package/dist/modules/v4/statistics.js +7 -0
  68. package/dist/modules/v4/statistics.js.map +1 -1
  69. package/dist/modules/v4/text.js +30 -0
  70. package/dist/modules/v4/text.js.map +1 -1
  71. package/dist/modules/v4/tic_tac_toe.js +9 -1
  72. package/dist/modules/v4/tic_tac_toe.js.map +1 -1
  73. package/dist/modules/v4/time.js +62 -11
  74. package/dist/modules/v4/time.js.map +1 -1
  75. package/dist/modules/v4/token.js +17 -8
  76. package/dist/modules/v4/token.js.map +1 -1
  77. package/dist/modules/v4/username.js +5 -0
  78. package/dist/modules/v4/username.js.map +1 -1
  79. package/dist/modules/v4/validate.js +21 -0
  80. package/dist/modules/v4/validate.js.map +1 -1
  81. package/dist/modules/v4.js +10 -1
  82. package/dist/modules/v4.js.map +1 -1
  83. package/dist/routes/delete.js.map +1 -1
  84. package/dist/routes/get.js +208 -48
  85. package/dist/routes/get.js.map +1 -1
  86. package/dist/routes/index.js +2 -2
  87. package/dist/routes/index.js.map +1 -1
  88. package/dist/routes/patch.js.map +1 -1
  89. package/dist/routes/post.js +2 -2
  90. package/dist/routes/post.js.map +1 -1
  91. package/dist/utils/response.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/config/versions.ts +8 -1
  94. package/src/constants.ts +27 -12
  95. package/src/modules/v4/address.ts +412 -0
  96. package/src/modules/v4/cron.ts +221 -0
  97. package/src/modules/v4/geo.ts +2 -1
  98. package/src/modules/v4/password.ts +379 -0
  99. package/src/modules/v4/personal.ts +4 -16
  100. package/src/modules/v4/qrcode.ts +2 -2
  101. package/src/modules/v4/regex.ts +67 -0
  102. package/src/modules/v4/time.ts +63 -17
  103. package/src/modules/v4/token.ts +8 -7
  104. package/src/modules/v4.ts +4 -0
  105. package/src/routes/get.ts +125 -46
  106. package/tests/integration/api.test.ts +111 -0
  107. package/tests/unit/address.test.ts +84 -0
  108. package/tests/unit/cron.test.ts +110 -0
  109. package/tests/unit/password.test.ts +86 -0
  110. package/tests/unit/regex.test.ts +95 -0
  111. package/tests/unit/time.test.ts +47 -0
package/src/routes/get.ts CHANGED
@@ -3,10 +3,12 @@ import { type Request, type Response, Router } from 'express';
3
3
  import { env } from '../config/env.js';
4
4
  import { versions } from '../config/versions.js';
5
5
  import { DOCS_URL, GITHUB_CACHE_TTL } from '../constants.js';
6
+ import type { AddressResult } from '../modules/v4/address.js';
6
7
  import type { UserAgentResult } from '../modules/v4/agent.js';
7
8
  import type { CaptchaOptions, CaptchaResult } from '../modules/v4/captcha.js';
8
9
  import type { ColorResult } from '../modules/v4/color.js';
9
10
  import type { IpResult } from '../modules/v4/ip.js';
11
+ import type { PasswordResult } from '../modules/v4/password.js';
10
12
  import type { QRCodeOptions, QRCodeResult } from '../modules/v4/qrcode.js';
11
13
  import { chatStorage, ipLimits } from '../storage/index.js';
12
14
  import { since } from '../utils/helpers.js';
@@ -176,6 +178,27 @@ router.get('/:version/convert', (req: Request, res: Response) => {
176
178
  }
177
179
  });
178
180
 
181
+ // Generate a fictional postal address
182
+ router.get('/:version/address', (req: Request, res: Response) => {
183
+ const { country, count } = req.query;
184
+ const { version } = req.params;
185
+
186
+ const addressFn = (req.module as { address?: (c?: string, n?: number) => AddressResult }).address;
187
+ if (!addressFn) {
188
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/address`);
189
+ return;
190
+ }
191
+
192
+ const parsedCount = count !== undefined ? parseInt(count as string, 10) : 1;
193
+
194
+ try {
195
+ const result = addressFn(country as string | undefined, parsedCount);
196
+ res.jsonResponse(result);
197
+ } catch (err) {
198
+ error(res, 400, (err as Error).message, `${req.version}/address`);
199
+ }
200
+ });
201
+
179
202
  // Echo request headers
180
203
  router.get('/:version/headers', (req: Request, res: Response) => {
181
204
  const redacted = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']);
@@ -234,6 +257,34 @@ router.get('/:version/domain', (req: Request, res: Response) => {
234
257
  }
235
258
  });
236
259
 
260
+ // Parse a cron expression and compute next execution dates
261
+ router.get('/:version/cron', (req: Request, res: Response) => {
262
+ const { expr, count, from, timezone } = req.query;
263
+ const { version } = req.params;
264
+
265
+ const cronFn = (req.module as { cron?: (e: string, n?: number, f?: string, tz?: string) => unknown }).cron;
266
+ if (!cronFn) {
267
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/cron`);
268
+ return;
269
+ }
270
+ if (!expr) {
271
+ error(res, 400, 'Please provide a cron expression (?expr=* * * * *)', `${version}/cron`);
272
+ return;
273
+ }
274
+
275
+ try {
276
+ const result = cronFn(
277
+ expr as string,
278
+ count !== undefined ? parseInt(count as string, 10) : 5,
279
+ from as string | undefined,
280
+ (timezone as string) ?? 'UTC',
281
+ );
282
+ res.jsonResponse(result);
283
+ } catch (err) {
284
+ error(res, 400, (err as Error).message, `${req.version}/cron`);
285
+ }
286
+ });
287
+
237
288
  // RPG Dice roller
238
289
  router.get('/:version/dice', (req: Request, res: Response) => {
239
290
  const { roll } = req.query;
@@ -376,6 +427,41 @@ router.get('/:version/palette', (req: Request, res: Response) => {
376
427
  }
377
428
  });
378
429
 
430
+ // Generate a password or passphrase
431
+ router.get('/:version/password', (req: Request, res: Response) => {
432
+ const { type, length, uppercase, lowercase, digits, symbols, exclude, count, separator } = req.query;
433
+ const { version } = req.params;
434
+
435
+ const passwordFn = (
436
+ req.module as { password?: (t: string, l: number, o: Record<string, unknown>) => PasswordResult }
437
+ ).password;
438
+ if (!passwordFn) {
439
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/password`);
440
+ return;
441
+ }
442
+
443
+ const parseBool = (v: unknown, def: boolean): boolean => (v === undefined ? def : v !== 'false');
444
+
445
+ try {
446
+ const result = passwordFn(
447
+ (type as string) ?? 'random',
448
+ length !== undefined ? parseInt(length as string, 10) : 16,
449
+ {
450
+ uppercase: parseBool(uppercase, true),
451
+ lowercase: parseBool(lowercase, true),
452
+ digits: parseBool(digits, true),
453
+ symbols: parseBool(symbols, false),
454
+ exclude: (exclude as string) ?? '',
455
+ count: count !== undefined ? parseInt(count as string, 10) : 1,
456
+ separator: (separator as string) ?? '-',
457
+ },
458
+ );
459
+ res.jsonResponse(result);
460
+ } catch (err) {
461
+ error(res, 400, (err as Error).message, `${req.version}/password`);
462
+ }
463
+ });
464
+
379
465
  // Generate personal data
380
466
  router.get('/:version/personal', (req: Request, res: Response) => {
381
467
  try {
@@ -451,6 +537,33 @@ router.get('/:version/qrcode', async (req: Request, res: Response) => {
451
537
  }
452
538
  });
453
539
 
540
+ // Test a regex pattern against a text
541
+ router.get('/:version/regex', (req: Request, res: Response) => {
542
+ const { pattern, text, flags } = req.query;
543
+ const { version } = req.params;
544
+
545
+ const regexFn = (req.module as { regex?: (p: string, t: string, f?: string) => unknown }).regex;
546
+ if (!regexFn) {
547
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/regex`);
548
+ return;
549
+ }
550
+ if (!pattern) {
551
+ error(res, 400, 'Please provide a pattern (?pattern={regex})', `${version}/regex`);
552
+ return;
553
+ }
554
+ if (!text) {
555
+ error(res, 400, 'Please provide a text (&text={string})', `${version}/regex`);
556
+ return;
557
+ }
558
+
559
+ try {
560
+ const result = regexFn(pattern as string, text as string, flags as string | undefined);
561
+ res.jsonResponse(result);
562
+ } catch (err) {
563
+ error(res, 400, (err as Error).message, `${req.version}/regex`);
564
+ }
565
+ });
566
+
454
567
  // Statistics on a list of numbers
455
568
  router.get('/:version/statistics', (req: Request, res: Response) => {
456
569
  const { values } = req.query;
@@ -551,60 +664,26 @@ router.get('/:version/tic-tac-toe/list', (_req: Request, res: Response) => {
551
664
  error(res, 405, 'This endpoint only supports POST requests.');
552
665
  });
553
666
 
554
- // Display or generate time informations
667
+ // Display or generate time informations, or compute a countdown
555
668
  router.get('/:version/time', (req: Request, res: Response) => {
556
- const { type = 'live', start, end, format, timezone } = req.query;
557
-
558
- const validFormats = [
559
- 'iso',
560
- 'utc',
561
- 'timestamp',
562
- 'locale',
563
- 'date',
564
- 'time',
565
- 'year',
566
- 'month',
567
- 'day',
568
- 'hour',
569
- 'minute',
570
- 'second',
571
- 'ms',
572
- 'dayOfWeek',
573
- 'dayOfYear',
574
- 'weekNumber',
575
- 'timezone',
576
- 'timezoneOffset',
577
- ];
578
- const validTimezones = ['UTC', 'America/New_York', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney'];
579
-
580
- if (type !== 'live' && type !== 'random') {
581
- error(res, 400, 'Please provide a valid type (?type={type})', `${req.version}/time`);
582
- return;
583
- }
584
- if (start && !Date.parse(start as string)) {
585
- error(res, 400, 'Please provide a valid start date (?start={YYYY-MM-DD})', `${req.version}/time`);
586
- return;
587
- }
588
- if (end && !Date.parse(end as string)) {
589
- error(res, 400, 'Please provide a valid end date (?end={YYYY-MM-DD})', `${req.version}/time`);
590
- return;
591
- }
592
- if (format && !validFormats.includes(format as string)) {
593
- error(res, 400, 'Please provide a valid format (?format={format})', `${req.version}/time`);
594
- return;
595
- }
596
- if (timezone && !validTimezones.includes(timezone as string)) {
597
- error(res, 400, 'Please provide a valid timezone (?timezone={timezone})', `${req.version}/time`);
598
- return;
599
- }
669
+ const { type = 'live', start, end, format, timezone, target } = req.query;
600
670
 
601
671
  try {
602
- const time = req.module.time(
672
+ const timeFn = req.module.time as (
673
+ t: string,
674
+ s?: string,
675
+ e?: string,
676
+ f?: string,
677
+ tz?: string,
678
+ target?: string,
679
+ ) => Record<string, unknown>;
680
+ const time = timeFn(
603
681
  type as string,
604
682
  start as string,
605
683
  end as string,
606
684
  format as string,
607
685
  timezone as string,
686
+ target as string,
608
687
  );
609
688
  res.jsonResponse(time);
610
689
  } catch (err) {
@@ -331,6 +331,19 @@ describe('GET /v4/time', () => {
331
331
  assert.ok(ts >= new Date('2020-01-01').getTime());
332
332
  assert.ok(ts <= new Date('2020-12-31').getTime());
333
333
  });
334
+
335
+ test('countdown future returns direction and remaining', async () => {
336
+ const future = new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString();
337
+ const { status, body } = await getJson(`/v4/time?type=countdown&target=${encodeURIComponent(future)}`);
338
+ assert.equal(status, 200);
339
+ assert.equal(body.direction, 'future');
340
+ assert.ok(typeof body.human === 'string');
341
+ });
342
+
343
+ test('countdown missing target returns 400', async () => {
344
+ const { status } = await getJson('/v4/time?type=countdown');
345
+ assert.equal(status, 400);
346
+ });
334
347
  });
335
348
 
336
349
  describe('GET /v4/username', () => {
@@ -643,6 +656,104 @@ describe('GET /v4/agent', () => {
643
656
  });
644
657
  });
645
658
 
659
+ describe('GET /v4/address', () => {
660
+ test('returns one address by default', async () => {
661
+ const { status, body } = await getJson('/v4/address');
662
+ assert.equal(status, 200);
663
+ assert.equal((body.addresses as unknown[]).length, 1);
664
+ });
665
+
666
+ test('country=fr returns a French address', async () => {
667
+ const { body } = await getJson('/v4/address?country=fr');
668
+ const a = (body.addresses as Record<string, string>[])[0]!;
669
+ assert.equal(a.countryCode, 'FR');
670
+ });
671
+
672
+ test('count=3 returns 3 addresses', async () => {
673
+ const { body } = await getJson('/v4/address?count=3');
674
+ assert.equal((body.addresses as unknown[]).length, 3);
675
+ });
676
+
677
+ test('unknown country returns 400', async () => {
678
+ const { status } = await getJson('/v4/address?country=xx');
679
+ assert.equal(status, 400);
680
+ });
681
+ });
682
+
683
+ describe('GET /v4/password', () => {
684
+ test('returns one password by default', async () => {
685
+ const { status, body } = await getJson('/v4/password');
686
+ assert.equal(status, 200);
687
+ assert.equal((body.passwords as string[]).length, 1);
688
+ assert.equal((body.passwords as string[])[0]!.length, 16);
689
+ });
690
+
691
+ test('length param is respected', async () => {
692
+ const { body } = await getJson('/v4/password?length=32');
693
+ assert.equal((body.passwords as string[])[0]!.length, 32);
694
+ });
695
+
696
+ test('passphrase mode returns words', async () => {
697
+ const { body } = await getJson('/v4/password?type=passphrase&length=4');
698
+ assert.equal(body.type, 'passphrase');
699
+ assert.equal((body.passwords as string[])[0]!.split('-').length, 4);
700
+ });
701
+
702
+ test('count=5 returns 5 passwords', async () => {
703
+ const { body } = await getJson('/v4/password?count=5');
704
+ assert.equal((body.passwords as string[]).length, 5);
705
+ });
706
+
707
+ test('no charset active returns 400', async () => {
708
+ const { status } = await getJson('/v4/password?uppercase=false&lowercase=false&digits=false&symbols=false');
709
+ assert.equal(status, 400);
710
+ });
711
+ });
712
+
713
+ describe('GET /v4/cron', () => {
714
+ test('returns next dates for * * * * *', async () => {
715
+ const { status, body } = await getJson('/v4/cron?expr=*%20*%20*%20*%20*');
716
+ assert.equal(status, 200);
717
+ assert.equal((body.next as string[]).length, 5);
718
+ assert.ok(typeof body.description === 'string');
719
+ });
720
+
721
+ test('count param is respected', async () => {
722
+ const { body } = await getJson('/v4/cron?expr=*%20*%20*%20*%20*&count=3');
723
+ assert.equal((body.next as string[]).length, 3);
724
+ });
725
+
726
+ test('invalid expression returns 400', async () => {
727
+ const { status } = await getJson('/v4/cron?expr=invalid');
728
+ assert.equal(status, 400);
729
+ });
730
+
731
+ test('missing expr returns 400', async () => {
732
+ const { status } = await getJson('/v4/cron');
733
+ assert.equal(status, 400);
734
+ });
735
+ });
736
+
737
+ describe('GET /v4/regex', () => {
738
+ test('basic match returns count and matches', async () => {
739
+ const { status, body } = await getJson('/v4/regex?pattern=hello&text=hello%20world');
740
+ assert.equal(status, 200);
741
+ assert.equal(body.valid, true);
742
+ assert.ok((body.count as number) >= 1);
743
+ });
744
+
745
+ test('invalid pattern returns valid: false with 200', async () => {
746
+ const { status, body } = await getJson('/v4/regex?pattern=%5Binvalid&text=hello');
747
+ assert.equal(status, 200);
748
+ assert.equal(body.valid, false);
749
+ });
750
+
751
+ test('missing pattern returns 400', async () => {
752
+ const { status } = await getJson('/v4/regex?text=hello');
753
+ assert.equal(status, 400);
754
+ });
755
+ });
756
+
646
757
  describe('Prototype access on dynamic endpoints', () => {
647
758
  test('algorithms?method=toString returns 400', async () => {
648
759
  const { status } = await getJson('/v4/algorithms?method=toString');
@@ -0,0 +1,84 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { describe, test } from 'node:test';
3
+
4
+ import address from '../../src/modules/v4/address.js';
5
+
6
+ describe('address', () => {
7
+ describe('default behavior', () => {
8
+ test('returns an addresses array with one entry by default', () => {
9
+ const r = address();
10
+ assert.equal(r.addresses.length, 1);
11
+ });
12
+
13
+ test('each address has required fields', () => {
14
+ const r = address();
15
+ const a = r.addresses[0]!;
16
+ assert.ok(a.street);
17
+ assert.ok(a.city);
18
+ assert.ok(a.zip);
19
+ assert.ok(a.state);
20
+ assert.ok(a.country);
21
+ assert.ok(a.countryCode);
22
+ });
23
+ });
24
+
25
+ describe('country filtering', () => {
26
+ test('fr returns a French address', () => {
27
+ const r = address('fr');
28
+ assert.equal(r.addresses[0]!.countryCode, 'FR');
29
+ assert.equal(r.addresses[0]!.country, 'France');
30
+ });
31
+
32
+ test('us returns a US address', () => {
33
+ const r = address('us');
34
+ assert.equal(r.addresses[0]!.countryCode, 'US');
35
+ assert.equal(r.addresses[0]!.country, 'United States');
36
+ });
37
+
38
+ test('uk returns a UK address', () => {
39
+ const r = address('uk');
40
+ assert.equal(r.addresses[0]!.countryCode, 'UK');
41
+ });
42
+
43
+ test('de returns a German address', () => {
44
+ const r = address('de');
45
+ assert.equal(r.addresses[0]!.countryCode, 'DE');
46
+ });
47
+
48
+ test('es returns a Spanish address', () => {
49
+ const r = address('es');
50
+ assert.equal(r.addresses[0]!.countryCode, 'ES');
51
+ });
52
+
53
+ test('country code is case-insensitive', () => {
54
+ const r = address('FR');
55
+ assert.equal(r.addresses[0]!.countryCode, 'FR');
56
+ });
57
+ });
58
+
59
+ describe('count', () => {
60
+ test('count=5 returns 5 addresses', () => {
61
+ const r = address('fr', 5);
62
+ assert.equal(r.addresses.length, 5);
63
+ });
64
+
65
+ test('count=10 returns 10 addresses', () => {
66
+ const r = address('us', 10);
67
+ assert.equal(r.addresses.length, 10);
68
+ });
69
+ });
70
+
71
+ describe('errors', () => {
72
+ test('unknown country throws', () => {
73
+ assert.throws(() => address('xx'), /Unknown country code/);
74
+ });
75
+
76
+ test('count=0 throws', () => {
77
+ assert.throws(() => address('fr', 0), /Count must be between/);
78
+ });
79
+
80
+ test('count=11 throws', () => {
81
+ assert.throws(() => address('fr', 11), /Count must be between/);
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,110 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { describe, test } from 'node:test';
3
+
4
+ import cron from '../../src/modules/v4/cron.js';
5
+
6
+ // Fixed reference point: a known Monday at 08:45 UTC
7
+ const FROM = '2026-01-05T08:45:00Z'; // Monday
8
+
9
+ describe('cron', () => {
10
+ describe('every minute', () => {
11
+ test('* * * * * returns 5 consecutive minutes', () => {
12
+ const r = cron('* * * * *', 5, FROM);
13
+ assert.equal(r.next.length, 5);
14
+ // First result should be the next minute after FROM
15
+ assert.ok(r.next[0]!.startsWith('2026-01-05T08:46'));
16
+ });
17
+
18
+ test('description is "Every minute"', () => {
19
+ const r = cron('* * * * *', 1, FROM);
20
+ assert.equal(r.description, 'Every minute');
21
+ });
22
+ });
23
+
24
+ describe('every N minutes', () => {
25
+ test('*/15 * * * * returns results 15 minutes apart', () => {
26
+ const r = cron('*/15 * * * *', 3, FROM);
27
+ assert.equal(r.next.length, 3);
28
+ assert.ok(r.next[0]!.includes('T09:00'));
29
+ assert.ok(r.next[1]!.includes('T09:15'));
30
+ assert.ok(r.next[2]!.includes('T09:30'));
31
+ });
32
+
33
+ test('description mentions every 15 minutes', () => {
34
+ const r = cron('*/15 * * * *', 1, FROM);
35
+ assert.match(r.description, /Every 15 minutes/);
36
+ });
37
+ });
38
+
39
+ describe('at fixed time', () => {
40
+ test('0 9 * * 1-5 fires at 09:00 on weekdays', () => {
41
+ const r = cron('0 9 * * 1-5', 3, FROM);
42
+ // Next Monday 09:00 is same day
43
+ assert.ok(r.next[0]!.includes('T09:00'));
44
+ // All results should be on weekdays (Mon–Fri)
45
+ for (const date of r.next) {
46
+ const d = new Date(date);
47
+ const dow = d.getUTCDay();
48
+ assert.ok(dow >= 1 && dow <= 5, `Expected weekday, got ${dow}`);
49
+ }
50
+ });
51
+
52
+ test('description includes Monday through Friday', () => {
53
+ const r = cron('0 9 * * 1-5', 1, FROM);
54
+ assert.match(r.description, /Monday through Friday/);
55
+ });
56
+ });
57
+
58
+ describe('plan example', () => {
59
+ test('*/15 9-17 * * 1-5 fires between 09:00 and 17:45 on weekdays', () => {
60
+ const r = cron('*/15 9-17 * * 1-5', 5, FROM);
61
+ assert.equal(r.next.length, 5);
62
+ for (const date of r.next) {
63
+ const d = new Date(date);
64
+ const hour = d.getUTCHours();
65
+ assert.ok(hour >= 9 && hour <= 17);
66
+ const dow = d.getUTCDay();
67
+ assert.ok(dow >= 1 && dow <= 5);
68
+ }
69
+ });
70
+
71
+ test('description includes "Every 15 minutes"', () => {
72
+ const r = cron('*/15 9-17 * * 1-5', 1, FROM);
73
+ assert.match(r.description, /Every 15 minutes/);
74
+ });
75
+ });
76
+
77
+ describe('count', () => {
78
+ test('count=10 returns 10 results', () => {
79
+ const r = cron('* * * * *', 10, FROM);
80
+ assert.equal(r.next.length, 10);
81
+ });
82
+
83
+ test('count=1 returns 1 result', () => {
84
+ const r = cron('*/30 * * * *', 1, FROM);
85
+ assert.equal(r.next.length, 1);
86
+ });
87
+ });
88
+
89
+ describe('errors', () => {
90
+ test('missing expr throws', () => {
91
+ assert.throws(() => cron(''), /expression/);
92
+ });
93
+
94
+ test('too few fields throws', () => {
95
+ assert.throws(() => cron('* * * *'), /5 fields/);
96
+ });
97
+
98
+ test('invalid range throws', () => {
99
+ assert.throws(() => cron('60-70 * * * *'), /valid: 0-59/);
100
+ });
101
+
102
+ test('invalid timezone throws', () => {
103
+ assert.throws(() => cron('* * * * *', 5, undefined, 'Mars/Olympus'), /timezone/);
104
+ });
105
+
106
+ test('count out of range throws', () => {
107
+ assert.throws(() => cron('* * * * *', 21), /Count/);
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,86 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { describe, test } from 'node:test';
3
+
4
+ import password from '../../src/modules/v4/password.js';
5
+
6
+ describe('password', () => {
7
+ describe('random mode', () => {
8
+ test('default returns one password of length 16', () => {
9
+ const r = password();
10
+ assert.equal(r.passwords.length, 1);
11
+ assert.equal(r.passwords[0]!.length, 16);
12
+ assert.equal(r.type, 'random');
13
+ assert.equal(r.length, 16);
14
+ });
15
+
16
+ test('length is respected', () => {
17
+ const r = password('random', 32);
18
+ assert.equal(r.passwords[0]!.length, 32);
19
+ });
20
+
21
+ test('no symbols when symbols=false', () => {
22
+ const r = password('random', 64, { symbols: false });
23
+ assert.ok(!/[!@#$%^&*()\-_=+[\]{}|;:,.<>?]/.test(r.passwords[0]!));
24
+ });
25
+
26
+ test('only digits when only digits enabled', () => {
27
+ const r = password('random', 16, { uppercase: false, lowercase: false, digits: true, symbols: false });
28
+ assert.ok(/^\d+$/.test(r.passwords[0]!));
29
+ });
30
+
31
+ test('exclude chars are absent from password', () => {
32
+ const r = password('random', 64, { exclude: 'aeiou' });
33
+ assert.ok(!/[aeiou]/.test(r.passwords[0]!));
34
+ });
35
+
36
+ test('count=5 returns 5 passwords', () => {
37
+ const r = password('random', 16, { count: 5 });
38
+ assert.equal(r.passwords.length, 5);
39
+ });
40
+
41
+ test('entropy and strength are present', () => {
42
+ const r = password();
43
+ assert.ok(typeof r.entropy === 'number');
44
+ assert.ok(['weak', 'moderate', 'strong', 'very_strong'].includes(r.strength));
45
+ });
46
+ });
47
+
48
+ describe('passphrase mode', () => {
49
+ test('returns words joined by separator', () => {
50
+ const r = password('passphrase', 4, { separator: '-' });
51
+ assert.equal(r.type, 'passphrase');
52
+ const words = r.passwords[0]!.split('-');
53
+ assert.equal(words.length, 4);
54
+ });
55
+
56
+ test('custom separator is used', () => {
57
+ const r = password('passphrase', 3, { separator: '.' });
58
+ assert.ok(r.passwords[0]!.includes('.'));
59
+ });
60
+ });
61
+
62
+ describe('errors', () => {
63
+ test('length below 8 throws', () => {
64
+ assert.throws(() => password('random', 4), /Length must be between/);
65
+ });
66
+
67
+ test('length above 128 throws', () => {
68
+ assert.throws(() => password('random', 200), /Length must be between/);
69
+ });
70
+
71
+ test('no charset active throws', () => {
72
+ assert.throws(
73
+ () => password('random', 16, { uppercase: false, lowercase: false, digits: false, symbols: false }),
74
+ /At least one character set/,
75
+ );
76
+ });
77
+
78
+ test('count above 20 throws', () => {
79
+ assert.throws(() => password('random', 16, { count: 21 }), /Count must be between/);
80
+ });
81
+
82
+ test('invalid type throws', () => {
83
+ assert.throws(() => password('invalid'), /Type must be/);
84
+ });
85
+ });
86
+ });