cadfa70e60
* #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>
674 lines
18 KiB
TypeScript
674 lines
18 KiB
TypeScript
import * as fetchSyncTokenChanges from '@/features/Calendars/api/fetchSyncTokenChanges'
|
|
import reducer from '@/features/Calendars/CalendarSlice'
|
|
import { Calendar } from '@/features/Calendars/CalendarTypes'
|
|
import {
|
|
refreshCalendarWithSyncToken,
|
|
SyncTokenUpdates
|
|
} from '@/features/Calendars/services/refreshCalendar'
|
|
import * as EventApi from '@/features/Events/EventApi'
|
|
import { CalendarEvent } from '@/features/Events/EventsTypes'
|
|
import { configureStore } from '@reduxjs/toolkit'
|
|
|
|
jest.mock('@/features/Calendars/api/fetchSyncTokenChanges')
|
|
jest.mock('@/features/Events/EventApi')
|
|
|
|
describe('refreshCalendarWithSyncToken', () => {
|
|
const mockCalendar: Calendar = {
|
|
id: 'user1/cal1',
|
|
name: 'Test Calendar',
|
|
link: '/calendars/user1/cal1.json',
|
|
owner: { firstname: 'Test User', emails: ['test@example.com'] },
|
|
description: 'Test calendar description',
|
|
delegated: false,
|
|
color: { light: '#006BD8', dark: '#FFF' },
|
|
visibility: 'private',
|
|
events: {
|
|
event1: {
|
|
uid: 'event1',
|
|
title: 'Existing Event',
|
|
start: '2024-01-01T10:00:00',
|
|
end: '2024-01-01T11:00:00',
|
|
calId: 'user1/cal1',
|
|
URL: '/calendars/user1/cal1/event1.ics'
|
|
} as CalendarEvent
|
|
},
|
|
syncToken: 'sync-token-123'
|
|
}
|
|
|
|
const calendarRange = {
|
|
start: new Date('2024-01-01'),
|
|
end: new Date('2024-01-31')
|
|
}
|
|
|
|
const storeFactory = () =>
|
|
configureStore({
|
|
reducer: { calendars: reducer },
|
|
preloadedState: {
|
|
calendars: {
|
|
list: { [mockCalendar.id]: mockCalendar },
|
|
templist: {},
|
|
pending: false,
|
|
error: null
|
|
}
|
|
}
|
|
})
|
|
|
|
beforeEach(() => {
|
|
jest.resetAllMocks()
|
|
})
|
|
|
|
it('should return early if calendar has no syncToken', async () => {
|
|
const calendarWithoutToken = { ...mockCalendar, syncToken: undefined }
|
|
const store = storeFactory()
|
|
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: calendarWithoutToken,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
expect(result.payload).toEqual({
|
|
calId: calendarWithoutToken.id,
|
|
deletedEvents: [],
|
|
createdOrUpdatedEvents: [],
|
|
calType: undefined,
|
|
syncStatus: 'NO_SYNC_TOKEN'
|
|
})
|
|
expect(fetchSyncTokenChanges.fetchSyncTokenChanges).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle deleted events (status 404)', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-sync-token-456',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 404,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/deleted-event.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
expect(result.payload).toMatchObject({
|
|
calId: mockCalendar.id,
|
|
deletedEvents: ['/calendars/user1/cal1/deleted-event.ics'],
|
|
createdOrUpdatedEvents: [],
|
|
syncToken: 'new-sync-token-456'
|
|
})
|
|
})
|
|
|
|
it('should handle created/updated events (status 200)', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-sync-token-789',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 200,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/new-event.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const mockEventData = {
|
|
data: [
|
|
null,
|
|
null,
|
|
[
|
|
[
|
|
'vevent',
|
|
[
|
|
['uid', {}, 'text', 'new-event'],
|
|
['summary', {}, 'text', 'New Event Title'],
|
|
['dtstart', {}, 'date-time', '2024-01-15T10:00:00Z'],
|
|
['dtend', {}, 'date-time', '2024-01-15T11:00:00Z']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
],
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/new-event.ics' }
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
;(EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
if (result.type === 'calendars/refreshWithSyncToken/fulfilled') {
|
|
const payload = result.payload as SyncTokenUpdates
|
|
expect(payload?.calId).toBe(mockCalendar.id)
|
|
expect(payload?.createdOrUpdatedEvents).toHaveLength(1)
|
|
expect(payload?.createdOrUpdatedEvents[0].uid).toBe('new-event')
|
|
expect(payload?.syncToken).toBe('new-sync-token-789')
|
|
}
|
|
})
|
|
|
|
it('should handle both deletions and updates in same response', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-sync-token-mixed',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 404,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/deleted-event.ics' }
|
|
}
|
|
},
|
|
{
|
|
status: 200,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/updated-event.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const mockEventData = {
|
|
data: [
|
|
null,
|
|
null,
|
|
[
|
|
[
|
|
'vevent',
|
|
[
|
|
['uid', {}, 'text', 'updated-event'],
|
|
['summary', {}, 'text', 'Updated Event'],
|
|
['dtstart', {}, 'date-time', '2024-01-20T10:00:00Z'],
|
|
['dtend', {}, 'date-time', '2024-01-20T11:00:00Z']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
],
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/updated-event.ics' }
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
;(EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
if (result.type === 'calendars/refreshWithSyncToken/fulfilled') {
|
|
const payload = result.payload as SyncTokenUpdates
|
|
expect(payload?.deletedEvents).toEqual([
|
|
'/calendars/user1/cal1/deleted-event.ics',
|
|
'/calendars/user1/cal1/updated-event.ics'
|
|
])
|
|
expect(payload?.createdOrUpdatedEvents).toHaveLength(1)
|
|
expect(payload?.createdOrUpdatedEvents[0].uid).toBe('updated-event')
|
|
}
|
|
})
|
|
|
|
it('should reject with SYNC_TOKEN_INVALID error on 410 status', async () => {
|
|
const mockSyncResponse = {
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 410,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/some-event.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/rejected')
|
|
expect(result.payload).toMatchObject({
|
|
message: expect.stringContaining('SYNC_TOKEN_INVALID')
|
|
})
|
|
})
|
|
|
|
it('should handle API errors gracefully', async () => {
|
|
const mockError = new Error('Network error')
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockRejectedValue(mockError)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/rejected')
|
|
expect(result.payload).toMatchObject({
|
|
message: expect.stringContaining('Network error')
|
|
})
|
|
})
|
|
|
|
it('should handle malformed sync response', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token'
|
|
// Missing _embedded field
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
expect(result.payload).toMatchObject({
|
|
calId: mockCalendar.id,
|
|
deletedEvents: [],
|
|
createdOrUpdatedEvents: [],
|
|
syncToken: 'new-token'
|
|
})
|
|
})
|
|
|
|
it('should handle events without href in _links', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 200,
|
|
_links: {
|
|
self: {} // Missing href
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
if (result.type === 'calendars/refreshWithSyncToken/fulfilled') {
|
|
const payload = result.payload as SyncTokenUpdates
|
|
expect(payload?.createdOrUpdatedEvents).toHaveLength(0)
|
|
}
|
|
})
|
|
|
|
it('should continue processing other events if one fails to fetch', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 200,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/failing-event.ics' }
|
|
}
|
|
},
|
|
{
|
|
status: 200,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/success-event.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const mockEventData = {
|
|
data: [
|
|
null,
|
|
null,
|
|
[
|
|
[
|
|
'vevent',
|
|
[
|
|
['uid', {}, 'text', 'success-event'],
|
|
['summary', {}, 'text', 'Success Event'],
|
|
['dtstart', {}, 'date-time', '2024-01-20T10:00:00Z'],
|
|
['dtend', {}, 'date-time', '2024-01-20T11:00:00Z']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
],
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/success-event.ics' }
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
;(EventApi.reportEvent as jest.Mock)
|
|
.mockRejectedValueOnce(new Error('Failed to fetch'))
|
|
.mockResolvedValueOnce(mockEventData)
|
|
|
|
const store = storeFactory()
|
|
const result = await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled')
|
|
if (result.type === 'calendars/refreshWithSyncToken/fulfilled') {
|
|
const payload = result.payload as SyncTokenUpdates
|
|
expect(payload?.createdOrUpdatedEvents).toHaveLength(1)
|
|
expect(payload?.createdOrUpdatedEvents[0].uid).toBe('success-event')
|
|
}
|
|
})
|
|
|
|
it('should update calendar state with new sync token', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-sync-token-state-test',
|
|
_embedded: {
|
|
'dav:item': []
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = storeFactory()
|
|
await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
const state = store.getState().calendars
|
|
expect(state.list[mockCalendar.id].syncToken).toBe(
|
|
'new-sync-token-state-test'
|
|
)
|
|
})
|
|
|
|
it('should remove deleted events from calendar state', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 404,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/event1.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = storeFactory()
|
|
await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
const state = store.getState().calendars
|
|
expect(state.list[mockCalendar.id].events['event1']).toBeUndefined()
|
|
})
|
|
|
|
it('should add new events to calendar state', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 200,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/new-event.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const mockEventData = {
|
|
data: [
|
|
null,
|
|
null,
|
|
[
|
|
[
|
|
'vevent',
|
|
[
|
|
['uid', {}, 'text', 'new-event'],
|
|
['summary', {}, 'text', 'New Event'],
|
|
['dtstart', {}, 'date-time', '2024-01-25T10:00:00Z'],
|
|
['dtend', {}, 'date-time', '2024-01-25T11:00:00Z']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
],
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/new-event.ics' }
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
;(EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData)
|
|
|
|
const store = storeFactory()
|
|
await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
const state = store.getState().calendars
|
|
expect(state.list[mockCalendar.id].events['new-event']).toBeDefined()
|
|
expect(state.list[mockCalendar.id].events['new-event'].title).toBe(
|
|
'New Event'
|
|
)
|
|
})
|
|
|
|
it('should handle recurring events with base UID matching', async () => {
|
|
const calendarWithRecurringEvent = {
|
|
...mockCalendar,
|
|
events: {
|
|
'recurring-base': {
|
|
uid: 'recurring-base',
|
|
title: 'Recurring Event',
|
|
start: '2024-01-01T10:00:00',
|
|
end: '2024-01-01T11:00:00',
|
|
calId: 'user1/cal1',
|
|
URL: '/calendars/user1/cal1/recurring-base.ics'
|
|
} as CalendarEvent,
|
|
'recurring-base/20240108': {
|
|
uid: 'recurring-base/20240108',
|
|
title: 'Recurring Event Instance',
|
|
start: '2024-01-08T10:00:00',
|
|
end: '2024-01-08T11:00:00',
|
|
calId: 'user1/cal1',
|
|
URL: '/calendars/user1/cal1/recurring-base.ics'
|
|
} as CalendarEvent
|
|
}
|
|
}
|
|
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token',
|
|
_embedded: {
|
|
'dav:item': [
|
|
{
|
|
status: 404,
|
|
_links: {
|
|
self: { href: '/calendars/user1/cal1/recurring-base.ics' }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
const store = configureStore({
|
|
reducer: { calendars: reducer },
|
|
preloadedState: {
|
|
calendars: {
|
|
list: { [calendarWithRecurringEvent.id]: calendarWithRecurringEvent },
|
|
templist: {},
|
|
pending: false,
|
|
error: null
|
|
}
|
|
}
|
|
})
|
|
|
|
await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: calendarWithRecurringEvent,
|
|
calendarRange
|
|
})
|
|
)
|
|
|
|
const state = store.getState().calendars
|
|
// Both base event and instance should be removed
|
|
expect(
|
|
state.list[calendarWithRecurringEvent.id].events['recurring-base']
|
|
).toBeUndefined()
|
|
expect(
|
|
state.list[calendarWithRecurringEvent.id].events[
|
|
'recurring-base/20240108'
|
|
]
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('should respect maxConcurrency parameter', async () => {
|
|
const mockSyncResponse = {
|
|
'sync-token': 'new-token',
|
|
_embedded: {
|
|
'dav:item': Array.from({ length: 20 }, (_, i) => ({
|
|
status: 200,
|
|
_links: {
|
|
self: { href: `/calendars/user1/cal1/event${i}.ics` }
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
|
|
const mockEventData = (uid: string) => ({
|
|
data: [
|
|
null,
|
|
null,
|
|
[
|
|
[
|
|
'vevent',
|
|
[
|
|
['uid', {}, 'text', uid],
|
|
['summary', {}, 'text', `Event ${uid}`],
|
|
['dtstart', {}, 'date-time', '2024-01-20T10:00:00Z'],
|
|
['dtend', {}, 'date-time', '2024-01-20T11:00:00Z']
|
|
],
|
|
[]
|
|
]
|
|
]
|
|
],
|
|
_links: {
|
|
self: { href: `/calendars/user1/cal1/${uid}.ics` }
|
|
}
|
|
})
|
|
|
|
;(
|
|
fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock
|
|
).mockResolvedValue(mockSyncResponse)
|
|
|
|
let concurrentCalls = 0
|
|
let maxConcurrent = 0
|
|
|
|
;(EventApi.reportEvent as jest.Mock).mockImplementation(async () => {
|
|
concurrentCalls++
|
|
maxConcurrent = Math.max(maxConcurrent, concurrentCalls)
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
concurrentCalls--
|
|
return mockEventData('test-event')
|
|
})
|
|
|
|
const store = storeFactory()
|
|
await store.dispatch(
|
|
refreshCalendarWithSyncToken({
|
|
calendar: mockCalendar,
|
|
calendarRange,
|
|
maxConcurrency: 5
|
|
})
|
|
)
|
|
|
|
// Max concurrent should not exceed 5
|
|
expect(maxConcurrent).toBeLessThanOrEqual(5)
|
|
})
|
|
})
|