Add testing infrastructure with vitest (#67)

- Add vitest as dev dependency
- Add test scripts: `npm test` (watch) and `npm run test:run` (CI)
- Add initial unit tests for pure utility functions:
  - src/utils/phone.test.ts (10 tests)
  - src/utils/server.test.ts (10 tests)
  - src/channels/attachments.test.ts (6 tests)

All 26 tests passing.

Written by Cameron ◯ Letta Code
This commit is contained in:
Cameron
2026-02-01 22:38:25 -08:00
committed by GitHub
parent 67f0550bd3
commit f3e619cd7b
5 changed files with 1567 additions and 2 deletions

1431
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@
"dev": "tsx src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "vitest",
"test:run": "vitest run",
"skills": "tsx src/cli.ts skills",
"skills:list": "tsx src/cli.ts skills list",
"skills:status": "tsx src/cli.ts skills status",
@@ -64,6 +66,7 @@
"discord.js": "^14.25.1"
},
"devDependencies": {
"@types/update-notifier": "^6.0.8"
"@types/update-notifier": "^6.0.8",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFilename } from './attachments.js';
describe('sanitizeFilename', () => {
it('preserves safe filenames', () => {
expect(sanitizeFilename('photo.jpg')).toBe('photo.jpg');
expect(sanitizeFilename('my-file_123.png')).toBe('my-file_123.png');
});
it('replaces unsafe characters with underscores', () => {
expect(sanitizeFilename('my file.jpg')).toBe('my_file.jpg');
expect(sanitizeFilename('file (1).jpg')).toBe('file__1_.jpg');
expect(sanitizeFilename('file<>:"/\\|?*.jpg')).toBe('file_________.jpg');
});
it('strips leading/trailing underscores', () => {
expect(sanitizeFilename('___file___')).toBe('file');
expect(sanitizeFilename(' file ')).toBe('file');
});
it('returns "attachment" for empty input', () => {
expect(sanitizeFilename('')).toBe('attachment');
expect(sanitizeFilename(' ')).toBe('attachment');
expect(sanitizeFilename('___')).toBe('attachment');
});
it('handles path traversal attempts', () => {
const result = sanitizeFilename('../../../etc/passwd');
// Note: dots are allowed, so '..' becomes '.._' - but slashes are stripped
// Path traversal is prevented by buildAttachmentPath using join() on sanitized components
expect(result).not.toContain('/');
expect(result).toMatch(/^[A-Za-z0-9._-]+$/);
});
it('handles unicode characters', () => {
const result = sanitizeFilename('photo_日本語.jpg');
expect(result).not.toContain('日');
expect(result).toContain('photo_');
expect(result).toContain('.jpg');
});
});

47
src/utils/phone.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { normalizePhoneForStorage, isSameContact } from './phone.js';
describe('normalizePhoneForStorage', () => {
it('strips WhatsApp DM suffix', () => {
expect(normalizePhoneForStorage('12345678901@s.whatsapp.net')).toBe('+12345678901');
});
it('strips WhatsApp group suffix', () => {
expect(normalizePhoneForStorage('12345678901@g.us')).toBe('+12345678901');
});
it('strips LID suffix', () => {
expect(normalizePhoneForStorage('12345678901@lid')).toBe('+12345678901');
});
it('strips port suffix', () => {
expect(normalizePhoneForStorage('12345678901:2')).toBe('+12345678901');
});
it('adds + prefix to raw numbers', () => {
expect(normalizePhoneForStorage('12345678901')).toBe('+12345678901');
});
it('preserves existing + prefix', () => {
expect(normalizePhoneForStorage('+12345678901')).toBe('+12345678901');
});
it('handles combined suffixes', () => {
expect(normalizePhoneForStorage('12345678901@lid:2')).toBe('+12345678901');
});
it('trims whitespace', () => {
expect(normalizePhoneForStorage(' 12345678901 ')).toBe('+12345678901');
});
});
describe('isSameContact', () => {
it('returns true for same number with different formats', () => {
expect(isSameContact('123@lid', '+123')).toBe(true);
expect(isSameContact('123@s.whatsapp.net', '123')).toBe(true);
});
it('returns false for different numbers', () => {
expect(isSameContact('123', '456')).toBe(false);
});
});

45
src/utils/server.test.ts Normal file
View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { isLettaCloudUrl } from './server.js';
describe('isLettaCloudUrl', () => {
it('returns true for undefined (default is cloud)', () => {
expect(isLettaCloudUrl(undefined)).toBe(true);
});
it('returns true for Letta Cloud URL', () => {
expect(isLettaCloudUrl('https://api.letta.com')).toBe(true);
});
it('returns true for Letta Cloud URL with trailing slash', () => {
expect(isLettaCloudUrl('https://api.letta.com/')).toBe(true);
});
it('returns true for Letta Cloud URL with path', () => {
expect(isLettaCloudUrl('https://api.letta.com/v1/agents')).toBe(true);
});
it('returns false for localhost', () => {
expect(isLettaCloudUrl('http://localhost:8283')).toBe(false);
});
it('returns false for 127.0.0.1', () => {
expect(isLettaCloudUrl('http://127.0.0.1:8283')).toBe(false);
});
it('returns false for custom server', () => {
expect(isLettaCloudUrl('https://custom.server.com')).toBe(false);
});
it('returns false for docker network URL', () => {
expect(isLettaCloudUrl('http://letta:8283')).toBe(false);
});
it('returns false for invalid URL', () => {
expect(isLettaCloudUrl('not-a-url')).toBe(false);
});
it('returns true for empty string (treated as default)', () => {
// Empty string is falsy, so it's treated like undefined (default to cloud)
expect(isLettaCloudUrl('')).toBe(true);
});
});