675 lines
18 KiB
TypeScript
675 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: "Test User",
|
|
ownerEmails: ["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: ["deleted-event"],
|
|
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([
|
|
"deleted-event",
|
|
"updated-event",
|
|
]);
|
|
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);
|
|
});
|
|
});
|