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:
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';
|
||||
Reference in New Issue
Block a user