add unit test suite: 80 tests across utils, api, auth, clients, events, emails
- Vitest + React Testing Library + jsdom setup - utils.test.ts: cn, formatDate, formatFullDate, getInitials, getRelativeTime, getDaysUntil - api.test.ts: token management, auth, CRUD for clients/events/emails, admin, error handling - auth.test.ts: login, logout, checkSession, setUser - clients.test.ts: fetch, create, update, delete, markContacted, filters - events.test.ts: fetch, create, update, delete, syncAll - emails.test.ts: fetch, generate, update, send, delete
This commit is contained in:
1194
package-lock.json
generated
1194
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -21,6 +23,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -29,9 +34,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
253
src/lib/api.test.ts
Normal file
253
src/lib/api.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn((key: string) => store[key] || null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
||||||
|
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
||||||
|
clear: vi.fn(() => { Object.keys(store).forEach(k => delete store[k]); }),
|
||||||
|
length: 0,
|
||||||
|
key: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||||
|
|
||||||
|
describe('ApiClient', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
localStorageMock.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('token management', () => {
|
||||||
|
it('sets token in localStorage', () => {
|
||||||
|
api.setToken('test-token');
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith('network-auth-token', 'test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes token when null', () => {
|
||||||
|
api.setToken(null);
|
||||||
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('network-auth-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSession', () => {
|
||||||
|
it('returns user session on success', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ user: { id: '1', name: 'Test', email: 'test@test.com' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await api.getSession();
|
||||||
|
expect(session?.user.name).toBe('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on failed response', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false });
|
||||||
|
const session = await api.getSession();
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
const session = await api.getSession();
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('calls sign-in endpoint and stores token', async () => {
|
||||||
|
const headers = new Map([['set-auth-token', 'abc123']]);
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: (key: string) => headers.get(key) || null },
|
||||||
|
json: () => Promise.resolve({ user: { id: '1' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.login('test@test.com', 'password');
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/auth/sign-in/email'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith('network-auth-token', 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on failed login', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ message: 'Invalid credentials' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(api.login('bad@test.com', 'wrong')).rejects.toThrow('Invalid credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clients', () => {
|
||||||
|
it('fetches clients list', async () => {
|
||||||
|
const clients = [{ id: '1', firstName: 'Alice', lastName: 'Smith' }];
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(clients)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.getClients();
|
||||||
|
expect(result).toEqual(clients);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches clients with search params', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve('[]'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.getClients({ search: 'alice', tag: 'vip' });
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('search=alice&tag=vip'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a client', async () => {
|
||||||
|
const newClient = { id: '2', firstName: 'Bob', lastName: 'Jones', createdAt: '', updatedAt: '', userId: '1' };
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(newClient)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.createClient({ firstName: 'Bob', lastName: 'Jones' });
|
||||||
|
expect(result.firstName).toBe('Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a client', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(api.deleteClient('1')).resolves.not.toThrow();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/clients/1'),
|
||||||
|
expect.objectContaining({ method: 'DELETE' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('events', () => {
|
||||||
|
it('fetches events', async () => {
|
||||||
|
const events = [{ id: '1', title: 'Birthday', type: 'birthday' }];
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(events)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.getEvents();
|
||||||
|
expect(result[0].title).toBe('Birthday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an event', async () => {
|
||||||
|
const event = { id: '2', title: 'Follow up', type: 'followup', date: '2026-02-01', createdAt: '', updatedAt: '', userId: '1' };
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(event)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.createEvent({ title: 'Follow up', type: 'followup', date: '2026-02-01' });
|
||||||
|
expect(result.title).toBe('Follow up');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emails', () => {
|
||||||
|
it('generates an email', async () => {
|
||||||
|
const email = { id: '1', subject: 'Hello', content: 'Hi there', status: 'draft' };
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(email)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.generateEmail({ clientId: '1', purpose: 'follow-up' });
|
||||||
|
expect(result.status).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends an email', async () => {
|
||||||
|
const sent = { id: '1', status: 'sent' };
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(sent)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.sendEmail('1');
|
||||||
|
expect(result.status).toBe('sent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin', () => {
|
||||||
|
it('fetches users', async () => {
|
||||||
|
const users = [{ id: '1', name: 'Admin', email: 'admin@test.com', role: 'admin' }];
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(users)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.getUsers();
|
||||||
|
expect(result[0].role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates user role', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(api.updateUserRole('1', 'admin')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates password reset link', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify({ resetUrl: 'https://app.test/reset/abc', email: 'user@test.com' })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.createPasswordReset('1');
|
||||||
|
expect(result.resetUrl).toContain('reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates invite', async () => {
|
||||||
|
const invite = { id: '1', email: 'new@test.com', name: 'New User', role: 'user', setupUrl: 'https://app.test/invite/xyz' };
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(invite)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.createInvite({ email: 'new@test.com', name: 'New User' });
|
||||||
|
expect(result.setupUrl).toContain('invite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws with error message from API', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: 'Not found' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(api.getClient('999')).rejects.toThrow('Not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws generic error when no message', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.reject(new Error()),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(api.getClient('999')).rejects.toThrow('Unknown error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
140
src/lib/utils.test.ts
Normal file
140
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { cn, formatDate, formatFullDate, getInitials, getRelativeTime, getDaysUntil } from './utils';
|
||||||
|
|
||||||
|
describe('cn', () => {
|
||||||
|
it('merges class names', () => {
|
||||||
|
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles conditional classes', () => {
|
||||||
|
expect(cn('base', false && 'hidden', 'visible')).toBe('base visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges tailwind conflicts', () => {
|
||||||
|
expect(cn('px-4', 'px-2')).toBe('px-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-01-28T12:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for undefined', () => {
|
||||||
|
expect(formatDate(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Today" for today\'s date', () => {
|
||||||
|
expect(formatDate('2026-01-28')).toBe('Today');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Tomorrow" for tomorrow\'s date', () => {
|
||||||
|
expect(formatDate('2026-01-29')).toBe('Tomorrow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Yesterday" for yesterday\'s date', () => {
|
||||||
|
expect(formatDate('2026-01-27')).toBe('Yesterday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns short format for same year', () => {
|
||||||
|
const result = formatDate('2026-03-15');
|
||||||
|
expect(result).toBe('Mar 15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full format for different year', () => {
|
||||||
|
const result = formatDate('2025-03-15');
|
||||||
|
expect(result).toBe('Mar 15, 2025');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFullDate', () => {
|
||||||
|
it('returns empty string for undefined', () => {
|
||||||
|
expect(formatFullDate(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full formatted date', () => {
|
||||||
|
const result = formatFullDate('2026-01-28');
|
||||||
|
expect(result).toContain('January');
|
||||||
|
expect(result).toContain('28');
|
||||||
|
expect(result).toContain('2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInitials', () => {
|
||||||
|
it('returns initials from first and last name', () => {
|
||||||
|
expect(getInitials('John', 'Doe')).toBe('JD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty strings', () => {
|
||||||
|
expect(getInitials('', '')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single name', () => {
|
||||||
|
expect(getInitials('Alice', '')).toBe('A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRelativeTime', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-01-28T12:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Never" for undefined', () => {
|
||||||
|
expect(getRelativeTime(undefined)).toBe('Never');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Today" for today', () => {
|
||||||
|
expect(getRelativeTime('2026-01-28T10:00:00Z')).toBe('Today');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Yesterday" for yesterday', () => {
|
||||||
|
expect(getRelativeTime('2026-01-27T10:00:00Z')).toBe('Yesterday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns days ago for recent dates', () => {
|
||||||
|
expect(getRelativeTime('2026-01-25T10:00:00Z')).toBe('3 days ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns weeks ago', () => {
|
||||||
|
expect(getRelativeTime('2026-01-14T10:00:00Z')).toBe('2 weeks ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns months ago', () => {
|
||||||
|
expect(getRelativeTime('2025-11-28T10:00:00Z')).toBe('2 months ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDaysUntil', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-01-28T12:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for today', () => {
|
||||||
|
expect(getDaysUntil('2026-01-28')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns positive for future dates', () => {
|
||||||
|
expect(getDaysUntil('2026-02-01')).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects past dates to next occurrence', () => {
|
||||||
|
// Jan 15 already passed, should project to next year
|
||||||
|
const days = getDaysUntil('2025-01-15');
|
||||||
|
expect(days).toBeGreaterThan(300); // ~352 days to Jan 15 2027
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/stores/auth.test.ts
Normal file
130
src/stores/auth.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useAuthStore } from './auth';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
// Mock the API
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: {
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
getSession: vi.fn(),
|
||||||
|
setToken: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAuthStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store state
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with no user', () => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets user directly', () => {
|
||||||
|
useAuthStore.getState().setUser({ id: '1', name: 'Test', email: 'test@test.com' });
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.user?.name).toBe('Test');
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
expect(state.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears user when set to null', () => {
|
||||||
|
useAuthStore.getState().setUser({ id: '1', name: 'Test', email: 'test@test.com' });
|
||||||
|
useAuthStore.getState().setUser(null);
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('authenticates and sets user on success', async () => {
|
||||||
|
vi.mocked(api.login).mockResolvedValue({ user: { id: '1' } });
|
||||||
|
vi.mocked(api.getSession).mockResolvedValue({
|
||||||
|
user: { id: '1', name: 'Donovan', email: 'don@test.com' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useAuthStore.getState().login('don@test.com', 'password');
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.user?.name).toBe('Donovan');
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when session fails after login', async () => {
|
||||||
|
vi.mocked(api.login).mockResolvedValue({});
|
||||||
|
vi.mocked(api.getSession).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(useAuthStore.getState().login('don@test.com', 'pw')).rejects.toThrow('Failed to get session');
|
||||||
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on login error', async () => {
|
||||||
|
vi.mocked(api.login).mockRejectedValue(new Error('Bad credentials'));
|
||||||
|
|
||||||
|
await expect(useAuthStore.getState().login('bad@test.com', 'wrong')).rejects.toThrow('Bad credentials');
|
||||||
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('clears user and calls api', async () => {
|
||||||
|
useAuthStore.getState().setUser({ id: '1', name: 'Test', email: 'test@test.com' });
|
||||||
|
vi.mocked(api.logout).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useAuthStore.getState().logout();
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
expect(api.setToken).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears state even if api logout fails', async () => {
|
||||||
|
useAuthStore.getState().setUser({ id: '1', name: 'Test', email: 'test@test.com' });
|
||||||
|
vi.mocked(api.logout).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
// logout uses try/finally so it always clears state, but won't catch the rejection
|
||||||
|
try {
|
||||||
|
await useAuthStore.getState().logout();
|
||||||
|
} catch {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
expect(useAuthStore.getState().user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkSession', () => {
|
||||||
|
it('restores session from API', async () => {
|
||||||
|
vi.mocked(api.getSession).mockResolvedValue({
|
||||||
|
user: { id: '1', name: 'Restored', email: 'r@test.com' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useAuthStore.getState().checkSession();
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.user?.name).toBe('Restored');
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears state when no session exists', async () => {
|
||||||
|
vi.mocked(api.getSession).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await useAuthStore.getState().checkSession();
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears state on error', async () => {
|
||||||
|
vi.mocked(api.getSession).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await useAuthStore.getState().checkSession();
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/stores/clients.test.ts
Normal file
118
src/stores/clients.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useClientsStore } from './clients';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Client } from '@/types';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: {
|
||||||
|
getClients: vi.fn(),
|
||||||
|
getClient: vi.fn(),
|
||||||
|
createClient: vi.fn(),
|
||||||
|
updateClient: vi.fn(),
|
||||||
|
deleteClient: vi.fn(),
|
||||||
|
markContacted: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockClient: Client = {
|
||||||
|
id: '1',
|
||||||
|
userId: 'u1',
|
||||||
|
firstName: 'Alice',
|
||||||
|
lastName: 'Smith',
|
||||||
|
email: 'alice@test.com',
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useClientsStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useClientsStore.setState({
|
||||||
|
clients: [],
|
||||||
|
selectedClient: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
searchQuery: '',
|
||||||
|
selectedTag: null,
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches clients', async () => {
|
||||||
|
vi.mocked(api.getClients).mockResolvedValue([mockClient]);
|
||||||
|
|
||||||
|
await useClientsStore.getState().fetchClients();
|
||||||
|
const state = useClientsStore.getState();
|
||||||
|
expect(state.clients).toHaveLength(1);
|
||||||
|
expect(state.clients[0].firstName).toBe('Alice');
|
||||||
|
expect(state.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches with search/tag filters', async () => {
|
||||||
|
vi.mocked(api.getClients).mockResolvedValue([]);
|
||||||
|
useClientsStore.setState({ searchQuery: 'alice', selectedTag: 'vip' });
|
||||||
|
|
||||||
|
await useClientsStore.getState().fetchClients();
|
||||||
|
expect(api.getClients).toHaveBeenCalledWith({ search: 'alice', tag: 'vip' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fetch error', async () => {
|
||||||
|
vi.mocked(api.getClients).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await useClientsStore.getState().fetchClients();
|
||||||
|
expect(useClientsStore.getState().error).toBe('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches single client', async () => {
|
||||||
|
vi.mocked(api.getClient).mockResolvedValue(mockClient);
|
||||||
|
|
||||||
|
await useClientsStore.getState().fetchClient('1');
|
||||||
|
expect(useClientsStore.getState().selectedClient?.firstName).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a client and adds to list', async () => {
|
||||||
|
const newClient = { ...mockClient, id: '2', firstName: 'Bob', lastName: 'Jones' };
|
||||||
|
vi.mocked(api.createClient).mockResolvedValue(newClient);
|
||||||
|
|
||||||
|
const result = await useClientsStore.getState().createClient({ firstName: 'Bob', lastName: 'Jones' });
|
||||||
|
expect(result.firstName).toBe('Bob');
|
||||||
|
expect(useClientsStore.getState().clients).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates client in list and selected', async () => {
|
||||||
|
const updated = { ...mockClient, firstName: 'Alicia' };
|
||||||
|
useClientsStore.setState({ clients: [mockClient], selectedClient: mockClient });
|
||||||
|
vi.mocked(api.updateClient).mockResolvedValue(updated);
|
||||||
|
|
||||||
|
await useClientsStore.getState().updateClient('1', { firstName: 'Alicia' });
|
||||||
|
expect(useClientsStore.getState().clients[0].firstName).toBe('Alicia');
|
||||||
|
expect(useClientsStore.getState().selectedClient?.firstName).toBe('Alicia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes client and clears selection', async () => {
|
||||||
|
useClientsStore.setState({ clients: [mockClient], selectedClient: mockClient });
|
||||||
|
vi.mocked(api.deleteClient).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useClientsStore.getState().deleteClient('1');
|
||||||
|
expect(useClientsStore.getState().clients).toHaveLength(0);
|
||||||
|
expect(useClientsStore.getState().selectedClient).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks client as contacted', async () => {
|
||||||
|
const contacted = { ...mockClient, lastContacted: '2026-01-28' };
|
||||||
|
useClientsStore.setState({ clients: [mockClient], selectedClient: mockClient });
|
||||||
|
vi.mocked(api.markContacted).mockResolvedValue(contacted);
|
||||||
|
|
||||||
|
await useClientsStore.getState().markContacted('1');
|
||||||
|
expect(useClientsStore.getState().clients[0].lastContacted).toBe('2026-01-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets search query', () => {
|
||||||
|
useClientsStore.getState().setSearchQuery('test');
|
||||||
|
expect(useClientsStore.getState().searchQuery).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets selected tag', () => {
|
||||||
|
useClientsStore.getState().setSelectedTag('vip');
|
||||||
|
expect(useClientsStore.getState().selectedTag).toBe('vip');
|
||||||
|
});
|
||||||
|
});
|
||||||
104
src/stores/emails.test.ts
Normal file
104
src/stores/emails.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useEmailsStore } from './emails';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Email } from '@/types';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: {
|
||||||
|
getEmails: vi.fn(),
|
||||||
|
generateEmail: vi.fn(),
|
||||||
|
generateBirthdayEmail: vi.fn(),
|
||||||
|
updateEmail: vi.fn(),
|
||||||
|
sendEmail: vi.fn(),
|
||||||
|
deleteEmail: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEmail: Email = {
|
||||||
|
id: '1',
|
||||||
|
userId: 'u1',
|
||||||
|
clientId: 'c1',
|
||||||
|
subject: 'Happy Birthday!',
|
||||||
|
content: 'Dear Alice...',
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: '2026-01-28',
|
||||||
|
updatedAt: '2026-01-28',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useEmailsStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEmailsStore.setState({
|
||||||
|
emails: [],
|
||||||
|
isLoading: false,
|
||||||
|
isGenerating: false,
|
||||||
|
error: null,
|
||||||
|
statusFilter: null,
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches emails', async () => {
|
||||||
|
vi.mocked(api.getEmails).mockResolvedValue([mockEmail]);
|
||||||
|
|
||||||
|
await useEmailsStore.getState().fetchEmails();
|
||||||
|
expect(useEmailsStore.getState().emails).toHaveLength(1);
|
||||||
|
expect(useEmailsStore.getState().emails[0].subject).toBe('Happy Birthday!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates email and prepends to list', async () => {
|
||||||
|
const generated = { ...mockEmail, id: '2', subject: 'Follow up' };
|
||||||
|
vi.mocked(api.generateEmail).mockResolvedValue(generated);
|
||||||
|
|
||||||
|
const result = await useEmailsStore.getState().generateEmail('c1', 'follow-up');
|
||||||
|
expect(result.subject).toBe('Follow up');
|
||||||
|
expect(useEmailsStore.getState().emails[0].id).toBe('2');
|
||||||
|
expect(useEmailsStore.getState().isGenerating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generate error', async () => {
|
||||||
|
vi.mocked(api.generateEmail).mockRejectedValue(new Error('AI error'));
|
||||||
|
|
||||||
|
await expect(useEmailsStore.getState().generateEmail('c1', 'test')).rejects.toThrow('AI error');
|
||||||
|
expect(useEmailsStore.getState().error).toBe('AI error');
|
||||||
|
expect(useEmailsStore.getState().isGenerating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates birthday email', async () => {
|
||||||
|
const birthday = { ...mockEmail, id: '3', purpose: 'birthday' };
|
||||||
|
vi.mocked(api.generateBirthdayEmail).mockResolvedValue(birthday);
|
||||||
|
|
||||||
|
const result = await useEmailsStore.getState().generateBirthdayEmail('c1');
|
||||||
|
expect(result.id).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates email in list', async () => {
|
||||||
|
const updated = { ...mockEmail, subject: 'Updated Subject' };
|
||||||
|
useEmailsStore.setState({ emails: [mockEmail] });
|
||||||
|
vi.mocked(api.updateEmail).mockResolvedValue(updated);
|
||||||
|
|
||||||
|
await useEmailsStore.getState().updateEmail('1', { subject: 'Updated Subject' });
|
||||||
|
expect(useEmailsStore.getState().emails[0].subject).toBe('Updated Subject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends email and updates status', async () => {
|
||||||
|
const sent = { ...mockEmail, status: 'sent' as const, sentAt: '2026-01-28T12:00:00Z' };
|
||||||
|
useEmailsStore.setState({ emails: [mockEmail] });
|
||||||
|
vi.mocked(api.sendEmail).mockResolvedValue(sent);
|
||||||
|
|
||||||
|
await useEmailsStore.getState().sendEmail('1');
|
||||||
|
expect(useEmailsStore.getState().emails[0].status).toBe('sent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes email from list', async () => {
|
||||||
|
useEmailsStore.setState({ emails: [mockEmail] });
|
||||||
|
vi.mocked(api.deleteEmail).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useEmailsStore.getState().deleteEmail('1');
|
||||||
|
expect(useEmailsStore.getState().emails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets status filter', () => {
|
||||||
|
useEmailsStore.getState().setStatusFilter('sent');
|
||||||
|
expect(useEmailsStore.getState().statusFilter).toBe('sent');
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/stores/events.test.ts
Normal file
97
src/stores/events.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useEventsStore } from './events';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Event } from '@/types';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: {
|
||||||
|
getEvents: vi.fn(),
|
||||||
|
createEvent: vi.fn(),
|
||||||
|
updateEvent: vi.fn(),
|
||||||
|
deleteEvent: vi.fn(),
|
||||||
|
syncAllEvents: vi.fn(),
|
||||||
|
syncClientEvents: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEvent: Event = {
|
||||||
|
id: '1',
|
||||||
|
userId: 'u1',
|
||||||
|
type: 'birthday',
|
||||||
|
title: "Alice's Birthday",
|
||||||
|
date: '2026-03-15',
|
||||||
|
recurring: true,
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useEventsStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEventsStore.setState({
|
||||||
|
events: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
typeFilter: null,
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches events', async () => {
|
||||||
|
vi.mocked(api.getEvents).mockResolvedValue([mockEvent]);
|
||||||
|
|
||||||
|
await useEventsStore.getState().fetchEvents();
|
||||||
|
expect(useEventsStore.getState().events).toHaveLength(1);
|
||||||
|
expect(useEventsStore.getState().events[0].title).toBe("Alice's Birthday");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fetch error', async () => {
|
||||||
|
vi.mocked(api.getEvents).mockRejectedValue(new Error('Failed'));
|
||||||
|
|
||||||
|
await useEventsStore.getState().fetchEvents();
|
||||||
|
expect(useEventsStore.getState().error).toBe('Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates event and adds to list', async () => {
|
||||||
|
const newEvent = { ...mockEvent, id: '2', title: 'Follow up', type: 'followup' as const };
|
||||||
|
vi.mocked(api.createEvent).mockResolvedValue(newEvent);
|
||||||
|
|
||||||
|
const result = await useEventsStore.getState().createEvent({
|
||||||
|
title: 'Follow up',
|
||||||
|
type: 'followup',
|
||||||
|
date: '2026-02-01',
|
||||||
|
});
|
||||||
|
expect(result.title).toBe('Follow up');
|
||||||
|
expect(useEventsStore.getState().events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates event in list', async () => {
|
||||||
|
const updated = { ...mockEvent, title: 'Updated Birthday' };
|
||||||
|
useEventsStore.setState({ events: [mockEvent] });
|
||||||
|
vi.mocked(api.updateEvent).mockResolvedValue(updated);
|
||||||
|
|
||||||
|
await useEventsStore.getState().updateEvent('1', { title: 'Updated Birthday' });
|
||||||
|
expect(useEventsStore.getState().events[0].title).toBe('Updated Birthday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes event from list', async () => {
|
||||||
|
useEventsStore.setState({ events: [mockEvent] });
|
||||||
|
vi.mocked(api.deleteEvent).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useEventsStore.getState().deleteEvent('1');
|
||||||
|
expect(useEventsStore.getState().events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs all events', async () => {
|
||||||
|
vi.mocked(api.syncAllEvents).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(api.getEvents).mockResolvedValue([mockEvent]);
|
||||||
|
|
||||||
|
await useEventsStore.getState().syncAll();
|
||||||
|
expect(api.syncAllEvents).toHaveBeenCalled();
|
||||||
|
expect(useEventsStore.getState().events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets type filter', () => {
|
||||||
|
useEventsStore.getState().setTypeFilter('birthday');
|
||||||
|
expect(useEventsStore.getState().typeFilter).toBe('birthday');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
18
vitest.config.ts
Normal file
18
vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user