@tehw0lf/yaft 0.0.10 → 0.0.15

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.
@@ -0,0 +1,260 @@
1
+ import { Feature } from '../FeatureToggle';
2
+ import { Clock, evaluate, parseTimestamp, systemClock } from '../evaluate';
3
+
4
+ /**
5
+ * Tests for the central evaluation rules.
6
+ *
7
+ * Every case carries its own `now`, so nothing here depends on when the suite
8
+ * runs. This mirrors how the conformance suite supplies a `now` per case, and
9
+ * is the reason the clock had to become injectable.
10
+ */
11
+ describe('evaluate', () => {
12
+ const NOW = Date.parse('2026-09-18T12:00:00Z');
13
+
14
+ const feature = (overrides: Partial<Feature> = {}): Feature => ({
15
+ key: 'f',
16
+ value: 'true',
17
+ activeAt: '',
18
+ disabledAt: '',
19
+ ...overrides,
20
+ });
21
+
22
+ describe('missing features', () => {
23
+ it('treats undefined as off', () => {
24
+ expect(evaluate(undefined, NOW)).toBe(false);
25
+ });
26
+
27
+ it('treats null as off', () => {
28
+ expect(evaluate(null, NOW)).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('value', () => {
33
+ it('is on for exactly "true"', () => {
34
+ expect(evaluate(feature({ value: 'true' }), NOW)).toBe(true);
35
+ });
36
+
37
+ // The backend stores the value as a string. Anything but "true" must read
38
+ // as off, or backend and client would silently disagree.
39
+ it.each(['false', 'TRUE', 'True', '1', '', 'yes'])(
40
+ 'is off for %p',
41
+ (value) => {
42
+ expect(evaluate(feature({ value }), NOW)).toBe(false);
43
+ }
44
+ );
45
+
46
+ it('is off when the value is missing entirely', () => {
47
+ const withoutValue = { key: 'f', activeAt: '', disabledAt: '' } as Feature;
48
+ expect(evaluate(withoutValue, NOW)).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe('activeAt', () => {
53
+ it('is off before activeAt', () => {
54
+ expect(
55
+ evaluate(feature({ activeAt: '2026-09-18T13:00:00Z' }), NOW)
56
+ ).toBe(false);
57
+ });
58
+
59
+ it('is on after activeAt', () => {
60
+ expect(
61
+ evaluate(feature({ activeAt: '2026-09-18T11:00:00Z' }), NOW)
62
+ ).toBe(true);
63
+ });
64
+
65
+ // The window is half-open: the comparison is `now < activeAt`.
66
+ it('is on at exactly activeAt', () => {
67
+ expect(
68
+ evaluate(feature({ activeAt: '2026-09-18T12:00:00Z' }), NOW)
69
+ ).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe('disabledAt', () => {
74
+ it('is on before disabledAt', () => {
75
+ expect(
76
+ evaluate(feature({ disabledAt: '2026-09-18T13:00:00Z' }), NOW)
77
+ ).toBe(true);
78
+ });
79
+
80
+ it('is off after disabledAt', () => {
81
+ expect(
82
+ evaluate(feature({ disabledAt: '2026-09-18T11:00:00Z' }), NOW)
83
+ ).toBe(false);
84
+ });
85
+
86
+ // The comparison is `now >= disabledAt`, so the boundary is off -- the
87
+ // opposite of the activeAt boundary.
88
+ it('is off at exactly disabledAt', () => {
89
+ expect(
90
+ evaluate(feature({ disabledAt: '2026-09-18T12:00:00Z' }), NOW)
91
+ ).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('both bounds', () => {
96
+ const windowed = feature({
97
+ activeAt: '2026-09-18T10:00:00Z',
98
+ disabledAt: '2026-09-18T14:00:00Z',
99
+ });
100
+
101
+ it('is on inside the window', () => {
102
+ expect(evaluate(windowed, NOW)).toBe(true);
103
+ });
104
+
105
+ it('is off before the window', () => {
106
+ expect(evaluate(windowed, Date.parse('2026-09-18T09:00:00Z'))).toBe(false);
107
+ });
108
+
109
+ it('is off after the window', () => {
110
+ expect(evaluate(windowed, Date.parse('2026-09-18T15:00:00Z'))).toBe(false);
111
+ });
112
+
113
+ // Not a special case: the bounds simply never overlap.
114
+ it('is off everywhere when activeAt is after disabledAt', () => {
115
+ const inverted = feature({
116
+ activeAt: '2026-09-18T14:00:00Z',
117
+ disabledAt: '2026-09-18T10:00:00Z',
118
+ });
119
+
120
+ for (const at of [
121
+ '2026-09-18T09:00:00Z',
122
+ '2026-09-18T12:00:00Z',
123
+ '2026-09-18T16:00:00Z',
124
+ ]) {
125
+ expect(evaluate(inverted, Date.parse(at))).toBe(false);
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('timezone offsets', () => {
131
+ it('converts an offset to the same instant as UTC', () => {
132
+ // 14:00+02:00 is 12:00Z, which is exactly NOW, so the activeAt boundary
133
+ // is inclusive and the feature is on.
134
+ expect(
135
+ evaluate(feature({ activeAt: '2026-09-18T14:00:00+02:00' }), NOW)
136
+ ).toBe(true);
137
+ });
138
+
139
+ it('respects a negative offset', () => {
140
+ // 08:00-05:00 is 13:00Z, one hour after NOW, so it is not active yet.
141
+ expect(
142
+ evaluate(feature({ activeAt: '2026-09-18T08:00:00-05:00' }), NOW)
143
+ ).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe('unset and invalid bounds are ignored', () => {
148
+ it.each([
149
+ ['empty string', ''],
150
+ ['null', null],
151
+ ['undefined', undefined],
152
+ ])('ignores %s', (_label, value) => {
153
+ const f = feature({
154
+ activeAt: value as string,
155
+ disabledAt: value as string,
156
+ });
157
+ expect(evaluate(f, NOW)).toBe(true);
158
+ });
159
+
160
+ // Rejected on purpose: JavaScript reads a bare date as UTC midnight and an
161
+ // offset-less timestamp as local time, while most other languages read
162
+ // both as local. Accepting them would make a feature flip at a different
163
+ // instant depending on the port.
164
+ it.each([
165
+ ['a bare date', '2026-09-18'],
166
+ ['no offset', '2026-09-18T15:00:00'],
167
+ ['garbage', 'not-a-date'],
168
+ ['a unix timestamp', '1758196800'],
169
+ ['a slash date', '2026/09/18'],
170
+ ])('ignores %s in activeAt', (_label, value) => {
171
+ // Would be off if parsed as a future bound; ignoring it leaves the
172
+ // feature on.
173
+ expect(evaluate(feature({ activeAt: value }), NOW)).toBe(true);
174
+ });
175
+
176
+ // Date.parse does not reject an impossible calendar date, it rolls it
177
+ // over: 2027-02-30 becomes 2027-03-02. The date must therefore be in the
178
+ // future, or the rolled-over value lands in the past and the activeAt
179
+ // check passes for the wrong reason.
180
+ it.each([
181
+ ['a day past the end of February', '2027-02-30T00:00:00Z'],
182
+ ['the 31st of a 30-day month', '2027-04-31T00:00:00Z'],
183
+ ['month 13', '2027-13-01T00:00:00Z'],
184
+ ['day zero', '2027-01-00T00:00:00Z'],
185
+ ['February 29 in a non-leap year', '2027-02-29T00:00:00Z'],
186
+ ['hour 24', '2027-01-01T24:00:00Z'],
187
+ ['minute 60', '2027-01-01T00:60:00Z'],
188
+ ])('ignores %s', (_label, value) => {
189
+ expect(evaluate(feature({ activeAt: value }), NOW)).toBe(true);
190
+ expect(parseTimestamp(value)).toBeUndefined();
191
+ });
192
+
193
+ it('accepts February 29 in a leap year', () => {
194
+ expect(parseTimestamp('2028-02-29T00:00:00Z')).toBe(
195
+ Date.parse('2028-02-29T00:00:00Z')
196
+ );
197
+ });
198
+
199
+ // RFC 3339 permits second 60 for a leap second, but Date.parse returns
200
+ // NaN for it, so the value is ignored like any other unusable bound. The
201
+ // range check lets it through and the NaN guard catches it; asserted here
202
+ // so the two stay consistent.
203
+ it('ignores a leap second, which Date.parse cannot represent', () => {
204
+ expect(parseTimestamp('2026-12-31T23:59:60Z')).toBeUndefined();
205
+ expect(
206
+ evaluate(feature({ activeAt: '2026-12-31T23:59:60Z' }), NOW)
207
+ ).toBe(true);
208
+ });
209
+
210
+ it('never throws on malformed input', () => {
211
+ expect(() =>
212
+ evaluate(feature({ activeAt: 'x', disabledAt: 'y' }), NOW)
213
+ ).not.toThrow();
214
+ });
215
+ });
216
+
217
+ describe('fractional seconds', () => {
218
+ it('accepts them', () => {
219
+ expect(
220
+ evaluate(feature({ disabledAt: '2026-09-18T12:00:00.001Z' }), NOW)
221
+ ).toBe(true);
222
+ });
223
+ });
224
+ });
225
+
226
+ describe('parseTimestamp', () => {
227
+ it('returns the epoch milliseconds for a valid timestamp', () => {
228
+ expect(parseTimestamp('2026-09-18T12:00:00Z')).toBe(
229
+ Date.parse('2026-09-18T12:00:00Z')
230
+ );
231
+ });
232
+
233
+ it.each([undefined, null, ''])('returns undefined for %p', (value) => {
234
+ expect(parseTimestamp(value)).toBeUndefined();
235
+ });
236
+
237
+ it('returns undefined for a format without an offset', () => {
238
+ expect(parseTimestamp('2026-09-18T12:00:00')).toBeUndefined();
239
+ });
240
+
241
+ it('accepts a lowercase t and z', () => {
242
+ expect(parseTimestamp('2026-09-18t12:00:00z')).toBe(
243
+ Date.parse('2026-09-18T12:00:00Z')
244
+ );
245
+ });
246
+ });
247
+
248
+ describe('systemClock', () => {
249
+ it('reports the current time', () => {
250
+ const before = Date.now();
251
+ const reading = systemClock();
252
+ expect(reading).toBeGreaterThanOrEqual(before);
253
+ expect(reading).toBeLessThanOrEqual(Date.now());
254
+ });
255
+
256
+ it('is the default, so a provider works without one', () => {
257
+ const clock: Clock = () => 0;
258
+ expect(typeof clock()).toBe('number');
259
+ });
260
+ });
@@ -0,0 +1,112 @@
1
+ import { Clock } from '../evaluate';
2
+ import { Feature } from '../FeatureToggle';
3
+ import { ApiServiceFeatureProvider } from '../examples/ApiServiceFeatureProvider';
4
+ import { LocalStorageFeatureProvider } from '../examples/LocalStorageFeatureProvider';
5
+
6
+ /**
7
+ * The clock has to be injectable for the conformance suite: every case there
8
+ * carries its own `now`, and an adapter cannot set it if the providers read
9
+ * `Date.now()` directly.
10
+ *
11
+ * These tests pin that down, and pin down that both feature-shaped providers
12
+ * answer identically -- the point of moving the logic into evaluate().
13
+ */
14
+ describe('injectable clock', () => {
15
+ const windowed: Feature = {
16
+ key: 'windowed',
17
+ value: 'true',
18
+ activeAt: '2026-09-18T10:00:00Z',
19
+ disabledAt: '2026-09-18T14:00:00Z',
20
+ };
21
+
22
+ const at = (iso: string): Clock => () => Date.parse(iso);
23
+
24
+ /** A provider holding `windowed`, without touching the filesystem. */
25
+ const localAt = (iso: string) => {
26
+ const provider = new LocalStorageFeatureProvider('', at(iso));
27
+ provider.data = { windowed };
28
+ return provider;
29
+ };
30
+
31
+ /** The same, for the API provider, without touching the network. */
32
+ const apiAt = (iso: string) => {
33
+ const provider = new ApiServiceFeatureProvider('', '', at(iso));
34
+ provider.data = { windowed };
35
+ return provider;
36
+ };
37
+
38
+ describe('LocalStorageFeatureProvider', () => {
39
+ it('is off before the window', () => {
40
+ expect(localAt('2026-09-18T09:59:59Z').isEnabled('windowed')).toBe(false);
41
+ });
42
+
43
+ it('is on inside the window', () => {
44
+ expect(localAt('2026-09-18T12:00:00Z').isEnabled('windowed')).toBe(true);
45
+ });
46
+
47
+ it('is off after the window', () => {
48
+ expect(localAt('2026-09-18T14:00:00Z').isEnabled('windowed')).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe('ApiServiceFeatureProvider', () => {
53
+ it('is off before the window', () => {
54
+ expect(apiAt('2026-09-18T09:59:59Z').isEnabled('windowed')).toBe(false);
55
+ });
56
+
57
+ it('is on inside the window', () => {
58
+ expect(apiAt('2026-09-18T12:00:00Z').isEnabled('windowed')).toBe(true);
59
+ });
60
+
61
+ it('is off after the window', () => {
62
+ expect(apiAt('2026-09-18T14:00:00Z').isEnabled('windowed')).toBe(false);
63
+ });
64
+ });
65
+
66
+ // Before this change each provider carried its own copy of the logic, so
67
+ // they could drift apart unnoticed. Now they must agree everywhere.
68
+ it('both feature providers agree at every instant', () => {
69
+ for (const iso of [
70
+ '2026-09-18T09:00:00Z',
71
+ '2026-09-18T10:00:00Z',
72
+ '2026-09-18T12:00:00Z',
73
+ '2026-09-18T13:59:59Z',
74
+ '2026-09-18T14:00:00Z',
75
+ '2026-09-18T20:00:00Z',
76
+ ]) {
77
+ expect(localAt(iso).isEnabled('windowed')).toBe(
78
+ apiAt(iso).isEnabled('windowed')
79
+ );
80
+ }
81
+ });
82
+
83
+ it('the same provider answers differently as its clock advances', () => {
84
+ let now = Date.parse('2026-09-18T09:00:00Z');
85
+ const provider = new LocalStorageFeatureProvider('', () => now);
86
+ provider.data = { windowed };
87
+
88
+ expect(provider.isEnabled('windowed')).toBe(false);
89
+
90
+ now = Date.parse('2026-09-18T12:00:00Z');
91
+ expect(provider.isEnabled('windowed')).toBe(true);
92
+
93
+ now = Date.parse('2026-09-18T15:00:00Z');
94
+ expect(provider.isEnabled('windowed')).toBe(false);
95
+ });
96
+
97
+ it('defaults to the system clock when none is given', () => {
98
+ const provider = new LocalStorageFeatureProvider('');
99
+ provider.data = {
100
+ open: { key: 'open', value: 'true', activeAt: '', disabledAt: '' },
101
+ expired: {
102
+ key: 'expired',
103
+ value: 'true',
104
+ activeAt: '',
105
+ disabledAt: '2000-01-01T00:00:00Z',
106
+ },
107
+ };
108
+
109
+ expect(provider.isEnabled('open')).toBe(true);
110
+ expect(provider.isEnabled('expired')).toBe(false);
111
+ });
112
+ });