diff --git a/apps/contacts/apps/backend/src/activity/__tests__/activity.service.spec.ts b/apps/contacts/apps/backend/src/activity/__tests__/activity.service.spec.ts new file mode 100644 index 000000000..906785119 --- /dev/null +++ b/apps/contacts/apps/backend/src/activity/__tests__/activity.service.spec.ts @@ -0,0 +1,173 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ActivityService } from '../activity.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +describe('ActivityService', () => { + let service: ActivityService; + let mockDb: any; + + const mockActivity = { + id: 'activity-1', + contactId: 'contact-1', + userId: 'user-1', + activityType: 'created', + description: 'Contact created', + metadata: null, + createdAt: new Date('2025-01-01'), + }; + + const mockActivity2 = { + id: 'activity-2', + contactId: 'contact-1', + userId: 'user-1', + activityType: 'called', + description: 'Called contact', + metadata: { duration: '5m' }, + createdAt: new Date('2025-01-02'), + }; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivityService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(ActivityService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByContactId', () => { + it('should return activities for a contact ordered by date descending', async () => { + mockDb.limit.mockResolvedValue([mockActivity2, mockActivity]); + + const result = await service.findByContactId('contact-1', 'user-1'); + + expect(result).toEqual([mockActivity2, mockActivity]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(mockDb.orderBy).toHaveBeenCalled(); + expect(mockDb.limit).toHaveBeenCalled(); + }); + + it('should return empty array when contact has no activities', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.findByContactId('contact-1', 'user-1'); + + expect(result).toEqual([]); + }); + + it('should respect custom limit parameter', async () => { + mockDb.limit.mockResolvedValue([mockActivity2]); + + const result = await service.findByContactId('contact-1', 'user-1', 1); + + expect(result).toEqual([mockActivity2]); + expect(mockDb.limit).toHaveBeenCalled(); + }); + + it('should use default limit of 50', async () => { + mockDb.limit.mockResolvedValue([]); + + await service.findByContactId('contact-1', 'user-1'); + + expect(mockDb.limit).toHaveBeenCalledWith(50); + }); + }); + + describe('create', () => { + it('should insert and return a new activity', async () => { + mockDb.returning.mockResolvedValue([mockActivity]); + + const newActivity = { + contactId: 'contact-1', + userId: 'user-1', + activityType: 'created', + description: 'Contact created', + }; + + const result = await service.create(newActivity as any); + + expect(result).toEqual(mockActivity); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith(newActivity); + }); + }); + + describe('logActivity', () => { + it('should log an activity with description', async () => { + mockDb.returning.mockResolvedValue([mockActivity2]); + + const result = await service.logActivity('contact-1', 'user-1', 'called', 'Called contact'); + + expect(result).toEqual(mockActivity2); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith({ + contactId: 'contact-1', + userId: 'user-1', + activityType: 'called', + description: 'Called contact', + metadata: undefined, + }); + }); + + it('should log an activity with metadata', async () => { + const activityWithMeta = { + ...mockActivity2, + metadata: { duration: '5m' }, + }; + mockDb.returning.mockResolvedValue([activityWithMeta]); + + const result = await service.logActivity('contact-1', 'user-1', 'called', 'Called contact', { + duration: '5m', + }); + + expect(result).toEqual(activityWithMeta); + expect(mockDb.values).toHaveBeenCalledWith({ + contactId: 'contact-1', + userId: 'user-1', + activityType: 'called', + description: 'Called contact', + metadata: { duration: '5m' }, + }); + }); + + it('should log an activity without description or metadata', async () => { + mockDb.returning.mockResolvedValue([mockActivity]); + + const result = await service.logActivity('contact-1', 'user-1', 'created'); + + expect(result).toEqual(mockActivity); + expect(mockDb.values).toHaveBeenCalledWith({ + contactId: 'contact-1', + userId: 'user-1', + activityType: 'created', + description: undefined, + metadata: undefined, + }); + }); + }); +}); diff --git a/apps/contacts/apps/backend/src/duplicates/__tests__/duplicates.service.spec.ts b/apps/contacts/apps/backend/src/duplicates/__tests__/duplicates.service.spec.ts new file mode 100644 index 000000000..f456c180e --- /dev/null +++ b/apps/contacts/apps/backend/src/duplicates/__tests__/duplicates.service.spec.ts @@ -0,0 +1,284 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DuplicatesService } from '../duplicates.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +describe('DuplicatesService', () => { + let service: DuplicatesService; + let mockDb: any; + + const mockContact1 = { + id: 'contact-1', + userId: 'user-1', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + email: 'john@example.com', + phone: '+1234567890', + mobile: null, + nickname: null, + street: null, + city: null, + postalCode: null, + country: null, + company: 'Acme Inc', + jobTitle: null, + department: null, + website: null, + birthday: null, + notes: 'Note 1', + photoUrl: null, + isFavorite: false, + isArchived: false, + organizationId: null, + teamId: null, + visibility: 'private', + sharedWith: null, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + const mockContact2 = { + id: 'contact-2', + userId: 'user-1', + firstName: 'John', + lastName: 'Doe', + displayName: 'John D.', + email: 'john@example.com', + phone: null, + mobile: '+9876543210', + nickname: null, + street: '123 Main St', + city: 'Springfield', + postalCode: null, + country: null, + company: null, + jobTitle: 'Developer', + department: null, + website: 'https://john.dev', + birthday: null, + notes: 'Note 2', + photoUrl: null, + isFavorite: true, + isArchived: false, + organizationId: null, + teamId: null, + visibility: 'private', + sharedWith: null, + createdAt: new Date('2025-01-02'), + updatedAt: new Date('2025-01-02'), + }; + + /** + * Create a fresh chainable mock that tracks call order. + * Each terminal call (limit, where as terminal, returning, execute) + * can be configured to resolve with specific values per invocation. + */ + function createMockDb() { + const db: any = { + select: jest.fn(), + from: jest.fn(), + where: jest.fn(), + limit: jest.fn(), + orderBy: jest.fn(), + groupBy: jest.fn(), + having: jest.fn(), + insert: jest.fn(), + values: jest.fn(), + returning: jest.fn(), + update: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + execute: jest.fn(), + }; + + // All methods return the db itself for chaining by default + for (const key of Object.keys(db)) { + db[key].mockReturnValue(db); + } + + return db; + } + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DuplicatesService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(DuplicatesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findDuplicates', () => { + it('should return empty array when no duplicates found', async () => { + // Email: select().from().where().groupBy().having().limit() -> [] + mockDb.limit.mockResolvedValueOnce([]); + // Phone: execute() -> [] + mockDb.execute.mockResolvedValueOnce([]); + // Name: select().from().where().groupBy().having().limit() -> [] + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.findDuplicates('user-1'); + + expect(result).toEqual([]); + }); + + it('should find email duplicates', async () => { + // Email grouping query: limit() returns duplicate email groups + mockDb.limit.mockResolvedValueOnce([{ normalizedEmail: 'john@example.com' }]); + // Fetch contacts matching those emails: where() returns contacts + // Since where() is the terminal here, we need it to resolve after the groupBy chain + // The second where() call (after the limit resolved) returns contacts + mockDb.where + .mockReturnValueOnce(mockDb) // first where in groupBy chain + .mockResolvedValueOnce([mockContact1, mockContact2]); // second where fetches contacts + // Phone duplicates: execute() -> [] + mockDb.execute.mockResolvedValueOnce([]); + // Name duplicates: limit() -> [] + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.findDuplicates('user-1'); + + expect(result.length).toBe(1); + expect(result[0].matchType).toBe('email'); + expect(result[0].matchValue).toBe('john@example.com'); + expect(result[0].contacts).toHaveLength(2); + }); + }); + + describe('mergeContacts', () => { + it('should merge contacts and return merged result', async () => { + const mergedContact = { + ...mockContact1, + mobile: '+9876543210', + street: '123 Main St', + city: 'Springfield', + jobTitle: 'Developer', + website: 'https://john.dev', + notes: 'Note 1\n\n---\n\nNote 2', + isFavorite: true, + updatedAt: new Date(), + }; + + // Get primary contact: select().from().where() -> [mockContact1] + mockDb.where.mockResolvedValueOnce([mockContact1]); + // Get contacts to merge: select().from().where() -> [mockContact2] + mockDb.where.mockResolvedValueOnce([mockContact2]); + // Update primary: update().set().where() -> chainable (returns mockDb) + mockDb.where.mockReturnValueOnce(mockDb); + // update chain .returning() -> [mergedContact] + mockDb.returning.mockResolvedValueOnce([mergedContact]); + // Delete merged: delete().where() -> undefined + mockDb.where.mockResolvedValueOnce(undefined); + + const result = await service.mergeContacts('contact-1', ['contact-2'], 'user-1'); + + expect(result.mergedContact).toEqual(mergedContact); + expect(result.deletedIds).toEqual(['contact-2']); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when primary contact is not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.mergeContacts('nonexistent', ['contact-2'], 'user-1')).rejects.toThrow( + NotFoundException + ); + }); + + it('should throw NotFoundException when merge contacts are not found', async () => { + // Primary contact found + mockDb.where.mockResolvedValueOnce([mockContact1]); + // Merge contacts not found (empty for 2 requested IDs) + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.mergeContacts('contact-1', ['contact-2', 'contact-3'], 'user-1') + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException when some merge contacts are missing', async () => { + // Primary contact found + mockDb.where.mockResolvedValueOnce([mockContact1]); + // Only 1 of 2 merge contacts found + mockDb.where.mockResolvedValueOnce([mockContact2]); + + await expect( + service.mergeContacts('contact-1', ['contact-2', 'contact-3'], 'user-1') + ).rejects.toThrow(NotFoundException); + }); + + it('should keep favorite status if any merged contact is favorite', async () => { + const mergedContact = { + ...mockContact1, + isFavorite: true, + }; + + // Primary (not favorite) + mockDb.where.mockResolvedValueOnce([mockContact1]); + // Merge contacts (contact2 is favorite) + mockDb.where.mockResolvedValueOnce([mockContact2]); + // Update chain: where() returns chainable + mockDb.where.mockReturnValueOnce(mockDb); + // Update returning + mockDb.returning.mockResolvedValueOnce([mergedContact]); + // Delete + mockDb.where.mockResolvedValueOnce(undefined); + + const result = await service.mergeContacts('contact-1', ['contact-2'], 'user-1'); + + expect(result.mergedContact.isFavorite).toBe(true); + }); + + it('should fill empty primary fields from merged contacts', async () => { + const primaryWithGaps = { + ...mockContact1, + street: null, + city: null, + jobTitle: null, + website: null, + mobile: null, + }; + const mergedResult = { + ...primaryWithGaps, + street: '123 Main St', + city: 'Springfield', + jobTitle: 'Developer', + website: 'https://john.dev', + mobile: '+9876543210', + }; + + mockDb.where.mockResolvedValueOnce([primaryWithGaps]); + mockDb.where.mockResolvedValueOnce([mockContact2]); + mockDb.where.mockReturnValueOnce(mockDb); + mockDb.returning.mockResolvedValueOnce([mergedResult]); + mockDb.where.mockResolvedValueOnce(undefined); + + const result = await service.mergeContacts('contact-1', ['contact-2'], 'user-1'); + + expect(result.mergedContact.street).toBe('123 Main St'); + expect(result.mergedContact.city).toBe('Springfield'); + expect(result.mergedContact.jobTitle).toBe('Developer'); + }); + }); + + describe('dismissDuplicate', () => { + it('should resolve without error (no-op for now)', async () => { + await expect( + service.dismissDuplicate('email-contact-1-contact-2', 'user-1') + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/contacts/apps/backend/src/note/__tests__/note.service.spec.ts b/apps/contacts/apps/backend/src/note/__tests__/note.service.spec.ts new file mode 100644 index 000000000..81836a1a9 --- /dev/null +++ b/apps/contacts/apps/backend/src/note/__tests__/note.service.spec.ts @@ -0,0 +1,183 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { NoteService } from '../note.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +describe('NoteService', () => { + let service: NoteService; + let mockDb: any; + + const mockNote = { + id: 'note-1', + contactId: 'contact-1', + userId: 'user-1', + content: 'Test note content', + isPinned: false, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + const mockNote2 = { + id: 'note-2', + contactId: 'contact-1', + userId: 'user-1', + content: 'Another note', + isPinned: true, + createdAt: new Date('2025-01-02'), + updatedAt: new Date('2025-01-02'), + }; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NoteService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(NoteService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByContactId', () => { + it('should return notes for a contact ordered by pinned and date', async () => { + mockDb.orderBy.mockResolvedValue([mockNote2, mockNote]); + + const result = await service.findByContactId('contact-1', 'user-1'); + + expect(result).toEqual([mockNote2, mockNote]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(mockDb.orderBy).toHaveBeenCalled(); + }); + + it('should return empty array when contact has no notes', async () => { + mockDb.orderBy.mockResolvedValue([]); + + const result = await service.findByContactId('contact-1', 'user-1'); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return a note when found', async () => { + mockDb.where.mockResolvedValue([mockNote]); + + const result = await service.findById('note-1', 'user-1'); + + expect(result).toEqual(mockNote); + }); + + it('should return null when note is not found', async () => { + mockDb.where.mockResolvedValue([]); + + const result = await service.findById('nonexistent', 'user-1'); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should insert and return a new note', async () => { + mockDb.returning.mockResolvedValue([mockNote]); + + const newNote = { + contactId: 'contact-1', + userId: 'user-1', + content: 'Test note content', + }; + + const result = await service.create(newNote as any); + + expect(result).toEqual(mockNote); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith(newNote); + }); + }); + + describe('update', () => { + it('should update and return the note', async () => { + const updatedNote = { ...mockNote, content: 'Updated content' }; + mockDb.returning.mockResolvedValue([updatedNote]); + + const result = await service.update('note-1', 'user-1', { content: 'Updated content' }); + + expect(result).toEqual(updatedNote); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when note is not found', async () => { + mockDb.returning.mockResolvedValue([]); + + await expect(service.update('nonexistent', 'user-1', { content: 'Updated' })).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('delete', () => { + it('should delete a note successfully', async () => { + mockDb.where.mockResolvedValue(undefined); + + await expect(service.delete('note-1', 'user-1')).resolves.toBeUndefined(); + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('togglePin', () => { + it('should toggle isPinned from false to true', async () => { + const pinnedNote = { ...mockNote, isPinned: true }; + // findById call + mockDb.where.mockResolvedValueOnce([mockNote]); + // update call (inside this.update) + mockDb.returning.mockResolvedValue([pinnedNote]); + + const result = await service.togglePin('note-1', 'user-1'); + + expect(result).toEqual(pinnedNote); + expect(result.isPinned).toBe(true); + }); + + it('should toggle isPinned from true to false', async () => { + const unpinnedNote = { ...mockNote2, isPinned: false }; + // findById call + mockDb.where.mockResolvedValueOnce([mockNote2]); + // update call + mockDb.returning.mockResolvedValue([unpinnedNote]); + + const result = await service.togglePin('note-2', 'user-1'); + + expect(result).toEqual(unpinnedNote); + expect(result.isPinned).toBe(false); + }); + + it('should throw NotFoundException when note is not found', async () => { + mockDb.where.mockResolvedValue([]); + + await expect(service.togglePin('nonexistent', 'user-1')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/contacts/apps/backend/src/tag/__tests__/tag.service.spec.ts b/apps/contacts/apps/backend/src/tag/__tests__/tag.service.spec.ts new file mode 100644 index 000000000..046351e9d --- /dev/null +++ b/apps/contacts/apps/backend/src/tag/__tests__/tag.service.spec.ts @@ -0,0 +1,193 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { TagService } from '../tag.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +describe('TagService', () => { + let service: TagService; + let mockDb: any; + + const mockTag = { + id: 'tag-1', + userId: 'user-1', + name: 'Familie', + color: '#ec4899', + createdAt: new Date('2025-01-01'), + }; + + const mockTag2 = { + id: 'tag-2', + userId: 'user-1', + name: 'Freunde', + color: '#22c55e', + createdAt: new Date('2025-01-01'), + }; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + onConflictDoNothing: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TagService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(TagService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByUserId', () => { + it('should return existing tags when user has tags', async () => { + mockDb.where.mockResolvedValue([mockTag, mockTag2]); + + const result = await service.findByUserId('user-1'); + + expect(result).toEqual([mockTag, mockTag2]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should create default tags when user has no tags', async () => { + const defaultTags = [ + { id: 'tag-a', userId: 'user-1', name: 'Familie', color: '#ec4899' }, + { id: 'tag-b', userId: 'user-1', name: 'Freunde', color: '#22c55e' }, + { id: 'tag-c', userId: 'user-1', name: 'Arbeit', color: '#3b82f6' }, + { id: 'tag-d', userId: 'user-1', name: 'Wichtig', color: '#ef4444' }, + ]; + // First call: findByUserId returns empty + mockDb.where.mockResolvedValue([]); + // Second call: createDefaultTags inserts and returns + mockDb.returning.mockResolvedValue(defaultTags); + + const result = await service.findByUserId('user-1'); + + expect(result).toEqual(defaultTags); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + expect(mockDb.returning).toHaveBeenCalled(); + }); + }); + + describe('findById', () => { + it('should return a tag when found', async () => { + mockDb.where.mockResolvedValue([mockTag]); + + const result = await service.findById('tag-1', 'user-1'); + + expect(result).toEqual(mockTag); + }); + + it('should return null when tag is not found', async () => { + mockDb.where.mockResolvedValue([]); + + const result = await service.findById('nonexistent', 'user-1'); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should insert and return a new tag', async () => { + mockDb.returning.mockResolvedValue([mockTag]); + + const newTag = { + userId: 'user-1', + name: 'Familie', + color: '#ec4899', + }; + + const result = await service.create(newTag as any); + + expect(result).toEqual(mockTag); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith(newTag); + }); + }); + + describe('update', () => { + it('should update and return the tag', async () => { + const updatedTag = { ...mockTag, name: 'Updated Name' }; + mockDb.returning.mockResolvedValue([updatedTag]); + + const result = await service.update('tag-1', 'user-1', { name: 'Updated Name' }); + + expect(result).toEqual(updatedTag); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when tag is not found', async () => { + mockDb.returning.mockResolvedValue([]); + + await expect(service.update('nonexistent', 'user-1', { name: 'Test' })).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('delete', () => { + it('should delete a tag successfully', async () => { + mockDb.where.mockResolvedValue(undefined); + + await expect(service.delete('tag-1', 'user-1')).resolves.toBeUndefined(); + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('addTagToContact', () => { + it('should add a tag to a contact', async () => { + mockDb.values.mockReturnValue({ onConflictDoNothing: mockDb.onConflictDoNothing }); + + await expect(service.addTagToContact('contact-1', 'tag-1')).resolves.toBeUndefined(); + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe('removeTagFromContact', () => { + it('should remove a tag from a contact', async () => { + mockDb.where.mockResolvedValue(undefined); + + await expect(service.removeTagFromContact('contact-1', 'tag-1')).resolves.toBeUndefined(); + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('getTagsForContact', () => { + it('should return tag IDs for a contact', async () => { + mockDb.where.mockResolvedValue([{ tagId: 'tag-1' }, { tagId: 'tag-2' }]); + + const result = await service.getTagsForContact('contact-1'); + + expect(result).toEqual(['tag-1', 'tag-2']); + }); + + it('should return empty array when contact has no tags', async () => { + mockDb.where.mockResolvedValue([]); + + const result = await service.getTagsForContact('contact-1'); + + expect(result).toEqual([]); + }); + }); +});