import { test, expect } from '@playwright/test';
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
test.describe('CalDAV Client Compatibility', () => {
const baseURL = process.env.APP_URL ?? 'http://localhost:8080';
const apiURL = process.env.API_URL ?? 'http://localhost:3002';
const testUser = {
email: process.env.E2E_EMAIL || 'test@tududi.com',
password: process.env.E2E_PASSWORD || 'password123',
username: 'test'
};
let authHeader: string;
test.beforeAll(async () => {
authHeader = 'Basic ' + Buffer.from(`${testUser.email}:${testUser.password}`).toString('base64');
});
test.describe('CalDAV Discovery', () => {
test('should redirect .well-known/caldav to /caldav/', async ({ request }) => {
const response = await request.get(`${apiURL}/.well-known/caldav`, {
maxRedirects: 0
});
expect([301, 302, 307, 308]).toContain(response.status());
const location = response.headers()['location'];
expect(location).toContain('/caldav');
});
test('should support OPTIONS on CalDAV endpoint', async ({ request }) => {
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'OPTIONS',
headers: {
'Authorization': authHeader
}
});
expect(response.ok()).toBeTruthy();
const dav = response.headers()['dav'];
expect(dav).toContain('calendar-access');
});
});
test.describe('PROPFIND - List Tasks', () => {
test('should list tasks in calendar collection', async ({ request }) => {
const propfindBody = `
`;
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'PROPFIND',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/xml',
'Depth': '1'
},
data: propfindBody
});
expect(response.status()).toBe(207);
const body = await response.text();
expect(body).toContain('multistatus');
});
test('should handle Depth: 0 for collection properties', async ({ request }) => {
const propfindBody = `
`;
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'PROPFIND',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/xml',
'Depth': '0'
},
data: propfindBody
});
expect(response.status()).toBe(207);
const body = await response.text();
expect(body).toContain('collection');
});
});
test.describe('REPORT - Calendar Query', () => {
test('should query tasks with calendar-query REPORT', async ({ request }) => {
const reportBody = `
`;
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'REPORT',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/xml',
'Depth': '1'
},
data: reportBody
});
expect(response.status()).toBe(207);
const body = await response.text();
expect(body).toContain('multistatus');
});
test('should filter tasks by time range', async ({ request }) => {
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
const endDate = new Date();
endDate.setDate(endDate.getDate() + 7);
const reportBody = `
`;
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'REPORT',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/xml',
'Depth': '1'
},
data: reportBody
});
expect(response.status()).toBe(207);
});
});
test.describe('GET/PUT/DELETE - Task Operations', () => {
let taskUID: string;
test('should create task via PUT', async ({ request }) => {
taskUID = `test-${Date.now()}@tududi.local`;
const vtodo = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tududi//E2E Test//EN
BEGIN:VTODO
UID:${taskUID}
SUMMARY:E2E Test Task
STATUS:NEEDS-ACTION
PRIORITY:5
DUE:${new Date(Date.now() + 86400000).toISOString().replace(/[-:]/g, '').split('.')[0]}Z
CREATED:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
END:VTODO
END:VCALENDAR`;
const response = await request.put(`${apiURL}/caldav/${testUser.username}/tasks/${taskUID}/`, {
headers: {
'Authorization': authHeader,
'Content-Type': 'text/calendar; charset=utf-8'
},
data: vtodo
});
expect([201, 204]).toContain(response.status());
const etag = response.headers()['etag'];
expect(etag).toBeTruthy();
});
test('should retrieve task via GET', async ({ request }) => {
const response = await request.get(`${apiURL}/caldav/${testUser.username}/tasks/${taskUID}/`, {
headers: {
'Authorization': authHeader
}
});
expect(response.ok()).toBeTruthy();
expect(response.headers()['content-type']).toContain('text/calendar');
const body = await response.text();
expect(body).toContain('BEGIN:VCALENDAR');
expect(body).toContain('BEGIN:VTODO');
expect(body).toContain(`UID:${taskUID}`);
expect(body).toContain('SUMMARY:E2E Test Task');
});
test('should update task via PUT', async ({ request }) => {
const updatedVtodo = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tududi//E2E Test//EN
BEGIN:VTODO
UID:${taskUID}
SUMMARY:Updated E2E Test Task
STATUS:IN-PROCESS
PRIORITY:3
DUE:${new Date(Date.now() + 86400000).toISOString().replace(/[-:]/g, '').split('.')[0]}Z
CREATED:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
LAST-MODIFIED:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
END:VTODO
END:VCALENDAR`;
const response = await request.put(`${apiURL}/caldav/${testUser.username}/tasks/${taskUID}/`, {
headers: {
'Authorization': authHeader,
'Content-Type': 'text/calendar; charset=utf-8'
},
data: updatedVtodo
});
expect([200, 204]).toContain(response.status());
const getResponse = await request.get(`${apiURL}/caldav/${testUser.username}/tasks/${taskUID}/`, {
headers: {
'Authorization': authHeader
}
});
const body = await getResponse.text();
expect(body).toContain('SUMMARY:Updated E2E Test Task');
expect(body).toContain('STATUS:IN-PROCESS');
});
test('should delete task via DELETE', async ({ request }) => {
const response = await request.delete(`${apiURL}/caldav/${testUser.username}/tasks/${taskUID}/`, {
headers: {
'Authorization': authHeader
}
});
expect([204, 200]).toContain(response.status());
const getResponse = await request.get(`${apiURL}/caldav/${testUser.username}/tasks/${taskUID}/`, {
headers: {
'Authorization': authHeader
},
maxRedirects: 0
});
expect(getResponse.status()).toBe(404);
});
});
test.describe('Recurring Tasks', () => {
let recurringUID: string;
test('should create recurring task with RRULE', async ({ request }) => {
recurringUID = `recurring-${Date.now()}@tududi.local`;
const vtodo = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tududi//E2E Test//EN
BEGIN:VTODO
UID:${recurringUID}
SUMMARY:Daily Recurring Task
STATUS:NEEDS-ACTION
RRULE:FREQ=DAILY;COUNT=7
DUE:${new Date(Date.now() + 86400000).toISOString().replace(/[-:]/g, '').split('.')[0]}Z
CREATED:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
END:VTODO
END:VCALENDAR`;
const response = await request.put(`${apiURL}/caldav/${testUser.username}/tasks/${recurringUID}/`, {
headers: {
'Authorization': authHeader,
'Content-Type': 'text/calendar; charset=utf-8'
},
data: vtodo
});
expect([201, 204]).toContain(response.status());
});
test('should expand recurring task instances in PROPFIND', async ({ request }) => {
const propfindBody = `
`;
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'PROPFIND',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/xml',
'Depth': '1'
},
data: propfindBody
});
expect(response.status()).toBe(207);
const body = await response.text();
expect(body).toContain(recurringUID);
});
test('should cleanup recurring task', async ({ request }) => {
await request.delete(`${apiURL}/caldav/${testUser.username}/tasks/${recurringUID}/`, {
headers: {
'Authorization': authHeader
}
});
});
});
test.describe('Authentication', () => {
test('should reject requests without authentication', async ({ request }) => {
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'PROPFIND',
headers: {
'Content-Type': 'application/xml',
'Depth': '1'
}
});
expect(response.status()).toBe(401);
expect(response.headers()['www-authenticate']).toBeTruthy();
});
test('should reject invalid credentials', async ({ request }) => {
const invalidAuth = 'Basic ' + Buffer.from('invalid:credentials').toString('base64');
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'PROPFIND',
headers: {
'Authorization': invalidAuth,
'Content-Type': 'application/xml',
'Depth': '1'
}
});
expect(response.status()).toBe(401);
});
});
test.describe('Performance', () => {
test('should handle PROPFIND for large calendar efficiently', async ({ request }) => {
const startTime = Date.now();
const propfindBody = `
`;
const response = await request.fetch(`${apiURL}/caldav/${testUser.username}/tasks/`, {
method: 'PROPFIND',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/xml',
'Depth': '1'
},
data: propfindBody
});
const endTime = Date.now();
const duration = endTime - startTime;
expect(response.status()).toBe(207);
expect(duration).toBeLessThan(5000);
});
});
test.describe('Edge Cases', () => {
test('should handle malformed VTODO gracefully', async ({ request }) => {
const malformedVtodo = `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:malformed-${Date.now()}
SUMMARY:Malformed Task
THIS-IS-NOT-VALID:foo
END:VCALENDAR`;
const response = await request.put(`${apiURL}/caldav/${testUser.username}/tasks/malformed-test/`, {
headers: {
'Authorization': authHeader,
'Content-Type': 'text/calendar'
},
data: malformedVtodo
});
expect([400, 422]).toContain(response.status());
});
test('should preserve timezone information', async ({ request }) => {
const tzUID = `tz-${Date.now()}@tududi.local`;
const vtodo = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tududi//E2E Test//EN
BEGIN:VTODO
UID:${tzUID}
SUMMARY:Timezone Test
DUE:20260420T140000Z
CREATED:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
END:VTODO
END:VCALENDAR`;
const putResponse = await request.put(`${apiURL}/caldav/${testUser.username}/tasks/${tzUID}/`, {
headers: {
'Authorization': authHeader,
'Content-Type': 'text/calendar'
},
data: vtodo
});
expect([201, 204]).toContain(putResponse.status());
const getResponse = await request.get(`${apiURL}/caldav/${testUser.username}/tasks/${tzUID}/`, {
headers: {
'Authorization': authHeader
}
});
const body = await getResponse.text();
expect(body).toContain('DUE:20260420T140000Z');
await request.delete(`${apiURL}/caldav/${testUser.username}/tasks/${tzUID}/`, {
headers: {
'Authorization': authHeader
}
});
});
});
});