@tehw0lf/yaft 0.0.4 → 0.0.6
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/.github/workflows/build.yml +1 -2
- package/jest.config.js +3 -0
- package/package.json +2 -2
- package/src/FeatureToggle.ts +1 -0
- package/src/examples/ApiServiceBooleanProvider.ts +18 -2
- package/src/examples/ApiServiceFeatureProvider.ts +43 -5
- package/src/examples/LocalStorageFeatureProvider.ts +20 -3
- package/src/test/error-handling.spec.ts +5 -3
- package/src/test/time-based-logic.spec.ts +169 -0
- package/tsconfig.json +2 -1
|
@@ -14,6 +14,7 @@ jobs:
|
|
|
14
14
|
actions: write
|
|
15
15
|
contents: write
|
|
16
16
|
packages: write
|
|
17
|
+
id-token: write
|
|
17
18
|
with:
|
|
18
19
|
tool: "npm"
|
|
19
20
|
test: "run test"
|
|
@@ -22,5 +23,3 @@ jobs:
|
|
|
22
23
|
event_name: ${{ github.event_name }}
|
|
23
24
|
artifact_path: "dist"
|
|
24
25
|
library_path: "dist"
|
|
25
|
-
secrets:
|
|
26
|
-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/jest.config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tehw0lf/yaft",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "YaFT - Feature Toggles using Go&PostgreSQL or any source!",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc && npm pkg delete devDependencies && cp package.json README.md logo.svg dist/",
|
|
8
|
-
"test": "jest"
|
|
8
|
+
"test": "npx jest"
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
package/src/FeatureToggle.ts
CHANGED
|
@@ -17,7 +17,7 @@ export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
|
|
|
17
17
|
async getCollectionHash(configPathOrUrl: string): Promise<void> {
|
|
18
18
|
try {
|
|
19
19
|
const response = await axios.get(configPathOrUrl);
|
|
20
|
-
const newHash = response.data.value;
|
|
20
|
+
const newHash = response.data.collectionHash || response.data.value;
|
|
21
21
|
if (this.collectionHash !== newHash) {
|
|
22
22
|
this.collectionHash = newHash;
|
|
23
23
|
await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
|
|
@@ -30,7 +30,23 @@ export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
|
|
|
30
30
|
async getConfig(configPathOrUrl: string): Promise<void> {
|
|
31
31
|
try {
|
|
32
32
|
const response = await axios.get(configPathOrUrl);
|
|
33
|
-
|
|
33
|
+
// Handle Go backend response format
|
|
34
|
+
const featuresArray = response.data.toggles || response.data.value || [];
|
|
35
|
+
|
|
36
|
+
// Convert array to keyed boolean object and handle capitalized field names
|
|
37
|
+
this.data = {};
|
|
38
|
+
if (Array.isArray(featuresArray)) {
|
|
39
|
+
featuresArray.forEach((feature: any) => {
|
|
40
|
+
const key = feature.key || feature.Key;
|
|
41
|
+
const value = feature.value || feature.Value;
|
|
42
|
+
if (key) {
|
|
43
|
+
this.data[key] = value === 'true' || value === true;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
// Fallback for object format
|
|
48
|
+
this.data = featuresArray;
|
|
49
|
+
}
|
|
34
50
|
} catch (error) {
|
|
35
51
|
console.error("Failed to fetch feature toggle from API:", error);
|
|
36
52
|
}
|
|
@@ -17,7 +17,7 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
|
17
17
|
async getCollectionHash(configPathOrUrl: string): Promise<void> {
|
|
18
18
|
try {
|
|
19
19
|
const response = await axios.get(configPathOrUrl);
|
|
20
|
-
const newHash = response.data.value;
|
|
20
|
+
const newHash = response.data.collectionHash || response.data.value;
|
|
21
21
|
if (this.collectionHash !== newHash) {
|
|
22
22
|
this.collectionHash = newHash;
|
|
23
23
|
await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
|
|
@@ -30,7 +30,28 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
|
30
30
|
async getConfig(configPathOrUrl: string): Promise<void> {
|
|
31
31
|
try {
|
|
32
32
|
const response = await axios.get(configPathOrUrl);
|
|
33
|
-
|
|
33
|
+
// Handle Go backend response format
|
|
34
|
+
const featuresArray = response.data.toggles || response.data.value || [];
|
|
35
|
+
|
|
36
|
+
// Convert array to keyed object and handle capitalized field names
|
|
37
|
+
this.data = {};
|
|
38
|
+
if (Array.isArray(featuresArray)) {
|
|
39
|
+
featuresArray.forEach((feature: any) => {
|
|
40
|
+
const normalizedFeature = {
|
|
41
|
+
key: feature.key || feature.Key,
|
|
42
|
+
value: feature.value || feature.Value,
|
|
43
|
+
activeAt: feature.activeAt || feature.ActiveAt,
|
|
44
|
+
disabledAt: feature.disabledAt || feature.DisabledAt,
|
|
45
|
+
tags: feature.tags || feature.Tags || [],
|
|
46
|
+
};
|
|
47
|
+
if (normalizedFeature.key) {
|
|
48
|
+
this.data[normalizedFeature.key] = normalizedFeature;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
// Fallback for object format
|
|
53
|
+
this.data = featuresArray;
|
|
54
|
+
}
|
|
34
55
|
} catch (error) {
|
|
35
56
|
console.error("Failed to fetch feature toggle from API:", error);
|
|
36
57
|
}
|
|
@@ -41,9 +62,26 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
|
41
62
|
|
|
42
63
|
if (feature === undefined || feature === null) return false;
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
if (
|
|
65
|
+
// First check: value must be "true"
|
|
66
|
+
if (feature.value !== "true") return false;
|
|
67
|
+
|
|
68
|
+
// Second check: if activeAt is set and in future, not yet active
|
|
69
|
+
if (feature.activeAt && feature.activeAt !== "") {
|
|
70
|
+
const activeTime = Date.parse(feature.activeAt);
|
|
71
|
+
if (!isNaN(activeTime) && Date.now() < activeTime) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Third check: if disabledAt is set and in past, already disabled
|
|
77
|
+
if (feature.disabledAt && feature.disabledAt !== "") {
|
|
78
|
+
const disabledTime = Date.parse(feature.disabledAt);
|
|
79
|
+
if (!isNaN(disabledTime) && Date.now() >= disabledTime) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
46
83
|
|
|
47
|
-
|
|
84
|
+
// All checks passed, feature is enabled
|
|
85
|
+
return true;
|
|
48
86
|
}
|
|
49
87
|
}
|
|
@@ -22,9 +22,26 @@ export class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
|
|
|
22
22
|
|
|
23
23
|
if (feature === undefined || feature === null) return false;
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
if (
|
|
25
|
+
// First check: value must be "true"
|
|
26
|
+
if (feature.value !== "true") return false;
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Second check: if activeAt is set and in future, not yet active
|
|
29
|
+
if (feature.activeAt && feature.activeAt !== "") {
|
|
30
|
+
const activeTime = Date.parse(feature.activeAt);
|
|
31
|
+
if (!isNaN(activeTime) && Date.now() < activeTime) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Third check: if disabledAt is set and in past, already disabled
|
|
37
|
+
if (feature.disabledAt && feature.disabledAt !== "") {
|
|
38
|
+
const disabledTime = Date.parse(feature.disabledAt);
|
|
39
|
+
if (!isNaN(disabledTime) && Date.now() >= disabledTime) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// All checks passed, feature is enabled
|
|
45
|
+
return true;
|
|
29
46
|
}
|
|
30
47
|
}
|
|
@@ -6,6 +6,7 @@ import axios from 'axios';
|
|
|
6
6
|
// Mock axios for API provider tests
|
|
7
7
|
jest.mock('axios');
|
|
8
8
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
9
|
+
mockedAxios.get = jest.fn();
|
|
9
10
|
|
|
10
11
|
describe('Error Handling and Edge Cases', () => {
|
|
11
12
|
let mockProvider: FeatureProvider<boolean>;
|
|
@@ -18,6 +19,8 @@ describe('Error Handling and Edge Cases', () => {
|
|
|
18
19
|
};
|
|
19
20
|
FeatureToggleBase.featureProvider = mockProvider;
|
|
20
21
|
jest.clearAllMocks();
|
|
22
|
+
// Reset axios mock
|
|
23
|
+
(mockedAxios.get as jest.Mock).mockReset();
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
describe('Provider Initialization Errors', () => {
|
|
@@ -136,9 +139,8 @@ describe('Error Handling and Edge Cases', () => {
|
|
|
136
139
|
}
|
|
137
140
|
})();
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}).toThrow(); // JSON.parse should throw for invalid boolean strings
|
|
142
|
+
// New behavior: safely returns false for any value that is not "true"
|
|
143
|
+
expect(provider.isEnabled('invalidBoolean')).toBe(false);
|
|
142
144
|
});
|
|
143
145
|
});
|
|
144
146
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Feature } from '../FeatureToggle';
|
|
2
|
+
import { LocalStorageFeatureProvider } from '../examples/LocalStorageFeatureProvider';
|
|
3
|
+
|
|
4
|
+
describe('Time-based Feature Evaluation Logic', () => {
|
|
5
|
+
describe('Current Time Logic', () => {
|
|
6
|
+
it('should handle feature with value=true and no dates (always enabled)', () => {
|
|
7
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('');
|
|
10
|
+
this.data = {
|
|
11
|
+
'always-enabled': {
|
|
12
|
+
key: 'always-enabled',
|
|
13
|
+
value: 'true',
|
|
14
|
+
activeAt: '',
|
|
15
|
+
disabledAt: ''
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
expect(provider.isEnabled('always-enabled')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should handle feature with value=false (always disabled)', () => {
|
|
25
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
26
|
+
constructor() {
|
|
27
|
+
super('');
|
|
28
|
+
this.data = {
|
|
29
|
+
'always-disabled': {
|
|
30
|
+
key: 'always-disabled',
|
|
31
|
+
value: 'false',
|
|
32
|
+
activeAt: '',
|
|
33
|
+
disabledAt: ''
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
expect(provider.isEnabled('always-disabled')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should disable feature if disabledAt is in the past', () => {
|
|
43
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
|
|
44
|
+
|
|
45
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
46
|
+
constructor() {
|
|
47
|
+
super('');
|
|
48
|
+
this.data = {
|
|
49
|
+
'past-disabled': {
|
|
50
|
+
key: 'past-disabled',
|
|
51
|
+
value: 'true',
|
|
52
|
+
activeAt: '',
|
|
53
|
+
disabledAt: pastDate
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
expect(provider.isEnabled('past-disabled')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should enable feature if activeAt is in the past and no disabledAt', () => {
|
|
63
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
|
|
64
|
+
|
|
65
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
66
|
+
constructor() {
|
|
67
|
+
super('');
|
|
68
|
+
this.data = {
|
|
69
|
+
'past-active': {
|
|
70
|
+
key: 'past-active',
|
|
71
|
+
value: 'true',
|
|
72
|
+
activeAt: pastDate,
|
|
73
|
+
disabledAt: ''
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
expect(provider.isEnabled('past-active')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('BUG: should NOT enable feature if activeAt is in the future', () => {
|
|
83
|
+
const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now
|
|
84
|
+
|
|
85
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
86
|
+
constructor() {
|
|
87
|
+
super('');
|
|
88
|
+
this.data = {
|
|
89
|
+
'future-active': {
|
|
90
|
+
key: 'future-active',
|
|
91
|
+
value: 'true',
|
|
92
|
+
activeAt: futureDate,
|
|
93
|
+
disabledAt: ''
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
|
|
99
|
+
// CURRENT BUGGY BEHAVIOR: Returns true because it falls through to line 28
|
|
100
|
+
// Date.now() >= Date.parse(futureDate) is false, so it skips line 26
|
|
101
|
+
// Falls through to: return JSON.parse(feature.value); which returns true
|
|
102
|
+
|
|
103
|
+
// EXPECTED BEHAVIOR: Should return false because feature is not yet active
|
|
104
|
+
expect(provider.isEnabled('future-active')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should enable feature if within activeAt and disabledAt window', () => {
|
|
108
|
+
const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
|
|
109
|
+
const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now
|
|
110
|
+
|
|
111
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
112
|
+
constructor() {
|
|
113
|
+
super('');
|
|
114
|
+
this.data = {
|
|
115
|
+
'windowed-feature': {
|
|
116
|
+
key: 'windowed-feature',
|
|
117
|
+
value: 'true',
|
|
118
|
+
activeAt: pastDate,
|
|
119
|
+
disabledAt: futureDate
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
|
|
125
|
+
expect(provider.isEnabled('windowed-feature')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle NaN dates gracefully (empty strings)', () => {
|
|
129
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
130
|
+
constructor() {
|
|
131
|
+
super('');
|
|
132
|
+
this.data = {
|
|
133
|
+
'empty-dates': {
|
|
134
|
+
key: 'empty-dates',
|
|
135
|
+
value: 'true',
|
|
136
|
+
activeAt: '',
|
|
137
|
+
disabledAt: ''
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
|
|
143
|
+
// Date.parse('') returns NaN
|
|
144
|
+
// Date.now() >= NaN is false for both checks
|
|
145
|
+
// Should fall through to: return JSON.parse(feature.value);
|
|
146
|
+
expect(provider.isEnabled('empty-dates')).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should handle invalid date strings gracefully', () => {
|
|
150
|
+
const provider = new (class extends LocalStorageFeatureProvider {
|
|
151
|
+
constructor() {
|
|
152
|
+
super('');
|
|
153
|
+
this.data = {
|
|
154
|
+
'invalid-dates': {
|
|
155
|
+
key: 'invalid-dates',
|
|
156
|
+
value: 'true',
|
|
157
|
+
activeAt: 'not-a-date',
|
|
158
|
+
disabledAt: 'also-not-a-date'
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
|
|
164
|
+
// Date.parse('not-a-date') returns NaN
|
|
165
|
+
// Similar behavior to empty strings
|
|
166
|
+
expect(provider.isEnabled('invalid-dates')).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|