diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts new file mode 100644 index 0000000..6237062 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts @@ -0,0 +1,363 @@ +/** + * Tests for CalDAV Helper functions + */ +import { + escapeXml, + XML_NS, + xmlProp, + xmlPropOptional, + buildCalendarPropsXml, + buildMkCalendarXml, + buildProppatchXml, + sharePrivilegeToXml, + parseSharePrivilege, + buildShareeSetXml, + buildShareRequestXml, + buildUnshareRequestXml, + buildInviteReplyXml, + buildSyncCollectionXml, + buildPrincipalSearchXml, + parseCalendarComponents, + parseShareStatus, + getCalendarUrlFromEventUrl, +} from '../caldav-helpers' +import type { SharePrivilege } from '../types/caldav-service' + +describe('caldav-helpers', () => { + // ============================================================================ + // XML Helpers + // ============================================================================ + describe('XML Helpers', () => { + describe('escapeXml', () => { + it('escapes ampersand', () => { + expect(escapeXml('Tom & Jerry')).toBe('Tom & Jerry') + }) + + it('escapes less than', () => { + expect(escapeXml('1 < 2')).toBe('1 < 2') + }) + + it('escapes greater than', () => { + expect(escapeXml('2 > 1')).toBe('2 > 1') + }) + + it('escapes double quotes', () => { + expect(escapeXml('He said "hello"')).toBe('He said "hello"') + }) + + it('escapes single quotes', () => { + expect(escapeXml("It's fine")).toBe('It's fine') + }) + + it('escapes multiple characters', () => { + expect(escapeXml('text & more')).toBe( + '<tag attr="val">text & more</tag>' + ) + }) + }) + + describe('XML_NS', () => { + it('contains correct namespaces', () => { + expect(XML_NS.DAV).toBe('xmlns:D="DAV:"') + expect(XML_NS.CALDAV).toBe('xmlns:C="urn:ietf:params:xml:ns:caldav"') + expect(XML_NS.APPLE).toBe('xmlns:A="http://apple.com/ns/ical/"') + expect(XML_NS.CS).toBe('xmlns:CS="http://calendarserver.org/ns/"') + }) + }) + + describe('xmlProp', () => { + it('creates XML element with namespace', () => { + expect(xmlProp('D', 'displayname', 'My Calendar')).toBe( + 'My Calendar' + ) + }) + + it('escapes value content', () => { + expect(xmlProp('D', 'displayname', 'Tom & Jerry')).toBe( + 'Tom & Jerry' + ) + }) + }) + + describe('xmlPropOptional', () => { + it('returns element when value is defined', () => { + expect(xmlPropOptional('D', 'displayname', 'Test')).toBe( + 'Test' + ) + }) + + it('returns empty string when value is undefined', () => { + expect(xmlPropOptional('D', 'displayname', undefined)).toBe('') + }) + }) + }) + + // ============================================================================ + // Calendar Property Builders + // ============================================================================ + describe('Calendar Property Builders', () => { + describe('buildCalendarPropsXml', () => { + it('builds displayName property', () => { + const result = buildCalendarPropsXml({ displayName: 'My Calendar' }) + expect(result).toContain('My Calendar') + }) + + it('builds description property', () => { + const result = buildCalendarPropsXml({ description: 'A test calendar' }) + expect(result).toContain('A test calendar') + }) + + it('builds color property', () => { + const result = buildCalendarPropsXml({ color: '#ff0000' }) + expect(result).toContain('#ff0000') + }) + + it('builds components property', () => { + const result = buildCalendarPropsXml({ components: ['VEVENT', 'VTODO'] }) + expect(result.join('')).toContain('supported-calendar-component-set') + expect(result.join('')).toContain('') + expect(result.join('')).toContain('') + }) + + it('builds multiple properties', () => { + const result = buildCalendarPropsXml({ + displayName: 'Work', + description: 'Work calendar', + color: '#0000ff', + }) + expect(result).toHaveLength(3) + }) + }) + + describe('buildMkCalendarXml', () => { + it('creates valid MKCALENDAR XML', () => { + const result = buildMkCalendarXml({ displayName: 'New Calendar' }) + expect(result).toContain('New Calendar') + expect(result).toContain('') + }) + }) + + describe('buildProppatchXml', () => { + it('creates valid PROPPATCH XML', () => { + const result = buildProppatchXml({ displayName: 'Updated Name' }) + expect(result).toContain('') + expect(result).toContain('Updated Name') + }) + }) + }) + + // ============================================================================ + // Sharing XML Builders + // ============================================================================ + describe('Sharing XML Builders', () => { + describe('sharePrivilegeToXml', () => { + it('converts read privilege', () => { + expect(sharePrivilegeToXml('read')).toBe('') + }) + + it('converts read-write privilege', () => { + expect(sharePrivilegeToXml('read-write')).toBe('') + }) + + it('converts admin privilege', () => { + expect(sharePrivilegeToXml('admin')).toBe('') + }) + + it('defaults to read for unknown', () => { + expect(sharePrivilegeToXml('unknown' as SharePrivilege)).toBe('') + }) + }) + + describe('parseSharePrivilege', () => { + it('returns read-write when present', () => { + expect(parseSharePrivilege({ 'read-write': true })).toBe('read-write') + }) + + it('returns admin when present', () => { + expect(parseSharePrivilege({ admin: true })).toBe('admin') + }) + + it('returns read as default', () => { + expect(parseSharePrivilege({})).toBe('read') + expect(parseSharePrivilege(null)).toBe('read') + }) + }) + + describe('buildShareeSetXml', () => { + it('builds basic sharee XML', () => { + const result = buildShareeSetXml({ + href: 'mailto:user@example.com', + privilege: 'read-write', + }) + expect(result).toContain('') + expect(result).toContain('mailto:user@example.com') + expect(result).toContain('') + }) + + it('includes displayName when provided', () => { + const result = buildShareeSetXml({ + href: 'mailto:user@example.com', + displayName: 'John Doe', + privilege: 'read', + }) + expect(result).toContain('John Doe') + }) + + it('includes summary when provided', () => { + const result = buildShareeSetXml({ + href: 'mailto:user@example.com', + summary: 'Shared calendar', + privilege: 'read', + }) + expect(result).toContain('Shared calendar') + }) + }) + + describe('buildShareRequestXml', () => { + it('builds share request with multiple sharees', () => { + const result = buildShareRequestXml([ + { href: 'mailto:user1@example.com', privilege: 'read' }, + { href: 'mailto:user2@example.com', privilege: 'read-write' }, + ]) + expect(result).toContain(' { + it('builds unshare request', () => { + const result = buildUnshareRequestXml('mailto:user@example.com') + expect(result).toContain('') + expect(result).toContain('mailto:user@example.com') + }) + }) + + describe('buildInviteReplyXml', () => { + it('builds accept reply', () => { + const result = buildInviteReplyXml('invite-123', true) + expect(result).toContain('invite-123') + expect(result).toContain('') + }) + + it('builds decline reply', () => { + const result = buildInviteReplyXml('invite-123', false) + expect(result).toContain('') + }) + }) + }) + + // ============================================================================ + // Sync XML Builders + // ============================================================================ + describe('Sync XML Builders', () => { + describe('buildSyncCollectionXml', () => { + it('builds sync-collection XML', () => { + const result = buildSyncCollectionXml({ + syncToken: 'token-123', + }) + expect(result).toContain('token-123') + expect(result).toContain('1') + expect(result).toContain('') + expect(result).toContain('') + }) + + it('uses custom sync level', () => { + const result = buildSyncCollectionXml({ + syncToken: 'token-123', + syncLevel: 'infinite', + }) + expect(result).toContain('infinite') + }) + }) + }) + + // ============================================================================ + // Principal Search XML Builder + // ============================================================================ + describe('Principal Search XML Builder', () => { + describe('buildPrincipalSearchXml', () => { + it('builds principal search XML', () => { + const result = buildPrincipalSearchXml('john') + expect(result).toContain('john') + expect(result).toContain('') + expect(result).toContain(' { + const result = buildPrincipalSearchXml('Tom & Jerry') + expect(result).toContain('Tom & Jerry') + }) + }) + }) + + // ============================================================================ + // Response Parsing Helpers + // ============================================================================ + describe('Response Parsing Helpers', () => { + describe('parseCalendarComponents', () => { + it('returns undefined for empty input', () => { + expect(parseCalendarComponents(null)).toBeUndefined() + expect(parseCalendarComponents(undefined)).toBeUndefined() + }) + + it('parses array of components', () => { + const input = { + comp: [ + { _attributes: { name: 'VEVENT' } }, + { _attributes: { name: 'VTODO' } }, + ], + } + const result = parseCalendarComponents(input) + expect(result).toEqual(['VEVENT', 'VTODO']) + }) + + it('parses single component', () => { + const input = { + comp: { _attributes: { name: 'VEVENT' } }, + } + const result = parseCalendarComponents(input) + expect(result).toEqual(['VEVENT']) + }) + }) + + describe('parseShareStatus', () => { + it('returns accepted when accepted is truthy', () => { + expect(parseShareStatus(true, false)).toBe('accepted') + }) + + it('returns pending when noResponse is truthy', () => { + expect(parseShareStatus(false, true)).toBe('pending') + }) + + it('returns declined as default', () => { + expect(parseShareStatus(false, false)).toBe('declined') + }) + }) + + describe('getCalendarUrlFromEventUrl', () => { + it('extracts calendar URL from event URL', () => { + const eventUrl = '/calendars/user/calendar-1/event-123.ics' + expect(getCalendarUrlFromEventUrl(eventUrl)).toBe('/calendars/user/calendar-1/') + }) + + it('handles URL without trailing slash', () => { + const eventUrl = '/calendars/user/calendar-1/event.ics' + expect(getCalendarUrlFromEventUrl(eventUrl)).toBe('/calendars/user/calendar-1/') + }) + }) + }) +}) diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts new file mode 100644 index 0000000..010f284 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/event-calendar-helper.test.ts @@ -0,0 +1,710 @@ +/** + * Tests for Event Calendar Helper functions + */ +import { + formatEventCalendarDate, + parseEventCalendarDate, + isSameDay, + startOfDay, + endOfDay, + startOfWeek, + endOfWeek, + startOfMonth, + endOfMonth, + parseDuration, + durationToSeconds, + secondsToDuration, + formatDuration, + addDuration, + subtractDuration, + isAllDayEvent, + isMultiDayEvent, + getEventDurationMinutes, + eventOverlapsRange, + filterEventsInRange, + sortEventsByStart, + groupEventsByDate, + moveEvent, + resizeEvent, + getViewDateRange, + isListView, + isResourceView, + isTimelineView, + findResourceById, + flattenResources, + getEventsForResource, + icsDateToJsDate, + jsDateToIcsDate, + isIcsEventAllDay, + getIcsEventTimezone, + hasRecurrence, + isRecurringInstance, + describeRecurrence, + stringToColor, + isColorDark, + getContrastingTextColor, + getAttendeeDisplayName, + getAttendeeStatusIcon, + getAttendeeStatusColor, +} from '../helpers/event-calendar-helper' +import type { EventCalendarEvent, EventCalendarResource, EventCalendarDuration } from '../types/event-calendar' +import type { IcsDateObject, IcsEvent, IcsRecurrenceRule } from 'ts-ics' + +describe('event-calendar-helper', () => { + // ============================================================================ + // Date/Time Helpers + // ============================================================================ + describe('Date/Time Helpers', () => { + describe('formatEventCalendarDate', () => { + it('returns ISO string for Date object', () => { + const date = new Date('2025-01-15T10:30:00.000Z') + const result = formatEventCalendarDate(date) + expect(result).toBe('2025-01-15T10:30:00.000Z') + }) + + it('returns string as-is', () => { + const dateStr = '2025-01-15T10:30:00.000Z' + expect(formatEventCalendarDate(dateStr)).toBe(dateStr) + }) + }) + + describe('parseEventCalendarDate', () => { + it('returns Date object for string input', () => { + const result = parseEventCalendarDate('2025-01-15T10:30:00.000Z') + expect(result instanceof Date).toBe(true) + expect(result.toISOString()).toBe('2025-01-15T10:30:00.000Z') + }) + + it('returns same Date object for Date input', () => { + const date = new Date('2025-01-15T10:30:00.000Z') + expect(parseEventCalendarDate(date)).toBe(date) + }) + }) + + describe('isSameDay', () => { + it('returns true for same day', () => { + const date1 = new Date('2025-01-15T08:00:00.000Z') + const date2 = new Date('2025-01-15T20:00:00.000Z') + expect(isSameDay(date1, date2)).toBe(true) + }) + + it('returns false for different days', () => { + // Using dates that are clearly different days in any timezone + const date1 = new Date('2025-01-15T12:00:00.000Z') + const date2 = new Date('2025-01-17T12:00:00.000Z') + expect(isSameDay(date1, date2)).toBe(false) + }) + }) + + describe('startOfDay', () => { + it('returns start of day', () => { + const date = new Date('2025-01-15T14:30:45.123Z') + const result = startOfDay(date) + expect(result.getHours()).toBe(0) + expect(result.getMinutes()).toBe(0) + expect(result.getSeconds()).toBe(0) + expect(result.getMilliseconds()).toBe(0) + }) + }) + + describe('endOfDay', () => { + it('returns end of day', () => { + const date = new Date('2025-01-15T14:30:45.123Z') + const result = endOfDay(date) + expect(result.getHours()).toBe(23) + expect(result.getMinutes()).toBe(59) + expect(result.getSeconds()).toBe(59) + expect(result.getMilliseconds()).toBe(999) + }) + }) + + describe('startOfWeek', () => { + it('returns Monday for firstDay=1 (default)', () => { + const date = new Date(2025, 0, 15) // Wednesday + const result = startOfWeek(date) + expect(result.getDay()).toBe(1) // Monday + }) + + it('returns Sunday for firstDay=0', () => { + const date = new Date(2025, 0, 15) // Wednesday + const result = startOfWeek(date, 0) + expect(result.getDay()).toBe(0) // Sunday + }) + }) + + describe('endOfWeek', () => { + it('returns Sunday for firstDay=1 (Monday start)', () => { + const date = new Date(2025, 0, 15) // Wednesday + const result = endOfWeek(date) + expect(result.getDay()).toBe(0) // Sunday + }) + }) + + describe('startOfMonth', () => { + it('returns first day of month', () => { + const date = new Date(2025, 0, 15) + const result = startOfMonth(date) + expect(result.getDate()).toBe(1) + expect(result.getMonth()).toBe(0) + }) + }) + + describe('endOfMonth', () => { + it('returns last day of month', () => { + const date = new Date(2025, 0, 15) // January + const result = endOfMonth(date) + expect(result.getDate()).toBe(31) + expect(result.getMonth()).toBe(0) + }) + + it('handles February in leap year', () => { + const date = new Date(2024, 1, 15) // February 2024 (leap year) + const result = endOfMonth(date) + expect(result.getDate()).toBe(29) + }) + }) + }) + + // ============================================================================ + // Duration Helpers + // ============================================================================ + describe('Duration Helpers', () => { + describe('parseDuration', () => { + it('parses number as total seconds', () => { + const result = parseDuration(3665) + expect(result.hours).toBe(1) + expect(result.minutes).toBe(1) + expect(result.seconds).toBe(5) + }) + + it('parses hh:mm string', () => { + const result = parseDuration('02:30') + expect(result.hours).toBe(2) + expect(result.minutes).toBe(30) + }) + + it('parses hh:mm:ss string', () => { + const result = parseDuration('01:30:45') + expect(result.hours).toBe(1) + expect(result.minutes).toBe(30) + expect(result.seconds).toBe(45) + }) + + it('returns object as-is', () => { + const duration = { hours: 1, minutes: 30 } + expect(parseDuration(duration)).toBe(duration) + }) + }) + + describe('durationToSeconds', () => { + it('converts duration to total seconds', () => { + const duration: EventCalendarDuration = { hours: 1, minutes: 30, seconds: 15 } + expect(durationToSeconds(duration)).toBe(5415) + }) + + it('handles days', () => { + const duration: EventCalendarDuration = { days: 1, hours: 2 } + expect(durationToSeconds(duration)).toBe(93600) + }) + }) + + describe('secondsToDuration', () => { + it('converts seconds to duration object', () => { + const result = secondsToDuration(5415) + expect(result.hours).toBe(1) + expect(result.minutes).toBe(30) + expect(result.seconds).toBe(15) + }) + + it('includes days when appropriate', () => { + const result = secondsToDuration(93600) + expect(result.days).toBe(1) + expect(result.hours).toBe(2) + }) + }) + + describe('formatDuration', () => { + it('formats as hh:mm', () => { + const duration: EventCalendarDuration = { hours: 2, minutes: 30 } + expect(formatDuration(duration)).toBe('02:30') + }) + + it('formats as hh:mm:ss when seconds present', () => { + const duration: EventCalendarDuration = { hours: 1, minutes: 5, seconds: 30 } + expect(formatDuration(duration)).toBe('01:05:30') + }) + }) + + describe('addDuration', () => { + it('adds duration to date', () => { + const date = new Date('2025-01-15T10:00:00.000Z') + const duration: EventCalendarDuration = { hours: 2, minutes: 30 } + const result = addDuration(date, duration) + expect(result.toISOString()).toBe('2025-01-15T12:30:00.000Z') + }) + }) + + describe('subtractDuration', () => { + it('subtracts duration from date', () => { + const date = new Date('2025-01-15T12:30:00.000Z') + const duration: EventCalendarDuration = { hours: 2, minutes: 30 } + const result = subtractDuration(date, duration) + expect(result.toISOString()).toBe('2025-01-15T10:00:00.000Z') + }) + }) + }) + + // ============================================================================ + // Event Helpers + // ============================================================================ + describe('Event Helpers', () => { + describe('isAllDayEvent', () => { + it('returns true when allDay is true', () => { + const event = { id: '1', start: new Date(), allDay: true } as EventCalendarEvent + expect(isAllDayEvent(event)).toBe(true) + }) + + it('returns false when allDay is false', () => { + const event = { id: '1', start: new Date(), allDay: false } as EventCalendarEvent + expect(isAllDayEvent(event)).toBe(false) + }) + }) + + describe('isMultiDayEvent', () => { + it('returns false for same day event', () => { + const event = { + id: '1', + start: new Date('2025-01-15T10:00:00.000Z'), + end: new Date('2025-01-15T12:00:00.000Z'), + } as EventCalendarEvent + expect(isMultiDayEvent(event)).toBe(false) + }) + + it('returns true for multi-day event', () => { + const event = { + id: '1', + start: new Date('2025-01-15T10:00:00.000Z'), + end: new Date('2025-01-16T12:00:00.000Z'), + } as EventCalendarEvent + expect(isMultiDayEvent(event)).toBe(true) + }) + }) + + describe('getEventDurationMinutes', () => { + it('calculates duration in minutes', () => { + const event = { + id: '1', + start: new Date('2025-01-15T10:00:00.000Z'), + end: new Date('2025-01-15T11:30:00.000Z'), + } as EventCalendarEvent + expect(getEventDurationMinutes(event)).toBe(90) + }) + }) + + describe('eventOverlapsRange', () => { + it('returns true when event overlaps range', () => { + const event = { + id: '1', + start: new Date('2025-01-15T10:00:00.000Z'), + end: new Date('2025-01-15T12:00:00.000Z'), + } as EventCalendarEvent + const result = eventOverlapsRange( + event, + '2025-01-15T11:00:00.000Z', + '2025-01-15T14:00:00.000Z' + ) + expect(result).toBe(true) + }) + + it('returns false when event is before range', () => { + const event = { + id: '1', + start: new Date('2025-01-15T08:00:00.000Z'), + end: new Date('2025-01-15T09:00:00.000Z'), + } as EventCalendarEvent + const result = eventOverlapsRange( + event, + '2025-01-15T10:00:00.000Z', + '2025-01-15T12:00:00.000Z' + ) + expect(result).toBe(false) + }) + }) + + describe('filterEventsInRange', () => { + it('filters events within range', () => { + const events = [ + { id: '1', start: new Date('2025-01-15T10:00:00.000Z'), end: new Date('2025-01-15T11:00:00.000Z') }, + { id: '2', start: new Date('2025-01-14T10:00:00.000Z'), end: new Date('2025-01-14T11:00:00.000Z') }, + { id: '3', start: new Date('2025-01-15T14:00:00.000Z'), end: new Date('2025-01-15T15:00:00.000Z') }, + ] as EventCalendarEvent[] + + const result = filterEventsInRange( + events, + '2025-01-15T09:00:00.000Z', + '2025-01-15T12:00:00.000Z' + ) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('1') + }) + }) + + describe('sortEventsByStart', () => { + it('sorts events by start date', () => { + const events = [ + { id: '2', start: new Date('2025-01-15T14:00:00.000Z') }, + { id: '1', start: new Date('2025-01-15T10:00:00.000Z') }, + { id: '3', start: new Date('2025-01-15T12:00:00.000Z') }, + ] as EventCalendarEvent[] + + const result = sortEventsByStart(events) + expect(result.map(e => e.id)).toEqual(['1', '3', '2']) + }) + }) + + describe('groupEventsByDate', () => { + it('groups events by date', () => { + const events = [ + { id: '1', start: new Date('2025-01-15T10:00:00.000Z') }, + { id: '2', start: new Date('2025-01-15T14:00:00.000Z') }, + { id: '3', start: new Date('2025-01-16T10:00:00.000Z') }, + ] as EventCalendarEvent[] + + const result = groupEventsByDate(events) + expect(result.size).toBe(2) + }) + }) + + describe('moveEvent', () => { + it('moves event preserving duration', () => { + const event = { + id: '1', + start: new Date('2025-01-15T10:00:00.000Z'), + end: new Date('2025-01-15T12:00:00.000Z'), + } as EventCalendarEvent + + const result = moveEvent(event, '2025-01-16T14:00:00.000Z') + expect((result.start as Date).toISOString()).toBe('2025-01-16T14:00:00.000Z') + expect((result.end as Date).toISOString()).toBe('2025-01-16T16:00:00.000Z') + }) + }) + + describe('resizeEvent', () => { + it('changes event end time', () => { + const event = { + id: '1', + start: new Date('2025-01-15T10:00:00.000Z'), + end: new Date('2025-01-15T12:00:00.000Z'), + } as EventCalendarEvent + + const result = resizeEvent(event, '2025-01-15T14:00:00.000Z') + expect((result.end as Date).toISOString()).toBe('2025-01-15T14:00:00.000Z') + }) + }) + }) + + // ============================================================================ + // View Helpers + // ============================================================================ + describe('View Helpers', () => { + describe('getViewDateRange', () => { + it('returns day range for dayGridDay view', () => { + const { start, end } = getViewDateRange('dayGridDay', '2025-01-15') + expect(start.getDate()).toBe(15) + expect(end.getDate()).toBe(15) + }) + + it('returns month range for dayGridMonth view', () => { + const { start, end } = getViewDateRange('dayGridMonth', '2025-01-15') + expect(start.getDate()).toBe(1) + expect(end.getDate()).toBe(31) + }) + }) + + describe('isListView', () => { + it('returns true for list views', () => { + expect(isListView('listDay')).toBe(true) + expect(isListView('listWeek')).toBe(true) + }) + + it('returns false for non-list views', () => { + expect(isListView('dayGridMonth')).toBe(false) + }) + }) + + describe('isResourceView', () => { + it('returns true for resource views', () => { + expect(isResourceView('resourceTimeGridDay')).toBe(true) + }) + + it('returns false for non-resource views', () => { + expect(isResourceView('dayGridMonth')).toBe(false) + }) + }) + + describe('isTimelineView', () => { + it('returns true for timeline views', () => { + expect(isTimelineView('resourceTimelineDay')).toBe(true) + }) + + it('returns false for non-timeline views', () => { + expect(isTimelineView('dayGridMonth')).toBe(false) + }) + }) + }) + + // ============================================================================ + // Resource Helpers + // ============================================================================ + describe('Resource Helpers', () => { + describe('findResourceById', () => { + it('finds resource at top level', () => { + const resources: EventCalendarResource[] = [ + { id: '1', title: 'Room A' }, + { id: '2', title: 'Room B' }, + ] + const result = findResourceById(resources, '2') + expect(result?.title).toBe('Room B') + }) + + it('finds nested resource', () => { + const resources: EventCalendarResource[] = [ + { id: '1', title: 'Building A', children: [ + { id: '1-1', title: 'Room 101' }, + ]}, + ] + const result = findResourceById(resources, '1-1') + expect(result?.title).toBe('Room 101') + }) + }) + + describe('flattenResources', () => { + it('flattens nested resources', () => { + const resources: EventCalendarResource[] = [ + { id: '1', title: 'Building A', children: [ + { id: '1-1', title: 'Room 101' }, + { id: '1-2', title: 'Room 102' }, + ]}, + { id: '2', title: 'Building B' }, + ] + const result = flattenResources(resources) + expect(result).toHaveLength(4) + }) + }) + + describe('getEventsForResource', () => { + it('filters events by resource', () => { + const events = [ + { id: '1', start: new Date(), resourceIds: ['room-1'] }, + { id: '2', start: new Date(), resourceIds: ['room-2'] }, + { id: '3', start: new Date(), resourceIds: ['room-1', 'room-2'] }, + ] as EventCalendarEvent[] + + const result = getEventsForResource(events, 'room-1') + expect(result).toHaveLength(2) + expect(result.map(e => e.id)).toEqual(['1', '3']) + }) + }) + }) + + // ============================================================================ + // ICS Conversion Helpers + // ============================================================================ + describe('ICS Conversion Helpers', () => { + describe('icsDateToJsDate', () => { + it('returns local date when present', () => { + const localDate = new Date('2025-01-15T11:00:00.000Z') + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: new Date('2025-01-15T10:00:00.000Z'), + local: { date: localDate, timezone: 'Europe/Paris', tzoffset: '+0100' }, + } + expect(icsDateToJsDate(icsDate)).toBe(localDate) + }) + + it('returns UTC date when no local', () => { + const utcDate = new Date('2025-01-15T10:00:00.000Z') + const icsDate: IcsDateObject = { + type: 'DATE-TIME', + date: utcDate, + } + expect(icsDateToJsDate(icsDate)).toBe(utcDate) + }) + }) + + describe('jsDateToIcsDate', () => { + it('creates DATE type for all-day', () => { + const date = new Date('2025-01-15T00:00:00.000Z') + const result = jsDateToIcsDate(date, true) + expect(result.type).toBe('DATE') + }) + + it('creates DATE-TIME type with timezone', () => { + const date = new Date('2025-01-15T10:00:00.000Z') + const result = jsDateToIcsDate(date, false, 'Europe/Paris') + expect(result.type).toBe('DATE-TIME') + expect(result.local?.timezone).toBe('Europe/Paris') + }) + }) + + describe('isIcsEventAllDay', () => { + it('returns true for DATE type start', () => { + const event = { + uid: 'test', + stamp: { date: new Date() }, + start: { type: 'DATE', date: new Date() }, + } as IcsEvent + expect(isIcsEventAllDay(event)).toBe(true) + }) + }) + + describe('getIcsEventTimezone', () => { + it('returns timezone from start local', () => { + const event = { + uid: 'test', + stamp: { date: new Date() }, + start: { + type: 'DATE-TIME', + date: new Date(), + local: { date: new Date(), timezone: 'Europe/Paris', tzoffset: '+0100' }, + }, + } as IcsEvent + expect(getIcsEventTimezone(event)).toBe('Europe/Paris') + }) + }) + }) + + // ============================================================================ + // Recurrence Helpers + // ============================================================================ + describe('Recurrence Helpers', () => { + describe('hasRecurrence', () => { + it('returns true when recurrenceRule exists', () => { + const event = { + uid: 'test', + stamp: { date: new Date() }, + start: { type: 'DATE-TIME', date: new Date() }, + recurrenceRule: { frequency: 'DAILY' }, + } as IcsEvent + expect(hasRecurrence(event)).toBe(true) + }) + }) + + describe('isRecurringInstance', () => { + it('returns true when recurrenceId exists', () => { + const event = { + uid: 'test', + stamp: { date: new Date() }, + start: { type: 'DATE-TIME', date: new Date() }, + recurrenceId: { value: { type: 'DATE-TIME', date: new Date() } }, + } as IcsEvent + expect(isRecurringInstance(event)).toBe(true) + }) + }) + + describe('describeRecurrence', () => { + it('describes daily recurrence', () => { + const rule: IcsRecurrenceRule = { frequency: 'DAILY' } + expect(describeRecurrence(rule)).toBe('Every day') + }) + + it('describes weekly with interval', () => { + const rule: IcsRecurrenceRule = { frequency: 'WEEKLY', interval: 2 } + expect(describeRecurrence(rule)).toBe('Every 2 weeks') + }) + + it('describes with count', () => { + const rule: IcsRecurrenceRule = { frequency: 'DAILY', count: 5 } + expect(describeRecurrence(rule)).toContain('5 times') + }) + }) + }) + + // ============================================================================ + // Color Helpers + // ============================================================================ + describe('Color Helpers', () => { + describe('stringToColor', () => { + it('generates consistent color for same string', () => { + const color1 = stringToColor('Calendar A') + const color2 = stringToColor('Calendar A') + expect(color1).toBe(color2) + }) + + it('generates different colors for different strings', () => { + const color1 = stringToColor('Calendar A') + const color2 = stringToColor('Calendar B') + expect(color1).not.toBe(color2) + }) + + it('returns valid HSL color', () => { + const color = stringToColor('Test') + expect(color).toMatch(/^hsl\(\d+, 65%, 50%\)$/) + }) + }) + + describe('isColorDark', () => { + it('returns true for dark hex colors', () => { + expect(isColorDark('#000000')).toBe(true) + expect(isColorDark('#333333')).toBe(true) + }) + + it('returns false for light hex colors', () => { + expect(isColorDark('#ffffff')).toBe(false) + expect(isColorDark('#eeeeee')).toBe(false) + }) + + it('handles short hex format', () => { + expect(isColorDark('#000')).toBe(true) + expect(isColorDark('#fff')).toBe(false) + }) + + it('handles rgb format', () => { + expect(isColorDark('rgb(0, 0, 0)')).toBe(true) + expect(isColorDark('rgb(255, 255, 255)')).toBe(false) + }) + }) + + describe('getContrastingTextColor', () => { + it('returns white for dark backgrounds', () => { + expect(getContrastingTextColor('#000000')).toBe('#ffffff') + }) + + it('returns black for light backgrounds', () => { + expect(getContrastingTextColor('#ffffff')).toBe('#000000') + }) + }) + }) + + // ============================================================================ + // Attendee Helpers + // ============================================================================ + describe('Attendee Helpers', () => { + describe('getAttendeeDisplayName', () => { + it('returns name when present', () => { + expect(getAttendeeDisplayName({ name: 'John Doe', email: 'john@example.com' })).toBe('John Doe') + }) + + it('returns email when no name', () => { + expect(getAttendeeDisplayName({ email: 'john@example.com' })).toBe('john@example.com') + }) + }) + + describe('getAttendeeStatusIcon', () => { + it('returns correct icons', () => { + expect(getAttendeeStatusIcon('ACCEPTED')).toBe('✓') + expect(getAttendeeStatusIcon('DECLINED')).toBe('✗') + expect(getAttendeeStatusIcon('TENTATIVE')).toBe('?') + expect(getAttendeeStatusIcon('NEEDS-ACTION')).toBe('○') + }) + }) + + describe('getAttendeeStatusColor', () => { + it('returns correct colors', () => { + expect(getAttendeeStatusColor('ACCEPTED')).toBe('#22c55e') + expect(getAttendeeStatusColor('DECLINED')).toBe('#ef4444') + expect(getAttendeeStatusColor('TENTATIVE')).toBe('#f59e0b') + }) + }) + }) +}) diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/ics-helper.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/ics-helper.test.ts new file mode 100644 index 0000000..5787ef4 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/ics-helper.test.ts @@ -0,0 +1,41 @@ +/** + * Tests for ICS Helper functions + */ +import type { IcsEvent } from 'ts-ics' +import { isEventAllDay } from '../helpers/ics-helper' + +describe('ics-helper', () => { + describe('isEventAllDay', () => { + it('returns true when start type is DATE', () => { + const event = { + start: { type: 'DATE', date: new Date() }, + uid: 'test', + stamp: { date: new Date() }, + } as IcsEvent + + expect(isEventAllDay(event)).toBe(true) + }) + + it('returns true when end type is DATE', () => { + const event = { + start: { type: 'DATE-TIME', date: new Date() }, + end: { type: 'DATE', date: new Date() }, + uid: 'test', + stamp: { date: new Date() }, + } as IcsEvent + + expect(isEventAllDay(event)).toBe(true) + }) + + it('returns false when both start and end are DATE-TIME', () => { + const event = { + start: { type: 'DATE-TIME', date: new Date() }, + end: { type: 'DATE-TIME', date: new Date() }, + uid: 'test', + stamp: { date: new Date() }, + } as IcsEvent + + expect(isEventAllDay(event)).toBe(false) + }) + }) +})