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:
2026-01-28 22:12:38 +00:00
parent c838a714d2
commit b6de50ba5e
10 changed files with 2063 additions and 3 deletions

1194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

18
vitest.config.ts Normal file
View 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}'],
},
});