/**
* Unit tests for contacts.js
* Tests contact resolution, phone normalization, and lookup functions
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock the dependencies before importing the module
vi.mock('fs', () => ({
default: {
existsSync: vi.fn(),
readdirSync: vi.fn()
},
existsSync: vi.fn(),
readdirSync: vi.fn()
}))
vi.mock('../../lib/shell.js', () => ({
safeSqlite3: vi.fn(),
safeSqlite3Json: vi.fn()
}))
// Now import after mocking
import fs from 'fs'
import { safeSqlite3, safeSqlite3Json } from '../../lib/shell.js'
import {
loadContacts,
resolveEmail,
resolvePhone,
resolveByName,
getContactIdentifiers,
searchContacts,
lookupContact,
formatContact,
getContactStats
} from '../../contacts.js'
// Sample contact data for tests
const sampleContacts = [
{
id: 1,
firstName: 'John',
lastName: 'Doe',
nickname: 'Johnny',
organization: 'Acme Corp',
department: 'Engineering',
jobTitle: 'Developer'
},
{
id: 2,
firstName: 'Jane',
lastName: 'Smith',
nickname: null,
organization: 'Tech Inc',
department: 'Sales',
jobTitle: 'Manager'
},
{
id: 3,
firstName: null,
lastName: null,
nickname: null,
organization: 'Anonymous LLC',
department: null,
jobTitle: null
}
]
const sampleEmails = [
{ contactId: 1, email: 'john@example.com', label: 'work' },
{ contactId: 1, email: 'john.doe@acme.com', label: 'work' },
{ contactId: 2, email: 'jane@example.com', label: 'personal' }
]
const samplePhones = [
{ contactId: 1, phone: '+1 (555) 123-4567', label: 'mobile' },
{ contactId: 1, phone: '555-987-6543', label: 'work' },
{ contactId: 2, phone: '1-800-555-0100', label: 'work' }
]
describe('contacts.js', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset the module state by re-importing (contacts has module-level state)
// Note: Vitest doesn't easily support module state reset, so we test what we can
})
describe('normalizePhone (via resolvePhone)', () => {
// We can't test normalizePhone directly since it's not exported,
// but we can test phone normalization via resolvePhone behavior
beforeEach(() => {
// Set up mocks for loading contacts
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('10') // count query
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should normalize phone with parentheses and spaces', () => {
// Load contacts first
const contacts = loadContacts()
expect(contacts.length).toBeGreaterThan(0)
// The phone +1 (555) 123-4567 should normalize to +5551234567
const contact = resolvePhone('+1 (555) 123-4567')
expect(contact).toBeTruthy()
expect(contact.firstName).toBe('John')
})
it('should normalize phone without country code', () => {
loadContacts()
// 555-987-6543 normalizes to 5559876543
const contact = resolvePhone('555-987-6543')
expect(contact).toBeTruthy()
})
it('should normalize US number with leading 1', () => {
loadContacts()
// 1-800-555-0100 has 11 digits with leading 1, normalizes to 8005550100
const contact = resolvePhone('1-800-555-0100')
expect(contact).toBeTruthy()
expect(contact.firstName).toBe('Jane')
})
it('should return null for non-existent phone', () => {
loadContacts()
expect(resolvePhone('999-999-9999')).toBeNull()
})
it('should return null for empty/null phone', () => {
loadContacts()
expect(resolvePhone(null)).toBeNull()
expect(resolvePhone('')).toBeNull()
expect(resolvePhone(undefined)).toBeNull()
})
})
describe('loadContacts', () => {
it('should load contacts from database', () => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
const contacts = loadContacts()
expect(contacts.length).toBe(3)
expect(contacts[0].firstName).toBe('John')
})
it('should return empty array when database not found', () => {
fs.existsSync.mockReturnValue(false)
fs.readdirSync.mockReturnValue([])
// Force cache invalidation by manipulating time
vi.useFakeTimers()
vi.advanceTimersByTime(10 * 60 * 1000) // 10 minutes
const contacts = loadContacts()
// Note: Due to module-level caching, this may return cached data
// In a real scenario, the module state would be reset
vi.useRealTimers()
})
it('should handle database errors gracefully', () => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation(() => {
throw new Error('Database error')
})
// Should not throw, returns empty array
expect(() => loadContacts()).not.toThrow()
})
})
describe('resolveEmail', () => {
beforeEach(() => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should resolve email to contact', () => {
loadContacts()
const contact = resolveEmail('john@example.com')
expect(contact).toBeTruthy()
expect(contact.firstName).toBe('John')
})
it('should be case-insensitive', () => {
loadContacts()
const contact = resolveEmail('JOHN@EXAMPLE.COM')
expect(contact).toBeTruthy()
expect(contact.firstName).toBe('John')
})
it('should resolve secondary email', () => {
loadContacts()
const contact = resolveEmail('john.doe@acme.com')
expect(contact).toBeTruthy()
expect(contact.firstName).toBe('John')
})
it('should return null for unknown email', () => {
loadContacts()
expect(resolveEmail('unknown@example.com')).toBeNull()
})
it('should return null for empty/null input', () => {
loadContacts()
expect(resolveEmail(null)).toBeNull()
expect(resolveEmail('')).toBeNull()
})
})
describe('resolveByName', () => {
beforeEach(() => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should find contact by full name', () => {
loadContacts()
const matches = resolveByName('John Doe')
expect(matches.length).toBeGreaterThan(0)
expect(matches[0].firstName).toBe('John')
})
it('should find contact by first name only', () => {
loadContacts()
const matches = resolveByName('John')
expect(matches.length).toBeGreaterThan(0)
})
it('should find contact by nickname', () => {
loadContacts()
const matches = resolveByName('Johnny')
expect(matches.length).toBeGreaterThan(0)
expect(matches[0].nickname).toBe('Johnny')
})
it('should be case-insensitive', () => {
loadContacts()
const matches = resolveByName('john doe')
expect(matches.length).toBeGreaterThan(0)
})
it('should return empty array for no match', () => {
loadContacts()
const matches = resolveByName('NonExistent Person')
expect(matches).toEqual([])
})
it('should return empty array for empty/null input', () => {
loadContacts()
expect(resolveByName(null)).toEqual([])
expect(resolveByName('')).toEqual([])
})
it('should find partial matches', () => {
loadContacts()
const matches = resolveByName('Doe')
expect(matches.length).toBeGreaterThan(0)
})
})
describe('getContactIdentifiers', () => {
beforeEach(() => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should return all identifiers for a contact', () => {
loadContacts()
const identifiers = getContactIdentifiers(1)
expect(identifiers.emails).toContain('john@example.com')
expect(identifiers.emails).toContain('john.doe@acme.com')
expect(identifiers.phones.length).toBeGreaterThan(0)
})
it('should return empty arrays for unknown contact', () => {
loadContacts()
const identifiers = getContactIdentifiers(999)
expect(identifiers.emails).toEqual([])
expect(identifiers.phones).toEqual([])
})
})
describe('searchContacts', () => {
beforeEach(() => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should search by name', () => {
loadContacts()
const results = searchContacts('John')
expect(results.length).toBeGreaterThan(0)
expect(results[0].firstName).toBe('John')
})
it('should search by organization', () => {
loadContacts()
const results = searchContacts('Acme')
expect(results.length).toBeGreaterThan(0)
expect(results[0].organization).toBe('Acme Corp')
})
it('should search by email', () => {
loadContacts()
const results = searchContacts('john@example')
expect(results.length).toBeGreaterThan(0)
})
it('should search by phone', () => {
loadContacts()
// Search uses raw phone string, so we need to match the format in our data
const results = searchContacts('555) 123')
expect(results.length).toBeGreaterThan(0)
})
it('should respect limit parameter', () => {
loadContacts()
const results = searchContacts('', 1)
expect(results.length).toBeLessThanOrEqual(1)
})
it('should return all contacts when query is empty', () => {
loadContacts()
const results = searchContacts('')
expect(results.length).toBeGreaterThan(0)
})
})
describe('lookupContact', () => {
beforeEach(() => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should lookup by email', () => {
loadContacts()
const contact = lookupContact('john@example.com')
expect(contact).toBeTruthy()
expect(contact.firstName).toBe('John')
})
it('should lookup by phone', () => {
loadContacts()
const contact = lookupContact('+1 (555) 123-4567')
expect(contact).toBeTruthy()
})
it('should lookup by name', () => {
loadContacts()
const contact = lookupContact('John Doe')
expect(contact).toBeTruthy()
})
it('should return null for unknown identifier', () => {
loadContacts()
expect(lookupContact('unknown')).toBeNull()
})
it('should return null for empty/null input', () => {
expect(lookupContact(null)).toBeNull()
expect(lookupContact('')).toBeNull()
})
})
describe('formatContact', () => {
it('should format contact with name and organization', () => {
const contact = {
displayName: 'John Doe',
organization: 'Acme Corp'
}
expect(formatContact(contact)).toBe('John Doe (Acme Corp)')
})
it('should format contact with name only', () => {
const contact = {
displayName: 'John Doe',
organization: null
}
expect(formatContact(contact)).toBe('John Doe')
})
it('should not duplicate organization in display', () => {
const contact = {
displayName: 'Acme Corp',
organization: 'Acme Corp'
}
expect(formatContact(contact)).toBe('Acme Corp')
})
it('should return Unknown for null/undefined contact', () => {
expect(formatContact(null)).toBe('Unknown')
expect(formatContact(undefined)).toBe('Unknown')
})
})
describe('getContactStats', () => {
beforeEach(() => {
fs.existsSync.mockReturnValue(true)
fs.readdirSync.mockReturnValue(['source1'])
safeSqlite3.mockReturnValue('5')
safeSqlite3Json.mockImplementation((dbPath, query) => {
if (query.includes('ZABCDRECORD')) return sampleContacts
if (query.includes('ZABCDEMAILADDRESS')) return sampleEmails
if (query.includes('ZABCDPHONENUMBER')) return samplePhones
return []
})
})
it('should return contact statistics', () => {
loadContacts()
const stats = getContactStats()
expect(stats.totalContacts).toBe(3)
expect(stats.uniqueEmails).toBeGreaterThan(0)
expect(stats.uniquePhones).toBeGreaterThan(0)
})
})
})