/**
* Edge case tests for calendar indexing
* Tests recurring events, all-day events, and date range filtering
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createEmbeddingMock,
MAC_ABSOLUTE_EPOCH
} from '../helpers/indexing-mocks.js'
describe('Calendar Edge Cases', () => {
let mockEmbedder
beforeEach(() => {
vi.clearAllMocks()
const mock = createEmbeddingMock()
mockEmbedder = mock.mockEmbedder
})
describe('recurring events via OccurrenceCache', () => {
it('should identify recurring events by recurrence join', () => {
// Simulated SQL result with recurrence data
const event = {
ROWID: 123,
summary: 'Weekly Meeting',
recurrence_id: 456 // Non-null means recurring
}
const isRecurring = event.recurrence_id !== null
expect(isRecurring).toBe(true)
})
it('should handle non-recurring events', () => {
const event = {
ROWID: 123,
summary: 'One-time Meeting',
recurrence_id: null
}
const isRecurring = event.recurrence_id !== null
expect(isRecurring).toBe(false)
})
it('should use OccurrenceCache for calculated dates', () => {
// OccurrenceCache contains actual occurrence dates for recurring events
const occurrences = [
{ occurrence_date: 725760000, owner_id: 123 }, // Jan 1, 2024
{ occurrence_date: 726364800, owner_id: 123 }, // Jan 8, 2024
{ occurrence_date: 726969600, owner_id: 123 } // Jan 15, 2024
]
expect(occurrences.length).toBe(3)
// Each occurrence should have same owner_id (parent event)
const uniqueOwners = new Set(occurrences.map(o => o.owner_id))
expect(uniqueOwners.size).toBe(1)
})
it('should calculate occurrence count', () => {
const event = {
ROWID: 123,
summary: 'Weekly Meeting',
occurrence_count: 52 // Weekly for 1 year
}
expect(event.occurrence_count).toBe(52)
})
})
describe('all-day events', () => {
it('should detect all_day=1', () => {
const event = {
summary: 'Holiday',
all_day: 1
}
const isAllDay = event.all_day === 1
expect(isAllDay).toBe(true)
})
it('should detect all_day=0', () => {
const event = {
summary: 'Meeting',
all_day: 0
}
const isAllDay = event.all_day === 1
expect(isAllDay).toBe(false)
})
it('should handle NULL all_day', () => {
const event = {
summary: 'Event',
all_day: null
}
const isAllDay = event.all_day === 1
expect(isAllDay).toBe(false)
})
it('should have 24-hour duration for all-day events', () => {
const event = {
start_date: 725760000, // Jan 1, 2024 00:00:00
end_date: 725846400, // Jan 2, 2024 00:00:00
all_day: 1
}
const durationSeconds = event.end_date - event.start_date
const durationHours = durationSeconds / 3600
expect(durationHours).toBe(24)
})
})
describe('multi-day events', () => {
it('should handle events spanning multiple days', () => {
const event = {
summary: 'Conference',
start_date: 725760000, // Jan 1, 2024
end_date: 726019200, // Jan 4, 2024
all_day: 1
}
const durationDays = (event.end_date - event.start_date) / (24 * 3600)
expect(durationDays).toBe(3)
})
it('should handle overnight non-all-day events', () => {
const event = {
summary: 'Night Shift',
start_date: 725796000, // Jan 1, 2024 10:00
end_date: 725824800, // Jan 2, 2024 02:00
all_day: 0
}
const durationHours = (event.end_date - event.start_date) / 3600
expect(durationHours).toBeGreaterThan(0)
})
})
describe('location extraction', () => {
it('should extract location from event', () => {
const event = {
summary: 'Meeting',
location: 'Conference Room A'
}
expect(event.location).toBe('Conference Room A')
})
it('should handle NULL location', () => {
const event = {
summary: 'Call',
location: null
}
const location = event.location || ''
expect(location).toBe('')
})
it('should handle location with address', () => {
const event = {
summary: 'Lunch',
location: '123 Main St, City, State 12345'
}
expect(event.location).toContain('Main St')
})
it('should handle virtual meeting locations', () => {
const event = {
summary: 'Video Call',
location: 'https://zoom.us/j/123456789'
}
expect(event.location).toContain('zoom.us')
})
})
describe('notes truncation', () => {
it('should truncate notes to 200 chars', () => {
const longNotes = 'x'.repeat(500)
const maxLength = 200
const truncated = longNotes.substring(0, maxLength)
expect(truncated.length).toBe(200)
})
it('should preserve short notes', () => {
const shortNotes = 'Brief meeting notes'
const maxLength = 200
const result = shortNotes.length > maxLength
? shortNotes.substring(0, maxLength)
: shortNotes
expect(result).toBe(shortNotes)
})
it('should handle NULL notes', () => {
const event = { notes: null }
const notes = event.notes || ''
expect(notes).toBe('')
})
})
describe('participant status codes', () => {
it('should map status 0 to unknown', () => {
const statusMap = {
0: 'unknown',
1: 'pending',
2: 'accepted',
3: 'declined',
4: 'tentative',
5: 'delegated',
6: 'completed',
7: 'in process'
}
expect(statusMap[0]).toBe('unknown')
})
it('should map status 2 to accepted', () => {
const statusMap = {
0: 'unknown',
1: 'pending',
2: 'accepted',
3: 'declined',
4: 'tentative'
}
expect(statusMap[2]).toBe('accepted')
})
it('should handle unknown status codes', () => {
const statusMap = {
0: 'unknown',
1: 'pending',
2: 'accepted',
3: 'declined',
4: 'tentative'
}
const getStatus = (code) => statusMap[code] || 'unknown'
expect(getStatus(99)).toBe('unknown')
})
it('should format participant with status', () => {
const participant = {
email: 'john@example.com',
status: 2
}
const statusMap = { 2: 'accepted' }
const formatted = `${participant.email} (${statusMap[participant.status]})`
expect(formatted).toBe('john@example.com (accepted)')
})
})
describe('time zone handling', () => {
it('should handle UTC times', () => {
// Mac Absolute Time is always UTC
const macTime = 725760000 // Jan 1, 2024 00:00:00 UTC
const unixTime = macTime + MAC_ABSOLUTE_EPOCH
const date = new Date(unixTime * 1000)
expect(date.getUTCFullYear()).toBe(2024)
expect(date.getUTCMonth()).toBe(0) // January
expect(date.getUTCDate()).toBe(1)
})
it('should handle timezone offset events', () => {
// Event in PST (UTC-8)
const event = {
start_date: 725760000, // UTC
timezone: 'America/Los_Angeles'
}
// Conversion would be done during display, not storage
expect(event.timezone).toBe('America/Los_Angeles')
})
it('should handle floating timezones', () => {
// Floating = same local time regardless of timezone
const event = {
start_date: 725760000,
timezone: 'floating'
}
expect(event.timezone).toBe('floating')
})
})
describe('date range filtering', () => {
it('should filter events within 90 days back', () => {
const now = Date.now()
const ninetyDaysAgo = now - (90 * 24 * 60 * 60 * 1000)
const events = [
{ start: now - (30 * 24 * 60 * 60 * 1000), title: 'Recent' },
{ start: now - (100 * 24 * 60 * 60 * 1000), title: 'Old' }
]
const filtered = events.filter(e => e.start >= ninetyDaysAgo)
expect(filtered.length).toBe(1)
expect(filtered[0].title).toBe('Recent')
})
it('should filter events within 365 days ahead', () => {
const now = Date.now()
const oneYearAhead = now + (365 * 24 * 60 * 60 * 1000)
const events = [
{ start: now + (30 * 24 * 60 * 60 * 1000), title: 'Soon' },
{ start: now + (400 * 24 * 60 * 60 * 1000), title: 'Far' }
]
const filtered = events.filter(e => e.start <= oneYearAhead)
expect(filtered.length).toBe(1)
expect(filtered[0].title).toBe('Soon')
})
it('should include today in range', () => {
const now = Date.now()
const ninetyDaysAgo = now - (90 * 24 * 60 * 60 * 1000)
const oneYearAhead = now + (365 * 24 * 60 * 60 * 1000)
const todayEvent = { start: now, title: 'Today' }
const inRange = todayEvent.start >= ninetyDaysAgo && todayEvent.start <= oneYearAhead
expect(inRange).toBe(true)
})
})
describe('composite ID generation', () => {
it('should create ID from title and start', () => {
const event = {
summary: 'Team Meeting',
start_date: 725760000
}
const id = `${event.summary}-${event.start_date}`
expect(id).toBe('Team Meeting-725760000')
})
it('should handle events with same title different times', () => {
const event1 = { summary: 'Standup', start_date: 725760000 }
const event2 = { summary: 'Standup', start_date: 725846400 }
const id1 = `${event1.summary}-${event1.start_date}`
const id2 = `${event2.summary}-${event2.start_date}`
expect(id1).not.toBe(id2)
})
it('should handle special characters in title', () => {
const event = {
summary: 'Meeting: Q1 Review (Important!)',
start_date: 725760000
}
const id = `${event.summary}-${event.start_date}`
expect(id).toContain('Meeting:')
expect(id).toContain('(Important!)')
})
})
describe('attendee serialization', () => {
it('should serialize attendees to JSON', () => {
const attendees = [
{ email: 'john@example.com', status: 'accepted' },
{ email: 'jane@example.com', status: 'pending' }
]
const serialized = JSON.stringify(attendees)
const parsed = JSON.parse(serialized)
expect(parsed.length).toBe(2)
expect(parsed[0].email).toBe('john@example.com')
})
it('should handle empty attendees', () => {
const attendees = []
const serialized = JSON.stringify(attendees)
expect(serialized).toBe('[]')
})
it('should count attendees', () => {
const attendees = [
{ email: 'a@example.com' },
{ email: 'b@example.com' },
{ email: 'c@example.com' }
]
expect(attendees.length).toBe(3)
})
})
describe('stale event detection', () => {
it('should identify events not in source', () => {
const indexedIds = ['event-1', 'event-2', 'event-3']
const sourceIds = ['event-1', 'event-3'] // event-2 deleted
const staleIds = indexedIds.filter(id => !sourceIds.includes(id))
expect(staleIds).toEqual(['event-2'])
})
it('should handle no stale events', () => {
const indexedIds = ['event-1', 'event-2']
const sourceIds = ['event-1', 'event-2', 'event-3']
const staleIds = indexedIds.filter(id => !sourceIds.includes(id))
expect(staleIds.length).toBe(0)
})
it('should handle all events stale', () => {
const indexedIds = ['old-1', 'old-2']
const sourceIds = ['new-1', 'new-2']
const staleIds = indexedIds.filter(id => !sourceIds.includes(id))
expect(staleIds).toEqual(['old-1', 'old-2'])
})
})
describe('search text generation', () => {
it('should combine title, location, and notes', () => {
const event = {
summary: 'Team Meeting',
location: 'Room 101',
notes: 'Discuss Q1 goals'
}
const searchText = `${event.summary} ${event.location || ''} ${event.notes || ''}`
.trim()
.substring(0, 500)
expect(searchText).toContain('Team Meeting')
expect(searchText).toContain('Room 101')
expect(searchText).toContain('Q1 goals')
})
it('should handle missing fields gracefully', () => {
const event = {
summary: 'Call',
location: null,
notes: null
}
const searchText = `${event.summary} ${event.location || ''} ${event.notes || ''}`
.trim()
expect(searchText).toBe('Call')
})
})
})