@stimulcross/rate-limiter 0.0.1
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/.editorconfig +21 -0
- package/.github/workflows/node.yml +87 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.megaignore +8 -0
- package/.prettierignore +3 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/commitlint.config.js +8 -0
- package/eslint.config.js +65 -0
- package/lint-staged.config.js +4 -0
- package/package.json +89 -0
- package/prettier.config.cjs +1 -0
- package/src/core/cancellable.ts +4 -0
- package/src/core/clock.ts +9 -0
- package/src/core/decision.ts +27 -0
- package/src/core/rate-limit-policy.ts +15 -0
- package/src/core/rate-limiter-status.ts +14 -0
- package/src/core/rate-limiter.ts +37 -0
- package/src/core/state-storage.ts +51 -0
- package/src/enums/rate-limit-error-code.ts +29 -0
- package/src/errors/custom.error.ts +14 -0
- package/src/errors/invalid-cost.error.ts +33 -0
- package/src/errors/rate-limit.error.ts +91 -0
- package/src/errors/rate-limiter-destroyed.error.ts +8 -0
- package/src/index.ts +11 -0
- package/src/interfaces/rate-limiter-options.ts +84 -0
- package/src/interfaces/rate-limiter-queue-options.ts +45 -0
- package/src/interfaces/rate-limiter-run-options.ts +58 -0
- package/src/limiters/abstract-rate-limiter.ts +206 -0
- package/src/limiters/composite.policy.ts +102 -0
- package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
- package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
- package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
- package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
- package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
- package/src/limiters/fixed-window/index.ts +4 -0
- package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
- package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
- package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
- package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
- package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
- package/src/limiters/generic-cell/index.ts +4 -0
- package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
- package/src/limiters/http-response-based/http-limit.info.ts +41 -0
- package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
- package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
- package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
- package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
- package/src/limiters/http-response-based/index.ts +6 -0
- package/src/limiters/leaky-bucket/index.ts +4 -0
- package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
- package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
- package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
- package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
- package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
- package/src/limiters/sliding-window-counter/index.ts +7 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
- package/src/limiters/sliding-window-log/index.ts +4 -0
- package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
- package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
- package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
- package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
- package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
- package/src/limiters/token-bucket/index.ts +4 -0
- package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
- package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
- package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
- package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
- package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
- package/src/runtime/default-clock.ts +8 -0
- package/src/runtime/execution-tickets.ts +34 -0
- package/src/runtime/in-memory-state-store.ts +135 -0
- package/src/runtime/rate-limiter.executor.ts +286 -0
- package/src/runtime/semaphore.ts +31 -0
- package/src/runtime/task.ts +141 -0
- package/src/types/limit-behavior.ts +8 -0
- package/src/utils/generate-random-string.ts +16 -0
- package/src/utils/promise-with-resolvers.ts +23 -0
- package/src/utils/sanitize-error.ts +4 -0
- package/src/utils/sanitize-priority.ts +22 -0
- package/src/utils/validate-cost.ts +16 -0
- package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
- package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
- package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
- package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
- package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
- package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
- package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
- package/tests/tsconfig.json +4 -0
- package/tests/unit/policies/composite.policy.spec.ts +244 -0
- package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
- package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
- package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
- package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
- package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
- package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
- package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
- package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
- package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
- package/tests/unit/runtime/semaphore.spec.ts +98 -0
- package/tests/unit/runtime/task.spec.ts +182 -0
- package/tests/unit/utils/generate-random-string.spec.ts +51 -0
- package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
- package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
- package/tests/unit/utils/validate-cost.spec.ts +48 -0
- package/tsconfig.json +14 -0
- package/vitest.config.js +22 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Priority } from '@stimulcross/ds-policy-priority-queue';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { RateLimitError, RateLimitErrorCode } from '../../../src/index.js';
|
|
4
|
+
import { Task } from '../../../src/runtime/task.js';
|
|
5
|
+
|
|
6
|
+
const defaultOpts = { id: 'task-1', key: 'key-1' };
|
|
7
|
+
|
|
8
|
+
describe('Task', () => {
|
|
9
|
+
describe('Initialization', () => {
|
|
10
|
+
it('should initialize with default values', () => {
|
|
11
|
+
const task = new Task(() => 'test', defaultOpts);
|
|
12
|
+
|
|
13
|
+
expect(task.id).toBe('task-1');
|
|
14
|
+
expect(task.key).toBe('key-1');
|
|
15
|
+
expect(task.priority).toBe(Priority.Normal);
|
|
16
|
+
expect(task.expiresAt).toBeUndefined();
|
|
17
|
+
expect(task.isActive).toBe(true);
|
|
18
|
+
expect(task.isAborted).toBe(false);
|
|
19
|
+
expect(task.isCancellable).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should initialize with provided options', () => {
|
|
23
|
+
const task = new Task(() => 'test', {
|
|
24
|
+
...defaultOpts,
|
|
25
|
+
priority: Priority.High,
|
|
26
|
+
expiresAt: 1000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(task.priority).toBe(Priority.High);
|
|
30
|
+
expect(task.expiresAt).toBe(1000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should be cancellable when initialized with an abort signal', () => {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const task = new Task(() => 'test', { ...defaultOpts, signal: controller.signal });
|
|
36
|
+
|
|
37
|
+
expect(task.isCancellable).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Execution', () => {
|
|
42
|
+
it('should resolve the promise when the task completes successfully', async () => {
|
|
43
|
+
const taskFn = vi.fn().mockResolvedValue('success');
|
|
44
|
+
const task = new Task(taskFn, defaultOpts);
|
|
45
|
+
|
|
46
|
+
await task.run();
|
|
47
|
+
|
|
48
|
+
await expect(task).resolves.toBe('success');
|
|
49
|
+
expect(task.isActive).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should reject the promise when the task throws an error', async () => {
|
|
53
|
+
const error = new Error('Task failed');
|
|
54
|
+
const taskFn = vi.fn().mockRejectedValue(error);
|
|
55
|
+
const task = new Task(taskFn, defaultOpts);
|
|
56
|
+
|
|
57
|
+
await task.run();
|
|
58
|
+
|
|
59
|
+
await expect(task).rejects.toThrow(error);
|
|
60
|
+
expect(task.isActive).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should mark task as inactive immediately when run starts', () => {
|
|
64
|
+
const task = new Task(async () => 'test', defaultOpts);
|
|
65
|
+
|
|
66
|
+
void task.run();
|
|
67
|
+
|
|
68
|
+
expect(task.isActive).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('Promise API (PromiseLike)', () => {
|
|
73
|
+
it('should support .then() chaining', async () => {
|
|
74
|
+
const task = new Task(() => 'chaining', defaultOpts);
|
|
75
|
+
|
|
76
|
+
const promise = task.then(val => `${val}-success`);
|
|
77
|
+
|
|
78
|
+
await task.run();
|
|
79
|
+
|
|
80
|
+
await expect(promise).resolves.toBe('chaining-success');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should support .catch() chaining', async () => {
|
|
84
|
+
const error = new Error('Fail');
|
|
85
|
+
const task = new Task(() => Promise.reject(error), defaultOpts);
|
|
86
|
+
|
|
87
|
+
const promise = task.catch(e => e.message);
|
|
88
|
+
|
|
89
|
+
await task.run();
|
|
90
|
+
|
|
91
|
+
await expect(promise).resolves.toBe('Fail');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should support .finally() execution', async () => {
|
|
95
|
+
const finallyFn = vi.fn();
|
|
96
|
+
const task = new Task(() => 'test', defaultOpts);
|
|
97
|
+
|
|
98
|
+
const promise = task.finally(finallyFn);
|
|
99
|
+
|
|
100
|
+
await task.run();
|
|
101
|
+
await promise;
|
|
102
|
+
|
|
103
|
+
expect(finallyFn).toHaveBeenCalledOnce();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('State Management', () => {
|
|
108
|
+
it('should allow manual rejection', async () => {
|
|
109
|
+
const task = new Task(() => 'test', defaultOpts);
|
|
110
|
+
const reason = new Error('Manual rejection');
|
|
111
|
+
|
|
112
|
+
task.reject(reason);
|
|
113
|
+
|
|
114
|
+
expect(task.isActive).toBe(false);
|
|
115
|
+
await expect(task).rejects.toThrow(reason);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should mark task as inactive when destroyed', () => {
|
|
119
|
+
const task = new Task(() => 'test', defaultOpts);
|
|
120
|
+
|
|
121
|
+
task.destroy();
|
|
122
|
+
|
|
123
|
+
expect(task.isActive).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('Abort Signal Handling', () => {
|
|
128
|
+
it('should abort task, call handler, and reject with RateLimitError when signal triggers', async () => {
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
const task = new Task(() => 'test', { ...defaultOpts, signal: controller.signal });
|
|
131
|
+
const abortHandler = vi.fn();
|
|
132
|
+
|
|
133
|
+
task.onAbort(abortHandler);
|
|
134
|
+
controller.abort();
|
|
135
|
+
// second call should not trigger handler
|
|
136
|
+
controller.abort();
|
|
137
|
+
|
|
138
|
+
expect(task.isAborted).toBe(true);
|
|
139
|
+
expect(task.isActive).toBe(false);
|
|
140
|
+
expect(abortHandler).toHaveBeenCalledOnce();
|
|
141
|
+
|
|
142
|
+
await expect(task).rejects.toThrow(RateLimitError);
|
|
143
|
+
await expect(task).rejects.toMatchObject({
|
|
144
|
+
code: RateLimitErrorCode.Cancelled,
|
|
145
|
+
message: 'Aborted by client',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should clean up signal references and event listeners when destroyed', () => {
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const removeEventListenerSpy = vi.spyOn(controller.signal, 'removeEventListener');
|
|
152
|
+
const task = new Task(() => 'test', { ...defaultOpts, signal: controller.signal });
|
|
153
|
+
|
|
154
|
+
task.destroy();
|
|
155
|
+
|
|
156
|
+
expect(task.isCancellable).toBe(false);
|
|
157
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should safely destroy even if no abort handler is registered', () => {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const task = new Task(() => 'test', { ...defaultOpts, signal: controller.signal });
|
|
163
|
+
|
|
164
|
+
expect(() => {
|
|
165
|
+
task.destroy();
|
|
166
|
+
}).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should not call abort handler if destroyed before signal aborts', () => {
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const task = new Task(() => 'test', { ...defaultOpts, signal: controller.signal });
|
|
172
|
+
const abortHandler = vi.fn();
|
|
173
|
+
|
|
174
|
+
task.onAbort(abortHandler);
|
|
175
|
+
task.destroy();
|
|
176
|
+
controller.abort();
|
|
177
|
+
|
|
178
|
+
expect(abortHandler).not.toHaveBeenCalled();
|
|
179
|
+
expect(task.isAborted).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateRandomString } from '../../../src/utils/generate-random-string.js';
|
|
3
|
+
|
|
4
|
+
describe('generateRandomString', () => {
|
|
5
|
+
const ALPHANUMERIC_REGEX = /^[A-Za-z\d]*$/u;
|
|
6
|
+
|
|
7
|
+
describe('length and boundary conditions', () => {
|
|
8
|
+
it('should return a string of length 7 by default', () => {
|
|
9
|
+
expect(generateRandomString()).toHaveLength(7);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should return a string of the exact specified length', () => {
|
|
13
|
+
expect(generateRandomString(5)).toHaveLength(5);
|
|
14
|
+
expect(generateRandomString(100)).toHaveLength(100);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return an empty string when length is 0', () => {
|
|
18
|
+
expect(generateRandomString(0)).toBe('');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('validation', () => {
|
|
23
|
+
it('should throw a RangeError for negative lengths', () => {
|
|
24
|
+
expect(() => generateRandomString(-1)).toThrow(RangeError);
|
|
25
|
+
expect(() => generateRandomString(-5)).toThrow(/Invalid length/u);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should throw a RangeError for non-integer lengths', () => {
|
|
29
|
+
expect(() => generateRandomString(5.5)).toThrow(RangeError);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should throw a RangeError for unsafe integers and non-numbers', () => {
|
|
33
|
+
expect(() => generateRandomString(Number.NaN)).toThrow(RangeError);
|
|
34
|
+
expect(() => generateRandomString(Infinity)).toThrow(RangeError);
|
|
35
|
+
expect(() => generateRandomString(Number.MAX_SAFE_INTEGER + 1)).toThrow(RangeError);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('content and semantics', () => {
|
|
40
|
+
it('should contain only allowed alphanumeric characters', () => {
|
|
41
|
+
const result = generateRandomString(50);
|
|
42
|
+
expect(ALPHANUMERIC_REGEX.test(result)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should generate different strings on subsequent calls', () => {
|
|
46
|
+
const string1 = generateRandomString(20);
|
|
47
|
+
const string2 = generateRandomString(20);
|
|
48
|
+
expect(string1).not.toBe(string2);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { promiseWithResolvers } from '../../../src/utils/promise-with-resolvers.js';
|
|
3
|
+
|
|
4
|
+
describe('promiseWithResolvers', () => {
|
|
5
|
+
it('should return an object with promise, resolve and reject', () => {
|
|
6
|
+
const r = promiseWithResolvers<number>();
|
|
7
|
+
|
|
8
|
+
expect(r).toBeTruthy();
|
|
9
|
+
expect(r.promise).toBeInstanceOf(Promise);
|
|
10
|
+
expect(typeof r.resolve).toBe('function');
|
|
11
|
+
expect(typeof r.reject).toBe('function');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should resolve the promise with a value', async () => {
|
|
15
|
+
const r = promiseWithResolvers<number>();
|
|
16
|
+
|
|
17
|
+
r.resolve(42);
|
|
18
|
+
|
|
19
|
+
await expect(r.promise).resolves.toBe(42);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should resolve the promise with a PromiseLike value', async () => {
|
|
23
|
+
const r = promiseWithResolvers<number>();
|
|
24
|
+
|
|
25
|
+
r.resolve(Promise.resolve(7));
|
|
26
|
+
|
|
27
|
+
await expect(r.promise).resolves.toBe(7);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should reject the promise with a reason', async () => {
|
|
31
|
+
const r = promiseWithResolvers<number>();
|
|
32
|
+
const err = new Error('boom');
|
|
33
|
+
|
|
34
|
+
r.reject(err);
|
|
35
|
+
|
|
36
|
+
await expect(r.promise).rejects.toBe(err);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should settle only once (resolve then reject)', async () => {
|
|
40
|
+
const r = promiseWithResolvers<number>();
|
|
41
|
+
|
|
42
|
+
r.resolve(1);
|
|
43
|
+
r.reject(new Error('should be ignored'));
|
|
44
|
+
|
|
45
|
+
await expect(r.promise).resolves.toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should settle only once (reject then resolve)', async () => {
|
|
49
|
+
const r = promiseWithResolvers<number>();
|
|
50
|
+
const err = new Error('first');
|
|
51
|
+
|
|
52
|
+
r.reject(err);
|
|
53
|
+
r.resolve(123);
|
|
54
|
+
|
|
55
|
+
await expect(r.promise).rejects.toBe(err);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Priority } from '@stimulcross/ds-policy-priority-queue';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { sanitizePriority } from '../../../src/utils/sanitize-priority.js';
|
|
4
|
+
|
|
5
|
+
describe('sanitizePriority (updated)', () => {
|
|
6
|
+
it('should return Priority.Normal for non-finite numbers', () => {
|
|
7
|
+
expect(sanitizePriority(Number.NaN)).toBe(Priority.Normal);
|
|
8
|
+
expect(sanitizePriority(Number.POSITIVE_INFINITY)).toBe(Priority.Normal);
|
|
9
|
+
expect(sanitizePriority(Number.NEGATIVE_INFINITY)).toBe(Priority.Normal);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should clamp below Priority.Lowest to Priority.Lowest (before rounding)', () => {
|
|
13
|
+
expect(sanitizePriority(Priority.Lowest - 1)).toBe(Priority.Lowest);
|
|
14
|
+
expect(sanitizePriority(Priority.Lowest - 0.1)).toBe(Priority.Lowest);
|
|
15
|
+
expect(sanitizePriority(Priority.Lowest - 1000)).toBe(Priority.Lowest);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should clamp above Priority.Highest to Priority.Highest (before rounding)', () => {
|
|
19
|
+
expect(sanitizePriority(Priority.Highest + 1)).toBe(Priority.Highest);
|
|
20
|
+
expect(sanitizePriority(Priority.Highest + 0.1)).toBe(Priority.Highest);
|
|
21
|
+
expect(sanitizePriority(Priority.Highest + 1000)).toBe(Priority.Highest);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should round non-integer priorities that are already within range', () => {
|
|
25
|
+
expect(sanitizePriority(1.4)).toBe(1);
|
|
26
|
+
expect(sanitizePriority(1.5)).toBe(2);
|
|
27
|
+
expect(sanitizePriority(3.7)).toBe(4);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return the same value for integers within range', () => {
|
|
31
|
+
expect(sanitizePriority(Priority.Lowest)).toBe(Priority.Lowest);
|
|
32
|
+
expect(sanitizePriority(Priority.Normal)).toBe(Priority.Normal);
|
|
33
|
+
expect(sanitizePriority(Priority.Highest)).toBe(Priority.Highest);
|
|
34
|
+
|
|
35
|
+
const mid = Math.trunc((Priority.Lowest + Priority.Highest) / 2);
|
|
36
|
+
expect(sanitizePriority(mid)).toBe(mid);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should not clamp after rounding (behavior change guard)', () => {
|
|
40
|
+
expect(sanitizePriority(Priority.Highest - 0.6)).toBe(Priority.Highest - 1);
|
|
41
|
+
expect(sanitizePriority(Priority.Highest - 0.4)).toBe(Priority.Highest);
|
|
42
|
+
|
|
43
|
+
expect(sanitizePriority(Priority.Lowest + 0.6)).toBe(Priority.Lowest + 1);
|
|
44
|
+
expect(sanitizePriority(Priority.Lowest + 0.4)).toBe(Priority.Lowest);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { InvalidCostError } from '../../../src/errors/invalid-cost.error.js';
|
|
3
|
+
import { validateCost } from '../../../src/utils/validate-cost.js';
|
|
4
|
+
|
|
5
|
+
describe('validateCost', () => {
|
|
6
|
+
it('should not throw for a non-negative safe integer', () => {
|
|
7
|
+
expect(() => validateCost(0)).not.toThrow();
|
|
8
|
+
expect(() => validateCost(1)).not.toThrow();
|
|
9
|
+
expect(() => validateCost(123_456)).not.toThrow();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should throw InvalidCostError for negative numbers', () => {
|
|
13
|
+
expect(() => validateCost(-1)).toThrow(InvalidCostError);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should throw InvalidCostError for non-integers', () => {
|
|
17
|
+
expect(() => validateCost(1.1)).toThrow(InvalidCostError);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should throw InvalidCostError for NaN', () => {
|
|
21
|
+
expect(() => validateCost(Number.NaN)).toThrow(InvalidCostError);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should throw InvalidCostError for Infinity', () => {
|
|
25
|
+
expect(() => validateCost(Number.POSITIVE_INFINITY)).toThrow(InvalidCostError);
|
|
26
|
+
expect(() => validateCost(Number.NEGATIVE_INFINITY)).toThrow(InvalidCostError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should throw InvalidCostError for non-safe integers', () => {
|
|
30
|
+
expect(() => validateCost(Number.MAX_SAFE_INTEGER + 1)).toThrow(InvalidCostError);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should respect max when provided', () => {
|
|
34
|
+
expect(() => validateCost(10, 10)).not.toThrow();
|
|
35
|
+
expect(() => validateCost(11, 10)).toThrow(InvalidCostError);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should respect min when provided', () => {
|
|
39
|
+
expect(() => validateCost(10, undefined, 10)).not.toThrow();
|
|
40
|
+
expect(() => validateCost(9, undefined, 10)).toThrow(InvalidCostError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should respect both min and max when provided', () => {
|
|
44
|
+
expect(() => validateCost(5, 10, 0)).not.toThrow();
|
|
45
|
+
expect(() => validateCost(11, 10, 0)).toThrow(InvalidCostError);
|
|
46
|
+
expect(() => validateCost(-1, 10, 0)).toThrow(InvalidCostError);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2022", "ES2024.Promise"],
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"outDir": "lib"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
include: ['tests/**/*.spec.ts'],
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
reportsDirectory: './coverage',
|
|
10
|
+
include: ['src/limiters', 'src/runtime', 'src/utils'],
|
|
11
|
+
exclude: [
|
|
12
|
+
'src/**/index.ts',
|
|
13
|
+
'src/**/*.info.ts',
|
|
14
|
+
'src/**/*.state.ts',
|
|
15
|
+
'src/**/*.status.ts',
|
|
16
|
+
'src/**/*.options.ts',
|
|
17
|
+
'src/runtime/queue/queue*.ts',
|
|
18
|
+
'src/**/http-limit-info.extractor',
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|