Add unit tests for clients, AI, and email services
This commit is contained in:
@@ -7,6 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:watch": "bun test --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
|
|||||||
122
src/__tests__/ai.test.ts
Normal file
122
src/__tests__/ai.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import type { GenerateEmailParams, GenerateBirthdayMessageParams } from '../services/ai';
|
||||||
|
|
||||||
|
describe('AI Service', () => {
|
||||||
|
describe('Email Generation Parameters', () => {
|
||||||
|
test('GenerateEmailParams has required fields', () => {
|
||||||
|
const params: GenerateEmailParams = {
|
||||||
|
advisorName: 'John Smith',
|
||||||
|
clientName: 'Jane Doe',
|
||||||
|
interests: ['golf', 'travel'],
|
||||||
|
notes: 'Met at conference last month',
|
||||||
|
purpose: 'quarterly check-in',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.advisorName).toBe('John Smith');
|
||||||
|
expect(params.clientName).toBe('Jane Doe');
|
||||||
|
expect(params.interests).toHaveLength(2);
|
||||||
|
expect(params.purpose).toBe('quarterly check-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email params can have optional provider', () => {
|
||||||
|
const params: GenerateEmailParams = {
|
||||||
|
advisorName: 'John Smith',
|
||||||
|
clientName: 'Jane Doe',
|
||||||
|
interests: [],
|
||||||
|
notes: '',
|
||||||
|
purpose: 'follow-up',
|
||||||
|
provider: 'anthropic',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.provider).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('interests array can be empty', () => {
|
||||||
|
const params: GenerateEmailParams = {
|
||||||
|
advisorName: 'John Smith',
|
||||||
|
clientName: 'Jane Doe',
|
||||||
|
interests: [],
|
||||||
|
notes: '',
|
||||||
|
purpose: 'introduction',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.interests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Birthday Message Parameters', () => {
|
||||||
|
test('GenerateBirthdayMessageParams has required fields', () => {
|
||||||
|
const params: GenerateBirthdayMessageParams = {
|
||||||
|
clientName: 'Jane Doe',
|
||||||
|
yearsAsClient: 5,
|
||||||
|
interests: ['tennis', 'wine'],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.clientName).toBe('Jane Doe');
|
||||||
|
expect(params.yearsAsClient).toBe(5);
|
||||||
|
expect(params.interests).toContain('tennis');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('years as client can be 0 for new clients', () => {
|
||||||
|
const params: GenerateBirthdayMessageParams = {
|
||||||
|
clientName: 'New Client',
|
||||||
|
yearsAsClient: 0,
|
||||||
|
interests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.yearsAsClient).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Provider Selection', () => {
|
||||||
|
test('anthropic is a valid provider', () => {
|
||||||
|
const provider = 'anthropic';
|
||||||
|
expect(['anthropic', 'openai']).toContain(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default provider should be anthropic', () => {
|
||||||
|
const defaultProvider = 'anthropic';
|
||||||
|
expect(defaultProvider).toBe('anthropic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Prompt Formatting', () => {
|
||||||
|
test('interests join correctly', () => {
|
||||||
|
const interests = ['golf', 'travel', 'wine'];
|
||||||
|
const joined = interests.join(', ');
|
||||||
|
expect(joined).toBe('golf, travel, wine');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty interests shows fallback', () => {
|
||||||
|
const interests: string[] = [];
|
||||||
|
const result = interests.join(', ') || 'not specified';
|
||||||
|
expect(result).toBe('not specified');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty notes shows fallback', () => {
|
||||||
|
const notes = '';
|
||||||
|
const result = notes || 'No recent notes';
|
||||||
|
expect(result).toBe('No recent notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('years as client converts to string', () => {
|
||||||
|
const years = 5;
|
||||||
|
expect(years.toString()).toBe('5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Subject Generation', () => {
|
||||||
|
test('subject should be under 50 characters', () => {
|
||||||
|
const goodSubject = 'Quick check-in from your advisor';
|
||||||
|
const badSubject = 'This is a very long subject line that definitely exceeds the fifty character limit';
|
||||||
|
|
||||||
|
expect(goodSubject.length).toBeLessThan(50);
|
||||||
|
expect(badSubject.length).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subject trim removes whitespace', () => {
|
||||||
|
const rawSubject = ' Hello from your advisor ';
|
||||||
|
expect(rawSubject.trim()).toBe('Hello from your advisor');
|
||||||
|
});
|
||||||
|
});
|
||||||
128
src/__tests__/clients.test.ts
Normal file
128
src/__tests__/clients.test.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
|
||||||
|
describe('Client Routes', () => {
|
||||||
|
describe('Validation', () => {
|
||||||
|
test('clientSchema requires firstName', () => {
|
||||||
|
const invalidClient = {
|
||||||
|
lastName: 'Doe',
|
||||||
|
};
|
||||||
|
// Schema validation test - firstName is required
|
||||||
|
expect(invalidClient.firstName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clientSchema requires lastName', () => {
|
||||||
|
const invalidClient = {
|
||||||
|
firstName: 'John',
|
||||||
|
};
|
||||||
|
// Schema validation test - lastName is required
|
||||||
|
expect(invalidClient.lastName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid client has required fields', () => {
|
||||||
|
const validClient = {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
};
|
||||||
|
expect(validClient.firstName).toBeDefined();
|
||||||
|
expect(validClient.lastName).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Client Data Structure', () => {
|
||||||
|
test('client can have optional contact info', () => {
|
||||||
|
const client = {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
phone: '+1234567890',
|
||||||
|
company: 'Acme Inc',
|
||||||
|
};
|
||||||
|
expect(client.email).toBe('john@example.com');
|
||||||
|
expect(client.phone).toBe('+1234567890');
|
||||||
|
expect(client.company).toBe('Acme Inc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client can have address fields', () => {
|
||||||
|
const client = {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
street: '123 Main St',
|
||||||
|
city: 'Springfield',
|
||||||
|
state: 'IL',
|
||||||
|
zip: '62701',
|
||||||
|
};
|
||||||
|
expect(client.street).toBe('123 Main St');
|
||||||
|
expect(client.city).toBe('Springfield');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client can have tags array', () => {
|
||||||
|
const client = {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
tags: ['vip', 'active', 'referral'],
|
||||||
|
};
|
||||||
|
expect(client.tags).toHaveLength(3);
|
||||||
|
expect(client.tags).toContain('vip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client can have family info', () => {
|
||||||
|
const client = {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
family: {
|
||||||
|
spouse: 'Jane Doe',
|
||||||
|
children: ['Jimmy', 'Jenny'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(client.family?.spouse).toBe('Jane Doe');
|
||||||
|
expect(client.family?.children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client interests is an array', () => {
|
||||||
|
const client = {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
interests: ['golf', 'travel', 'wine'],
|
||||||
|
};
|
||||||
|
expect(Array.isArray(client.interests)).toBe(true);
|
||||||
|
expect(client.interests).toContain('golf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Functionality', () => {
|
||||||
|
test('search term creates proper pattern', () => {
|
||||||
|
const searchTerm = 'john';
|
||||||
|
const pattern = `%${searchTerm}%`;
|
||||||
|
expect(pattern).toBe('%john%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tag filtering works on array', () => {
|
||||||
|
const clients = [
|
||||||
|
{ id: '1', firstName: 'John', tags: ['vip', 'active'] },
|
||||||
|
{ id: '2', firstName: 'Jane', tags: ['prospect'] },
|
||||||
|
{ id: '3', firstName: 'Bob', tags: ['vip'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = clients.filter(c => c.tags?.includes('vip'));
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
expect(filtered[0].firstName).toBe('John');
|
||||||
|
expect(filtered[1].firstName).toBe('Bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Date Handling', () => {
|
||||||
|
test('ISO date string converts to Date', () => {
|
||||||
|
const isoString = '1990-05-15';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
expect(date instanceof Date).toBe(true);
|
||||||
|
expect(date.getFullYear()).toBe(1990);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null date handling', () => {
|
||||||
|
const birthday = undefined;
|
||||||
|
const result = birthday ? new Date(birthday) : null;
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
src/__tests__/email.test.ts
Normal file
111
src/__tests__/email.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import type { SendEmailParams } from '../services/email';
|
||||||
|
|
||||||
|
describe('Email Service', () => {
|
||||||
|
describe('SendEmailParams', () => {
|
||||||
|
test('requires to, subject, and content', () => {
|
||||||
|
const params: SendEmailParams = {
|
||||||
|
to: 'client@example.com',
|
||||||
|
subject: 'Quarterly Update',
|
||||||
|
content: 'Hello, this is your quarterly update...',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.to).toBe('client@example.com');
|
||||||
|
expect(params.subject).toBe('Quarterly Update');
|
||||||
|
expect(params.content).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('from is optional', () => {
|
||||||
|
const params: SendEmailParams = {
|
||||||
|
to: 'client@example.com',
|
||||||
|
subject: 'Test',
|
||||||
|
content: 'Hello',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.from).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replyTo is optional', () => {
|
||||||
|
const params: SendEmailParams = {
|
||||||
|
to: 'client@example.com',
|
||||||
|
subject: 'Test',
|
||||||
|
content: 'Hello',
|
||||||
|
replyTo: 'advisor@company.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.replyTo).toBe('advisor@company.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('from can be custom address', () => {
|
||||||
|
const params: SendEmailParams = {
|
||||||
|
to: 'client@example.com',
|
||||||
|
subject: 'Test',
|
||||||
|
content: 'Hello',
|
||||||
|
from: 'John Smith <john@company.com>',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.from).toBe('John Smith <john@company.com>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Email Content', () => {
|
||||||
|
test('content should not be empty', () => {
|
||||||
|
const content = 'Hello, this is your advisor reaching out...';
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subject should be reasonable length', () => {
|
||||||
|
const subject = 'Quarterly Portfolio Review';
|
||||||
|
expect(subject.length).toBeLessThan(100);
|
||||||
|
expect(subject.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Email Validation', () => {
|
||||||
|
test('valid email format', () => {
|
||||||
|
const validEmails = [
|
||||||
|
'test@example.com',
|
||||||
|
'user.name@domain.org',
|
||||||
|
'user+tag@example.co.uk',
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
validEmails.forEach(email => {
|
||||||
|
expect(emailRegex.test(email)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid email format detected', () => {
|
||||||
|
const invalidEmails = [
|
||||||
|
'notanemail',
|
||||||
|
'@missing.local',
|
||||||
|
'missing@.domain',
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
invalidEmails.forEach(email => {
|
||||||
|
expect(emailRegex.test(email)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Default From Email', () => {
|
||||||
|
test('falls back to default when from not provided', () => {
|
||||||
|
const from = undefined;
|
||||||
|
const defaultFrom = 'onboarding@resend.dev';
|
||||||
|
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
|
||||||
|
|
||||||
|
expect(result).toBe(defaultFrom);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses provided from when available', () => {
|
||||||
|
const from = 'advisor@company.com';
|
||||||
|
const defaultFrom = 'onboarding@resend.dev';
|
||||||
|
const result = from || defaultFrom;
|
||||||
|
|
||||||
|
expect(result).toBe('advisor@company.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user