const request = require('supertest');
const bcrypt = require('bcrypt');
const axios = require('axios');
const app = require('../../app');
const {
sequelize,
User,
Task,
CalDAVCalendar,
CalDAVRemoteCalendar,
CalDAVSyncState,
} = require('../../models');
const syncEngine = require('../../modules/caldav/sync/sync-engine');
const encryptionService = require('../../modules/caldav/services/encryption-service');
jest.mock('axios');
describe('CalDAV Sync Engine', () => {
let testUser;
let calendar;
let remoteCalendar;
beforeAll(async () => {
await sequelize.sync({ force: true });
});
beforeEach(async () => {
testUser = await User.create({
email: 'synctest@test.com',
password_digest: await bcrypt.hash('password', 10),
verified: true,
});
calendar = await CalDAVCalendar.create({
uid: 'calendar-uid-1',
user_id: testUser.id,
name: 'Test Calendar',
enabled: true,
sync_direction: 'bidirectional',
sync_interval_minutes: 15,
conflict_resolution: 'last_write_wins',
});
remoteCalendar = await CalDAVRemoteCalendar.create({
user_id: testUser.id,
local_calendar_id: calendar.id,
name: 'Remote Test Calendar',
server_url: 'https://caldav.example.com',
calendar_path: '/calendars/test/tasks/',
username: 'testuser',
password_encrypted: encryptionService.encrypt('password123'),
auth_type: 'basic',
enabled: true,
sync_direction: 'bidirectional',
});
});
afterEach(async () => {
await CalDAVSyncState.destroy({ where: {} });
await CalDAVRemoteCalendar.destroy({ where: {} });
await CalDAVCalendar.destroy({ where: {} });
await Task.destroy({ where: {} });
await User.destroy({ where: {} });
jest.clearAllMocks();
});
afterAll(async () => {
await sequelize.close();
});
describe('Pull Phase', () => {
test('should fetch and create new tasks from remote', async () => {
const mockReportResponse = `
/calendars/test/tasks/task-1.ics
"etag-task-1"
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tududi//EN
BEGIN:VTODO
UID:remote-task-1
SUMMARY:Remote Task
STATUS:NEEDS-ACTION
PRIORITY:5
END:VTODO
END:VCALENDAR
HTTP/1.1 200 OK
new-sync-token-123
`;
axios.mockResolvedValue({
status: 207,
data: mockReportResponse,
headers: {},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'pull' }
);
expect(result.success).toBe(true);
expect(result.stats.pulled).toBeGreaterThan(0);
const createdTask = await Task.findOne({
where: { uid: 'remote-task-1' },
});
expect(createdTask).toBeTruthy();
expect(createdTask.name).toBe('Remote Task');
});
test('should detect deleted tasks from remote', async () => {
const localTask = await Task.create({
uid: 'task-to-delete',
user_id: testUser.id,
name: 'Task to Delete',
status: 0,
});
await CalDAVSyncState.create({
task_id: localTask.id,
calendar_id: calendar.id,
etag: 'old-etag',
last_modified: new Date(),
last_synced_at: new Date(),
sync_status: 'synced',
});
const mockReportResponse = `
/calendars/test/tasks/task-to-delete.ics
HTTP/1.1 404 Not Found
`;
axios.mockResolvedValue({
status: 207,
data: mockReportResponse,
headers: {},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'pull' }
);
expect(result.success).toBe(true);
const deletedTask = await Task.findOne({
where: { uid: 'task-to-delete' },
});
expect(deletedTask).toBeNull();
});
test('should handle authentication failure', async () => {
axios.mockRejectedValue({
response: { status: 401 },
message: 'Unauthorized',
});
await expect(
syncEngine.syncCalendar(calendar.id, testUser.id, {
direction: 'pull',
})
).rejects.toThrow();
});
});
describe('Merge Phase', () => {
test('should update task from remote when only remote modified', async () => {
const task = await Task.create({
uid: 'test-task',
user_id: testUser.id,
name: 'Original Name',
status: 0,
});
const pastTime = new Date(Date.now() - 10000);
await CalDAVSyncState.create({
task_id: task.id,
calendar_id: calendar.id,
etag: 'old-etag',
last_modified: pastTime,
last_synced_at: pastTime,
sync_status: 'synced',
});
const mockReportResponse = `
/calendars/test/tasks/test-task.ics
"new-etag"
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:test-task
SUMMARY:Updated Name from Remote
STATUS:IN-PROCESS
END:VTODO
END:VCALENDAR
HTTP/1.1 200 OK
`;
axios.mockResolvedValue({
status: 207,
data: mockReportResponse,
headers: {},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'pull' }
);
expect(result.success).toBe(true);
await task.reload();
expect(task.name).toBe('Updated Name from Remote');
expect(task.status).toBe(1);
});
test('should detect conflict when both local and remote modified', async () => {
const task = await Task.create({
uid: 'conflict-task',
user_id: testUser.id,
name: 'Original Name',
status: 0,
});
const pastTime = new Date(Date.now() - 10000);
await CalDAVSyncState.create({
task_id: task.id,
calendar_id: calendar.id,
etag: 'old-etag',
last_modified: pastTime,
last_synced_at: pastTime,
sync_status: 'synced',
});
await task.update({ name: 'Local Update' });
const mockReportResponse = `
/calendars/test/tasks/conflict-task.ics
"new-etag"
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:conflict-task
SUMMARY:Remote Update
STATUS:IN-PROCESS
END:VTODO
END:VCALENDAR
HTTP/1.1 200 OK
`;
axios.mockResolvedValue({
status: 207,
data: mockReportResponse,
headers: {},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'pull' }
);
expect(result.success).toBe(true);
});
});
describe('Push Phase', () => {
test('should push new local task to remote', async () => {
const task = await Task.create({
uid: 'new-local-task',
user_id: testUser.id,
name: 'New Local Task',
status: 0,
});
axios.mockResolvedValue({
status: 201,
headers: {
etag: '"new-task-etag"',
},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'push' }
);
expect(result.success).toBe(true);
expect(result.stats.pushed).toBeGreaterThan(0);
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'PUT',
url: expect.stringContaining('new-local-task.ics'),
})
);
});
test('should push modified local task to remote', async () => {
const task = await Task.create({
uid: 'modified-task',
user_id: testUser.id,
name: 'Modified Task',
status: 1,
});
const pastTime = new Date(Date.now() - 10000);
await CalDAVSyncState.create({
task_id: task.id,
calendar_id: calendar.id,
etag: 'old-etag',
last_modified: pastTime,
last_synced_at: pastTime,
sync_status: 'synced',
});
await task.update({ name: 'Updated Locally' });
axios.mockResolvedValue({
status: 204,
headers: {
etag: '"updated-etag"',
},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'push' }
);
expect(result.success).toBe(true);
expect(result.stats.pushed).toBeGreaterThan(0);
});
test('should detect conflict on push with precondition failed', async () => {
const task = await Task.create({
uid: 'push-conflict-task',
user_id: testUser.id,
name: 'Task',
status: 0,
});
await CalDAVSyncState.create({
task_id: task.id,
calendar_id: calendar.id,
etag: 'etag-1',
last_modified: new Date(),
last_synced_at: new Date(),
sync_status: 'synced',
});
await task.update({ name: 'Local Update' });
axios.mockRejectedValue({
response: { status: 412 },
message: 'Precondition Failed',
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'push' }
);
expect(result.success).toBe(true);
expect(result.phases.push.errors.length).toBeGreaterThan(0);
});
});
describe('Bidirectional Sync', () => {
test('should complete full bidirectional sync', async () => {
const localTask = await Task.create({
uid: 'local-task',
user_id: testUser.id,
name: 'Local Task',
status: 0,
});
const mockReportResponse = `
/calendars/test/tasks/remote-task.ics
"remote-etag"
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:remote-task
SUMMARY:Remote Task
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR
HTTP/1.1 200 OK
`;
axios.mockImplementation((config) => {
if (config.method === 'REPORT') {
return Promise.resolve({
status: 207,
data: mockReportResponse,
headers: {},
});
} else if (config.method === 'PUT') {
return Promise.resolve({
status: 201,
headers: { etag: '"new-etag"' },
});
}
return Promise.reject(new Error('Unexpected request'));
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'bidirectional' }
);
expect(result.success).toBe(true);
expect(result.stats.pulled).toBeGreaterThan(0);
expect(result.stats.pushed).toBeGreaterThan(0);
const remoteTaskLocal = await Task.findOne({
where: { uid: 'remote-task' },
});
expect(remoteTaskLocal).toBeTruthy();
});
});
describe('Dry Run Mode', () => {
test('should not apply changes in dry run mode', async () => {
const mockReportResponse = `
/calendars/test/tasks/dry-run-task.ics
"dry-run-etag"
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:dry-run-task
SUMMARY:Dry Run Task
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR
HTTP/1.1 200 OK
`;
axios.mockResolvedValue({
status: 207,
data: mockReportResponse,
headers: {},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'pull', dryRun: true }
);
expect(result.success).toBe(true);
expect(result.dryRun).toBe(true);
const task = await Task.findOne({ where: { uid: 'dry-run-task' } });
expect(task).toBeNull();
});
});
describe('Sync Status', () => {
test('should update calendar sync status on success', async () => {
axios.mockResolvedValue({
status: 207,
data: '',
headers: {},
});
await syncEngine.syncCalendar(calendar.id, testUser.id);
await calendar.reload();
expect(calendar.last_sync_at).toBeTruthy();
expect(calendar.last_sync_status).toBe('success');
});
test('should update calendar sync status on error', async () => {
axios.mockRejectedValue(new Error('Network error'));
await expect(
syncEngine.syncCalendar(calendar.id, testUser.id)
).rejects.toThrow();
await calendar.reload();
expect(calendar.last_sync_status).toBe('error');
});
});
describe('Conflict Resolution Strategies', () => {
test('should use local_wins strategy', async () => {
await calendar.update({ conflict_resolution: 'local_wins' });
const task = await Task.create({
uid: 'strategy-test-task',
user_id: testUser.id,
name: 'Local Version',
status: 1,
});
const pastTime = new Date(Date.now() - 10000);
await CalDAVSyncState.create({
task_id: task.id,
calendar_id: calendar.id,
etag: 'old-etag',
last_modified: pastTime,
last_synced_at: pastTime,
sync_status: 'synced',
});
await task.update({ name: 'Updated Local Version' });
const mockReportResponse = `
/calendars/test/tasks/strategy-test-task.ics
"new-etag"
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:strategy-test-task
SUMMARY:Remote Version
STATUS:COMPLETED
END:VTODO
END:VCALENDAR
HTTP/1.1 200 OK
`;
axios.mockResolvedValue({
status: 207,
data: mockReportResponse,
headers: {},
});
const result = await syncEngine.syncCalendar(
calendar.id,
testUser.id,
{ direction: 'pull' }
);
expect(result.success).toBe(true);
await task.reload();
expect(task.name).toBe('Updated Local Version');
});
});
});