Skip to content

Commit d8f9d99

Browse files
test(hooks): Add SMS notification tests (STA-413)
- Add tests for loadSMSConfig, saveSMSConfig - Add tests for getMissingConfig - Add tests for processIncomingResponse - Add tests for cleanupExpiredPrompts - 16 new tests, all passing
1 parent ca5455d commit d8f9d99

1 file changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
/**
2+
* Tests for SMS notification functionality
3+
*
4+
* Note: These tests use the actual config path (~/.stackmemory) but with
5+
* isolated test data. We save/restore the config around tests.
6+
*/
7+
8+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9+
import {
10+
existsSync,
11+
readFileSync,
12+
writeFileSync,
13+
unlinkSync,
14+
mkdirSync,
15+
} from 'fs';
16+
import { join } from 'path';
17+
import { homedir } from 'os';
18+
19+
import {
20+
loadSMSConfig,
21+
saveSMSConfig,
22+
getMissingConfig,
23+
processIncomingResponse,
24+
cleanupExpiredPrompts,
25+
type SMSConfig,
26+
} from '../sms-notify.js';
27+
28+
const CONFIG_PATH = join(homedir(), '.stackmemory', 'sms-notify.json');
29+
let originalConfig: string | null = null;
30+
31+
describe('SMS Notify', () => {
32+
// Store original env vars
33+
const originalEnv: Record<string, string | undefined> = {};
34+
35+
beforeEach(() => {
36+
// Save original config
37+
if (existsSync(CONFIG_PATH)) {
38+
originalConfig = readFileSync(CONFIG_PATH, 'utf8');
39+
}
40+
41+
// Create directory if needed
42+
mkdirSync(join(homedir(), '.stackmemory'), { recursive: true });
43+
44+
// Write clean test config to isolate from real config
45+
writeFileSync(
46+
CONFIG_PATH,
47+
JSON.stringify({
48+
enabled: false,
49+
channel: 'whatsapp',
50+
notifyOn: {
51+
taskComplete: true,
52+
reviewReady: true,
53+
error: true,
54+
custom: true,
55+
},
56+
responseTimeout: 300,
57+
pendingPrompts: [],
58+
})
59+
);
60+
61+
// Save and clear env vars
62+
const envVars = [
63+
'TWILIO_ACCOUNT_SID',
64+
'TWILIO_AUTH_TOKEN',
65+
'TWILIO_WHATSAPP_FROM',
66+
'TWILIO_WHATSAPP_TO',
67+
'TWILIO_SMS_FROM',
68+
'TWILIO_SMS_TO',
69+
'TWILIO_CHANNEL',
70+
'TWILIO_FROM_NUMBER',
71+
'TWILIO_TO_NUMBER',
72+
];
73+
for (const v of envVars) {
74+
originalEnv[v] = process.env[v];
75+
delete process.env[v];
76+
}
77+
});
78+
79+
afterEach(() => {
80+
// Restore original config
81+
if (originalConfig) {
82+
writeFileSync(CONFIG_PATH, originalConfig);
83+
} else if (existsSync(CONFIG_PATH)) {
84+
unlinkSync(CONFIG_PATH);
85+
}
86+
originalConfig = null;
87+
88+
// Restore env vars
89+
for (const [k, v] of Object.entries(originalEnv)) {
90+
if (v !== undefined) {
91+
process.env[k] = v;
92+
} else {
93+
delete process.env[k];
94+
}
95+
}
96+
});
97+
98+
describe('loadSMSConfig', () => {
99+
it('should return default config when no file exists', () => {
100+
const config = loadSMSConfig();
101+
102+
expect(config.enabled).toBe(false);
103+
expect(config.channel).toBe('whatsapp');
104+
expect(config.responseTimeout).toBe(300);
105+
expect(config.pendingPrompts).toEqual([]);
106+
});
107+
108+
it('should load config from file', () => {
109+
const configPath = join(homedir(), '.stackmemory', 'sms-notify.json');
110+
const savedConfig = {
111+
enabled: true,
112+
channel: 'sms',
113+
responseTimeout: 600,
114+
};
115+
writeFileSync(configPath, JSON.stringify(savedConfig));
116+
117+
const config = loadSMSConfig();
118+
119+
expect(config.enabled).toBe(true);
120+
expect(config.channel).toBe('sms');
121+
expect(config.responseTimeout).toBe(600);
122+
});
123+
124+
it('should apply environment variables', () => {
125+
process.env['TWILIO_ACCOUNT_SID'] = 'test-sid';
126+
process.env['TWILIO_AUTH_TOKEN'] = 'test-token';
127+
process.env['TWILIO_WHATSAPP_FROM'] = '+14155238886';
128+
process.env['TWILIO_WHATSAPP_TO'] = '+18005551234';
129+
130+
const config = loadSMSConfig();
131+
132+
expect(config.accountSid).toBe('test-sid');
133+
expect(config.authToken).toBe('test-token');
134+
expect(config.whatsappFromNumber).toBe('+14155238886');
135+
expect(config.whatsappToNumber).toBe('+18005551234');
136+
});
137+
138+
it('should prefer env vars over file config for credentials', () => {
139+
const configPath = join(homedir(), '.stackmemory', 'sms-notify.json');
140+
writeFileSync(configPath, JSON.stringify({ accountSid: 'file-sid' }));
141+
process.env['TWILIO_ACCOUNT_SID'] = 'env-sid';
142+
143+
const config = loadSMSConfig();
144+
145+
expect(config.accountSid).toBe('env-sid');
146+
});
147+
});
148+
149+
describe('saveSMSConfig', () => {
150+
it('should save config without sensitive credentials', () => {
151+
const config: SMSConfig = {
152+
enabled: true,
153+
channel: 'whatsapp',
154+
accountSid: 'secret-sid',
155+
authToken: 'secret-token',
156+
whatsappFromNumber: '+14155238886',
157+
whatsappToNumber: '+18005551234',
158+
notifyOn: {
159+
taskComplete: true,
160+
reviewReady: true,
161+
error: false,
162+
custom: true,
163+
},
164+
responseTimeout: 300,
165+
pendingPrompts: [],
166+
};
167+
168+
saveSMSConfig(config);
169+
170+
const configPath = join(homedir(), '.stackmemory', 'sms-notify.json');
171+
const saved = JSON.parse(readFileSync(configPath, 'utf8'));
172+
173+
expect(saved.accountSid).toBeUndefined();
174+
expect(saved.authToken).toBeUndefined();
175+
expect(saved.enabled).toBe(true);
176+
expect(saved.whatsappFromNumber).toBe('+14155238886');
177+
});
178+
});
179+
180+
describe('getMissingConfig', () => {
181+
it('should return result with missing and configured arrays', () => {
182+
const result = getMissingConfig();
183+
184+
// Result should have the expected shape
185+
expect(result).toHaveProperty('ready');
186+
expect(result).toHaveProperty('missing');
187+
expect(result).toHaveProperty('configured');
188+
expect(Array.isArray(result.missing)).toBe(true);
189+
expect(Array.isArray(result.configured)).toBe(true);
190+
});
191+
192+
it('should report ready when fully configured', () => {
193+
process.env['TWILIO_ACCOUNT_SID'] = 'test-sid';
194+
process.env['TWILIO_AUTH_TOKEN'] = 'test-token';
195+
process.env['TWILIO_WHATSAPP_FROM'] = '+14155238886';
196+
process.env['TWILIO_WHATSAPP_TO'] = '+18005551234';
197+
198+
const result = getMissingConfig();
199+
200+
expect(result.ready).toBe(true);
201+
expect(result.missing).toHaveLength(0);
202+
expect(result.configured).toContain('TWILIO_ACCOUNT_SID');
203+
});
204+
205+
it('should check SMS numbers when channel is sms', () => {
206+
process.env['TWILIO_ACCOUNT_SID'] = 'test-sid';
207+
process.env['TWILIO_AUTH_TOKEN'] = 'test-token';
208+
process.env['TWILIO_CHANNEL'] = 'sms';
209+
process.env['TWILIO_SMS_FROM'] = '+12025551234';
210+
process.env['TWILIO_SMS_TO'] = '+18005551234';
211+
212+
const result = getMissingConfig();
213+
214+
expect(result.ready).toBe(true);
215+
expect(result.configured).toContain('TWILIO_SMS_FROM');
216+
});
217+
});
218+
219+
describe('processIncomingResponse', () => {
220+
beforeEach(() => {
221+
// Set up a config with pending prompts
222+
const config: SMSConfig = {
223+
enabled: true,
224+
channel: 'whatsapp',
225+
notifyOn: {
226+
taskComplete: true,
227+
reviewReady: true,
228+
error: true,
229+
custom: true,
230+
},
231+
responseTimeout: 300,
232+
pendingPrompts: [
233+
{
234+
id: 'test-prompt-1',
235+
timestamp: new Date().toISOString(),
236+
message: 'Choose an option',
237+
options: [
238+
{ key: '1', label: 'Option A' },
239+
{ key: '2', label: 'Option B' },
240+
],
241+
type: 'options',
242+
expiresAt: new Date(Date.now() + 60000).toISOString(), // 1 min from now
243+
},
244+
],
245+
};
246+
saveSMSConfig(config);
247+
});
248+
249+
it('should match numeric response to options', () => {
250+
const result = processIncomingResponse('+18005551234', '1');
251+
252+
expect(result.matched).toBe(true);
253+
expect(result.response).toBe('1'); // Returns the key, not label
254+
});
255+
256+
it('should match second option', () => {
257+
const result = processIncomingResponse('+18005551234', '2');
258+
259+
expect(result.matched).toBe(true);
260+
expect(result.response).toBe('2'); // Returns the key, not label
261+
});
262+
263+
it('should not match invalid option', () => {
264+
const result = processIncomingResponse('+18005551234', '5');
265+
266+
expect(result.matched).toBe(false);
267+
expect(result.prompt).toBeDefined();
268+
});
269+
270+
it('should return no match when no pending prompts', () => {
271+
// Clear pending prompts
272+
const config = loadSMSConfig();
273+
config.pendingPrompts = [];
274+
saveSMSConfig(config);
275+
276+
const result = processIncomingResponse('+18005551234', '1');
277+
278+
expect(result.matched).toBe(false);
279+
expect(result.prompt).toBeUndefined();
280+
});
281+
282+
it('should handle yes/no prompts', () => {
283+
const config = loadSMSConfig();
284+
config.pendingPrompts = [
285+
{
286+
id: 'yesno-prompt',
287+
timestamp: new Date().toISOString(),
288+
message: 'Continue?',
289+
options: [
290+
{ key: 'y', label: 'Yes' },
291+
{ key: 'n', label: 'No' },
292+
],
293+
type: 'yesno',
294+
expiresAt: new Date(Date.now() + 60000).toISOString(),
295+
},
296+
];
297+
saveSMSConfig(config);
298+
299+
const yesResult = processIncomingResponse('+18005551234', 'y');
300+
expect(yesResult.matched).toBe(true);
301+
expect(yesResult.response).toBe('y'); // Returns the key, not label
302+
});
303+
304+
it('should handle freeform prompts', () => {
305+
const config = loadSMSConfig();
306+
config.pendingPrompts = [
307+
{
308+
id: 'freeform-prompt',
309+
timestamp: new Date().toISOString(),
310+
message: 'Enter your message',
311+
options: [],
312+
type: 'freeform',
313+
expiresAt: new Date(Date.now() + 60000).toISOString(),
314+
},
315+
];
316+
saveSMSConfig(config);
317+
318+
const result = processIncomingResponse('+18005551234', 'Hello world');
319+
expect(result.matched).toBe(true);
320+
expect(result.response).toBe('hello world');
321+
});
322+
});
323+
324+
describe('cleanupExpiredPrompts', () => {
325+
it('should remove expired prompts', () => {
326+
const config: SMSConfig = {
327+
enabled: true,
328+
channel: 'whatsapp',
329+
notifyOn: {
330+
taskComplete: true,
331+
reviewReady: true,
332+
error: true,
333+
custom: true,
334+
},
335+
responseTimeout: 300,
336+
pendingPrompts: [
337+
{
338+
id: 'expired-prompt',
339+
timestamp: new Date(Date.now() - 120000).toISOString(),
340+
message: 'Old prompt',
341+
options: [],
342+
type: 'freeform',
343+
expiresAt: new Date(Date.now() - 60000).toISOString(), // Expired 1 min ago
344+
},
345+
{
346+
id: 'valid-prompt',
347+
timestamp: new Date().toISOString(),
348+
message: 'New prompt',
349+
options: [],
350+
type: 'freeform',
351+
expiresAt: new Date(Date.now() + 60000).toISOString(), // Valid for 1 min
352+
},
353+
],
354+
};
355+
saveSMSConfig(config);
356+
357+
const removed = cleanupExpiredPrompts();
358+
359+
expect(removed).toBe(1);
360+
361+
const updatedConfig = loadSMSConfig();
362+
expect(updatedConfig.pendingPrompts).toHaveLength(1);
363+
expect(updatedConfig.pendingPrompts[0].id).toBe('valid-prompt');
364+
});
365+
366+
it('should return 0 when no prompts expired', () => {
367+
const config: SMSConfig = {
368+
enabled: true,
369+
channel: 'whatsapp',
370+
notifyOn: {
371+
taskComplete: true,
372+
reviewReady: true,
373+
error: true,
374+
custom: true,
375+
},
376+
responseTimeout: 300,
377+
pendingPrompts: [
378+
{
379+
id: 'valid-prompt',
380+
timestamp: new Date().toISOString(),
381+
message: 'Valid prompt',
382+
options: [],
383+
type: 'freeform',
384+
expiresAt: new Date(Date.now() + 60000).toISOString(),
385+
},
386+
],
387+
};
388+
saveSMSConfig(config);
389+
390+
const removed = cleanupExpiredPrompts();
391+
392+
expect(removed).toBe(0);
393+
});
394+
});
395+
});

0 commit comments

Comments
 (0)