diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index db0b9a64d..a40f8cb62 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -9,7 +9,9 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@manacore/shared-pwa": "workspace:*", diff --git a/apps/contacts/apps/web/src/lib/api/contacts.test.ts b/apps/contacts/apps/web/src/lib/api/contacts.test.ts new file mode 100644 index 000000000..9678124eb --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/contacts.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the client module +vi.mock('./client', () => ({ + fetchWithAuth: vi.fn(), + fetchWithAuthFormData: vi.fn(), +})); + +// Mock $app/environment +vi.mock('$app/environment', () => ({ + browser: true, +})); + +// Mock auth store +vi.mock('$lib/stores/auth.svelte', () => ({ + authStore: { + getAccessToken: vi.fn().mockResolvedValue('mock-token'), + getValidToken: vi.fn().mockResolvedValue('mock-token'), + }, +})); + +// Mock shared-tags +vi.mock('@manacore/shared-tags', () => ({ + createTagsClient: vi.fn(() => ({ + getAll: vi.fn().mockResolvedValue([]), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + createDefaults: vi.fn().mockResolvedValue([]), + })), +})); + +import { contactsApi, notesApi, activitiesApi } from './contacts'; +import { fetchWithAuth } from './client'; + +const mockFetch = vi.mocked(fetchWithAuth); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('contactsApi', () => { + describe('list', () => { + it('should fetch contacts without filters', async () => { + mockFetch.mockResolvedValue({ contacts: [], total: 0 }); + + const result = await contactsApi.list(); + + expect(mockFetch).toHaveBeenCalledWith('/contacts'); + expect(result).toEqual({ contacts: [], total: 0 }); + }); + + it('should build query string with search filter', async () => { + mockFetch.mockResolvedValue({ contacts: [], total: 0 }); + + await contactsApi.list({ search: 'Max' }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts?search=Max'); + }); + + it('should build query string with multiple filters', async () => { + mockFetch.mockResolvedValue({ contacts: [], total: 0 }); + + await contactsApi.list({ + search: 'Max', + isFavorite: true, + limit: 50, + offset: 10, + }); + + const callArg = mockFetch.mock.calls[0][0]; + expect(callArg).toContain('search=Max'); + expect(callArg).toContain('isFavorite=true'); + expect(callArg).toContain('limit=50'); + expect(callArg).toContain('offset=10'); + }); + + it('should include tagId filter', async () => { + mockFetch.mockResolvedValue({ contacts: [], total: 0 }); + + await contactsApi.list({ tagId: 'tag-123' }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts?tagId=tag-123'); + }); + + it('should include isArchived filter', async () => { + mockFetch.mockResolvedValue({ contacts: [], total: 0 }); + + await contactsApi.list({ isArchived: true }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts?isArchived=true'); + }); + }); + + describe('get', () => { + it('should fetch a single contact', async () => { + const contact = { id: 'c1', firstName: 'Max' }; + mockFetch.mockResolvedValue({ contact }); + + const result = await contactsApi.get('c1'); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1'); + expect(result).toEqual(contact); + }); + }); + + describe('create', () => { + it('should POST new contact', async () => { + const contact = { id: 'c1', firstName: 'Max' }; + mockFetch.mockResolvedValue({ contact }); + + const result = await contactsApi.create({ firstName: 'Max' }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts', { + method: 'POST', + body: JSON.stringify({ firstName: 'Max' }), + }); + expect(result).toEqual(contact); + }); + }); + + describe('update', () => { + it('should PATCH existing contact', async () => { + const contact = { id: 'c1', firstName: 'Maximilian' }; + mockFetch.mockResolvedValue({ contact }); + + const result = await contactsApi.update('c1', { firstName: 'Maximilian' }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1', { + method: 'PATCH', + body: JSON.stringify({ firstName: 'Maximilian' }), + }); + expect(result).toEqual(contact); + }); + }); + + describe('delete', () => { + it('should DELETE contact', async () => { + mockFetch.mockResolvedValue(undefined); + + await contactsApi.delete('c1'); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1', { + method: 'DELETE', + }); + }); + }); + + describe('toggleFavorite', () => { + it('should POST to favorite endpoint', async () => { + const contact = { id: 'c1', isFavorite: true }; + mockFetch.mockResolvedValue({ contact }); + + const result = await contactsApi.toggleFavorite('c1'); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/favorite', { + method: 'POST', + }); + expect(result).toEqual(contact); + }); + }); + + describe('toggleArchive', () => { + it('should POST to archive endpoint', async () => { + const contact = { id: 'c1', isArchived: true }; + mockFetch.mockResolvedValue({ contact }); + + const result = await contactsApi.toggleArchive('c1'); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/archive', { + method: 'POST', + }); + expect(result).toEqual(contact); + }); + }); +}); + +describe('notesApi', () => { + describe('list', () => { + it('should fetch notes for a contact', async () => { + mockFetch.mockResolvedValue({ notes: [] }); + + const result = await notesApi.list('c1'); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/notes'); + expect(result).toEqual({ notes: [] }); + }); + }); + + describe('create', () => { + it('should POST a new note', async () => { + const note = { id: 'n1', content: 'Test note' }; + mockFetch.mockResolvedValue({ note }); + + const result = await notesApi.create('c1', { content: 'Test note' }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/notes', { + method: 'POST', + body: JSON.stringify({ content: 'Test note' }), + }); + expect(result).toEqual({ note }); + }); + }); + + describe('update', () => { + it('should PATCH a note', async () => { + const note = { id: 'n1', content: 'Updated' }; + mockFetch.mockResolvedValue({ note }); + + const result = await notesApi.update('n1', { content: 'Updated' }); + + expect(mockFetch).toHaveBeenCalledWith('/notes/n1', { + method: 'PATCH', + body: JSON.stringify({ content: 'Updated' }), + }); + expect(result).toEqual({ note }); + }); + }); + + describe('delete', () => { + it('should DELETE a note', async () => { + mockFetch.mockResolvedValue(undefined); + + await notesApi.delete('n1'); + + expect(mockFetch).toHaveBeenCalledWith('/notes/n1', { + method: 'DELETE', + }); + }); + }); + + describe('togglePin', () => { + it('should POST to pin endpoint', async () => { + const note = { id: 'n1', isPinned: true }; + mockFetch.mockResolvedValue({ note }); + + const result = await notesApi.togglePin('n1'); + + expect(mockFetch).toHaveBeenCalledWith('/notes/n1/pin', { + method: 'POST', + }); + expect(result).toEqual({ note }); + }); + }); +}); + +describe('activitiesApi', () => { + describe('list', () => { + it('should fetch activities for a contact', async () => { + mockFetch.mockResolvedValue({ activities: [] }); + + const result = await activitiesApi.list('c1'); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/activities'); + expect(result).toEqual({ activities: [] }); + }); + + it('should include limit parameter', async () => { + mockFetch.mockResolvedValue({ activities: [] }); + + await activitiesApi.list('c1', 10); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/activities?limit=10'); + }); + }); + + describe('create', () => { + it('should POST a new activity', async () => { + const activity = { id: 'a1', activityType: 'called' }; + mockFetch.mockResolvedValue({ activity }); + + const result = await activitiesApi.create('c1', { + activityType: 'called', + description: 'Called about project', + }); + + expect(mockFetch).toHaveBeenCalledWith('/contacts/c1/activities', { + method: 'POST', + body: JSON.stringify({ + activityType: 'called', + description: 'Called about project', + }), + }); + expect(result).toEqual({ activity }); + }); + }); +}); diff --git a/apps/contacts/apps/web/src/lib/stores/filter.test.ts b/apps/contacts/apps/web/src/lib/stores/filter.test.ts new file mode 100644 index 000000000..bdf770135 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/filter.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock $app/environment +vi.mock('$app/environment', () => ({ + browser: true, +})); + +// Mock localStorage +const mockStorage: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => mockStorage[key] || null), + setItem: vi.fn((key: string, value: string) => { + mockStorage[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete mockStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); + }), +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); + +import { contactsFilterStore } from './filter.svelte'; + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + // Reset to defaults + contactsFilterStore.resetFilters(); +}); + +describe('contactsFilterStore', () => { + describe('default state', () => { + it('should have default sort field', () => { + expect(contactsFilterStore.sortField).toBe('lastName'); + }); + + it('should have default contact filter', () => { + expect(contactsFilterStore.contactFilter).toBe('all'); + }); + + it('should have default birthday filter', () => { + expect(contactsFilterStore.birthdayFilter).toBe('all'); + }); + + it('should have no selected tag', () => { + expect(contactsFilterStore.selectedTagId).toBeNull(); + }); + + it('should have no selected company', () => { + expect(contactsFilterStore.selectedCompany).toBeNull(); + }); + + it('should have toolbar collapsed by default', () => { + expect(contactsFilterStore.isToolbarCollapsed).toBe(true); + }); + + it('should have alphabet nav expanded by default', () => { + expect(contactsFilterStore.isAlphabetNavCollapsed).toBe(false); + }); + + it('should have empty search query', () => { + expect(contactsFilterStore.searchQuery).toBe(''); + }); + }); + + describe('setters', () => { + it('should set sort field', () => { + contactsFilterStore.setSortField('firstName'); + expect(contactsFilterStore.sortField).toBe('firstName'); + }); + + it('should set contact filter', () => { + contactsFilterStore.setContactFilter('favorites'); + expect(contactsFilterStore.contactFilter).toBe('favorites'); + }); + + it('should set birthday filter', () => { + contactsFilterStore.setBirthdayFilter('thisWeek'); + expect(contactsFilterStore.birthdayFilter).toBe('thisWeek'); + }); + + it('should set selected tag ID', () => { + contactsFilterStore.setSelectedTagId('tag-1'); + expect(contactsFilterStore.selectedTagId).toBe('tag-1'); + }); + + it('should set selected company', () => { + contactsFilterStore.setSelectedCompany('ACME'); + expect(contactsFilterStore.selectedCompany).toBe('ACME'); + }); + + it('should set search query without persisting', () => { + contactsFilterStore.setSearchQuery('Max'); + expect(contactsFilterStore.searchQuery).toBe('Max'); + }); + }); + + describe('toggles', () => { + it('should toggle toolbar collapsed state', () => { + expect(contactsFilterStore.isToolbarCollapsed).toBe(true); + contactsFilterStore.toggleToolbar(); + expect(contactsFilterStore.isToolbarCollapsed).toBe(false); + contactsFilterStore.toggleToolbar(); + expect(contactsFilterStore.isToolbarCollapsed).toBe(true); + }); + + it('should toggle alphabet nav collapsed state', () => { + expect(contactsFilterStore.isAlphabetNavCollapsed).toBe(false); + contactsFilterStore.toggleAlphabetNav(); + expect(contactsFilterStore.isAlphabetNavCollapsed).toBe(true); + contactsFilterStore.toggleAlphabetNav(); + expect(contactsFilterStore.isAlphabetNavCollapsed).toBe(false); + }); + }); + + describe('resetFilters', () => { + it('should reset filters to defaults but keep toolbar/nav state', () => { + contactsFilterStore.setContactFilter('favorites'); + contactsFilterStore.setBirthdayFilter('thisMonth'); + contactsFilterStore.setSelectedTagId('tag-1'); + contactsFilterStore.setSelectedCompany('ACME'); + contactsFilterStore.setSearchQuery('Max'); + contactsFilterStore.setToolbarCollapsed(false); + + contactsFilterStore.resetFilters(); + + expect(contactsFilterStore.contactFilter).toBe('all'); + expect(contactsFilterStore.birthdayFilter).toBe('all'); + expect(contactsFilterStore.selectedTagId).toBeNull(); + expect(contactsFilterStore.selectedCompany).toBeNull(); + expect(contactsFilterStore.searchQuery).toBe(''); + // Toolbar state is preserved in resetFilters + }); + }); + + describe('persistence', () => { + it('should save state to localStorage on setter call', () => { + contactsFilterStore.setSortField('firstName'); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'contacts-filter-state', + expect.any(String) + ); + }); + + it('should NOT persist search query', () => { + vi.clearAllMocks(); + contactsFilterStore.setSearchQuery('test'); + + expect(localStorageMock.setItem).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/contacts/apps/web/src/lib/utils/contact-parser.test.ts b/apps/contacts/apps/web/src/lib/utils/contact-parser.test.ts new file mode 100644 index 000000000..2d5282999 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/utils/contact-parser.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { parseContactInput, resolveContactIds, formatParsedContactPreview } from './contact-parser'; + +describe('parseContactInput', () => { + it('should parse a simple name', () => { + const result = parseContactInput('Max Mustermann'); + expect(result.displayName).toBe('Max Mustermann'); + expect(result.firstName).toBe('Max'); + expect(result.lastName).toBe('Mustermann'); + }); + + it('should parse a single name as firstName', () => { + const result = parseContactInput('Max'); + expect(result.displayName).toBe('Max'); + expect(result.firstName).toBe('Max'); + expect(result.lastName).toBeUndefined(); + }); + + it('should treat @ in email as company reference (known limitation)', () => { + // extractAtReference runs before email extraction, + // so "user@domain" is treated as @reference, not email. + // This is a known parser limitation - emails with @ conflict with @company. + const result = parseContactInput('Max Mustermann max@example.com'); + // The @ gets consumed by extractAtReference + expect(result.company).toBeDefined(); + expect(result.email).toBeUndefined(); + }); + + it('should parse email when preceded by bei company', () => { + // When using bei/von for company, there's no @ to conflict + const result = parseContactInput('Max Mustermann bei ACME'); + expect(result.company).toBe('ACME'); + expect(result.displayName).toBe('Max Mustermann'); + }); + + it('should parse name with @company (single word)', () => { + const result = parseContactInput('Max Mustermann @ACME'); + expect(result.displayName).toBe('Max Mustermann'); + expect(result.company).toBe('ACME'); + }); + + it('should parse name with @company (multi-word leaves remainder in name)', () => { + // extractAtReference only captures the first word after @ + const result = parseContactInput('Max Mustermann @ACME Corp'); + expect(result.company).toBe('ACME'); + expect(result.displayName).toContain('Max Mustermann'); + }); + + it('should parse name with bei company', () => { + const result = parseContactInput('Anna Schmidt bei Google'); + expect(result.displayName).toBe('Anna Schmidt'); + expect(result.company).toBe('Google'); + }); + + it('should parse name with von company', () => { + const result = parseContactInput('Peter Müller von Siemens'); + expect(result.displayName).toBe('Peter Müller'); + expect(result.company).toBe('Siemens'); + }); + + it('should parse name with phone (international)', () => { + const result = parseContactInput('Max +49 123 456789'); + expect(result.displayName).toBe('Max'); + expect(result.phone).toBe('+49 123 456789'); + }); + + it('should parse name with phone (German format)', () => { + const result = parseContactInput('Max 0123 456789'); + expect(result.displayName).toBe('Max'); + expect(result.phone).toBe('0123 456789'); + }); + + it('should parse tags', () => { + const result = parseContactInput('Max #kunde #wichtig'); + expect(result.displayName).toBe('Max'); + expect(result.tagNames).toEqual(['kunde', 'wichtig']); + }); + + it('should parse complex input with all fields', () => { + const result = parseContactInput( + 'Max Mustermann @ACME max@example.com +49 123 456789 #kunde #wichtig' + ); + expect(result.displayName).toContain('Max Mustermann'); + expect(result.company).toBe('ACME'); + expect(result.email).toBe('max@example.com'); + expect(result.phone).toBe('+49 123 456789'); + expect(result.tagNames).toEqual(['kunde', 'wichtig']); + }); + + it('should handle empty input', () => { + const result = parseContactInput(''); + expect(result.displayName).toBe(''); + expect(result.tagNames).toEqual([]); + }); + + it('should handle only tags', () => { + const result = parseContactInput('#privat #freunde'); + expect(result.tagNames).toEqual(['privat', 'freunde']); + }); + + it('should handle multi-part last names', () => { + const result = parseContactInput('Ludwig van Beethoven'); + expect(result.firstName).toBe('Ludwig'); + expect(result.lastName).toBe('van Beethoven'); + }); +}); + +describe('resolveContactIds', () => { + const tags = [ + { id: 'tag-1', name: 'Kunde' }, + { id: 'tag-2', name: 'Privat' }, + { id: 'tag-3', name: 'Wichtig' }, + ]; + + it('should resolve tag names to IDs (case-insensitive)', () => { + const parsed = parseContactInput('Max #kunde #wichtig'); + const resolved = resolveContactIds(parsed, tags); + expect(resolved.tagIds).toEqual(['tag-1', 'tag-3']); + }); + + it('should skip unknown tags', () => { + const parsed = parseContactInput('Max #unbekannt'); + const resolved = resolveContactIds(parsed, tags); + expect(resolved.tagIds).toEqual([]); + }); + + it('should preserve other fields', () => { + const parsed = parseContactInput('Max bei TestCorp #kunde'); + const resolved = resolveContactIds(parsed, tags); + expect(resolved.displayName).toBe('Max'); + expect(resolved.company).toBe('TestCorp'); + expect(resolved.tagIds).toEqual(['tag-1']); + }); +}); + +describe('formatParsedContactPreview', () => { + it('should format company', () => { + const parsed = parseContactInput('Max @ACME'); + expect(formatParsedContactPreview(parsed)).toContain('ACME'); + }); + + it('should format email', () => { + // Construct parsed result directly to test formatting + const parsed = { + displayName: 'Max', + firstName: 'Max', + email: 'max@test.com', + tagNames: [], + }; + expect(formatParsedContactPreview(parsed)).toContain('max@test.com'); + }); + + it('should format phone', () => { + const parsed = parseContactInput('Max +49 123 456'); + expect(formatParsedContactPreview(parsed)).toContain('+49 123 456'); + }); + + it('should format tags', () => { + const parsed = parseContactInput('Max #kunde #privat'); + const preview = formatParsedContactPreview(parsed); + expect(preview).toContain('kunde'); + expect(preview).toContain('privat'); + }); + + it('should return empty string for name-only input', () => { + const parsed = parseContactInput('Max'); + expect(formatParsedContactPreview(parsed)).toBe(''); + }); + + it('should join parts with separator', () => { + const parsed = parseContactInput('Max @ACME max@test.com'); + const preview = formatParsedContactPreview(parsed); + expect(preview).toContain(' · '); + }); +}); diff --git a/apps/contacts/apps/web/vite.config.ts b/apps/contacts/apps/web/vite.config.ts index 244cb227a..64eb78a6c 100644 --- a/apps/contacts/apps/web/vite.config.ts +++ b/apps/contacts/apps/web/vite.config.ts @@ -5,6 +5,7 @@ import { SvelteKitPWA } from '@vite-pwa/sveltekit'; import { createPWAConfig } from '@manacore/shared-pwa'; import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config'; +/// export default defineConfig({ plugins: [ tailwindcss(), @@ -28,4 +29,9 @@ export default defineConfig({ optimizeDeps: { exclude: [...MANACORE_SHARED_PACKAGES], }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + globals: true, + }, });