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,257 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useIdentityStore } from '../identity-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
|
||||
id: 'id-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
mayDelete: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('identity-store', () => {
|
||||
beforeEach(() => {
|
||||
useIdentityStore.setState({
|
||||
identities: [],
|
||||
selectedIdentityId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
subAddress: { recentTags: [], tagSuggestions: {} },
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIdentities', () => {
|
||||
it('should set identities list', () => {
|
||||
const identities = [makeIdentity(), makeIdentity({ id: 'id-2', email: 'other@test.com' })];
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should replace existing identities', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity()]);
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-new' })]);
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(1);
|
||||
expect(useIdentityStore.getState().identities[0].id).toBe('id-new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addIdentity', () => {
|
||||
it('should append identity to list', () => {
|
||||
useIdentityStore.getState().addIdentity(makeIdentity());
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not remove existing identities', () => {
|
||||
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-1' }));
|
||||
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-2' }));
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIdentityLocal', () => {
|
||||
it('should update matching identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1', name: 'Old' })]);
|
||||
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'New' });
|
||||
expect(useIdentityStore.getState().identities[0].name).toBe('New');
|
||||
});
|
||||
|
||||
it('should not modify other identities', () => {
|
||||
useIdentityStore.getState().setIdentities([
|
||||
makeIdentity({ id: 'id-1', name: 'First' }),
|
||||
makeIdentity({ id: 'id-2', name: 'Second' }),
|
||||
]);
|
||||
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'Updated' });
|
||||
expect(useIdentityStore.getState().identities[1].name).toBe('Second');
|
||||
});
|
||||
|
||||
it('should no-op for non-existent identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity()]);
|
||||
useIdentityStore.getState().updateIdentityLocal('nonexistent', { name: 'X' });
|
||||
expect(useIdentityStore.getState().identities[0].name).toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeIdentity', () => {
|
||||
it('should remove identity by id', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
|
||||
useIdentityStore.getState().removeIdentity('id-1');
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(1);
|
||||
expect(useIdentityStore.getState().identities[0].id).toBe('id-2');
|
||||
});
|
||||
|
||||
it('should clear selectedIdentityId when removing selected identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' })]);
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().removeIdentity('id-1');
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve selectedIdentityId when removing different identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().removeIdentity('id-2');
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectIdentity', () => {
|
||||
it('should set selectedIdentityId', () => {
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
|
||||
});
|
||||
|
||||
it('should allow null to deselect', () => {
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().selectIdentity(null);
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLoading', () => {
|
||||
it('should set loading state', () => {
|
||||
useIdentityStore.getState().setLoading(true);
|
||||
expect(useIdentityStore.getState().isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear loading state', () => {
|
||||
useIdentityStore.getState().setLoading(true);
|
||||
useIdentityStore.getState().setLoading(false);
|
||||
expect(useIdentityStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setError', () => {
|
||||
it('should set error message', () => {
|
||||
useIdentityStore.getState().setError('Something went wrong');
|
||||
expect(useIdentityStore.getState().error).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should clear error with null', () => {
|
||||
useIdentityStore.getState().setError('error');
|
||||
useIdentityStore.getState().setError(null);
|
||||
expect(useIdentityStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearIdentities', () => {
|
||||
it('should clear identities and selection', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity()]);
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().setError('old error');
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
|
||||
const state = useIdentityStore.getState();
|
||||
expect(state.identities).toEqual([]);
|
||||
expect(state.selectedIdentityId).toBeNull();
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should not clear sub-address state', () => {
|
||||
useIdentityStore.getState().addRecentTag('shopping');
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toContain('shopping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRecentTag', () => {
|
||||
it('should add tag to recent tags', () => {
|
||||
useIdentityStore.getState().addRecentTag('shopping');
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toEqual(['shopping']);
|
||||
});
|
||||
|
||||
it('should prepend new tags', () => {
|
||||
useIdentityStore.getState().addRecentTag('first');
|
||||
useIdentityStore.getState().addRecentTag('second');
|
||||
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('second');
|
||||
});
|
||||
|
||||
it('should deduplicate tags by moving to front', () => {
|
||||
useIdentityStore.getState().addRecentTag('a');
|
||||
useIdentityStore.getState().addRecentTag('b');
|
||||
useIdentityStore.getState().addRecentTag('a');
|
||||
const tags = useIdentityStore.getState().subAddress.recentTags;
|
||||
expect(tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should cap at 10 recent tags', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
useIdentityStore.getState().addRecentTag(`tag-${i}`);
|
||||
}
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toHaveLength(10);
|
||||
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('tag-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTagSuggestion', () => {
|
||||
it('should add suggestion for domain', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
|
||||
});
|
||||
|
||||
it('should not duplicate existing suggestion', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
|
||||
});
|
||||
|
||||
it('should cap at 5 suggestions per domain', () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', `tag-${i}`);
|
||||
}
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should keep suggestions separate per domain', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('a.com', 'tag-a');
|
||||
useIdentityStore.getState().addTagSuggestion('b.com', 'tag-b');
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['a.com']).toEqual(['tag-a']);
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['b.com']).toEqual(['tag-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagSuggestionsForDomain', () => {
|
||||
it('should return suggestions for known domain', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
expect(useIdentityStore.getState().getTagSuggestionsForDomain('example.com')).toEqual(['promo']);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown domain', () => {
|
||||
expect(useIdentityStore.getState().getTagSuggestionsForDomain('unknown.com')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearRecentTags', () => {
|
||||
it('should clear recent tags', () => {
|
||||
useIdentityStore.getState().addRecentTag('a');
|
||||
useIdentityStore.getState().addRecentTag('b');
|
||||
useIdentityStore.getState().clearRecentTags();
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not clear tag suggestions', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
useIdentityStore.getState().clearRecentTags();
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('should only persist subAddress state', () => {
|
||||
const { partialize } = (useIdentityStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
|
||||
const fullState = {
|
||||
identities: [makeIdentity()],
|
||||
selectedIdentityId: 'id-1',
|
||||
isLoading: true,
|
||||
error: 'err',
|
||||
subAddress: { recentTags: ['a'], tagSuggestions: {} },
|
||||
};
|
||||
const persisted = partialize(fullState);
|
||||
expect(persisted).toHaveProperty('subAddress');
|
||||
expect(persisted).not.toHaveProperty('identities');
|
||||
expect(persisted).not.toHaveProperty('selectedIdentityId');
|
||||
expect(persisted).not.toHaveProperty('isLoading');
|
||||
expect(persisted).not.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user