@arikajs/config 0.0.5

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,330 @@
1
+
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert';
4
+ import { Repository, EnvLoader, env, config, setConfigRepository } from '../src/index.js';
5
+
6
+ // ─── Basic Functionality ────────────────────────────────────────────────────
7
+
8
+ describe('Repository: Core', () => {
9
+ it('can get and set values', () => {
10
+ const repo = new Repository({ app: { name: 'Arika' } });
11
+ assert.strictEqual(repo.get('app.name'), 'Arika');
12
+ repo.set('app.env', 'test');
13
+ assert.strictEqual(repo.get('app.env'), 'test');
14
+ });
15
+
16
+ it('returns default value if key not found', () => {
17
+ const repo = new Repository();
18
+ assert.strictEqual(repo.get('non.existent', 'default'), 'default');
19
+ });
20
+
21
+ it('checks key existence with has()', () => {
22
+ const repo = new Repository({ app: { name: 'Arika' } });
23
+ assert.strictEqual(repo.has('app.name'), true);
24
+ assert.strictEqual(repo.has('app.missing'), false);
25
+ });
26
+
27
+ it('can mark as booted and prevent modifications', () => {
28
+ const repo = new Repository();
29
+ repo.markAsBooted();
30
+ assert.throws(
31
+ () => repo.set('any', 'value'),
32
+ /Configuration cannot be modified after boot/,
33
+ );
34
+ });
35
+ });
36
+
37
+ // ─── Feature 1: Config Caching ─────────────────────────────────────────────
38
+
39
+ describe('Repository: Config Caching', () => {
40
+ it('builds flat cache on boot for O(1) lookups', () => {
41
+ const repo = new Repository({
42
+ app: { name: 'Arika', settings: { debug: true } },
43
+ database: { host: 'localhost', port: 5432 },
44
+ });
45
+
46
+ repo.markAsBooted();
47
+
48
+ // These should all resolve from the flat cache
49
+ assert.strictEqual(repo.get('app.name'), 'Arika');
50
+ assert.strictEqual(repo.get('app.settings.debug'), true);
51
+ assert.strictEqual(repo.get('database.host'), 'localhost');
52
+ assert.strictEqual(repo.get('database.port'), 5432);
53
+ assert.strictEqual(repo.get('missing', 'fallback'), 'fallback');
54
+ });
55
+
56
+ it('has() uses flat cache after boot', () => {
57
+ const repo = new Repository({ app: { name: 'Test' } });
58
+ repo.markAsBooted();
59
+
60
+ assert.strictEqual(repo.has('app.name'), true);
61
+ assert.strictEqual(repo.has('missing.key'), false);
62
+ });
63
+ });
64
+
65
+ // ─── Feature 2: Schema Validation ──────────────────────────────────────────
66
+
67
+ describe('Repository: Schema Validation', () => {
68
+ it('validates required fields', () => {
69
+ const repo = new Repository({ app: {} });
70
+ repo.defineSchema({
71
+ 'app.name': { type: 'string', required: true },
72
+ });
73
+
74
+ const errors = repo.validate();
75
+ assert.strictEqual(errors.length, 1);
76
+ assert.ok(errors[0].message.includes('required'));
77
+ });
78
+
79
+ it('validates type mismatches', () => {
80
+ const repo = new Repository({ app: { port: 'not-a-number' } });
81
+ repo.defineSchema({
82
+ 'app.port': { type: 'number' },
83
+ });
84
+
85
+ const errors = repo.validate();
86
+ assert.strictEqual(errors.length, 1);
87
+ assert.ok(errors[0].message.includes('number'));
88
+ });
89
+
90
+ it('validates enum values', () => {
91
+ const repo = new Repository({ app: { env: 'invalid' } });
92
+ repo.defineSchema({
93
+ 'app.env': {
94
+ type: 'string',
95
+ enum: ['development', 'production', 'testing'],
96
+ },
97
+ });
98
+
99
+ const errors = repo.validate();
100
+ assert.strictEqual(errors.length, 1);
101
+ assert.ok(errors[0].message.includes('one of'));
102
+ });
103
+
104
+ it('validates min/max for numbers', () => {
105
+ const repo = new Repository({ server: { port: 99999 } });
106
+ repo.defineSchema({
107
+ 'server.port': { type: 'number', min: 1, max: 65535 },
108
+ });
109
+
110
+ const errors = repo.validate();
111
+ assert.strictEqual(errors.length, 1);
112
+ assert.ok(errors[0].message.includes('65535'));
113
+ });
114
+
115
+ it('validates min/max for string length', () => {
116
+ const repo = new Repository({ app: { key: 'ab' } });
117
+ repo.defineSchema({
118
+ 'app.key': { type: 'string', min: 16 },
119
+ });
120
+
121
+ const errors = repo.validate();
122
+ assert.strictEqual(errors.length, 1);
123
+ assert.ok(errors[0].message.includes('16'));
124
+ });
125
+
126
+ it('validates regex patterns', () => {
127
+ const repo = new Repository({ app: { key: 'invalid-key' } });
128
+ repo.defineSchema({
129
+ 'app.key': {
130
+ type: 'string',
131
+ pattern: /^base64:[A-Za-z0-9+/=]+$/,
132
+ },
133
+ });
134
+
135
+ const errors = repo.validate();
136
+ assert.strictEqual(errors.length, 1);
137
+ assert.ok(errors[0].message.includes('pattern'));
138
+ });
139
+
140
+ it('throws on boot if schema validation fails', () => {
141
+ const repo = new Repository({});
142
+ repo.defineSchema({
143
+ 'app.name': { type: 'string', required: true },
144
+ });
145
+
146
+ assert.throws(
147
+ () => repo.markAsBooted(),
148
+ /Configuration validation failed/,
149
+ );
150
+ });
151
+
152
+ it('passes validation with correct config', () => {
153
+ const repo = new Repository({
154
+ app: { name: 'Arika', env: 'production' },
155
+ });
156
+ repo.defineSchema({
157
+ 'app.name': { type: 'string', required: true },
158
+ 'app.env': {
159
+ type: 'string',
160
+ enum: ['development', 'production'],
161
+ },
162
+ });
163
+
164
+ const errors = repo.validate();
165
+ assert.strictEqual(errors.length, 0);
166
+ // Should not throw
167
+ repo.markAsBooted();
168
+ });
169
+ });
170
+
171
+ // ─── Feature 3: Environment-based Merging ───────────────────────────────────
172
+
173
+ describe('Repository: Environment Merging', () => {
174
+ it('identifies env-specific files correctly', () => {
175
+ const repo = new Repository();
176
+ // This is tested indirectly via loadConfigDirectory
177
+ // The internal isEnvSpecificFile handles this
178
+ assert.ok(repo); // Placeholder — real test requires temp dirs
179
+ });
180
+ });
181
+
182
+ // ─── Feature 4: Config Change Listeners ─────────────────────────────────────
183
+
184
+ describe('Repository: Change Listeners', () => {
185
+ it('notifies key-specific listeners on set()', () => {
186
+ const repo = new Repository({ app: { name: 'Old' } });
187
+ let captured: any = null;
188
+
189
+ repo.onChange('app.name', (key, newVal, oldVal) => {
190
+ captured = { key, newVal, oldVal };
191
+ });
192
+
193
+ repo.set('app.name', 'New');
194
+
195
+ assert.deepStrictEqual(captured, {
196
+ key: 'app.name',
197
+ newVal: 'New',
198
+ oldVal: 'Old',
199
+ });
200
+ });
201
+
202
+ it('notifies parent listeners when child changes', () => {
203
+ const repo = new Repository({ db: { host: 'old' } });
204
+ let notified = false;
205
+
206
+ repo.onChange('db', (key) => {
207
+ notified = true;
208
+ assert.strictEqual(key, 'db.host');
209
+ });
210
+
211
+ repo.set('db.host', 'new-host');
212
+ assert.strictEqual(notified, true);
213
+ });
214
+
215
+ it('notifies global listeners on any change', () => {
216
+ const repo = new Repository();
217
+ const changes: string[] = [];
218
+
219
+ repo.onAnyChange((key) => {
220
+ changes.push(key);
221
+ });
222
+
223
+ repo.set('a', 1);
224
+ repo.set('b', 2);
225
+
226
+ assert.deepStrictEqual(changes, ['a', 'b']);
227
+ });
228
+ });
229
+
230
+ // ─── Feature 5: Deep Freeze ────────────────────────────────────────────────
231
+
232
+ describe('Repository: Deep Freeze', () => {
233
+ it('freezes config on boot to prevent mutations', () => {
234
+ const repo = new Repository({
235
+ app: { name: 'Arika', nested: { deep: 'value' } },
236
+ });
237
+ repo.markAsBooted();
238
+
239
+ const app = repo.get('app');
240
+ assert.throws(() => {
241
+ 'use strict';
242
+ app.name = 'hacked';
243
+ });
244
+ });
245
+
246
+ it('freezes deeply nested objects', () => {
247
+ const repo = new Repository({
248
+ level1: { level2: { level3: { secret: 'safe' } } },
249
+ });
250
+ repo.markAsBooted();
251
+
252
+ const deep = repo.get('level1.level2.level3');
253
+ assert.throws(() => {
254
+ 'use strict';
255
+ deep.secret = 'hacked';
256
+ });
257
+ });
258
+ });
259
+
260
+ // ─── Feature 6: Encrypted Config Values ─────────────────────────────────────
261
+
262
+ describe('Repository: Encrypted Values', () => {
263
+ it('auto-decrypts enc: prefixed values when decrypter is set', () => {
264
+ const repo = new Repository({
265
+ secrets: { api_key: 'enc:aGVsbG8td29ybGQ=' },
266
+ });
267
+
268
+ // Set a simple base64 decrypter
269
+ repo.setDecrypter((encrypted) => {
270
+ return Buffer.from(encrypted, 'base64').toString('utf-8');
271
+ });
272
+
273
+ assert.strictEqual(repo.get('secrets.api_key'), 'hello-world');
274
+ });
275
+
276
+ it('returns raw value minus prefix when no decrypter is set', () => {
277
+ const repo = new Repository({
278
+ secrets: { key: 'enc:raw-encrypted-data' },
279
+ });
280
+
281
+ assert.strictEqual(repo.get('secrets.key'), 'raw-encrypted-data');
282
+ });
283
+
284
+ it('auto-decrypts after boot using flat cache', () => {
285
+ const repo = new Repository({
286
+ secrets: { token: 'enc:c2VjcmV0' },
287
+ });
288
+
289
+ repo.setDecrypter((encrypted) => {
290
+ return Buffer.from(encrypted, 'base64').toString('utf-8');
291
+ });
292
+
293
+ repo.markAsBooted();
294
+
295
+ assert.strictEqual(repo.get('secrets.token'), 'secret');
296
+ });
297
+ });
298
+
299
+ // ─── EnvLoader & Helpers ────────────────────────────────────────────────────
300
+
301
+ describe('EnvLoader & Helpers', () => {
302
+ it('handles environment variable casting', () => {
303
+ process.env.TEST_TRUE = 'true';
304
+ process.env.TEST_FALSE = 'false';
305
+ process.env.TEST_NULL = 'null';
306
+ process.env.TEST_STR = 'hello';
307
+
308
+ assert.strictEqual(EnvLoader.get('TEST_TRUE'), true);
309
+ assert.strictEqual(EnvLoader.get('TEST_FALSE'), false);
310
+ assert.strictEqual(EnvLoader.get('TEST_NULL'), null);
311
+ assert.strictEqual(EnvLoader.get('TEST_STR'), 'hello');
312
+ assert.strictEqual(EnvLoader.get('NON_EXISTENT', 'fb'), 'fb');
313
+
314
+ delete process.env.TEST_TRUE;
315
+ delete process.env.TEST_FALSE;
316
+ delete process.env.TEST_NULL;
317
+ delete process.env.TEST_STR;
318
+ });
319
+
320
+ it('global helpers work correctly', () => {
321
+ const repo = new Repository({ site: { url: 'https://arika.js' } });
322
+ setConfigRepository(repo);
323
+
324
+ assert.strictEqual(config('site.url'), 'https://arika.js');
325
+
326
+ process.env.HELPER_TEST = 'works';
327
+ assert.strictEqual(env('HELPER_TEST'), 'works');
328
+ delete process.env.HELPER_TEST;
329
+ });
330
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": [
7
+ "ES2022"
8
+ ],
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "declaration": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": [
19
+ "src/**/*"
20
+ ],
21
+ "exclude": [
22
+ "node_modules",
23
+ "dist",
24
+ "tests"
25
+ ]
26
+ }