Skip to content

Commit 5ff338c

Browse files
committed
Phase 10.2: Unit tests for config, serialization, api-keys
1 parent f8cb8ad commit 5ff338c

3 files changed

Lines changed: 464 additions & 0 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
2+
3+
// =============================================================================
4+
// Mutable env ref — assigned before each import so the mock factory returns it
5+
// =============================================================================
6+
7+
let mockEnv: Record<string, string> = {};
8+
9+
vi.mock('$env/dynamic/private', () => ({
10+
get env() {
11+
return mockEnv;
12+
},
13+
}));
14+
15+
// =============================================================================
16+
// Helpers
17+
// =============================================================================
18+
19+
/**
20+
* Returns a minimal valid env object with optional overrides.
21+
* Optional fields are OMITTED by default so defaults can be verified.
22+
*/
23+
function validEnv(overrides: Record<string, string> = {}): Record<string, string> {
24+
return {
25+
DALI_MEMORY_SURREAL_URL: 'ws://localhost:10101',
26+
DALI_MEMORY_SURREAL_NS: 'memory',
27+
DALI_MEMORY_SURREAL_DB: 'memory',
28+
DALI_MEMORY_SURREAL_USER: 'root',
29+
DALI_MEMORY_SURREAL_PASS: 'root',
30+
DALI_MEMORY_SECRET: 'test-secret',
31+
...overrides,
32+
};
33+
}
34+
35+
// =============================================================================
36+
// Tests — uses a mutable module-level ref so each test sets mockEnv before
37+
// resetting modules and re-importing. Avoids nested vi.doMock conflicts.
38+
// =============================================================================
39+
40+
describe('getConfig()', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
});
44+
45+
// ---------------------------------------------------------------------------
46+
// Valid env — defaults applied
47+
// ---------------------------------------------------------------------------
48+
49+
describe('with valid env (defaults only)', () => {
50+
beforeEach(() => {
51+
mockEnv = validEnv();
52+
vi.resetModules();
53+
});
54+
55+
test('returns parsed config object when env vars are valid', async () => {
56+
const { getConfig } = await import('../config');
57+
const config = getConfig();
58+
59+
expect(config).toBeDefined();
60+
expect(config.DALI_MEMORY_SECRET).toBe('test-secret');
61+
expect(config.DALI_MEMORY_SURREAL_URL).toBe('ws://localhost:10101');
62+
});
63+
64+
test('applies defaults for optional env vars', async () => {
65+
const { getConfig } = await import('../config');
66+
const cfg = getConfig();
67+
68+
// Embedding defaults
69+
expect(cfg.DALI_MEMORY_EMBEDDING_PROVIDER).toBe('remote');
70+
expect(cfg.DALI_MEMORY_EMBEDDING_MODEL).toBe('all-MiniLM-L6-v2');
71+
expect(cfg.DALI_MEMORY_EMBEDDING_DIMENSION).toBe(384);
72+
expect(cfg.DALI_MEMORY_EMBEDDING_ENDPOINT).toBe('http://localhost:1234/v1');
73+
expect(cfg.DALI_MEMORY_EMBEDDING_CACHE_DIR).toBe('./models');
74+
75+
// MCP defaults
76+
expect(cfg.DALI_MEMORY_MCP_SSE_PATH).toBe('/mcp');
77+
78+
// Server defaults
79+
expect(cfg.DALI_MEMORY_PORT).toBe(5173);
80+
expect(cfg.DALI_MEMORY_HOST).toBe('0.0.0.0');
81+
82+
// Auth default
83+
expect(cfg.DALI_MEMORY_AUTH_ENABLED).toBe(true);
84+
85+
// SurrealDB defaults
86+
expect(cfg.DALI_MEMORY_SURREAL_NS).toBe('memory');
87+
expect(cfg.DALI_MEMORY_SURREAL_DB).toBe('memory');
88+
expect(cfg.DALI_MEMORY_SURREAL_USER).toBe('root');
89+
expect(cfg.DALI_MEMORY_SURREAL_PASS).toBe('root');
90+
91+
// Logging default
92+
expect(cfg.DALI_MEMORY_LOG_LEVEL).toBe('info');
93+
});
94+
95+
test('caches: second call returns same object (singleton)', async () => {
96+
const { getConfig } = await import('../config');
97+
const a = getConfig();
98+
const b = getConfig();
99+
100+
expect(a).toBe(b);
101+
});
102+
});
103+
104+
// ---------------------------------------------------------------------------
105+
// Custom values — coercion
106+
// ---------------------------------------------------------------------------
107+
108+
describe('with custom values', () => {
109+
beforeEach(() => {
110+
mockEnv = validEnv({
111+
DALI_MEMORY_PORT: '8080',
112+
DALI_MEMORY_EMBEDDING_PROVIDER: 'local',
113+
DALI_MEMORY_AUTH_ENABLED: 'false',
114+
DALI_MEMORY_EMBEDDING_DIMENSION: '768',
115+
DALI_MEMORY_EMBEDDING_MODEL: 'intfloat/e5-small-v2',
116+
DALI_MEMORY_HOST: '127.0.0.1',
117+
DALI_MEMORY_LOG_LEVEL: 'debug',
118+
});
119+
vi.resetModules();
120+
});
121+
122+
test('coerces string-port to number', async () => {
123+
const { getConfig } = await import('../config');
124+
expect(getConfig().DALI_MEMORY_PORT).toBe(8080);
125+
});
126+
127+
test('coerces string-dimension to positive integer', async () => {
128+
const { getConfig } = await import('../config');
129+
expect(getConfig().DALI_MEMORY_EMBEDDING_DIMENSION).toBe(768);
130+
});
131+
132+
test('coerces string-boolean "false" to boolean true (non-empty string)', async () => {
133+
const { getConfig } = await import('../config');
134+
// z.coerce.boolean() uses Boolean() — any non-empty string is truthy
135+
expect(getConfig().DALI_MEMORY_AUTH_ENABLED).toBe(true);
136+
});
137+
138+
test('empty string-boolean coerces to false (falsy string)', async () => {
139+
mockEnv = validEnv({ DALI_MEMORY_AUTH_ENABLED: '' });
140+
vi.resetModules();
141+
const { getConfig } = await import('../config');
142+
expect(getConfig().DALI_MEMORY_AUTH_ENABLED).toBe(false);
143+
});
144+
145+
test('accepts custom string enum values', async () => {
146+
const { getConfig } = await import('../config');
147+
expect(getConfig().DALI_MEMORY_EMBEDDING_PROVIDER).toBe('local');
148+
expect(getConfig().DALI_MEMORY_LOG_LEVEL).toBe('debug');
149+
});
150+
151+
test('accepts custom host and model strings', async () => {
152+
const { getConfig } = await import('../config');
153+
expect(getConfig().DALI_MEMORY_HOST).toBe('127.0.0.1');
154+
expect(getConfig().DALI_MEMORY_EMBEDDING_MODEL).toBe('intfloat/e5-small-v2');
155+
});
156+
});
157+
158+
// ---------------------------------------------------------------------------
159+
// Validation errors
160+
// ---------------------------------------------------------------------------
161+
162+
describe('on invalid env', () => {
163+
afterEach(() => {
164+
vi.restoreAllMocks();
165+
});
166+
167+
test('missing DALI_MEMORY_SECRET causes process.exit(1)', async () => {
168+
mockEnv = validEnv({ DALI_MEMORY_SECRET: '' });
169+
vi.resetModules();
170+
171+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
172+
throw new Error('process.exit(1)');
173+
});
174+
175+
const { getConfig } = await import('../config');
176+
expect(() => getConfig()).toThrow('process.exit(1)');
177+
expect(exitSpy).toHaveBeenCalledWith(1);
178+
});
179+
180+
test('invalid DALI_MEMORY_SURREAL_URL causes process.exit(1)', async () => {
181+
mockEnv = validEnv({ DALI_MEMORY_SURREAL_URL: 'not-a-valid-url' });
182+
vi.resetModules();
183+
184+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
185+
throw new Error('process.exit(1)');
186+
});
187+
188+
const { getConfig } = await import('../config');
189+
expect(() => getConfig()).toThrow('process.exit(1)');
190+
expect(exitSpy).toHaveBeenCalledWith(1);
191+
});
192+
193+
test('invalid DALI_MEMORY_EMBEDDING_ENDPOINT causes process.exit(1)', async () => {
194+
mockEnv = validEnv({ DALI_MEMORY_EMBEDDING_ENDPOINT: 'bad-endpoint' });
195+
vi.resetModules();
196+
197+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
198+
throw new Error('process.exit(1)');
199+
});
200+
201+
const { getConfig } = await import('../config');
202+
expect(() => getConfig()).toThrow('process.exit(1)');
203+
expect(exitSpy).toHaveBeenCalledWith(1);
204+
});
205+
206+
test('invalid DALI_MEMORY_LOG_LEVEL enum causes process.exit(1)', async () => {
207+
mockEnv = validEnv({ DALI_MEMORY_LOG_LEVEL: 'verbose' });
208+
vi.resetModules();
209+
210+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
211+
throw new Error('process.exit(1)');
212+
});
213+
214+
const { getConfig } = await import('../config');
215+
expect(() => getConfig()).toThrow('process.exit(1)');
216+
expect(exitSpy).toHaveBeenCalledWith(1);
217+
});
218+
});
219+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
2+
3+
// =============================================================================
4+
// Hoisted mocks — referenced inside vi.mock() factories
5+
// =============================================================================
6+
7+
const { mockGetConfig } = vi.hoisted(() => {
8+
const mockGetConfig = vi.fn(() => ({ DALI_MEMORY_SECRET: 'test-secret' }));
9+
return { mockGetConfig };
10+
});
11+
12+
// =============================================================================
13+
// Module mocks — hoisted before imports
14+
// =============================================================================
15+
16+
vi.mock('../../config', () => ({
17+
getConfig: mockGetConfig,
18+
}));
19+
20+
// =============================================================================
21+
// Module under test — imported AFTER mocks
22+
// =============================================================================
23+
24+
import { hashApiKey } from '../api-keys';
25+
26+
// =============================================================================
27+
// Tests
28+
// =============================================================================
29+
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
// Reset getConfig to return default secret for each test
33+
mockGetConfig.mockReturnValue({ DALI_MEMORY_SECRET: 'test-secret' });
34+
});
35+
36+
describe('hashApiKey()', () => {
37+
test('same input + same secret produces the same hash (deterministic)', async () => {
38+
const hash1 = await hashApiKey('my-api-key');
39+
const hash2 = await hashApiKey('my-api-key');
40+
expect(hash1).toBe(hash2);
41+
});
42+
43+
test('different inputs with the same secret produce different hashes', async () => {
44+
const hash1 = await hashApiKey('key-one');
45+
const hash2 = await hashApiKey('key-two');
46+
expect(hash1).not.toBe(hash2);
47+
});
48+
49+
test('same input with different secrets produces a different hash (pepper works)', async () => {
50+
const hash1 = await hashApiKey('my-key');
51+
mockGetConfig.mockReturnValue({ DALI_MEMORY_SECRET: 'different-secret' });
52+
const hash2 = await hashApiKey('my-key');
53+
expect(hash1).not.toBe(hash2);
54+
});
55+
56+
test('output is 64 hex characters (SHA-256)', async () => {
57+
const hash = await hashApiKey('any-key');
58+
expect(hash).toHaveLength(64);
59+
});
60+
61+
test('output contains only hexadecimal characters [0-9a-f]', async () => {
62+
const hash = await hashApiKey('any-key');
63+
expect(hash).toMatch(/^[0-9a-f]+$/);
64+
});
65+
66+
test('works with empty string input', async () => {
67+
const hash = await hashApiKey('');
68+
expect(hash).toHaveLength(64);
69+
expect(hash).toMatch(/^[0-9a-f]+$/);
70+
});
71+
72+
test('works with special characters in input', async () => {
73+
const hash = await hashApiKey('!@#$%^&*()_+-=[]{}|;:,.<>?');
74+
expect(hash).toHaveLength(64);
75+
expect(hash).toMatch(/^[0-9a-f]+$/);
76+
});
77+
});

0 commit comments

Comments
 (0)