import * as calAPI from '@/features/Calendars/CalendarApi' import reducer, { addEvent, createCalendar, removeEvent, removeTempCal, updateEventLocal } from '@/features/Calendars/CalendarSlice' import { Calendar } from '@/features/Calendars/CalendarTypes' import { addSharedCalendarAsync, createCalendarAsync, getCalendarDetailAsync, getCalendarsListAsync, getEventAsync, getTempCalendarsListAsync, patchACLCalendarAsync } from '@/features/Calendars/services' import { CalendarEvent } from '@/features/Events/EventsTypes' import * as userAPI from '@/features/User/userAPI' import userReducer, { setUserData } from '@/features/User/userSlice' import { configureStore } from '@reduxjs/toolkit' jest.mock('@/features/Calendars/CalendarApi') jest.mock('@/features/User/userAPI') jest.mock('@/features/Events/EventApi') jest.mock('@/features/Events/utils') jest.mock('@/utils/apiUtils') describe('CalendarSlice', () => { const initialState = { list: {}, templist: {}, pending: false, error: null } beforeEach(() => { jest.resetAllMocks() }) describe('reducers', () => { it('createCalendar adds new calendar', () => { const action = createCalendar({ name: 'Test Cal', color: '#ff0000', description: 'desc' }) const state = reducer(initialState, action) const values = Object.values(state.list) expect(values[0].name).toBe('Test Cal') expect(values[0].color).toBe('#ff0000') }) it('addEvent adds event to calendar', () => { const calId = 'user/cal' const event = { uid: 'event1', title: 'My Event' } as any const stateWithCal = { ...initialState, list: { [calId]: { id: calId, events: {} } as any } } const state = reducer( stateWithCal, addEvent({ calendarUid: calId, event }) ) expect(state.list[calId].events['event1']).toEqual( expect.objectContaining({ uid: 'event1' }) ) }) it('removeEvent deletes event', () => { const calId = 'user/cal' const stateWithEvent = { ...initialState, list: { [calId]: { id: calId, events: { e1: { uid: 'e1' } } } as unknown as Calendar } } const state = reducer( stateWithEvent, removeEvent({ calendarUid: calId, eventUid: 'e1' }) ) expect(state.list[calId].events).toEqual({}) }) it('updateEventLocal updates an event', () => { const calId = 'user/cal' const stateWithEvent = { ...initialState, list: { [calId]: { id: calId, events: { e1: { uid: 'e1', title: 'Old' } } } as unknown as Calendar } } const state = reducer( stateWithEvent, updateEventLocal({ calId, event: { uid: 'e1', title: 'New' } as any }) ) expect(state.list[calId].events.e1.title).toBe('New') }) it('removeTempCal deletes temp calendar', () => { const stateWithTemp = { ...initialState, templist: { temp1: { id: 'temp1' } as any } } const state = reducer(stateWithTemp, removeTempCal('temp1')) expect(state.templist).toEqual({}) }) }) describe('extraReducers (thunks)', () => { const storeFactory = () => configureStore({ reducer: { calendars: reducer, user: userReducer } }) beforeEach(() => { jest.resetAllMocks() }) it('getCalendarsListAsync.fulfilled replaces list', async () => { ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ _embedded: { 'dav:calendar': [] } }) ;(userAPI.getUserDetails as jest.Mock).mockResolvedValue({ firstname: 'Alice', lastname: 'Smith', emails: ['a@b.com'] }) const store = storeFactory() await store.dispatch(getCalendarsListAsync() as any) const state = store.getState().calendars expect(state.list).toEqual({}) }) it('getCalendarsListAsync loads user details in parallel for multiple owners', async () => { const mockCalendars = [ { _links: { self: { href: '/calendars/u1/cal1.json' } }, 'dav:name': 'Calendar 1', 'apple:color': '#FF0000', acl: [] }, { _links: { self: { href: '/calendars/u2/cal2.json' } }, 'dav:name': 'Calendar 2', 'apple:color': '#00FF00', acl: [] }, { _links: { self: { href: '/calendars/u3/cal3.json' } }, 'dav:name': 'Calendar 3', 'apple:color': '#0000FF', acl: [] } ] ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ _embedded: { 'dav:calendar': mockCalendars } }) const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock .mockResolvedValueOnce({ firstname: 'Alice', lastname: 'Smith', emails: ['alice@example.com'] }) .mockResolvedValueOnce({ firstname: 'Bob', lastname: 'Jones', emails: ['bob@example.com'] }) .mockResolvedValueOnce({ firstname: 'Charlie', lastname: 'Brown', emails: ['charlie@example.com'] }) const store = storeFactory() await store.dispatch(getCalendarsListAsync() as any) expect(getUserDetailsMock).toHaveBeenCalledTimes(3) expect(getUserDetailsMock).toHaveBeenCalledWith('u1') expect(getUserDetailsMock).toHaveBeenCalledWith('u2') expect(getUserDetailsMock).toHaveBeenCalledWith('u3') const state = store.getState().calendars expect(state.list['u1/cal1'].owner.firstname).toContain('Alice') expect(state.list['u2/cal2'].owner.firstname).toContain('Bob') expect(state.list['u3/cal3'].owner.firstname).toContain('Charlie') }) it('getCalendarsListAsync deduplicates getUserDetails calls for same ownerId', async () => { const mockCalendars = [ { _links: { self: { href: '/calendars/u1/cal1.json' } }, 'dav:name': 'Calendar 1', 'apple:color': '#FF0000', acl: [] }, { _links: { self: { href: '/calendars/u1/cal2.json' } }, 'dav:name': 'Calendar 2', 'apple:color': '#00FF00', acl: [] }, { _links: { self: { href: '/calendars/u1/cal3.json' } }, 'dav:name': 'Calendar 3', 'apple:color': '#0000FF', acl: [] } ] ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ _embedded: { 'dav:calendar': mockCalendars } }) const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock.mockResolvedValue({ firstname: 'Alice', lastname: 'Smith', emails: ['alice@example.com'] }) const store = storeFactory() await store.dispatch(getCalendarsListAsync() as any) expect(getUserDetailsMock).toHaveBeenCalledTimes(1) expect(getUserDetailsMock).toHaveBeenCalledWith('u1') }) it('getCalendarsListAsync processes owners in batches of 20', async () => { const mockCalendars = Array.from({ length: 45 }, (_, i) => ({ _links: { self: { href: `/calendars/u${i + 1}/cal${i + 1}.json` } }, 'dav:name': `Calendar ${i + 1}`, 'apple:color': '#FF0000', acl: [] })) ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ _embedded: { 'dav:calendar': mockCalendars } }) const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock.mockImplementation((ownerId: string) => Promise.resolve({ firstname: 'User', lastname: ownerId, emails: [`${ownerId}@example.com`] }) ) const store = storeFactory() await store.dispatch(getCalendarsListAsync() as any) expect(getUserDetailsMock).toHaveBeenCalledTimes(45) const state = store.getState().calendars expect(Object.keys(state.list)).toHaveLength(45) }) it('getCalendarsListAsync doesnt call getUserDetails if userdata exist in store', async () => { const existingCalendars = { 'u1/cal1': { id: 'u1/cal1', name: 'Existing Calendar', events: {} } as Calendar } const store = storeFactory() store.dispatch({ type: 'calendars/getCalendars/fulfilled', payload: { importedCalendars: existingCalendars, errors: '' } }) store.dispatch(setUserData({ openpaasId: 'bla' })) const getUserDetailsMock = userAPI.getUserDetails as jest.Mock await store.dispatch(getCalendarsListAsync() as any) expect(getUserDetailsMock).not.toHaveBeenCalled() const state = store.getState().calendars expect(state.list).toEqual(existingCalendars) }) it('getCalendarsListAsync handles errors in getUserDetails gracefully', async () => { const mockCalendars = [ { _links: { self: { href: '/calendars/u1/cal1.json' } }, 'dav:name': 'Calendar 1', 'apple:color': '#FF0000', acl: [] }, { _links: { self: { href: '/calendars/u2/cal2.json' } }, 'dav:name': 'Calendar 2', 'apple:color': '#00FF00', acl: [] } ] ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ _embedded: { 'dav:calendar': mockCalendars } }) const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock .mockResolvedValueOnce({ firstname: 'Alice', lastname: 'Smith', emails: ['alice@example.com'] }) .mockRejectedValueOnce(new Error('Failed to fetch user')) const store = storeFactory() const result = await store.dispatch(getCalendarsListAsync() as any) expect(getUserDetailsMock).toHaveBeenCalledTimes(2) const state = store.getState().calendars expect(state.list['u1/cal1'].owner.firstname).toContain('Alice') expect(state.list['u2/cal2'].owner.lastname).toContain('Unknown User') expect(result.payload.errors).toBeTruthy() }) it('patchACLCalendarAsync.fulfilled sets visibility', () => { const prev = { ...initialState, list: { c1: { id: 'c1', visibility: 'public' } as any } } const state = reducer( prev, patchACLCalendarAsync.fulfilled( { calId: 'c1', calLink: 'l', request: '' }, 'req3', { calId: 'c1', calLink: 'l', request: '' } ) ) expect(state.list.c1.visibility).toBe('private') }) it('createCalendarAsync.fulfilled adds a new calendar', () => { const payload = { userData: { openpaasId: 'u1', family_name: 'Owner', email: 'o@example.com', sid: '', sub: '', given_name: 'Test', name: 'Test Owner' }, calId: 'cal1', color: { 'apple:color': '#f00' }, name: 'Test', desc: 'Desc' } const payloadResponse = { userId: 'u1', calId: 'cal1', color: { 'apple:color': '#f00' }, name: 'Test', desc: 'Desc', owner: { firstname: 'Owner', emails: ['o@example.com'] } } const state = reducer( initialState, createCalendarAsync.fulfilled(payloadResponse, 'req4', payload) ) expect(state.list['u1/cal1'].name).toBe('Test') expect(state.list['u1/cal1'].color?.['apple:color']).toBe('#f00') }) it('addSharedCalendarAsync.fulfilled adds shared calendar', () => { const payload = { calId: 'c1', color: { 'apple:color': '#0f0' }, link: '/calendars/u1/c1.json', name: 'Shared', desc: 'Shared Desc', owner: { firstname: 'O', emails: ['o@example.com'] } } const mockCal = { cal: { _links: { self: { href: '/calendars/u1/c1.json' } }, 'apple:color': '#0f0', 'caldav:description': 'Shared Desc', 'dav:name': 'Shared' } } const state = reducer( initialState, addSharedCalendarAsync.fulfilled(payload, 'req5', { userId: 'u1', calId: 'c1', cal: mockCal }) ) expect(state.list['c1'].name).toBe('Shared') }) it('getTempCalendarsListAsync.fulfilled updates templist', () => { const payload = { t1: { id: 't1', name: 'Temp', color: { 'apple:color': '#aaa' }, events: {}, visibility: 'public', owner: { firstname: 'O', emails: ['o@o.com'] }, link: '/calendars/t1.json', description: 'desc' } as Calendar } const state = reducer( initialState, getTempCalendarsListAsync.fulfilled(payload, 'req7', { openpaasId: 'u1', color: { 'apple:color': '#aaa' }, displayName: 'test', avatarUrl: '', email: 'test@test.com' }) ) expect(state.templist.t1.name).toBe('Temp') }) it('getEventAsync.fulfilled adds single event', () => { const payload = { calId: 'c1', event: { uid: 'e1' } as any } const state = reducer( initialState, getEventAsync.fulfilled(payload, 'req9', { uid: 'e1' } as any) ) expect(state.list.c1.events.e1.uid).toBe('e1') }) it('getCalendarDetailAsync.fulfilled adds calendar events', () => { const payload = { calId: 'c1', events: [{ uid: 'e1' }] as any[] } const state = reducer( { ...initialState, list: { ['c1']: { id: 'c1', events: {} } as unknown as Calendar } }, getCalendarDetailAsync.fulfilled(payload, 'req11', { calId: 'c1', match: { start: '', end: '' } }) ) expect(state.list.c1.events.e1.uid).toBe('e1') }) it('getEventAsync.fulfilled doesnt create new events when there are already event with base UID', () => { const baseUid = 'recurring-event-base' const existingEvent = { uid: `${baseUid}/20240115`, title: 'Existing Recurring Event', recurrenceId: null } as unknown as CalendarEvent const newEventInstance = { uid: baseUid, title: 'Fetched Master Event', recurrenceId: '20240115' } as CalendarEvent const stateWithEvent = { ...initialState, list: { c1: { id: 'c1', events: { [`${baseUid}/20240115`]: existingEvent } } as unknown as Calendar } } const payload = { calId: 'c1', event: newEventInstance } const state = reducer( stateWithEvent, getEventAsync.fulfilled(payload, 'req', newEventInstance) ) // Should still only have the base event, not the instance expect(Object.keys(state.list.c1.events)).toHaveLength(1) expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined() expect(state.list.c1.events[baseUid]).toBeUndefined() }) it("getEventAsync.fulfilled create new event when there isn't any event with base UID", () => { const eventUid = 'new-event-uid' const newEvent = { uid: eventUid, title: 'New Event', recurrenceId: null } as unknown as CalendarEvent const stateWithoutEvent = { ...initialState, list: { c1: { id: 'c1', events: {} } as unknown as Calendar } } const payload = { calId: 'c1', event: newEvent } const state = reducer( stateWithoutEvent, getEventAsync.fulfilled(payload, 'req', newEvent) ) // Should create the new event expect(Object.keys(state.list.c1.events)).toHaveLength(1) expect(state.list.c1.events[eventUid]).toBeDefined() expect(state.list.c1.events[eventUid].uid).toBe(eventUid) expect(state.list.c1.events[eventUid].title).toBe('New Event') }) }) })