test(calendar): add 69 backend unit tests for event-tags, sync, and notifications

- event-tag.service.spec.ts: 13 tests (CRUD, event associations, group assignment)
- event-tag-group.service.spec.ts: 13 tests (CRUD, default creation, reorder)
- sync.service.spec.ts: 32 tests (iCal/CalDAV/Google connect, disconnect, sync, export)
- notification.service.spec.ts: 11 tests (register/remove token, send push, deactivation)
Total: 132 backend tests (was 63)

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

View file

@ -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<string, unknown> = {}) {
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>(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();
});
});
});

View file

@ -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<string, unknown> = {}) {
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>(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
);
});
});
});

View file

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

View file

@ -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<string, unknown> = {}) {
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>(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
);
});
});
});