test(contacts): add 34 backend unit tests for tags, notes, activities, duplicates

- tag.service.spec.ts: 10 tests (CRUD, default creation, contact associations)
- note.service.spec.ts: 10 tests (CRUD, togglePin, ordering)
- activity.service.spec.ts: 7 tests (findByContact, create, logActivity)
- duplicates.service.spec.ts: 8 tests (findDuplicates, mergeContacts, dismiss)
Total: 55 backend tests (was 15)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 11:26:24 +01:00
parent f53e460ef0
commit 5345e19e24
4 changed files with 833 additions and 0 deletions

View file

@ -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>(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,
});
});
});
});

View file

@ -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>(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();
});
});
});

View file

@ -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>(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);
});
});
});

View file

@ -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>(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([]);
});
});
});