Skip to content

Commit 2531efb

Browse files
committed
feat(appstash): add per-context vars, getClientConfig with 3-tier resolution
- Add setVar/getVar/deleteVar/listVars for per-context key-value storage - Add getClientConfig(targetName) with 3-tier resolution: store → env vars → throw - Export ClientConfig type - Fix readJson shared-reference mutation bug (DEFAULT_SETTINGS was being mutated across stores) - Add comprehensive tests for vars and getClientConfig (31 new test cases)
1 parent 51bb1c6 commit 2531efb

3 files changed

Lines changed: 349 additions & 5 deletions

File tree

packages/appstash/__tests__/config-store.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,235 @@ describe('createConfigStore', () => {
344344
});
345345
});
346346

347+
describe('vars (key-value store)', () => {
348+
it('should return null for getVar when no context is active', () => {
349+
const store = createStore();
350+
expect(store.getVar('orgId')).toBeNull();
351+
});
352+
353+
it('should throw on setVar when no context is active', () => {
354+
const store = createStore();
355+
expect(() => store.setVar('orgId', 'abc-123')).toThrow('No active context');
356+
});
357+
358+
it('should set and get a var in the current context', () => {
359+
const store = createStore();
360+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
361+
store.setCurrentContext('production');
362+
store.setVar('orgId', 'abc-123');
363+
expect(store.getVar('orgId')).toBe('abc-123');
364+
});
365+
366+
it('should set and get a var by explicit context name', () => {
367+
const store = createStore();
368+
store.createContext('staging', { endpoint: 'https://staging.example.com/graphql' });
369+
store.setVar('orgId', 'staging-org', 'staging');
370+
expect(store.getVar('orgId', 'staging')).toBe('staging-org');
371+
});
372+
373+
it('should overwrite existing var', () => {
374+
const store = createStore();
375+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
376+
store.setCurrentContext('production');
377+
store.setVar('orgId', 'old');
378+
store.setVar('orgId', 'new');
379+
expect(store.getVar('orgId')).toBe('new');
380+
});
381+
382+
it('should return null for non-existent var', () => {
383+
const store = createStore();
384+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
385+
store.setCurrentContext('production');
386+
expect(store.getVar('nonexistent')).toBeNull();
387+
});
388+
389+
it('should delete a var', () => {
390+
const store = createStore();
391+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
392+
store.setCurrentContext('production');
393+
store.setVar('orgId', 'abc-123');
394+
const deleted = store.deleteVar('orgId');
395+
expect(deleted).toBe(true);
396+
expect(store.getVar('orgId')).toBeNull();
397+
});
398+
399+
it('should return false when deleting non-existent var', () => {
400+
const store = createStore();
401+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
402+
store.setCurrentContext('production');
403+
expect(store.deleteVar('nonexistent')).toBe(false);
404+
});
405+
406+
it('should return false for deleteVar when no context is active', () => {
407+
const store = createStore();
408+
expect(store.deleteVar('orgId')).toBe(false);
409+
});
410+
411+
it('should list all vars for current context', () => {
412+
const store = createStore();
413+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
414+
store.setCurrentContext('production');
415+
store.setVar('orgId', 'abc-123');
416+
store.setVar('defaultDatabase', 'my-app');
417+
const vars = store.listVars();
418+
expect(vars).toEqual({ orgId: 'abc-123', defaultDatabase: 'my-app' });
419+
});
420+
421+
it('should return empty object for listVars when no context is active', () => {
422+
const store = createStore();
423+
expect(store.listVars()).toEqual({});
424+
});
425+
426+
it('should isolate vars between contexts', () => {
427+
const store = createStore();
428+
store.createContext('production', { endpoint: 'https://api.example.com/graphql' });
429+
store.createContext('staging', { endpoint: 'https://staging.example.com/graphql' });
430+
431+
store.setVar('orgId', 'prod-org', 'production');
432+
store.setVar('orgId', 'staging-org', 'staging');
433+
434+
expect(store.getVar('orgId', 'production')).toBe('prod-org');
435+
expect(store.getVar('orgId', 'staging')).toBe('staging-org');
436+
});
437+
});
438+
439+
describe('getClientConfig', () => {
440+
it('should return endpoint and auth header from store', () => {
441+
const store = createStore();
442+
store.createContext('production', {
443+
endpoint: 'https://api.example.com/graphql',
444+
targets: {
445+
auth: { endpoint: 'https://auth.example.com/graphql' },
446+
public: { endpoint: 'https://public.example.com/graphql' },
447+
},
448+
});
449+
store.setCurrentContext('production');
450+
store.setCredentials('production', { token: 'my-jwt-token' });
451+
452+
const config = store.getClientConfig('auth');
453+
expect(config.endpoint).toBe('https://auth.example.com/graphql');
454+
expect(config.headers['Authorization']).toBe('Bearer my-jwt-token');
455+
});
456+
457+
it('should return config for explicit context name', () => {
458+
const store = createStore();
459+
store.createContext('staging', {
460+
endpoint: 'https://staging.example.com/graphql',
461+
targets: {
462+
public: { endpoint: 'https://public.staging.example.com/graphql' },
463+
},
464+
});
465+
store.setCredentials('staging', { token: 'staging-token' });
466+
467+
const config = store.getClientConfig('public', 'staging');
468+
expect(config.endpoint).toBe('https://public.staging.example.com/graphql');
469+
expect(config.headers['Authorization']).toBe('Bearer staging-token');
470+
});
471+
472+
it('should return config without auth header when no credentials', () => {
473+
const store = createStore();
474+
store.createContext('production', {
475+
endpoint: 'https://api.example.com/graphql',
476+
});
477+
store.setCurrentContext('production');
478+
479+
const config = store.getClientConfig('public');
480+
expect(config.endpoint).toBe('https://api.example.com/graphql');
481+
expect(config.headers['Authorization']).toBeUndefined();
482+
});
483+
484+
it('should fall back to env vars when no context exists', () => {
485+
const store = createStore();
486+
process.env['TESTAPP_PUBLIC_ENDPOINT'] = 'https://env.example.com/graphql';
487+
process.env['TESTAPP_TOKEN'] = 'env-token';
488+
489+
try {
490+
const config = store.getClientConfig('public');
491+
expect(config.endpoint).toBe('https://env.example.com/graphql');
492+
expect(config.headers['Authorization']).toBe('Bearer env-token');
493+
} finally {
494+
delete process.env['TESTAPP_PUBLIC_ENDPOINT'];
495+
delete process.env['TESTAPP_TOKEN'];
496+
}
497+
});
498+
499+
it('should fall back to generic endpoint env var', () => {
500+
const store = createStore();
501+
process.env['TESTAPP_ENDPOINT'] = 'https://generic.example.com/graphql';
502+
503+
try {
504+
const config = store.getClientConfig('auth');
505+
expect(config.endpoint).toBe('https://generic.example.com/graphql');
506+
expect(config.headers['Authorization']).toBeUndefined();
507+
} finally {
508+
delete process.env['TESTAPP_ENDPOINT'];
509+
}
510+
});
511+
512+
it('should prefer target-specific env var over generic', () => {
513+
const store = createStore();
514+
process.env['TESTAPP_AUTH_ENDPOINT'] = 'https://auth-specific.example.com/graphql';
515+
process.env['TESTAPP_ENDPOINT'] = 'https://generic.example.com/graphql';
516+
process.env['TESTAPP_TOKEN'] = 'env-token';
517+
518+
try {
519+
const config = store.getClientConfig('auth');
520+
expect(config.endpoint).toBe('https://auth-specific.example.com/graphql');
521+
} finally {
522+
delete process.env['TESTAPP_AUTH_ENDPOINT'];
523+
delete process.env['TESTAPP_ENDPOINT'];
524+
delete process.env['TESTAPP_TOKEN'];
525+
}
526+
});
527+
528+
it('should throw with actionable error when nothing is configured', () => {
529+
const store = createStore();
530+
expect(() => store.getClientConfig('public')).toThrow(
531+
/No configuration found for target "public"/
532+
);
533+
expect(() => store.getClientConfig('public')).toThrow(
534+
/TESTAPP_PUBLIC_ENDPOINT/
535+
);
536+
});
537+
538+
it('should prioritize store over env vars', () => {
539+
const store = createStore();
540+
store.createContext('production', {
541+
endpoint: 'https://api.example.com/graphql',
542+
targets: {
543+
public: { endpoint: 'https://store.example.com/graphql' },
544+
},
545+
});
546+
store.setCurrentContext('production');
547+
store.setCredentials('production', { token: 'store-token' });
548+
process.env['TESTAPP_PUBLIC_ENDPOINT'] = 'https://env.example.com/graphql';
549+
process.env['TESTAPP_TOKEN'] = 'env-token';
550+
551+
try {
552+
const config = store.getClientConfig('public');
553+
expect(config.endpoint).toBe('https://store.example.com/graphql');
554+
expect(config.headers['Authorization']).toBe('Bearer store-token');
555+
} finally {
556+
delete process.env['TESTAPP_PUBLIC_ENDPOINT'];
557+
delete process.env['TESTAPP_TOKEN'];
558+
}
559+
});
560+
561+
it('should fall back to main endpoint when target not in store targets', () => {
562+
const store = createStore();
563+
store.createContext('production', {
564+
endpoint: 'https://api.example.com/graphql',
565+
targets: {
566+
auth: { endpoint: 'https://auth.example.com/graphql' },
567+
},
568+
});
569+
store.setCurrentContext('production');
570+
571+
const config = store.getClientConfig('public');
572+
expect(config.endpoint).toBe('https://api.example.com/graphql');
573+
});
574+
});
575+
347576
describe('full workflow', () => {
348577
it('should support the complete context + auth workflow', () => {
349578
const store = createStore();

0 commit comments

Comments
 (0)