import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useContactStore } from '../contact-store'; import type { ContactCard } from '@/lib/jmap/types'; vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' }); const makeContact = (overrides: Partial = {}): ContactCard => ({ id: 'contact-1', addressBookIds: { 'ab-1': true }, name: { components: [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }], isOrdered: true }, emails: { e0: { address: 'john@example.com' } }, ...overrides, }); const makeGroup = (overrides: Partial = {}): ContactCard => ({ id: 'group-1', addressBookIds: {}, kind: 'group', name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true }, members: {}, ...overrides, }); const defaultState = { contacts: [], addressBooks: [], selectedContactId: null, searchQuery: '', isLoading: false, error: null, supportsSync: false, selectedContactIds: new Set(), activeTab: 'all' as const, }; describe('contact-store', () => { beforeEach(() => { useContactStore.setState(defaultState); }); describe('addLocalContact', () => { it('should append contact to array', () => { useContactStore.getState().addLocalContact(makeContact()); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('contact-1'); }); it('should preserve existing contacts', () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); expect(useContactStore.getState().contacts).toHaveLength(2); }); }); describe('updateLocalContact', () => { it('should update matching contact', () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'updated@example.com' } }, }); expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com'); }); it('should not modify other contacts', () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } })); useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } }); expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com'); }); it('should no-op for non-existent id', () => { useContactStore.getState().addLocalContact(makeContact()); useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' }); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].kind).toBeUndefined(); }); }); describe('deleteLocalContact', () => { it('should remove contact by id', () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); useContactStore.getState().deleteLocalContact('c1'); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('c2'); }); it('should clear selectedContactId when deleting selected', () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().setSelectedContact('c1'); useContactStore.getState().deleteLocalContact('c1'); expect(useContactStore.getState().selectedContactId).toBeNull(); }); it('should preserve selectedContactId when deleting other', () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); useContactStore.getState().setSelectedContact('c1'); useContactStore.getState().deleteLocalContact('c2'); expect(useContactStore.getState().selectedContactId).toBe('c1'); }); }); describe('setSelectedContact', () => { it('should set selectedContactId', () => { useContactStore.getState().setSelectedContact('c1'); expect(useContactStore.getState().selectedContactId).toBe('c1'); }); it('should allow null to deselect', () => { useContactStore.getState().setSelectedContact('c1'); useContactStore.getState().setSelectedContact(null); expect(useContactStore.getState().selectedContactId).toBeNull(); }); }); describe('setSearchQuery', () => { it('should set search query', () => { useContactStore.getState().setSearchQuery('john'); expect(useContactStore.getState().searchQuery).toBe('john'); }); }); describe('setSupportsSync', () => { it('should set supportsSync flag', () => { useContactStore.getState().setSupportsSync(true); expect(useContactStore.getState().supportsSync).toBe(true); }); }); describe('setActiveTab', () => { it('should set active tab', () => { useContactStore.getState().setActiveTab('groups'); expect(useContactStore.getState().activeTab).toBe('groups'); }); }); describe('clearContacts', () => { it('should reset all contact-related state', () => { useContactStore.setState({ contacts: [makeContact()], addressBooks: [{ id: 'ab-1', name: 'Default', isDefault: true }], selectedContactId: 'c1', searchQuery: 'test', error: 'some error', selectedContactIds: new Set(['c1', 'c2']), activeTab: 'groups', }); useContactStore.getState().clearContacts(); const state = useContactStore.getState(); expect(state.contacts).toEqual([]); expect(state.addressBooks).toEqual([]); expect(state.selectedContactId).toBeNull(); expect(state.searchQuery).toBe(''); expect(state.error).toBeNull(); expect(state.selectedContactIds.size).toBe(0); expect(state.activeTab).toBe('all'); }); }); describe('toggleContactSelection', () => { it('should add id to selection', () => { useContactStore.getState().toggleContactSelection('c1'); expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(true); }); it('should remove id when already selected', () => { useContactStore.getState().toggleContactSelection('c1'); useContactStore.getState().toggleContactSelection('c1'); expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false); }); it('should handle multiple selections independently', () => { useContactStore.getState().toggleContactSelection('c1'); useContactStore.getState().toggleContactSelection('c2'); expect(useContactStore.getState().selectedContactIds.size).toBe(2); useContactStore.getState().toggleContactSelection('c1'); expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false); expect(useContactStore.getState().selectedContactIds.has('c2')).toBe(true); }); }); describe('selectAllContacts', () => { it('should set all provided ids', () => { useContactStore.getState().selectAllContacts(['c1', 'c2', 'c3']); expect(useContactStore.getState().selectedContactIds.size).toBe(3); }); it('should replace previous selection', () => { useContactStore.getState().toggleContactSelection('c0'); useContactStore.getState().selectAllContacts(['c1', 'c2']); expect(useContactStore.getState().selectedContactIds.has('c0')).toBe(false); expect(useContactStore.getState().selectedContactIds.size).toBe(2); }); }); describe('clearSelection', () => { it('should empty the selection set', () => { useContactStore.getState().selectAllContacts(['c1', 'c2']); useContactStore.getState().clearSelection(); expect(useContactStore.getState().selectedContactIds.size).toBe(0); }); }); describe('getAutocomplete', () => { beforeEach(() => { useContactStore.setState({ contacts: [ makeContact({ id: 'c1' }), makeContact({ id: 'c2', name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Smith' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }), makeContact({ id: 'c3', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.org' } } }), ], }); }); it('should return empty for empty query', () => { expect(useContactStore.getState().getAutocomplete('')).toEqual([]); }); it('should match by name', () => { const results = useContactStore.getState().getAutocomplete('john'); expect(results).toHaveLength(1); expect(results[0].email).toBe('john@example.com'); }); it('should match by email address', () => { const results = useContactStore.getState().getAutocomplete('test.org'); expect(results).toHaveLength(1); expect(results[0].name).toBe('Bob'); }); it('should be case insensitive', () => { const results = useContactStore.getState().getAutocomplete('JANE'); expect(results).toHaveLength(1); expect(results[0].email).toBe('jane@example.com'); }); it('should cap results at 10', () => { const manyContacts = Array.from({ length: 15 }, (_, i) => makeContact({ id: `c${i}`, name: { components: [{ kind: 'given', value: `User${i}` }], isOrdered: true }, emails: { e0: { address: `user${i}@test.com` } } }) ); useContactStore.setState({ contacts: manyContacts }); const results = useContactStore.getState().getAutocomplete('user'); expect(results.length).toBeLessThanOrEqual(10); }); it('should expand group members when group name matches', () => { const member1 = makeContact({ id: 'm1', name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true }, emails: { e0: { address: 'alice@test.com' } } }); const member2 = makeContact({ id: 'm2', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.com' } } }); const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } }); useContactStore.setState({ contacts: [member1, member2, group] }); const results = useContactStore.getState().getAutocomplete('Team'); expect(results).toHaveLength(2); expect(results.map(r => r.email).sort()).toEqual(['alice@test.com', 'bob@test.com']); }); it('should not include group itself in results', () => { const group = makeGroup({ id: 'g1' }); useContactStore.setState({ contacts: [group] }); const results = useContactStore.getState().getAutocomplete('Team'); expect(results).toHaveLength(0); }); it('should return contacts with multiple emails as separate results', () => { const multi = makeContact({ id: 'multi', name: { components: [{ kind: 'given', value: 'Multi' }], isOrdered: true }, emails: { e0: { address: 'a@test.com' }, e1: { address: 'b@test.com' } }, }); useContactStore.setState({ contacts: [multi] }); const results = useContactStore.getState().getAutocomplete('Multi'); expect(results).toHaveLength(2); }); }); describe('getGroups', () => { it('should return only group contacts', () => { useContactStore.setState({ contacts: [makeContact(), makeGroup()] }); const groups = useContactStore.getState().getGroups(); expect(groups).toHaveLength(1); expect(groups[0].kind).toBe('group'); }); it('should return empty when no groups', () => { useContactStore.setState({ contacts: [makeContact()] }); expect(useContactStore.getState().getGroups()).toHaveLength(0); }); }); describe('getIndividuals', () => { it('should return non-group contacts', () => { useContactStore.setState({ contacts: [makeContact(), makeGroup()] }); const individuals = useContactStore.getState().getIndividuals(); expect(individuals).toHaveLength(1); expect(individuals[0].kind).not.toBe('group'); }); it('should include org and undefined kind', () => { useContactStore.setState({ contacts: [ makeContact({ id: 'c1' }), makeContact({ id: 'c2', kind: 'org' }), makeGroup(), ], }); expect(useContactStore.getState().getIndividuals()).toHaveLength(2); }); }); describe('getGroupMembers', () => { it('should return contacts whose ids are in group members', () => { const m1 = makeContact({ id: 'm1' }); const m2 = makeContact({ id: 'm2' }); const nonMember = makeContact({ id: 'nm' }); const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } }); useContactStore.setState({ contacts: [m1, m2, nonMember, group] }); const members = useContactStore.getState().getGroupMembers('g1'); expect(members).toHaveLength(2); expect(members.map(m => m.id).sort()).toEqual(['m1', 'm2']); }); it('should return empty for group with no members', () => { useContactStore.setState({ contacts: [makeGroup({ id: 'g1', members: {} })] }); expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(0); }); it('should return empty for non-existent group', () => { expect(useContactStore.getState().getGroupMembers('nonexistent')).toHaveLength(0); }); it('should match by uid as well as id', () => { const m1 = makeContact({ id: 'm1', uid: 'uid-m1' }); const group = makeGroup({ id: 'g1', members: { 'uid-m1': true } }); useContactStore.setState({ contacts: [m1, group] }); expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1); }); it('should exclude members with false value', () => { const m1 = makeContact({ id: 'm1' }); const m2 = makeContact({ id: 'm2' }); const group = makeGroup({ id: 'g1', members: { m1: true, m2: false } }); useContactStore.setState({ contacts: [m1, m2, group] }); expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1); }); }); describe('createGroup (local mode)', () => { it('should create group with local- prefix id', async () => { await useContactStore.getState().createGroup(null, 'Friends', ['c1', 'c2']); const contacts = useContactStore.getState().contacts; expect(contacts).toHaveLength(1); expect(contacts[0].id).toMatch(/^local-/); expect(contacts[0].kind).toBe('group'); }); it('should set members from provided ids', async () => { await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2']); const group = useContactStore.getState().contacts[0]; expect(group.members).toEqual({ m1: true, m2: true }); }); it('should set group name', async () => { await useContactStore.getState().createGroup(null, 'Work', []); const group = useContactStore.getState().contacts[0]; expect(group.name?.components?.[0]?.value).toBe('Work'); }); }); describe('updateGroup (local mode)', () => { it('should update group name', async () => { await useContactStore.getState().createGroup(null, 'Old', []); const groupId = useContactStore.getState().contacts[0].id; await useContactStore.getState().updateGroup(null, groupId, 'New'); expect(useContactStore.getState().contacts[0].name?.components?.[0]?.value).toBe('New'); }); it('should preserve group members when renaming', async () => { await useContactStore.getState().createGroup(null, 'Team', ['m1']); const groupId = useContactStore.getState().contacts[0].id; await useContactStore.getState().updateGroup(null, groupId, 'Renamed'); expect(useContactStore.getState().contacts[0].members).toEqual({ m1: true }); }); }); describe('addMembersToGroup (local mode)', () => { it('should add new members to group', async () => { await useContactStore.getState().createGroup(null, 'Team', ['m1']); const groupId = useContactStore.getState().contacts[0].id; await useContactStore.getState().addMembersToGroup(null, groupId, ['m2', 'm3']); const members = useContactStore.getState().contacts[0].members; expect(members).toEqual({ m1: true, m2: true, m3: true }); }); it('should no-op for non-existent group', async () => { await useContactStore.getState().addMembersToGroup(null, 'nonexistent', ['m1']); expect(useContactStore.getState().contacts).toHaveLength(0); }); }); describe('removeMembersFromGroup (local mode)', () => { it('should remove members from group', async () => { await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2', 'm3']); const groupId = useContactStore.getState().contacts[0].id; await useContactStore.getState().removeMembersFromGroup(null, groupId, ['m2']); const members = useContactStore.getState().contacts[0].members; expect(members).toEqual({ m1: true, m3: true }); }); it('should no-op for group without members', async () => { const group = makeGroup({ id: 'g1', members: undefined }); useContactStore.setState({ contacts: [group] }); await useContactStore.getState().removeMembersFromGroup(null, 'g1', ['m1']); expect(useContactStore.getState().contacts[0].members).toBeUndefined(); }); }); describe('deleteGroup (local mode)', () => { it('should remove group from contacts', async () => { await useContactStore.getState().createGroup(null, 'Team', []); const groupId = useContactStore.getState().contacts[0].id; await useContactStore.getState().deleteGroup(null, groupId); expect(useContactStore.getState().contacts).toHaveLength(0); }); it('should clear selectedContactId when deleting selected group', async () => { await useContactStore.getState().createGroup(null, 'Team', []); const groupId = useContactStore.getState().contacts[0].id; useContactStore.getState().setSelectedContact(groupId); await useContactStore.getState().deleteGroup(null, groupId); expect(useContactStore.getState().selectedContactId).toBeNull(); }); it('should preserve selectedContactId when deleting other group', async () => { useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().setSelectedContact('c1'); await useContactStore.getState().createGroup(null, 'Team', []); const groupId = useContactStore.getState().contacts[1].id; await useContactStore.getState().deleteGroup(null, groupId); expect(useContactStore.getState().selectedContactId).toBe('c1'); }); }); describe('bulkDeleteContacts (local mode)', () => { it('should remove multiple contacts', async () => { useContactStore.setState({ contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' }), makeContact({ id: 'c3' })], }); await useContactStore.getState().bulkDeleteContacts(null, ['c1', 'c3']); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('c2'); }); it('should clear selection after bulk delete', async () => { useContactStore.setState({ contacts: [makeContact({ id: 'c1' })], selectedContactIds: new Set(['c1']), }); await useContactStore.getState().bulkDeleteContacts(null, ['c1']); expect(useContactStore.getState().selectedContactIds.size).toBe(0); }); it('should clear selectedContactId if deleted', async () => { useContactStore.setState({ contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' })], selectedContactId: 'c1', }); await useContactStore.getState().bulkDeleteContacts(null, ['c1']); expect(useContactStore.getState().selectedContactId).toBeNull(); }); }); describe('bulkAddToGroup (local mode)', () => { it('should add contacts to group and clear selection', async () => { const m1 = makeContact({ id: 'm1' }); const group = makeGroup({ id: 'g1', members: {} }); useContactStore.setState({ contacts: [m1, group], selectedContactIds: new Set(['m1']), }); await useContactStore.getState().bulkAddToGroup(null, 'g1', ['m1']); expect(useContactStore.getState().contacts.find(c => c.id === 'g1')?.members).toEqual({ m1: true }); expect(useContactStore.getState().selectedContactIds.size).toBe(0); }); }); describe('importContacts (local mode)', () => { it('should import contacts with local- prefix ids', async () => { const toImport = [makeContact({ id: 'orig-1' }), makeContact({ id: 'orig-2' })]; const count = await useContactStore.getState().importContacts(null, toImport); expect(count).toBe(2); expect(useContactStore.getState().contacts).toHaveLength(2); expect(useContactStore.getState().contacts[0].id).toMatch(/^local-/); }); }); describe('persistence/partialize', () => { it('should persist contacts when supportsSync is false', () => { const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record) => Record } } }).persist.getOptions(); const state = { contacts: [makeContact()], supportsSync: false }; const persisted = partialize(state); expect(persisted.contacts).toHaveLength(1); expect(persisted.supportsSync).toBe(false); }); it('should persist empty contacts array when supportsSync is true', () => { const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record) => Record } } }).persist.getOptions(); const state = { contacts: [makeContact()], supportsSync: true }; const persisted = partialize(state); expect(persisted.contacts).toEqual([]); expect(persisted.supportsSync).toBe(true); }); }); });