sliding-window-limiter 1.0.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 +85 -0
- package/index.js +5 -0
- package/lib/null_store.js +9 -0
- package/lib/rate_limiter.js +78 -0
- package/lib/window.js +101 -0
- package/package.json +32 -0
- package/test/rate_limiter.spec.js +151 -0
- package/test/window.spec.js +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# sliding-window-limiter
|
|
2
|
+
|
|
3
|
+
A JavaScript implementation of the Sliding Window rate limiting algorithm.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Sliding window rate limiting with configurable window size, unit, and slice width
|
|
8
|
+
- Pluggable persistence store (in-memory by default)
|
|
9
|
+
- Written in modern JavaScript, using [lodash](https://lodash.com/) and [luxon](https://moment.github.io/luxon/)
|
|
10
|
+
- Thoroughly tested with Jest
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install sliding-window-limiter
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const { RateLimiter } = require('sliding-window-limiter');
|
|
22
|
+
|
|
23
|
+
const limiter = new RateLimiter({
|
|
24
|
+
name: 'api-rate-limit',
|
|
25
|
+
max: 100, // max requests per window
|
|
26
|
+
window: {
|
|
27
|
+
unit: 'minute', // time unit for window
|
|
28
|
+
size: 10, // number of slices in window
|
|
29
|
+
width: 1, // width of each slice
|
|
30
|
+
},
|
|
31
|
+
store: customStore, // optional: provide your own store
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// To check and update the rate limit:
|
|
35
|
+
const allowed = await limiter.update(1, new Date());
|
|
36
|
+
if (!allowed) {
|
|
37
|
+
// Rate limit exceeded
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
### `RateLimiter`
|
|
44
|
+
|
|
45
|
+
#### Constructor
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
new RateLimiter(config)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `config.name` (string): Unique name for the limiter
|
|
52
|
+
- `config.max` (number): Maximum allowed value in the window
|
|
53
|
+
- `config.window` (object): Window configuration
|
|
54
|
+
- `unit` (string): Time unit (`second`, `minute`, `hour`, etc.)
|
|
55
|
+
- `size` (number): Number of slices in the window
|
|
56
|
+
- `width` (number): Width of each slice
|
|
57
|
+
- `config.store` (object): Store for persistence (must implement `get` and `set`)
|
|
58
|
+
|
|
59
|
+
#### Methods
|
|
60
|
+
|
|
61
|
+
- `static async RateLimiter.load(config)`: Loads a limiter and its state from the store
|
|
62
|
+
- `async update(value, timestamp)`: Attempts to add `value` at `timestamp`. Returns `true` if allowed, `false` if limit exceeded.
|
|
63
|
+
- `set(state)`: Sets the internal window state
|
|
64
|
+
- `valueOf()`: Returns the current sum of the window
|
|
65
|
+
|
|
66
|
+
### `Window`
|
|
67
|
+
|
|
68
|
+
- Internal class for managing the sliding window logic
|
|
69
|
+
|
|
70
|
+
## Testing
|
|
71
|
+
|
|
72
|
+
Run all tests with:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npm test
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
ISC
|
|
81
|
+
|
|
82
|
+
## Author
|
|
83
|
+
|
|
84
|
+
Dima Michaelov
|
|
85
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const {isNumber} = require('lodash/fp');
|
|
2
|
+
const {Window} = require('./window');
|
|
3
|
+
const {NullStore} = require('./null_store');
|
|
4
|
+
|
|
5
|
+
// window with twenty three-second buckets
|
|
6
|
+
const DefaultConfig = () => ({
|
|
7
|
+
name: 'request-rate-per-minute-max10',
|
|
8
|
+
max: 10,
|
|
9
|
+
window: {
|
|
10
|
+
unit: 'second', // unit of time ('second', 'minute', 'hour', 'day', 'week', 'month', 'year')
|
|
11
|
+
size: 20, // number of time slices
|
|
12
|
+
width: 3, // width of slice
|
|
13
|
+
},
|
|
14
|
+
store: NullStore, // store to handle persistence
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
class RateLimiter {
|
|
18
|
+
constructor(config = DefaultConfig()) {
|
|
19
|
+
({name: this.name, max: this.max, store: this.store, window: this.windowConfig} = config);
|
|
20
|
+
if (!this.name)
|
|
21
|
+
throw new TypeError('name must be specified and be unique');
|
|
22
|
+
if (!isNumber(this.max) || this.max <= 0)
|
|
23
|
+
throw new TypeError('max must be a positive number');
|
|
24
|
+
if (!this.store)
|
|
25
|
+
throw new TypeError('store must be specified');
|
|
26
|
+
if (!this.windowConfig)
|
|
27
|
+
throw new TypeError('window configuration must be specified');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static async load(config) {
|
|
31
|
+
if (!config.name)
|
|
32
|
+
throw new TypeError('name must be specified and be unique');
|
|
33
|
+
if (!config.store)
|
|
34
|
+
throw new TypeError('store must be specified');
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const state = await config.store.get(config.name);
|
|
38
|
+
return new RateLimiter(config).set(state);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new Error(`Failed to load rate-limit window ${config.name}`, {cause: err});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// should update the buckets according to configuration
|
|
46
|
+
// returns true if update successful
|
|
47
|
+
async update(value, timestamp) {
|
|
48
|
+
if (!isNumber(value) || value <= 0)
|
|
49
|
+
throw new TypeError('value must be a positive number');
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
this.window = this.window || Window.fromConfig(this.windowConfig);
|
|
53
|
+
if (this.max < this.valueOf() + value)
|
|
54
|
+
return false;
|
|
55
|
+
|
|
56
|
+
this.window.update(value, timestamp);
|
|
57
|
+
await this.store.set(this.name, this.window.toObject());
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
throw new Error(`Failed to update rate-limit window ${this.name}`, {cause: err});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
set(state) {
|
|
66
|
+
if (this.window) return;
|
|
67
|
+
this.window = state ? new Window(state) : Window.fromConfig(this.windowConfig);
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
valueOf() {
|
|
72
|
+
return this.window?.valueOf();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
RateLimiter,
|
|
78
|
+
};
|
package/lib/window.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const {isNumber, isString, min, rangeStep, slice, sum} = require('lodash/fp');
|
|
2
|
+
const {DateTime} = require('luxon');
|
|
3
|
+
|
|
4
|
+
// window with twenty three-second timeSlices
|
|
5
|
+
const DefaultWindow = () => ({
|
|
6
|
+
timeSlices: [],
|
|
7
|
+
updated: DateTime.now(),
|
|
8
|
+
unit: 'second',
|
|
9
|
+
width: 3,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const zeroes = rangeStep(0, 0);
|
|
13
|
+
const mod = (a, b) => ((a % b) + b) % b;
|
|
14
|
+
const validUnit = unit => ['second', 'minute', 'hour', 'day', 'week', 'month', 'year'].includes(unit);
|
|
15
|
+
|
|
16
|
+
class Window {
|
|
17
|
+
constructor(data = DefaultWindow()) {
|
|
18
|
+
({timeSlices: this.timeSlices, updated: this.updated, unit: this.unit, width: this.width} = data);
|
|
19
|
+
if (!this.unit || !validUnit(this.unit))
|
|
20
|
+
throw new TypeError("unit must be one of 'second', 'minute', 'hour', 'day', 'week', 'month', 'year'")
|
|
21
|
+
if (isString(this.updated))
|
|
22
|
+
this.updated = DateTime.fromISO(this.updated);
|
|
23
|
+
if (!this.updated.isValid) throw new TypeError('updated must be luxon DateTime');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static fromConfig(config) {
|
|
27
|
+
if (!isNumber(config.size) || config.size <= 0)
|
|
28
|
+
throw new TypeError('config.size must be a positive number');
|
|
29
|
+
return new Window({
|
|
30
|
+
timeSlices: zeroes(config.size),
|
|
31
|
+
updated: DateTime.now(),
|
|
32
|
+
unit: config.unit,
|
|
33
|
+
width: config.width,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
update(value, timestamp = DateTime.now()) {
|
|
38
|
+
if (!isNumber(value))
|
|
39
|
+
throw new TypeError('value must be a number');
|
|
40
|
+
if (value < 0)
|
|
41
|
+
throw new TypeError('value must be a positive number');
|
|
42
|
+
|
|
43
|
+
let _timestamp = timestamp;
|
|
44
|
+
if (timestamp instanceof Date)
|
|
45
|
+
_timestamp = DateTime.fromJSDate(timestamp);
|
|
46
|
+
if (isString(timestamp))
|
|
47
|
+
_timestamp = DateTime.fromISO(timestamp);
|
|
48
|
+
if (!_timestamp.isValid)
|
|
49
|
+
throw new TypeError('timestamp must be valid DateTime, Date or ISO string');
|
|
50
|
+
|
|
51
|
+
this.recalculate(_timestamp);
|
|
52
|
+
this.timeSlices[0] += value;
|
|
53
|
+
this.updated = _timestamp;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
slicesBetween(now, start) {
|
|
57
|
+
let count = now.diff(start, this.unit).get(this.unit);
|
|
58
|
+
if (this.width) {
|
|
59
|
+
count = count < this.width ? 0 : Math.floor(count / this.width);
|
|
60
|
+
}
|
|
61
|
+
return count;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
start() {
|
|
65
|
+
let time = this.updated;
|
|
66
|
+
if (this.width) {
|
|
67
|
+
const val = time.get(this.unit);
|
|
68
|
+
time = time.set({[this.unit]: val - (mod(val, this.width))});
|
|
69
|
+
}
|
|
70
|
+
return time.startOf(this.unit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
recalculate(timestamp) {
|
|
74
|
+
const slicesCount = this.slicesBetween(timestamp, this.start());
|
|
75
|
+
if (slicesCount > 0) {
|
|
76
|
+
const invalidSlices = min([slicesCount, this.timeSlices.length]);
|
|
77
|
+
this.timeSlices = [
|
|
78
|
+
...zeroes(invalidSlices),
|
|
79
|
+
...slice(0, -invalidSlices)(this.timeSlices),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
valueOf() {
|
|
85
|
+
return sum(this.timeSlices);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
toObject() {
|
|
89
|
+
return {
|
|
90
|
+
timeSlices: this.timeSlices,
|
|
91
|
+
updated: this.updated.toISO(),
|
|
92
|
+
unit: this.unit,
|
|
93
|
+
width: this.width,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
DefaultWindow,
|
|
100
|
+
Window,
|
|
101
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sliding-window-limiter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Implementation of the Sliding Window rate limiting algorithm",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sliding-window",
|
|
7
|
+
"rolling-buckets",
|
|
8
|
+
"rate-limiting"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/Dimch/sliding-window-limiter#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Dimch/sliding-window-limiter/issues"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Dimch/sliding-window-limiter.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"author": "Dima Michaelov",
|
|
20
|
+
"type": "commonjs",
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "jest"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"lodash": "^4.17.21"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"jest": "^30.2.0",
|
|
30
|
+
"luxon": "^3.7.2"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const {DateTime} = require('luxon');
|
|
2
|
+
const {cloneDeep} = require('lodash/fp');
|
|
3
|
+
const {RateLimiter} = require('../lib/rate_limiter');
|
|
4
|
+
const {Window} = require('../lib/window');
|
|
5
|
+
const {NullStore} = require('../lib/null_store');
|
|
6
|
+
|
|
7
|
+
const MockStoreFactory = () => {
|
|
8
|
+
let internalValue = {
|
|
9
|
+
timeSlices: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
10
|
+
updated: '2025-01-01T00:00:00',
|
|
11
|
+
unit: 'second',
|
|
12
|
+
width: 3,
|
|
13
|
+
}
|
|
14
|
+
return ({
|
|
15
|
+
get: jest.fn(name => name !== 'ip-req-per-minute-max10-u00001' ? null : cloneDeep(internalValue)),
|
|
16
|
+
set: jest.fn((name, value) => (internalValue = cloneDeep(value))),
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MockStore = MockStoreFactory();
|
|
21
|
+
|
|
22
|
+
const ErrorStore = ({
|
|
23
|
+
get: jest.fn().mockRejectedValue(new Error('network error')),
|
|
24
|
+
set: jest.fn().mockRejectedValue(new Error('network error')),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const DefaultOptions = () => ({
|
|
28
|
+
name: 'ip-req-per-minute-max10-u00001',
|
|
29
|
+
max: 10,
|
|
30
|
+
window: {
|
|
31
|
+
unit: 'second',
|
|
32
|
+
size: 20,
|
|
33
|
+
width: 3,
|
|
34
|
+
},
|
|
35
|
+
store: MockStore,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('RateLimiter', () => {
|
|
39
|
+
|
|
40
|
+
describe('#constructor', () => {
|
|
41
|
+
test('should correctly initialize RateLimiter', () => {
|
|
42
|
+
const lim = new RateLimiter(DefaultOptions());
|
|
43
|
+
expect(lim.windowConfig).toStrictEqual({unit: 'second', size: 20, width: 3});
|
|
44
|
+
expect(lim.max).toBe(10);
|
|
45
|
+
expect(lim.name).toBe('ip-req-per-minute-max10-u00001');
|
|
46
|
+
expect(lim.store).toBe(MockStore);
|
|
47
|
+
expect(lim.window).toBeFalsy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('should throw error if one of properties is invalid', () => {
|
|
51
|
+
let fn = () => new RateLimiter({});
|
|
52
|
+
expect(fn).toThrow('name must be specified and be unique');
|
|
53
|
+
fn = () => new RateLimiter({name: 'ip-hour-0001', max: 0});
|
|
54
|
+
expect(fn).toThrow('max must be a positive number');
|
|
55
|
+
fn = () => new RateLimiter({name: 'ip-hour-0001', max: 20});
|
|
56
|
+
expect(fn).toThrow('store must be specified');
|
|
57
|
+
fn = () => new RateLimiter({name: 'ip-hour-0001', max: 20, store: NullStore});
|
|
58
|
+
expect(fn).toThrow('window configuration must be specified');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('#load', () => {
|
|
63
|
+
test('should correctly initialize RateLimiter and load Window state', async () => {
|
|
64
|
+
const lim = await RateLimiter.load(DefaultOptions());
|
|
65
|
+
expect(lim.name).toBe('ip-req-per-minute-max10-u00001');
|
|
66
|
+
expect(lim.max).toBe(10);
|
|
67
|
+
expect(lim.store).toBe(MockStore);
|
|
68
|
+
expect(MockStore.get).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(MockStore.get.mock.calls[0][0]).toBe(DefaultOptions().name);
|
|
70
|
+
expect(lim.window).toStrictEqual(new Window({
|
|
71
|
+
timeSlices: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
72
|
+
updated: '2025-01-01T00:00:00.000+02:00',
|
|
73
|
+
unit: 'second',
|
|
74
|
+
width: 3,
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('should throw error if one of properties is invalid', async () => {
|
|
79
|
+
let fn = () => RateLimiter.load({});
|
|
80
|
+
await expect(fn()).rejects.toThrow('name must be specified and be unique');
|
|
81
|
+
fn = () => RateLimiter.load({name: 'ip-hour-0001'});
|
|
82
|
+
await expect(fn()).rejects.toThrow('store must be specified');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('should throw an error if failed to load state', async () => {
|
|
86
|
+
const fn = () => RateLimiter.load({...DefaultOptions(), store: ErrorStore});
|
|
87
|
+
await expect(fn()).rejects.toThrow('Failed to load rate-limit window ip-req-per-minute-max10-u00001');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('#set', () => {
|
|
92
|
+
test('should not reset existing window state', async () => {
|
|
93
|
+
const lim = await RateLimiter.load(DefaultOptions());
|
|
94
|
+
expect(lim.valueOf()).toBe(0);
|
|
95
|
+
lim.set({
|
|
96
|
+
timeSlices: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
97
|
+
updated: '2025-01-01T00:00:00',
|
|
98
|
+
unit: 'second',
|
|
99
|
+
width: 3,
|
|
100
|
+
});
|
|
101
|
+
expect(lim.valueOf()).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('should set provided set', () => {
|
|
105
|
+
const lim = new RateLimiter(DefaultOptions());
|
|
106
|
+
expect(lim.valueOf()).toBe(undefined);
|
|
107
|
+
lim.set({
|
|
108
|
+
timeSlices: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
109
|
+
updated: '2025-01-01T00:00:00',
|
|
110
|
+
unit: 'second',
|
|
111
|
+
width: 3,
|
|
112
|
+
});
|
|
113
|
+
expect(lim.valueOf()).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('should set default set if state is empty', async () => {
|
|
117
|
+
const lim = await RateLimiter.load({...DefaultOptions(), store: NullStore});
|
|
118
|
+
expect(lim.valueOf()).toBe(0);
|
|
119
|
+
expect(lim.window.timeSlices).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
120
|
+
expect(lim.window.unit).toBe(lim.windowConfig.unit);
|
|
121
|
+
expect(lim.window.width).toBe(lim.windowConfig.width);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('#update', () => {
|
|
126
|
+
test('should should throw error if one of arguments is invalid', async () => {
|
|
127
|
+
const lim = new RateLimiter(DefaultOptions());
|
|
128
|
+
const fn = () => lim.update(0);
|
|
129
|
+
await expect(fn()).rejects.toThrow('value must be a positive number');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('should return false if getting over max threshold', async () => {
|
|
133
|
+
const lim = new RateLimiter(DefaultOptions());
|
|
134
|
+
const fn = () => lim.update(40, '2025-01-01T00:00:30Z');
|
|
135
|
+
await expect(fn()).resolves.toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('should return true if window state is updated', async () => {
|
|
139
|
+
const lim = new RateLimiter(DefaultOptions());
|
|
140
|
+
const fn = () => lim.update(10, '2025-01-01T00:00:30Z');
|
|
141
|
+
await expect(fn()).resolves.toBe(true);
|
|
142
|
+
expect(lim.valueOf()).toBe(10);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('should throw an error if failed to update store', async () => {
|
|
146
|
+
const lim = new RateLimiter({...DefaultOptions(), store: ErrorStore});
|
|
147
|
+
const fn = () => lim.update(1, '2025-01-01T00:00:00Z');
|
|
148
|
+
await expect(fn()).rejects.toThrow('Failed to update rate-limit window ip-req-per-minute-max10-u00001');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const {DateTime} = require('luxon');
|
|
2
|
+
const {isString} = require('lodash/fp');
|
|
3
|
+
const {Window} = require('../lib/window');
|
|
4
|
+
|
|
5
|
+
describe('Window', () => {
|
|
6
|
+
|
|
7
|
+
describe('#constructor', () => {
|
|
8
|
+
test('should initialize Window', () => {
|
|
9
|
+
const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
|
|
10
|
+
expect(win.timeSlices).toHaveLength(5);
|
|
11
|
+
expect(win.timeSlices).toStrictEqual([0, 0, 0, 0, 0]);
|
|
12
|
+
expect(win.unit).toBe('minute');
|
|
13
|
+
expect(win.width).toBe(2);
|
|
14
|
+
expect(win.updated.toUTC().toISO()).toBe('2025-01-01T00:00:00.000Z');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('should throw error if updated is invalid', () => {
|
|
18
|
+
const fn = () => new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: 'abc'});
|
|
19
|
+
expect(fn).toThrow(TypeError);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('#fromConfig', () => {
|
|
24
|
+
test('should initialize Window', () => {
|
|
25
|
+
const win = Window.fromConfig({unit: 'minute', width: 2, size: 5});
|
|
26
|
+
expect(win.timeSlices).toHaveLength(5);
|
|
27
|
+
expect(win.timeSlices).toStrictEqual([0, 0, 0, 0, 0]);
|
|
28
|
+
expect(win.unit).toBe('minute');
|
|
29
|
+
expect(win.width).toBe(2);
|
|
30
|
+
expect(DateTime.isDateTime(win.updated)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('#slicesBetween', () => {
|
|
35
|
+
const win = Window.fromConfig({unit: 'minute', width: 2, size: 5});
|
|
36
|
+
test('should return number of slices between timestamps', () => {
|
|
37
|
+
const date0 = '2014-01-01T00:00:00Z';
|
|
38
|
+
const date1 = '2014-01-01T00:01:00Z';
|
|
39
|
+
const date2 = '2014-01-01T00:05:00Z';
|
|
40
|
+
expect(win.slicesBetween(DateTime.fromISO(date0), DateTime.fromISO(date0))).toBe(0);
|
|
41
|
+
expect(win.slicesBetween(DateTime.fromISO(date1), DateTime.fromISO(date0))).toBe(0);
|
|
42
|
+
expect(win.slicesBetween(DateTime.fromISO(date2), DateTime.fromISO(date0))).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('#valueOf', () => {
|
|
47
|
+
const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
|
|
48
|
+
test('should return sum of values', () => {
|
|
49
|
+
expect(win.valueOf()).toBe(0);
|
|
50
|
+
win.update(1, '2025-01-01T00:01:00Z');
|
|
51
|
+
expect(win.valueOf()).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('#toObject', () => {
|
|
56
|
+
const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
|
|
57
|
+
test('should return sum of values', () => {
|
|
58
|
+
const obj = win.toObject();
|
|
59
|
+
expect(obj.timeSlices).toEqual([0, 0, 0, 0, 0]);
|
|
60
|
+
expect(obj.unit).toEqual('minute');
|
|
61
|
+
expect(obj.width).toEqual(2);
|
|
62
|
+
expect(isString(obj.updated)).toBe(true);
|
|
63
|
+
win.update(1, '2025-01-01T00:01:00Z');
|
|
64
|
+
expect(win.toObject().timeSlices).toEqual([1, 0, 0, 0, 0]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('#update()', () => {
|
|
69
|
+
test('should throw error if invalid parameter', () => {
|
|
70
|
+
const win = new Window({timeSlices: [0, 0, 0, 0, 0], unit: 'minute', width: 2, updated: '2025-01-01T00:00:00Z'});
|
|
71
|
+
let fn = () => win.update('a');
|
|
72
|
+
expect(fn).toThrow('value must be a number');
|
|
73
|
+
fn = () => win.update(-1);
|
|
74
|
+
expect(fn).toThrow('value must be a positive number');
|
|
75
|
+
fn = () => win.update(1, '2025-01-02 00:00:00');
|
|
76
|
+
expect(fn).toThrow('timestamp must be valid DateTime, Date or ISO string');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|