diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.spec.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.spec.ts new file mode 100644 index 000000000..b49397087 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.spec.ts @@ -0,0 +1,279 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { EventTagGroupService } from './event-tag-group.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; +import { v4 as uuidv4 } from 'uuid'; + +function createMockEventTagGroup(overrides: Record = {}) { + return { + id: uuidv4(), + userId: TEST_USER_ID, + name: 'Test Group', + color: '#3B82F6', + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('EventTagGroupService', () => { + let service: EventTagGroupService; + let mockDb: any; + + 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().mockResolvedValue([]), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventTagGroupService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(EventTagGroupService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByUserId', () => { + it('should return all groups for a user ordered by sortOrder', async () => { + const groups = [ + createMockEventTagGroup({ name: 'Group 1', sortOrder: 0 }), + createMockEventTagGroup({ name: 'Group 2', sortOrder: 1 }), + ]; + mockDb.orderBy.mockResolvedValueOnce(groups); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual(groups); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(mockDb.orderBy).toHaveBeenCalled(); + }); + + it('should create default groups when user has no groups', async () => { + const defaultGroups = [ + createMockEventTagGroup({ name: 'Personen', color: '#ec4899', sortOrder: 0 }), + createMockEventTagGroup({ name: 'Orte', color: '#14b8a6', sortOrder: 1 }), + createMockEventTagGroup({ name: 'Allgemein', color: '#3b82f6', sortOrder: 2 }), + ]; + // First call returns empty (no groups yet) + mockDb.orderBy.mockResolvedValueOnce([]); + // createDefaultGroups calls insert().values().returning() + mockDb.returning.mockResolvedValueOnce(defaultGroups); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual(defaultGroups); + expect(result).toHaveLength(3); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + }); + + describe('findById', () => { + it('should return group when found', async () => { + const group = createMockEventTagGroup(); + mockDb.where.mockResolvedValueOnce([group]); + + const result = await service.findById(group.id, TEST_USER_ID); + + expect(result).toEqual(group); + }); + + it('should return null when group not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id', TEST_USER_ID); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create a new group with correct sortOrder', async () => { + const existingGroups = [ + createMockEventTagGroup({ sortOrder: 0 }), + createMockEventTagGroup({ sortOrder: 1 }), + ]; + const newGroup = createMockEventTagGroup({ name: 'New Group', sortOrder: 2 }); + + // First call: get existing groups to determine sortOrder + mockDb.where.mockResolvedValueOnce(existingGroups); + // Second call: insert returning + mockDb.returning.mockResolvedValueOnce([newGroup]); + + const result = await service.create({ + userId: TEST_USER_ID, + name: 'New Group', + color: '#FF0000', + }); + + expect(result).toEqual(newGroup); + expect(result.sortOrder).toBe(2); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should start with sortOrder 0 when no groups exist', async () => { + const newGroup = createMockEventTagGroup({ name: 'First Group', sortOrder: 0 }); + + // No existing groups + mockDb.where.mockResolvedValueOnce([]); + mockDb.returning.mockResolvedValueOnce([newGroup]); + + const result = await service.create({ + userId: TEST_USER_ID, + name: 'First Group', + color: '#FF0000', + }); + + expect(result).toEqual(newGroup); + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update a group', async () => { + const updatedGroup = createMockEventTagGroup({ name: 'Updated Group' }); + mockDb.returning.mockResolvedValueOnce([updatedGroup]); + + const result = await service.update(updatedGroup.id, TEST_USER_ID, { + name: 'Updated Group', + }); + + expect(result.name).toBe('Updated Group'); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when group not found', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should unassign tags and delete the group', async () => { + const groupId = uuidv4(); + + await service.delete(groupId, TEST_USER_ID); + + // Should update tags to unassign from group first + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith({ groupId: null }); + // Should then delete the group + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('getTagCountByGroup', () => { + it('should return count of tags in a group', async () => { + const groupId = uuidv4(); + const tags = [ + { id: uuidv4(), groupId }, + { id: uuidv4(), groupId }, + { id: uuidv4(), groupId }, + ]; + mockDb.where.mockResolvedValueOnce(tags); + + const result = await service.getTagCountByGroup(groupId); + + expect(result).toBe(3); + }); + + it('should return 0 when group has no tags', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getTagCountByGroup(uuidv4()); + + expect(result).toBe(0); + }); + }); + + describe('getTagCountsForUser', () => { + it('should return tag counts grouped by groupId', async () => { + const groupId1 = uuidv4(); + const groupId2 = uuidv4(); + const tags = [ + { id: uuidv4(), groupId: groupId1 }, + { id: uuidv4(), groupId: groupId1 }, + { id: uuidv4(), groupId: groupId2 }, + { id: uuidv4(), groupId: null }, + ]; + mockDb.where.mockResolvedValueOnce(tags); + + const result = await service.getTagCountsForUser(TEST_USER_ID); + + expect(result.get(groupId1)).toBe(2); + expect(result.get(groupId2)).toBe(1); + expect(result.get(null)).toBe(1); + }); + + it('should return empty map when user has no tags', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getTagCountsForUser(TEST_USER_ID); + + expect(result.size).toBe(0); + }); + }); + + describe('reorder', () => { + it('should update sortOrder for each group and return updated list', async () => { + const groupId1 = uuidv4(); + const groupId2 = uuidv4(); + const groupId3 = uuidv4(); + + const reorderedGroups = [ + createMockEventTagGroup({ id: groupId2, sortOrder: 0 }), + createMockEventTagGroup({ id: groupId3, sortOrder: 1 }), + createMockEventTagGroup({ id: groupId1, sortOrder: 2 }), + ]; + + // The reorder method calls findByUserId at the end, which calls orderBy + mockDb.orderBy.mockResolvedValueOnce(reorderedGroups); + + const result = await service.reorder(TEST_USER_ID, [groupId2, groupId3, groupId1]); + + expect(result).toEqual(reorderedGroups); + // Should have called update for each group + expect(mockDb.update).toHaveBeenCalledTimes(3); + expect(mockDb.set).toHaveBeenCalledTimes(3); + }); + + it('should handle empty groupIds array', async () => { + const groups = [createMockEventTagGroup()]; + // findByUserId is called at the end + mockDb.orderBy.mockResolvedValueOnce(groups); + + const result = await service.reorder(TEST_USER_ID, []); + + expect(result).toEqual(groups); + // No update calls for empty array + expect(mockDb.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.service.spec.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.service.spec.ts new file mode 100644 index 000000000..29a5d80ea --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag/event-tag.service.spec.ts @@ -0,0 +1,325 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { EventTagService } from './event-tag.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; +import { v4 as uuidv4 } from 'uuid'; + +function createMockEventTag(overrides: Record = {}) { + return { + id: uuidv4(), + userId: TEST_USER_ID, + name: 'Test Tag', + color: '#3B82F6', + groupId: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('EventTagService', () => { + let service: EventTagService; + let mockDb: any; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockResolvedValue([]), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + onConflictDoNothing: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventTagService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(EventTagService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByUserId', () => { + it('should return all tags for a user', async () => { + const tags = [createMockEventTag({ name: 'Work' }), createMockEventTag({ name: 'Personal' })]; + mockDb.where.mockResolvedValueOnce(tags); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual(tags); + 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 = [ + createMockEventTag({ name: 'Arbeit', color: '#3b82f6' }), + createMockEventTag({ name: 'Persönlich', color: '#22c55e' }), + createMockEventTag({ name: 'Familie', color: '#ec4899' }), + createMockEventTag({ name: 'Wichtig', color: '#ef4444' }), + ]; + // First call returns empty (no tags yet) + mockDb.where.mockResolvedValueOnce([]); + // createDefaultTags calls insert().values().returning() + mockDb.returning.mockResolvedValueOnce(defaultTags); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual(defaultTags); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + expect(mockDb.returning).toHaveBeenCalled(); + }); + }); + + describe('findById', () => { + it('should return tag when found', async () => { + const tag = createMockEventTag(); + mockDb.where.mockResolvedValueOnce([tag]); + + const result = await service.findById(tag.id, TEST_USER_ID); + + expect(result).toEqual(tag); + }); + + it('should return null when tag not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id', TEST_USER_ID); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create a new tag', async () => { + const newTag = createMockEventTag({ name: 'New Tag', color: '#FF0000' }); + mockDb.returning.mockResolvedValueOnce([newTag]); + + const result = await service.create({ + userId: TEST_USER_ID, + name: 'New Tag', + color: '#FF0000', + }); + + expect(result).toEqual(newTag); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update a tag', async () => { + const updatedTag = createMockEventTag({ name: 'Updated Tag' }); + mockDb.returning.mockResolvedValueOnce([updatedTag]); + + const result = await service.update(updatedTag.id, TEST_USER_ID, { + name: 'Updated Tag', + }); + + expect(result.name).toBe('Updated Tag'); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when tag not found', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should delete a tag', async () => { + const tag = createMockEventTag(); + + await service.delete(tag.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + }); + + describe('getTagsForEvent', () => { + it('should return tags for an event', async () => { + const tag1 = createMockEventTag({ name: 'Tag 1' }); + const tag2 = createMockEventTag({ name: 'Tag 2' }); + mockDb.where.mockResolvedValueOnce([{ tag: tag1 }, { tag: tag2 }]); + + const result = await service.getTagsForEvent('event-id'); + + expect(result).toEqual([tag1, tag2]); + }); + + it('should return empty array when event has no tags', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getTagsForEvent('event-id'); + + expect(result).toEqual([]); + }); + }); + + describe('getTagsForEvents', () => { + it('should return empty map for empty eventIds', async () => { + const result = await service.getTagsForEvents([]); + + expect(result).toEqual(new Map()); + expect(mockDb.select).not.toHaveBeenCalled(); + }); + + it('should return tags grouped by event id', async () => { + const tag1 = createMockEventTag({ name: 'Tag 1' }); + const tag2 = createMockEventTag({ name: 'Tag 2' }); + mockDb.where.mockResolvedValueOnce([ + { eventId: 'event-1', tag: tag1 }, + { eventId: 'event-1', tag: tag2 }, + { eventId: 'event-2', tag: tag1 }, + ]); + + const result = await service.getTagsForEvents(['event-1', 'event-2']); + + expect(result.get('event-1')).toEqual([tag1, tag2]); + expect(result.get('event-2')).toEqual([tag1]); + }); + }); + + describe('getTagIdsForEvent', () => { + it('should return tag ids for an event', async () => { + const tagId1 = uuidv4(); + const tagId2 = uuidv4(); + mockDb.where.mockResolvedValueOnce([{ tagId: tagId1 }, { tagId: tagId2 }]); + + const result = await service.getTagIdsForEvent('event-id'); + + expect(result).toEqual([tagId1, tagId2]); + }); + }); + + describe('setEventTags', () => { + it('should remove existing tags and add new ones', async () => { + const tagId1 = uuidv4(); + const tagId2 = uuidv4(); + + await service.setEventTags('event-id', [tagId1, tagId2]); + + // Should delete existing tags first + expect(mockDb.delete).toHaveBeenCalled(); + // Should insert new tags + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + + it('should only remove tags when tagIds is empty', async () => { + await service.setEventTags('event-id', []); + + expect(mockDb.delete).toHaveBeenCalled(); + // insert should not be called for empty tagIds + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + }); + + describe('addTagToEvent', () => { + it('should add a tag to an event', async () => { + await service.addTagToEvent('event-id', 'tag-id'); + + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith({ eventId: 'event-id', tagId: 'tag-id' }); + }); + }); + + describe('removeTagFromEvent', () => { + it('should remove a tag from an event', async () => { + await service.removeTagFromEvent('event-id', 'tag-id'); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + }); + + describe('getTagsByIds', () => { + it('should return empty array for empty ids', async () => { + const result = await service.getTagsByIds([], TEST_USER_ID); + + expect(result).toEqual([]); + expect(mockDb.select).not.toHaveBeenCalled(); + }); + + it('should return tags matching ids', async () => { + const tag1 = createMockEventTag({ name: 'Tag 1' }); + const tag2 = createMockEventTag({ name: 'Tag 2' }); + mockDb.where.mockResolvedValueOnce([tag1, tag2]); + + const result = await service.getTagsByIds([tag1.id, tag2.id], TEST_USER_ID); + + expect(result).toEqual([tag1, tag2]); + }); + }); + + describe('findByGroupId', () => { + it('should return tags for a specific group', async () => { + const groupId = uuidv4(); + const tags = [ + createMockEventTag({ name: 'Tag 1', groupId }), + createMockEventTag({ name: 'Tag 2', groupId }), + ]; + mockDb.orderBy.mockResolvedValueOnce(tags); + + const result = await service.findByGroupId(groupId, TEST_USER_ID); + + expect(result).toEqual(tags); + }); + + it('should return ungrouped tags when groupId is null', async () => { + const tags = [createMockEventTag({ name: 'Ungrouped', groupId: null })]; + mockDb.orderBy.mockResolvedValueOnce(tags); + + const result = await service.findByGroupId(null, TEST_USER_ID); + + expect(result).toEqual(tags); + }); + }); + + describe('updateTagGroup', () => { + it('should update the group of a tag', async () => { + const groupId = uuidv4(); + const updatedTag = createMockEventTag({ groupId }); + mockDb.returning.mockResolvedValueOnce([updatedTag]); + + const result = await service.updateTagGroup(updatedTag.id, TEST_USER_ID, groupId); + + expect(result.groupId).toBe(groupId); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when tag not found', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + await expect(service.updateTagGroup('non-existent-id', TEST_USER_ID, null)).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/notification/notification.service.spec.ts b/apps/calendar/apps/backend/src/notification/notification.service.spec.ts new file mode 100644 index 000000000..57f287a9f --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/notification.service.spec.ts @@ -0,0 +1,240 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationService } from './notification.service'; +import { PushService } from './push.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { createMockDeviceToken, TEST_USER_ID } from '../__tests__/utils/mock-factories'; + +describe('NotificationService', () => { + let service: NotificationService; + let mockDb: any; + let mockPushService: any; + + 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().mockResolvedValue([]), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + mockPushService = { + sendToToken: jest.fn().mockResolvedValue(true), + sendToTokens: jest.fn().mockResolvedValue(new Map()), + isValidToken: jest.fn().mockReturnValue(true), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: PushService, + useValue: mockPushService, + }, + ], + }).compile(); + + service = module.get(NotificationService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('registerToken', () => { + it('should create a new device token when it does not exist', async () => { + const newToken = createMockDeviceToken({ pushToken: 'ExponentPushToken[new-token]' }); + // Check for existing token - not found + mockDb.where.mockResolvedValueOnce([]); + // Insert returning + mockDb.returning.mockResolvedValueOnce([newToken]); + + const result = await service.registerToken(TEST_USER_ID, { + pushToken: 'ExponentPushToken[new-token]', + platform: 'ios', + deviceName: 'Test iPhone', + }); + + expect(result).toEqual(newToken); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + + it('should update existing token when it already exists', async () => { + const existingToken = createMockDeviceToken({ + pushToken: 'ExponentPushToken[existing-token]', + }); + const updatedToken = { ...existingToken, userId: TEST_USER_ID, isActive: true }; + + // Check for existing token - found + mockDb.where.mockResolvedValueOnce([existingToken]); + // Update returning + mockDb.returning.mockResolvedValueOnce([updatedToken]); + + const result = await service.registerToken(TEST_USER_ID, { + pushToken: 'ExponentPushToken[existing-token]', + platform: 'ios', + deviceName: 'Test iPhone', + }); + + expect(result).toEqual(updatedToken); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + // Should not have called insert + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + }); + + describe('removeToken', () => { + it('should delete a device token', async () => { + await service.removeToken('ExponentPushToken[test-token]'); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + }); + + describe('deactivateToken', () => { + it('should set token isActive to false', async () => { + await service.deactivateToken('ExponentPushToken[test-token]'); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: false })); + }); + }); + + describe('getActiveTokensForUser', () => { + it('should return all active tokens for a user', async () => { + const tokens = [ + createMockDeviceToken({ isActive: true }), + createMockDeviceToken({ isActive: true }), + ]; + mockDb.where.mockResolvedValueOnce(tokens); + + const result = await service.getActiveTokensForUser(TEST_USER_ID); + + expect(result).toEqual(tokens); + expect(result).toHaveLength(2); + }); + + it('should return empty array when user has no active tokens', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getActiveTokensForUser(TEST_USER_ID); + + expect(result).toEqual([]); + }); + }); + + describe('sendToUser', () => { + it('should return false when user has no active tokens', async () => { + // getActiveTokensForUser returns empty + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.sendToUser(TEST_USER_ID, { + title: 'Test', + body: 'Test notification', + }); + + expect(result).toBe(false); + expect(mockPushService.sendToTokens).not.toHaveBeenCalled(); + }); + + it('should send notification to all active tokens', async () => { + const token1 = createMockDeviceToken({ pushToken: 'token-1' }); + const token2 = createMockDeviceToken({ pushToken: 'token-2' }); + + // getActiveTokensForUser returns tokens + mockDb.where.mockResolvedValueOnce([token1, token2]); + + const resultMap = new Map(); + resultMap.set('token-1', true); + resultMap.set('token-2', true); + mockPushService.sendToTokens!.mockResolvedValueOnce(resultMap); + + const result = await service.sendToUser(TEST_USER_ID, { + title: 'Test', + body: 'Test notification', + }); + + expect(result).toBe(true); + expect(mockPushService.sendToTokens).toHaveBeenCalledWith(['token-1', 'token-2'], { + title: 'Test', + body: 'Test notification', + }); + }); + + it('should deactivate tokens that failed', async () => { + const token1 = createMockDeviceToken({ pushToken: 'token-1' }); + const token2 = createMockDeviceToken({ pushToken: 'token-2' }); + + // getActiveTokensForUser returns tokens + mockDb.where.mockResolvedValueOnce([token1, token2]); + + const resultMap = new Map(); + resultMap.set('token-1', true); + resultMap.set('token-2', false); // This token failed + mockPushService.sendToTokens!.mockResolvedValueOnce(resultMap); + + const result = await service.sendToUser(TEST_USER_ID, { + title: 'Test', + body: 'Test notification', + }); + + expect(result).toBe(true); + // Should deactivate the failed token + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: false })); + }); + + it('should return false when all tokens fail', async () => { + const token1 = createMockDeviceToken({ pushToken: 'token-1' }); + + mockDb.where.mockResolvedValueOnce([token1]); + + const resultMap = new Map(); + resultMap.set('token-1', false); + mockPushService.sendToTokens!.mockResolvedValueOnce(resultMap); + + const result = await service.sendToUser(TEST_USER_ID, { + title: 'Test', + body: 'Test notification', + }); + + expect(result).toBe(false); + }); + }); + + describe('getTokenCount', () => { + it('should return count of active tokens', async () => { + const tokens = [ + createMockDeviceToken({ isActive: true }), + createMockDeviceToken({ isActive: true }), + createMockDeviceToken({ isActive: true }), + ]; + mockDb.where.mockResolvedValueOnce(tokens); + + const result = await service.getTokenCount(TEST_USER_ID); + + expect(result).toBe(3); + }); + + it('should return 0 when user has no active tokens', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getTokenCount(TEST_USER_ID); + + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/sync/sync.service.spec.ts b/apps/calendar/apps/backend/src/sync/sync.service.spec.ts new file mode 100644 index 000000000..d08ce7cc9 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/sync.service.spec.ts @@ -0,0 +1,357 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { SyncService } from './sync.service'; +import { ICalService } from './ical.service'; +import { CalDavService } from './caldav.service'; +import { GoogleCalendarService } from './google-calendar.service'; +import { EncryptionService } from '../common/encryption.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; +import { v4 as uuidv4 } from 'uuid'; + +function createMockExternalCalendar(overrides: Record = {}) { + return { + id: uuidv4(), + userId: TEST_USER_ID, + name: 'External Calendar', + provider: 'ical_url', + calendarUrl: 'https://example.com/calendar.ics', + username: null, + encryptedPassword: null, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + syncEnabled: true, + syncDirection: 'both', + syncInterval: 15, + lastSyncAt: null, + lastSyncError: null, + color: '#6B7280', + isVisible: true, + providerData: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('SyncService', () => { + let service: SyncService; + let mockDb: any; + let mockICalService: any; + let mockCalDavService: any; + let mockGoogleCalendarService: any; + let mockEncryptionService: any; + + 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().mockResolvedValue([]), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + mockICalService = { + fetchAndParseICalUrl: jest.fn().mockResolvedValue([]), + generateICalData: jest.fn().mockReturnValue('BEGIN:VCALENDAR\nEND:VCALENDAR'), + }; + + mockCalDavService = { + discoverCalendars: jest.fn().mockResolvedValue([]), + getAppleCalDavUrl: jest.fn().mockReturnValue('https://caldav.icloud.com'), + fetchEvents: jest.fn().mockResolvedValue({ events: [], ctag: null }), + upsertEvent: jest.fn().mockResolvedValue(undefined), + }; + + mockGoogleCalendarService = { + isConfigured: jest.fn().mockReturnValue(false), + getAuthUrl: jest.fn().mockReturnValue('https://google.com/auth'), + exchangeCodeForTokens: jest.fn(), + listCalendars: jest.fn(), + refreshAccessToken: jest.fn(), + fetchEvents: jest.fn().mockResolvedValue([]), + createEvent: jest.fn(), + updateEvent: jest.fn(), + }; + + mockEncryptionService = { + encrypt: jest.fn().mockReturnValue('encrypted-password'), + decrypt: jest.fn().mockReturnValue('decrypted-password'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SyncService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: ICalService, + useValue: mockICalService, + }, + { + provide: CalDavService, + useValue: mockCalDavService, + }, + { + provide: GoogleCalendarService, + useValue: mockGoogleCalendarService, + }, + { + provide: EncryptionService, + useValue: mockEncryptionService, + }, + ], + }).compile(); + + service = module.get(SyncService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByUser', () => { + it('should return all external calendars for a user', async () => { + const calendars = [ + createMockExternalCalendar({ name: 'Calendar 1' }), + createMockExternalCalendar({ name: 'Calendar 2' }), + ]; + mockDb.where.mockResolvedValueOnce(calendars); + + const result = await service.findByUser(TEST_USER_ID); + + expect(result).toEqual(calendars); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + }); + + it('should return empty array when user has no external calendars', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findByUser(TEST_USER_ID); + + expect(result).toEqual([]); + }); + }); + + describe('findOne', () => { + it('should return an external calendar when found', async () => { + const calendar = createMockExternalCalendar(); + mockDb.where.mockResolvedValueOnce([calendar]); + + const result = await service.findOne(calendar.id as string, TEST_USER_ID); + + expect(result).toEqual(calendar); + }); + + it('should throw NotFoundException when calendar not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findOne('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('connect', () => { + it('should connect an iCal URL calendar', async () => { + const newCalendar = createMockExternalCalendar({ provider: 'ical_url' }); + mockDb.returning.mockResolvedValueOnce([newCalendar]); + + const result = await service.connect(TEST_USER_ID, { + name: 'External Calendar', + provider: 'ical_url', + calendarUrl: 'https://example.com/calendar.ics', + }); + + expect(result).toEqual(newCalendar); + expect(mockICalService.fetchAndParseICalUrl).toHaveBeenCalledWith( + 'https://example.com/calendar.ics' + ); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should throw BadRequestException for CalDAV without credentials', async () => { + await expect( + service.connect(TEST_USER_ID, { + name: 'CalDAV Calendar', + provider: 'caldav', + calendarUrl: 'https://caldav.example.com/cal', + }) + ).rejects.toThrow(BadRequestException); + }); + + it('should connect a CalDAV calendar with credentials', async () => { + const newCalendar = createMockExternalCalendar({ provider: 'caldav' }); + mockDb.returning.mockResolvedValueOnce([newCalendar]); + + const result = await service.connect(TEST_USER_ID, { + name: 'CalDAV Calendar', + provider: 'caldav', + calendarUrl: 'https://caldav.example.com/cal', + username: 'user', + password: 'pass', + }); + + expect(result).toEqual(newCalendar); + expect(mockCalDavService.discoverCalendars).toHaveBeenCalled(); + expect(mockEncryptionService.encrypt).toHaveBeenCalledWith('pass'); + }); + + it('should throw BadRequestException for Google without access token', async () => { + await expect( + service.connect(TEST_USER_ID, { + name: 'Google Calendar', + provider: 'google', + calendarUrl: 'https://google.com/calendar', + }) + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('disconnect', () => { + it('should disconnect an external calendar and delete synced events', async () => { + const calendar = createMockExternalCalendar(); + mockDb.where.mockResolvedValueOnce([calendar]); + + await service.disconnect(calendar.id as string, TEST_USER_ID); + + // Should delete synced events and the calendar + expect(mockDb.delete).toHaveBeenCalledTimes(2); + }); + + it('should throw NotFoundException when calendar not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.disconnect('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('update', () => { + it('should update external calendar settings', async () => { + const calendar = createMockExternalCalendar(); + const updatedCalendar = { ...calendar, name: 'Updated Name' }; + + // findOne + mockDb.where.mockResolvedValueOnce([calendar]); + // update returning + mockDb.returning.mockResolvedValueOnce([updatedCalendar]); + + const result = await service.update(calendar.id as string, TEST_USER_ID, { + name: 'Updated Name', + }); + + expect(result.name).toBe('Updated Name'); + expect(mockDb.update).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when updating non-existent calendar', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('syncCalendar', () => { + it('should return early when sync is disabled', async () => { + const calendar = createMockExternalCalendar({ syncEnabled: false }); + mockDb.where.mockResolvedValueOnce([calendar]); + + const result = await service.syncCalendar(calendar.id as string); + + expect(result.success).toBe(true); + expect(result.eventsImported).toBe(0); + expect(result.eventsExported).toBe(0); + expect(result.errors).toContain('Sync is disabled'); + }); + + it('should throw NotFoundException when calendar not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.syncCalendar('non-existent-id')).rejects.toThrow(NotFoundException); + }); + }); + + describe('discoverCalDav', () => { + it('should discover CalDAV calendars', async () => { + mockCalDavService.discoverCalendars!.mockResolvedValueOnce([ + { + url: 'https://caldav.example.com/cal/1', + displayName: 'My Calendar', + color: '#3B82F6', + description: 'Test calendar', + ctag: null, + }, + ]); + + const result = await service.discoverCalDav({ + serverUrl: 'https://caldav.example.com', + username: 'user', + password: 'pass', + }); + + expect(result.calendars).toHaveLength(1); + expect(result.calendars[0].name).toBe('My Calendar'); + expect(result.calendars[0].url).toBe('https://caldav.example.com/cal/1'); + }); + }); + + describe('getGoogleAuthUrl', () => { + it('should throw BadRequestException when Google is not configured', () => { + mockGoogleCalendarService.isConfigured!.mockReturnValue(false); + + expect(() => service.getGoogleAuthUrl()).toThrow(BadRequestException); + }); + + it('should return auth URL when Google is configured', () => { + mockGoogleCalendarService.isConfigured!.mockReturnValue(true); + mockGoogleCalendarService.getAuthUrl!.mockReturnValue('https://google.com/auth?state=test'); + + const result = service.getGoogleAuthUrl('test'); + + expect(result).toBe('https://google.com/auth?state=test'); + }); + }); + + describe('exportCalendarAsIcal', () => { + it('should export a calendar as iCal data', async () => { + const calendar = { + id: uuidv4(), + userId: TEST_USER_ID, + name: 'My Calendar', + }; + const calendarEvents = [{ id: uuidv4(), title: 'Event 1' }]; + + // Find calendar + mockDb.where.mockResolvedValueOnce([calendar]); + // Find events + mockDb.where.mockResolvedValueOnce(calendarEvents); + + const result = await service.exportCalendarAsIcal(calendar.id, TEST_USER_ID); + + expect(result).toBe('BEGIN:VCALENDAR\nEND:VCALENDAR'); + expect(mockICalService.generateICalData).toHaveBeenCalledWith('My Calendar', calendarEvents); + }); + + it('should throw NotFoundException when calendar not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.exportCalendarAsIcal('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); +});