feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations - Advanced search with JMAP filter panel, search chips, cross-mailbox queries - Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator - TOTP two-factor authentication support - Docker multi-stage build with standalone output and docker-compose - CSP Report-Only headers and security headers via proxy middleware - Virtual scrolling for large email lists - Structured server-side logger (text/JSON, configurable level) - 450+ tests (contacts, vCard, threads, headers, identity, components) - Playwright E2E framework setup - Updated README and ROADMAP with all new features
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const contact: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
phones: { p0: { number: '+33612345678' } },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
addresses: { a0: { street: '123 Main St', locality: 'Paris', country: 'France' } },
|
||||
notes: { n0: { note: 'VIP customer' } },
|
||||
};
|
||||
|
||||
describe('ContactDetail', () => {
|
||||
it('shows empty state when contact is null', () => {
|
||||
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('detail.no_contact_selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the contact name', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays email addresses as mailto links', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
const link = screen.getByText('alice@example.com');
|
||||
expect(link.closest('a')).toHaveAttribute('href', 'mailto:alice@example.com');
|
||||
});
|
||||
|
||||
it('displays phone numbers', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('+33612345678')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays organization name', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
const matches = screen.getAllByText('Acme Corp');
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls onEdit when edit button is clicked', () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={onEdit} onDelete={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('form.edit_title'));
|
||||
expect(onEdit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||
const trashButtons = screen.getAllByRole('button').filter(
|
||||
btn => btn.querySelector('svg') && btn.textContent?.trim() === ''
|
||||
);
|
||||
fireEvent.click(trashButtons[trashButtons.length - 1]);
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactForm } from '../contact-form';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const existingContact: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
phones: { p0: { number: '+33612345678' } },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
notes: { n0: { note: 'VIP' } },
|
||||
};
|
||||
|
||||
describe('ContactForm', () => {
|
||||
it('renders create form with empty fields', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByText('create_title')).toBeInTheDocument();
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
const emptyInputs = inputs.filter(i => (i as HTMLInputElement).value === '');
|
||||
expect(emptyInputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders edit form with pre-populated data', () => {
|
||||
render(<ContactForm contact={existingContact} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByText('edit_title')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText('cancel'));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows error on submit with empty name', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('name_required')).toBeInTheDocument();
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds email entry when add button is clicked', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
const emailInputsBefore = screen.getAllByPlaceholderText('email_placeholder');
|
||||
fireEvent.click(screen.getByText('add_email'));
|
||||
const emailInputsAfter = screen.getAllByPlaceholderText('email_placeholder');
|
||||
expect(emailInputsAfter.length).toBe(emailInputsBefore.length + 1);
|
||||
});
|
||||
|
||||
it('adds phone entry when add button is clicked', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
const phoneBefore = screen.queryAllByPlaceholderText('phone_placeholder');
|
||||
fireEvent.click(screen.getByText('add_phone'));
|
||||
const phoneAfter = screen.getAllByPlaceholderText('phone_placeholder');
|
||||
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
||||
});
|
||||
|
||||
it('submits form data correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
fireEvent.change(inputs[0], { target: { value: 'Jane' } });
|
||||
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const savedData = onSave.mock.calls[0][0];
|
||||
expect(savedData.name.components).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactListItem } from '../contact-list-item';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const contact: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
};
|
||||
|
||||
const noNameContact: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: 'nobody@example.com' } },
|
||||
};
|
||||
|
||||
const _emptyContact: ContactCard = {
|
||||
id: '3',
|
||||
addressBookIds: {},
|
||||
};
|
||||
|
||||
describe('ContactListItem', () => {
|
||||
it('renders contact name and email', () => {
|
||||
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders organization', () => {
|
||||
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies selected styling', () => {
|
||||
const { container } = render(<ContactListItem contact={contact} isSelected={true} onClick={vi.fn()} />);
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain('bg-accent');
|
||||
});
|
||||
|
||||
it('shows email as display name when no name exists', () => {
|
||||
render(<ContactListItem contact={noNameContact} isSelected={false} onClick={vi.fn()} />);
|
||||
const matches = screen.getAllByText('nobody@example.com');
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<ContactListItem contact={contact} isSelected={false} onClick={onClick} />);
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactList } from '../contact-list';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
function makeContact(overrides: Partial<ContactCard> & { id: string }): ContactCard {
|
||||
return {
|
||||
addressBookIds: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const alice = makeContact({
|
||||
id: '1',
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
});
|
||||
|
||||
const bob = makeContact({
|
||||
id: '2',
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
emails: { e0: { address: 'bob@example.com' } },
|
||||
});
|
||||
|
||||
const group = makeContact({
|
||||
id: '3',
|
||||
kind: 'group',
|
||||
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
||||
members: { '1': true },
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
contacts: [alice, bob],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
supportsSync: true,
|
||||
selectedContactIds: new Set<string>(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectAll: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
groups: [],
|
||||
};
|
||||
|
||||
describe('ContactList', () => {
|
||||
it('renders contact names', () => {
|
||||
render(<ContactList {...defaultProps} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters contacts by search query', () => {
|
||||
render(<ContactList {...defaultProps} searchQuery="alice" />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no contacts match', () => {
|
||||
render(<ContactList {...defaultProps} contacts={[]} />);
|
||||
expect(screen.getByText('empty_state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search empty state when search has no results', () => {
|
||||
render(<ContactList {...defaultProps} searchQuery="zzz" />);
|
||||
expect(screen.getByText('empty_search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows local mode banner when supportsSync is false', () => {
|
||||
render(<ContactList {...defaultProps} supportsSync={false} />);
|
||||
expect(screen.getByText('local_mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides local mode banner when supportsSync is true', () => {
|
||||
render(<ContactList {...defaultProps} supportsSync={true} />);
|
||||
expect(screen.queryByText('local_mode')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCreateNew when create button is clicked', () => {
|
||||
const onCreateNew = vi.fn();
|
||||
render(<ContactList {...defaultProps} onCreateNew={onCreateNew} />);
|
||||
fireEvent.click(screen.getByText('create_new'));
|
||||
expect(onCreateNew).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows bulk action bar when contacts are selected', () => {
|
||||
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
|
||||
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
|
||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes groups from the list', () => {
|
||||
render(<ContactList {...defaultProps} contacts={[alice, bob, group]} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Team')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user