522 lines
13 KiB
TypeScript
522 lines
13 KiB
TypeScript
import {
|
|
hasFreeBusyConflict,
|
|
useAttendeesFreeBusy
|
|
} from '@/components/Attendees/useFreeBusy'
|
|
import * as CalendarApi from '@/features/Calendars/CalendarApi'
|
|
import * as FreeBusyDao from '@/features/Events/FreeBusyDao'
|
|
import * as UserDao from '@/features/User/UserDao'
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
|
|
jest.mock('moment-timezone', () => {
|
|
const actual = jest.requireActual('moment-timezone')
|
|
return actual
|
|
})
|
|
|
|
jest.mock('@/features/Events/FreeBusyDao')
|
|
jest.mock('@/features/User/UserDao')
|
|
jest.mock('@/features/Calendars/CalendarApi')
|
|
|
|
const mockGetUserData = UserDao.fetchUserByEmail as jest.MockedFunction<
|
|
typeof UserDao.fetchUserByEmail
|
|
>
|
|
const mockREPORT = FreeBusyDao.fetchFreeBusyReports as jest.MockedFunction<
|
|
typeof FreeBusyDao.fetchFreeBusyReports
|
|
>
|
|
const mockPOST = FreeBusyDao.fetchFreeBusyPost as jest.MockedFunction<
|
|
typeof FreeBusyDao.fetchFreeBusyPost
|
|
>
|
|
const mockGetCalendars = CalendarApi.getCalendars as jest.MockedFunction<
|
|
typeof CalendarApi.getCalendars
|
|
>
|
|
|
|
const mockCalendarsResponse = {
|
|
_embedded: {
|
|
'dav:calendar': [
|
|
{
|
|
_links: {
|
|
self: {
|
|
href: '/calendars/user-carol/user-carol'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const freeIcal = [
|
|
{
|
|
_links: {
|
|
self: {
|
|
href: '\/calendars\/user-carol\/e38d1ea7-6d2c-46c6-8933-cdb325d4458e'
|
|
}
|
|
},
|
|
data: [
|
|
'vcalendar',
|
|
[],
|
|
[
|
|
[
|
|
'vfreebusy',
|
|
[
|
|
['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
|
|
['dtend', {}, 'date-time', '2026-03-14T15:00:00Z'],
|
|
['dtstamp', {}, 'date-time', '2026-03-14T13:00:00Z']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
]
|
|
|
|
const busyIcal = [
|
|
{
|
|
_links: {
|
|
self: {
|
|
href: '\/calendars\/user-carol\/e38d1ea7-6d2c-46c6-8933-cdb325d4458e'
|
|
}
|
|
},
|
|
data: [
|
|
'vcalendar',
|
|
[],
|
|
[
|
|
[
|
|
'vfreebusy',
|
|
[
|
|
['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
|
|
['dtend', {}, 'date-time', '2026-03-14T15:00:00Z'],
|
|
['dtstamp', {}, 'date-time', '2026-03-14T13:00:00Z'],
|
|
[
|
|
'freebusy',
|
|
{},
|
|
'period',
|
|
['2026-03-14T14:00:00Z', '2026-03-14T15:00:00Z']
|
|
]
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
]
|
|
|
|
const START = '2026-03-14T14:00:00'
|
|
const END = '2026-03-14T15:00:00'
|
|
const TZ = 'Europe/Paris'
|
|
|
|
const freePostResponse = {
|
|
start: '20260314T130000',
|
|
end: '20260314T140000',
|
|
users: [
|
|
{
|
|
id: 'user-carol',
|
|
calendars: [{ id: 'user-carol', busy: [] }]
|
|
}
|
|
]
|
|
}
|
|
|
|
const busyPostResponse = {
|
|
start: '20260314T130000',
|
|
end: '20260314T140000',
|
|
users: [
|
|
{
|
|
id: 'user-carol',
|
|
calendars: [
|
|
{
|
|
id: 'user-carol',
|
|
busy: [
|
|
{
|
|
uid: 'fa507111-07d5-44b4-a611-c27845a66ccf',
|
|
start: START,
|
|
end: END
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
describe('hasFreeBusyConflict', () => {
|
|
it('returns false for non-vcalendar data', () => {
|
|
expect(hasFreeBusyConflict({ data: ['not-vcalendar', [], []] })).toBe(false)
|
|
})
|
|
|
|
it('returns false when vfreebusy has no freebusy property', () => {
|
|
const data = {
|
|
data: [
|
|
'vcalendar',
|
|
[],
|
|
[
|
|
[
|
|
'vfreebusy',
|
|
[['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z']],
|
|
[]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
expect(hasFreeBusyConflict(data)).toBe(false)
|
|
})
|
|
|
|
it('returns true when vfreebusy contains a freebusy property', () => {
|
|
const data = {
|
|
data: [
|
|
'vcalendar',
|
|
[],
|
|
[
|
|
[
|
|
'vfreebusy',
|
|
[
|
|
['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
|
|
['freebusy', {}, 'period', '2026-03-14T14:00:00Z/PT1H']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
expect(hasFreeBusyConflict(data)).toBe(true)
|
|
})
|
|
|
|
it('returns false for malformed data without throwing', () => {
|
|
expect(hasFreeBusyConflict(null)).toBe(false)
|
|
expect(hasFreeBusyConflict('garbage')).toBe(false)
|
|
expect(hasFreeBusyConflict({ data: null })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
})
|
|
|
|
const newAttendee = { email: 'alice@example.com', userId: 'user-alice' }
|
|
|
|
it('shows loading then free for a new attendee', async () => {
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
mockREPORT.mockResolvedValue(freeIcal)
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [newAttendee],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
})
|
|
)
|
|
|
|
expect(result.current[newAttendee.email]).toBe('loading')
|
|
|
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
|
|
})
|
|
|
|
it('shows busy when REPORT returns true', async () => {
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
mockREPORT.mockResolvedValue(busyIcal)
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [newAttendee],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
})
|
|
)
|
|
|
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe('busy'))
|
|
})
|
|
|
|
it('shows unknown when REPORT throws', async () => {
|
|
mockREPORT.mockRejectedValue(new Error('network error'))
|
|
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [newAttendee],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
})
|
|
)
|
|
|
|
await waitFor(() =>
|
|
expect(result.current[newAttendee.email]).toBe('unknown')
|
|
)
|
|
})
|
|
|
|
it('resolves userId via getUserDataFromEmail when not provided', async () => {
|
|
mockGetUserData.mockResolvedValue([{ _id: 'resolved-id' }] as never)
|
|
mockGetCalendars.mockResolvedValue({
|
|
_embedded: {
|
|
'dav:calendar': [
|
|
{
|
|
_links: {
|
|
self: {
|
|
href: '/calendars/resolved-id/resolved-id'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
} as never)
|
|
mockREPORT.mockResolvedValue(freeIcal)
|
|
const attendeeWithoutId = { email: 'bob@example.com' }
|
|
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [attendeeWithoutId],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
})
|
|
)
|
|
|
|
await waitFor(() =>
|
|
expect(result.current[attendeeWithoutId.email]).toBe('free')
|
|
)
|
|
expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com')
|
|
expect(mockREPORT).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
hrefs: ['/calendars/resolved-id/resolved-id'],
|
|
start: expect.any(String),
|
|
end: expect.any(String)
|
|
})
|
|
)
|
|
})
|
|
|
|
it('shows unknown when userId cannot be resolved', async () => {
|
|
mockGetUserData.mockResolvedValue([])
|
|
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [{ email: 'ghost@example.com' }],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
})
|
|
)
|
|
|
|
await waitFor(() =>
|
|
expect(result.current['ghost@example.com']).toBe('unknown')
|
|
)
|
|
expect(mockREPORT).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not re-fetch an attendee already fetched', async () => {
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
mockREPORT.mockResolvedValue(freeIcal)
|
|
const { result, rerender } = renderHook(
|
|
({ attendees }) =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: attendees,
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
}),
|
|
{ initialProps: { attendees: [newAttendee] } }
|
|
)
|
|
|
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
|
|
|
|
rerender({ attendees: [newAttendee] })
|
|
expect(mockREPORT).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('removes departed attendee from the map', async () => {
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
mockREPORT.mockResolvedValue(freeIcal)
|
|
const { result, rerender } = renderHook(
|
|
({ attendees }) =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: attendees,
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
}),
|
|
{ initialProps: { attendees: [newAttendee] } }
|
|
)
|
|
|
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
|
|
|
|
rerender({ attendees: [] })
|
|
|
|
await waitFor(() =>
|
|
expect(result.current[newAttendee.email]).toBeUndefined()
|
|
)
|
|
})
|
|
|
|
it('invalidates cache and re-fetches when time window changes', async () => {
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
mockREPORT.mockResolvedValue(freeIcal)
|
|
const { result, rerender } = renderHook(
|
|
({ start, end }) =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [newAttendee],
|
|
start,
|
|
end,
|
|
timezone: TZ
|
|
}),
|
|
{ initialProps: { start: START, end: END } }
|
|
)
|
|
|
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
|
|
|
|
rerender({ start: '2026-03-15T14:00:00', end: '2026-03-15T15:00:00' })
|
|
|
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
|
|
|
|
expect(mockREPORT).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('passes UTC-converted iCal times to the API', async () => {
|
|
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
|
mockREPORT.mockResolvedValue(freeIcal)
|
|
renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [],
|
|
newAttendees: [newAttendee],
|
|
start: '2026-03-14T16:00:00', // 16:00 Paris = 15:00 UTC in winter
|
|
end: '2026-03-14T17:00:00',
|
|
timezone: 'Europe/Paris'
|
|
})
|
|
)
|
|
|
|
await waitFor(() => expect(mockREPORT).toHaveBeenCalled())
|
|
|
|
// Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC
|
|
expect(mockREPORT).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
start: '20260314T150000',
|
|
end: '20260314T160000'
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
|
|
beforeEach(() => jest.clearAllMocks())
|
|
|
|
const existingAttendee = { email: 'carol@example.com', userId: 'user-carol' }
|
|
|
|
it('does not fetch when eventUid is missing', () => {
|
|
renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [existingAttendee],
|
|
newAttendees: [],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ
|
|
})
|
|
)
|
|
|
|
expect(mockPOST).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows loading then free for existing attendees', async () => {
|
|
mockPOST.mockResolvedValue(freePostResponse)
|
|
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [existingAttendee],
|
|
newAttendees: [],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ,
|
|
eventUid: 'event-123'
|
|
})
|
|
)
|
|
|
|
expect(result.current[existingAttendee.email]).toBe('loading')
|
|
|
|
await waitFor(() =>
|
|
expect(result.current[existingAttendee.email]).toBe('free')
|
|
)
|
|
})
|
|
|
|
it('shows busy when POST returns busy for userId', async () => {
|
|
mockPOST.mockResolvedValue(busyPostResponse)
|
|
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [existingAttendee],
|
|
newAttendees: [],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ,
|
|
eventUid: 'event-123'
|
|
})
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current[existingAttendee.email]).toBe('busy')
|
|
})
|
|
})
|
|
|
|
it('shows unknown when POST throws', async () => {
|
|
mockPOST.mockRejectedValue(new Error('server error'))
|
|
|
|
const { result } = renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [existingAttendee],
|
|
newAttendees: [],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ,
|
|
eventUid: 'event-123'
|
|
})
|
|
)
|
|
|
|
await waitFor(() =>
|
|
expect(result.current[existingAttendee.email]).toBe('unknown')
|
|
)
|
|
})
|
|
|
|
it('passes the eventUid to the POST API', async () => {
|
|
mockPOST.mockResolvedValue(freePostResponse)
|
|
|
|
renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [existingAttendee],
|
|
newAttendees: [],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ,
|
|
eventUid: 'event-abc'
|
|
})
|
|
)
|
|
|
|
await waitFor(() => expect(mockPOST).toHaveBeenCalled())
|
|
expect(mockPOST).toHaveBeenCalledWith({
|
|
end: '20260314T140000',
|
|
eventUid: 'event-abc',
|
|
start: '20260314T130000',
|
|
userIds: ['user-carol']
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('useAttendeesFreeBusy — enabled flag', () => {
|
|
it('does not fetch when disabled', () => {
|
|
renderHook(() =>
|
|
useAttendeesFreeBusy({
|
|
existingAttendees: [{ email: 'x@x.com', userId: 'uid' }],
|
|
newAttendees: [{ email: 'y@y.com', userId: 'uid2' }],
|
|
start: START,
|
|
end: END,
|
|
timezone: TZ,
|
|
eventUid: 'event-123',
|
|
enabled: false
|
|
})
|
|
)
|
|
|
|
expect(mockPOST).not.toHaveBeenCalled()
|
|
expect(mockREPORT).not.toHaveBeenCalled()
|
|
})
|
|
})
|