@tehw0lf/yaft 0.0.3 → 0.0.4

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.
@@ -16,6 +16,7 @@ jobs:
16
16
  packages: write
17
17
  with:
18
18
  tool: "npm"
19
+ test: "run test"
19
20
  build_branch: "run build"
20
21
  build_main: "run build"
21
22
  event_name: ${{ github.event_name }}
package/CLAUDE.md ADDED
@@ -0,0 +1,138 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ YaFT (Yet another Feature Toggle) is a TypeScript library that provides decorator-based feature toggles for classes and methods. It integrates with backend services (Go/PostgreSQL by default) or local data sources to enable/disable features at runtime.
8
+
9
+ ## Development Commands
10
+
11
+ ### Building
12
+ ```bash
13
+ npm run build # Compile TypeScript and prepare package for publishing
14
+ ```
15
+
16
+ ### Testing
17
+ ```bash
18
+ npm test # Run Jest tests with ts-jest transform
19
+ npx jest # Alternative test command
20
+ npx jest --watch # Run tests in watch mode
21
+ npx jest --coverage # Run tests with coverage report
22
+ ```
23
+
24
+ ### Running Single Tests
25
+ ```bash
26
+ npx jest yaft.spec.ts # Run specific test file
27
+ npx jest -t "test name" # Run specific test by name pattern
28
+ ```
29
+
30
+ ## Architecture
31
+
32
+ ### Core Components
33
+
34
+ **FeatureToggle Decorator (`src/FeatureToggle.ts`)**
35
+ - Main decorator function that can be applied to classes or methods
36
+ - Evaluates feature state at decoration time using the configured provider
37
+ - Returns fallback implementations when features are disabled
38
+
39
+ **FeatureToggleBase Class**
40
+ - Static container for the global `featureProvider` instance
41
+ - Must be configured before using any `@FeatureToggle` decorators
42
+
43
+ **FeatureProvider Interface**
44
+ - Defines contract for data sources: `isEnabled(key: string): boolean`
45
+ - Optional API integration with `apiUrl`, `baseUUID`, and collection hash support
46
+ - Flexible data structure support via generic type parameter
47
+
48
+ ### Provider Implementations
49
+
50
+ **API Providers** (`src/examples/ApiService*Provider.ts`)
51
+ - Integrate with YaFT Go backend via HTTP API
52
+ - Support collection hash for efficient cache invalidation
53
+ - Handle both boolean and Feature data types
54
+ - Include error handling for network failures
55
+
56
+ **Local Storage Providers** (`src/examples/LocalStorage*Provider.ts`)
57
+ - Load configuration from local JSON files using `require()`
58
+ - Support both boolean and Feature data types
59
+ - Fallback to empty configuration on file load errors
60
+
61
+ ### Feature Data Model
62
+
63
+ ```typescript
64
+ type Feature = {
65
+ key: string; // Unique feature identifier
66
+ value: string; // Boolean value as string
67
+ activeAt: string; // ISO date when feature becomes active
68
+ disabledAt: string; // ISO date when feature gets disabled
69
+ }
70
+ ```
71
+
72
+ Features support time-based activation/deactivation logic evaluated at runtime.
73
+
74
+ ### Decorator Behavior
75
+
76
+ **Method Decoration:**
77
+ - Replaces method implementation based on feature state
78
+ - Supports fallback method when feature is disabled
79
+ - Returns empty function if no fallback provided
80
+
81
+ **Class Decoration:**
82
+ - Replaces entire class constructor and prototype
83
+ - Supports fallback class when feature is disabled
84
+ - Creates EmptyClass with stub methods if no fallback provided
85
+ - Preserves async method signatures in EmptyClass stubs
86
+
87
+ ## Usage Patterns
88
+
89
+ ### Initialization
90
+ ```typescript
91
+ import { FeatureToggleBase } from "@tehw0lf/yaft";
92
+ import { LocalStorageFeatureProvider } from "./examples/LocalStorageFeatureProvider";
93
+
94
+ FeatureToggleBase.featureProvider = new LocalStorageFeatureProvider("./config.json");
95
+ ```
96
+
97
+ ### Method Toggle
98
+ ```typescript
99
+ @FeatureToggle("myFeature", fallbackMethod)
100
+ myMethod() {
101
+ // Implementation when feature is enabled
102
+ }
103
+ ```
104
+
105
+ ### Class Toggle
106
+ ```typescript
107
+ @FeatureToggle("myFeature", FallbackClass)
108
+ class MyClass {
109
+ // Implementation when feature is enabled
110
+ }
111
+ ```
112
+
113
+ ## Testing Strategy
114
+
115
+ Tests use LocalStorageFeatureProvider with test configuration files (`src/test/test-*.json`). The test suite demonstrates:
116
+ - Class decoration with fallback classes
117
+ - Method decoration with and without fallbacks
118
+ - Async method handling
119
+ - Provider configuration and feature evaluation
120
+
121
+ ## Build Configuration
122
+
123
+ - **TypeScript**: CommonJS output targeting ES2022
124
+ - **Decorators**: Experimental decorators enabled for `@FeatureToggle` syntax
125
+ - **Output**: Compiled to `dist/yaft/` with declaration files
126
+ - **Package**: Strips devDependencies during build process
127
+
128
+ ## Dependencies
129
+
130
+ - **Runtime**: `reflect-metadata` for decorator metadata
131
+ - **Development**: TypeScript, Jest, ts-jest for testing
132
+ - **Optional**: `axios` for API provider implementations
133
+
134
+ ## Pre-commit Validation
135
+
136
+ ```bash
137
+ npm test && npm run build
138
+ ```
package/jest.config.js ADDED
@@ -0,0 +1,13 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} **/
2
+ module.exports = {
3
+ displayName: 'yaft',
4
+ setupFilesAfterEnv: [
5
+ '<rootDir>/src/test/test-setup.ts'
6
+ ],
7
+ globals: {},
8
+ coverageDirectory: 'coverage/yaft',
9
+ testEnvironment: "node",
10
+ transform: {
11
+ "^.+\.tsx?$": ["ts-jest",{}],
12
+ },
13
+ };
package/logo.svg CHANGED
@@ -23,5 +23,5 @@
23
23
  <!-- cross -->
24
24
  <path d="M5,20 L20,5" stroke="red" stroke-width="4" fill="none" />
25
25
  <!-- check -->
26
- <path d="M40.8,20.8, L30.8,10.8" stroke="green" stroke-width="4" fill="none" />
26
+ <path d="M40.8,20.8 L30.8,10.8" stroke="green" stroke-width="4" fill="none" />
27
27
  </svg>
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@tehw0lf/yaft",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "YaFT - Feature Toggles using Go&PostgreSQL or any source!",
5
5
  "type": "commonjs",
6
6
  "scripts": {
7
- "build": "tsc && npm pkg delete devDependencies && cp package.json README.md logo.svg dist/"
7
+ "build": "tsc && npm pkg delete devDependencies && cp package.json README.md logo.svg dist/",
8
+ "test": "jest"
8
9
  },
9
10
  "repository": {
10
11
  "type": "git",
@@ -26,8 +27,12 @@
26
27
  "reflect-metadata": "^0.2.2"
27
28
  },
28
29
  "devDependencies": {
30
+ "@types/jest": "^29.5.14",
29
31
  "@types/node": "^22.14.1",
30
32
  "axios": "^1.8.4",
33
+ "jest": "^29.7.0",
34
+ "ts-jest": "^29.3.2",
35
+ "ts-node": "^10.9.2",
31
36
  "typescript": "^4.2.3"
32
37
  }
33
38
  }
@@ -0,0 +1,359 @@
1
+ import { FeatureToggle, FeatureToggleBase, FeatureProvider } from '../FeatureToggle';
2
+
3
+ describe('FeatureToggle Decorator Behavior', () => {
4
+ let mockProvider: FeatureProvider<boolean>;
5
+
6
+ beforeEach(() => {
7
+ mockProvider = {
8
+ data: {},
9
+ getConfig: jest.fn(),
10
+ isEnabled: jest.fn()
11
+ };
12
+ FeatureToggleBase.featureProvider = mockProvider;
13
+ });
14
+
15
+ afterEach(() => {
16
+ jest.clearAllMocks();
17
+ });
18
+
19
+ describe('Method Decoration', () => {
20
+ it('should execute original method when feature is enabled', () => {
21
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
22
+ let executionResult = '';
23
+
24
+ class TestClass {
25
+ @FeatureToggle('enabledFeature')
26
+ testMethod() {
27
+ executionResult = 'original method executed';
28
+ return 'original result';
29
+ }
30
+ }
31
+
32
+ const instance = new TestClass();
33
+ const result = instance.testMethod();
34
+
35
+ expect(executionResult).toBe('original method executed');
36
+ expect(result).toBe('original result');
37
+ expect(mockProvider.isEnabled).toHaveBeenCalledWith('enabledFeature');
38
+ });
39
+
40
+ it('should execute fallback method when feature is disabled', () => {
41
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
42
+ let executionResult = '';
43
+
44
+ function fallbackMethod() {
45
+ executionResult = 'fallback method executed';
46
+ return 'fallback result';
47
+ }
48
+
49
+ class TestClass {
50
+ @FeatureToggle('disabledFeature', fallbackMethod)
51
+ testMethod() {
52
+ executionResult = 'original method executed';
53
+ return 'original result';
54
+ }
55
+ }
56
+
57
+ const instance = new TestClass();
58
+ const result = instance.testMethod();
59
+
60
+ expect(executionResult).toBe('fallback method executed');
61
+ expect(result).toBe('fallback result');
62
+ expect(mockProvider.isEnabled).toHaveBeenCalledWith('disabledFeature');
63
+ });
64
+
65
+ it('should execute empty function when feature is disabled and no fallback provided', () => {
66
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
67
+ let executionResult = '';
68
+
69
+ class TestClass {
70
+ @FeatureToggle('disabledFeature')
71
+ testMethod() {
72
+ executionResult = 'original method executed';
73
+ return 'original result';
74
+ }
75
+ }
76
+
77
+ const instance = new TestClass();
78
+ const result = instance.testMethod();
79
+
80
+ expect(executionResult).toBe('');
81
+ expect(result).toBeUndefined();
82
+ });
83
+
84
+ it('should preserve method context (this binding)', () => {
85
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
86
+
87
+ class TestClass {
88
+ value = 'test value';
89
+
90
+ @FeatureToggle('enabledFeature')
91
+ getValue() {
92
+ return this.value;
93
+ }
94
+ }
95
+
96
+ const instance = new TestClass();
97
+ const result = instance.getValue();
98
+
99
+ expect(result).toBe('test value');
100
+ });
101
+
102
+ it('should handle method arguments correctly', () => {
103
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
104
+
105
+ class TestClass {
106
+ @FeatureToggle('enabledFeature')
107
+ add(a: number, b: number) {
108
+ return a + b;
109
+ }
110
+ }
111
+
112
+ const instance = new TestClass();
113
+ const result = instance.add(5, 3);
114
+
115
+ expect(result).toBe(8);
116
+ });
117
+
118
+ it('should handle async methods', async () => {
119
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
120
+
121
+ class TestClass {
122
+ @FeatureToggle('enabledFeature')
123
+ async asyncMethod() {
124
+ return Promise.resolve('async result');
125
+ }
126
+ }
127
+
128
+ const instance = new TestClass();
129
+ const result = await instance.asyncMethod();
130
+
131
+ expect(result).toBe('async result');
132
+ });
133
+
134
+ it('should handle async fallback methods', async () => {
135
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
136
+
137
+ async function asyncFallback() {
138
+ return Promise.resolve('async fallback result');
139
+ }
140
+
141
+ class TestClass {
142
+ @FeatureToggle('disabledFeature', asyncFallback)
143
+ async asyncMethod() {
144
+ return Promise.resolve('async original result');
145
+ }
146
+ }
147
+
148
+ const instance = new TestClass();
149
+ const result = await instance.asyncMethod();
150
+
151
+ expect(result).toBe('async fallback result');
152
+ });
153
+ });
154
+
155
+ describe('Class Decoration', () => {
156
+ it('should use original class when feature is enabled', () => {
157
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
158
+
159
+ @FeatureToggle('enabledFeature')
160
+ class TestClass {
161
+ value = 'original class';
162
+
163
+ getValue() {
164
+ return this.value;
165
+ }
166
+ }
167
+
168
+ const instance = new TestClass();
169
+ expect(instance.getValue()).toBe('original class');
170
+ expect(instance instanceof TestClass).toBe(true);
171
+ });
172
+
173
+ it('should use fallback class when feature is disabled', () => {
174
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
175
+
176
+ class FallbackClass {
177
+ value = 'fallback class';
178
+
179
+ getValue() {
180
+ return this.value;
181
+ }
182
+ }
183
+
184
+ @FeatureToggle('disabledFeature', FallbackClass)
185
+ class TestClass {
186
+ value = 'original class';
187
+
188
+ getValue() {
189
+ return this.value;
190
+ }
191
+ }
192
+
193
+ const instance = new TestClass();
194
+ expect(instance.getValue()).toBe('fallback class');
195
+ });
196
+
197
+ it('should use EmptyClass when feature is disabled and no fallback provided', () => {
198
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
199
+
200
+ @FeatureToggle('disabledFeature')
201
+ class TestClass {
202
+ value = 'original class';
203
+
204
+ getValue() {
205
+ return this.value;
206
+ }
207
+
208
+ async getAsyncValue() {
209
+ return Promise.resolve(this.value);
210
+ }
211
+ }
212
+
213
+ const instance = new TestClass();
214
+
215
+ // Should have empty methods
216
+ expect(instance.getValue()).toBeUndefined();
217
+
218
+ // Should handle async methods
219
+ expect(instance.getAsyncValue()).toBeInstanceOf(Promise);
220
+ });
221
+
222
+ it('should preserve constructor arguments in fallback class', () => {
223
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
224
+
225
+ class FallbackClass {
226
+ constructor(public name: string, public age: number) {}
227
+
228
+ getInfo() {
229
+ return `${this.name} is ${this.age}`;
230
+ }
231
+ }
232
+
233
+ @FeatureToggle('disabledFeature', FallbackClass)
234
+ class TestClass {
235
+ constructor(public name: string, public age: number) {}
236
+
237
+ getInfo() {
238
+ return `Original: ${this.name} is ${this.age}`;
239
+ }
240
+ }
241
+
242
+ const instance = new TestClass('John', 30);
243
+ expect(instance.getInfo()).toBe('John is 30');
244
+ });
245
+
246
+ it('should handle inheritance in fallback classes', () => {
247
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
248
+
249
+ class BaseClass {
250
+ baseMethod() {
251
+ return 'base method';
252
+ }
253
+ }
254
+
255
+ class FallbackClass extends BaseClass {
256
+ fallbackMethod() {
257
+ return 'fallback method';
258
+ }
259
+ }
260
+
261
+ @FeatureToggle('disabledFeature', FallbackClass)
262
+ class TestClass {
263
+ originalMethod() {
264
+ return 'original method';
265
+ }
266
+ }
267
+
268
+ const instance = new TestClass();
269
+ expect((instance as any).baseMethod()).toBe('base method');
270
+ expect((instance as any).fallbackMethod()).toBe('fallback method');
271
+ });
272
+ });
273
+
274
+ describe('Edge Cases and Error Conditions', () => {
275
+ it('should throw error when FeatureToggleProvider is not set', () => {
276
+ FeatureToggleBase.featureProvider = null as any;
277
+
278
+ expect(() => {
279
+ class TestClass {
280
+ @FeatureToggle('anyFeature')
281
+ testMethod() {}
282
+ }
283
+ }).toThrow('FeatureToggleProvider not set');
284
+ });
285
+
286
+ it('should handle provider throwing errors gracefully', () => {
287
+ (mockProvider.isEnabled as jest.Mock).mockImplementation(() => {
288
+ throw new Error('Provider error');
289
+ });
290
+
291
+ expect(() => {
292
+ class TestClass {
293
+ @FeatureToggle('errorFeature')
294
+ testMethod() {}
295
+ }
296
+ }).toThrow('Provider error');
297
+ });
298
+
299
+ it('should handle undefined/null feature keys', () => {
300
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
301
+
302
+ class TestClass {
303
+ @FeatureToggle(undefined as any)
304
+ testMethod() {
305
+ return 'should not execute';
306
+ }
307
+ }
308
+
309
+ const instance = new TestClass();
310
+ const result = instance.testMethod();
311
+
312
+ expect(result).toBeUndefined();
313
+ expect(mockProvider.isEnabled).toHaveBeenCalledWith(undefined);
314
+ });
315
+
316
+ it('should handle empty string feature keys', () => {
317
+ (mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
318
+
319
+ class TestClass {
320
+ @FeatureToggle('')
321
+ testMethod() {
322
+ return 'should not execute';
323
+ }
324
+ }
325
+
326
+ const instance = new TestClass();
327
+ const result = instance.testMethod();
328
+
329
+ expect(result).toBeUndefined();
330
+ expect(mockProvider.isEnabled).toHaveBeenCalledWith('');
331
+ });
332
+ });
333
+
334
+ describe('Multiple Decorators on Same Class/Method', () => {
335
+ it('should handle multiple method decorators', () => {
336
+ (mockProvider.isEnabled as jest.Mock)
337
+ .mockReturnValueOnce(true) // First feature enabled
338
+ .mockReturnValueOnce(false); // Second feature disabled
339
+
340
+ function fallback() {
341
+ return 'fallback executed';
342
+ }
343
+
344
+ class TestClass {
345
+ @FeatureToggle('feature1')
346
+ @FeatureToggle('feature2', fallback)
347
+ testMethod() {
348
+ return 'original executed';
349
+ }
350
+ }
351
+
352
+ const instance = new TestClass();
353
+ const result = instance.testMethod();
354
+
355
+ // The behavior depends on decorator execution order
356
+ expect(mockProvider.isEnabled).toHaveBeenCalledTimes(2);
357
+ });
358
+ });
359
+ });