Files
workavia-calendar-front/__test__/features/Events/FreeBusy.test.tsx
T
lethemanh cadfa70e60 #708 apply strictier linting rules (#717)
* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
2026-04-01 17:15:10 +02:00

400 lines
11 KiB
TypeScript

import {
hasFreeBusyConflict,
useAttendeesFreeBusy
} from '@/components/Attendees/useFreeBusy'
import * as getFreeBusyREPORT from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
import * as getFreeBusyPOST from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
import * as getUserData from '@/features/Events/api/getUserDataFromEmail'
import { renderHook, waitFor } from '@testing-library/react'
jest.mock('moment-timezone', () => {
const actual = jest.requireActual('moment-timezone')
return actual
})
jest.mock('@/features/Events/api/getUserDataFromEmail')
jest.mock('@/features/Events/api/getFreeBusyForAddedAttendeesREPORT')
jest.mock('@/features/Events/api/getFreeBusyForEventAttendeesPOST')
const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction<
typeof getUserData.getUserDataFromEmail
>
const mockREPORT =
getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction<
typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT
>
const mockPOST =
getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction<
typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST
>
const START = '2026-03-14T14:00:00'
const END = '2026-03-14T15:00:00'
const TZ = 'Europe/Paris'
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())
const newAttendee = { email: 'alice@example.com', userId: 'user-alice' }
it('shows loading then free for a new attendee', async () => {
mockREPORT.mockResolvedValue(false)
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 () => {
mockREPORT.mockResolvedValue(true)
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)
mockREPORT.mockResolvedValue(false)
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(
'resolved-id',
expect.any(String),
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 () => {
mockREPORT.mockResolvedValue(false)
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 () => {
mockREPORT.mockResolvedValue(false)
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 () => {
mockREPORT.mockResolvedValue(false)
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 () => {
mockREPORT.mockResolvedValue(false)
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(
'user-alice',
'20260314T150000',
'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({ 'user-carol': false })
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({ 'user-carol': true })
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({ 'user-carol': false })
renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
eventUid: 'event-abc'
})
)
await waitFor(() => expect(mockPOST).toHaveBeenCalled())
expect(mockPOST).toHaveBeenCalledWith(
['user-carol'],
expect.any(String),
expect.any(String),
'event-abc'
)
})
})
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()
})
})