#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>
This commit is contained in:
lethemanh
2026-04-01 22:15:10 +07:00
committed by GitHub
parent 2bff6aae78
commit cadfa70e60
321 changed files with 27452 additions and 27600 deletions
+5 -5
View File
@@ -1,9 +1,9 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 80,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
"arrowParens": "avoid",
"endOfLine": "auto"
}
+11 -11
View File
@@ -103,21 +103,21 @@ Example:
```js
var appList = [
{
name: "Chat",
link: "/twake",
icon: "/assets/images/svg/app-chat.svg",
name: 'Chat',
link: '/twake',
icon: '/assets/images/svg/app-chat.svg'
},
{
name: "Drive",
link: "/drive",
icon: "/assets/images/svg/app-drive.svg",
name: 'Drive',
link: '/drive',
icon: '/assets/images/svg/app-drive.svg'
},
{
name: "Mail",
link: "/mail",
icon: "/assets/images/svg/app-mail.svg",
},
];
name: 'Mail',
link: '/mail',
icon: '/assets/images/svg/app-mail.svg'
}
]
```
**Note**: `appList.js` is gitignored, so each environment can have its own configuration. The icon files in `public/assets/images/svg/` should be committed to the repository.
File diff suppressed because it is too large Load Diff
+73 -73
View File
@@ -1,106 +1,106 @@
import CalendarLayout from "@/components/Calendar/CalendarLayout";
import { act, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
import CalendarLayout from '@/components/Calendar/CalendarLayout'
import { act, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
const PIVOT_UTC = new Date("2026-03-16T17:00:00Z");
const PIVOT_UTC = new Date('2026-03-16T17:00:00Z')
const makeState = (timezone: string) => ({
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
tokens: { accessToken: 'token' }
},
settings: { view: "calendar", timeZone: timezone },
settings: { view: 'calendar', timeZone: timezone },
calendars: {
list: {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["test@test.com"] },
events: {},
},
'user1/cal1': {
name: 'Calendar personal',
id: 'user1/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['test@test.com'] },
events: {}
}
},
pending: false,
},
});
pending: false
}
})
describe("Calendar dayHeaderContent respects selected timezone", () => {
describe('Calendar dayHeaderContent respects selected timezone', () => {
afterEach(() => {
jest.useRealTimers();
});
jest.useRealTimers()
})
async function renderAtPivotTime(timezone: string): Promise<string[]> {
// Set fake timers the same way the working test does — single chained call
jest.useFakeTimers().setSystemTime(PIVOT_UTC);
jest.useFakeTimers().setSystemTime(PIVOT_UTC)
await act(async () => {
renderWithProviders(<CalendarLayout />, makeState(timezone));
});
renderWithProviders(<CalendarLayout />, makeState(timezone))
})
// Advance past the debounce (300ms) like the working navigation test does
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
const calendarRef = window.__calendarRef;
await waitFor(() => expect(calendarRef?.current).not.toBeNull());
const calendarRef = window.__calendarRef
await waitFor(() => expect(calendarRef?.current).not.toBeNull())
await act(async () => {
calendarRef.current?.changeView("timeGridWeek");
});
calendarRef.current?.changeView('timeGridWeek')
})
await waitFor(() => {
expect(document.querySelectorAll(".fc-col-header-cell").length).toBe(7);
});
expect(document.querySelectorAll('.fc-col-header-cell').length).toBe(7)
})
return Array.from(document.querySelectorAll(".fc-col-header-cell")).map(
(cell) => cell.textContent ?? ""
);
return Array.from(document.querySelectorAll('.fc-col-header-cell')).map(
cell => cell.textContent ?? ''
)
}
it("UTC: Monday March 16 column exists", async () => {
const cells = await renderAtPivotTime("UTC");
const monday = cells.find((t) => t.includes("16"));
expect(monday).toBeDefined();
expect(monday).toMatch(/MON/i);
});
it('UTC: Monday March 16 column exists', async () => {
const cells = await renderAtPivotTime('UTC')
const monday = cells.find(t => t.includes('16'))
expect(monday).toBeDefined()
expect(monday).toMatch(/MON/i)
})
it("Europe/Paris: Monday March 16 column exists (UTC+1, no day shift)", async () => {
const cells = await renderAtPivotTime("Europe/Paris");
const monday = cells.find((t) => t.includes("16"));
expect(monday).toBeDefined();
expect(monday).toMatch(/MON/i);
});
it('Europe/Paris: Monday March 16 column exists (UTC+1, no day shift)', async () => {
const cells = await renderAtPivotTime('Europe/Paris')
const monday = cells.find(t => t.includes('16'))
expect(monday).toBeDefined()
expect(monday).toMatch(/MON/i)
})
it("Asia/Jakarta: Tuesday March 17 column exists (UTC+7, day shifts forward)", async () => {
const cells = await renderAtPivotTime("Asia/Jakarta");
const tuesday = cells.find((t) => t.includes("17"));
expect(tuesday).toBeDefined();
expect(tuesday).toMatch(/TUE/i);
});
it('Asia/Jakarta: Tuesday March 17 column exists (UTC+7, day shifts forward)', async () => {
const cells = await renderAtPivotTime('Asia/Jakarta')
const tuesday = cells.find(t => t.includes('17'))
expect(tuesday).toBeDefined()
expect(tuesday).toMatch(/TUE/i)
})
it("Asia/Jakarta: no column incorrectly labeled MON 17 regression", async () => {
const cells = await renderAtPivotTime("Asia/Jakarta");
const wrongCell = cells.find((t) => t.includes("17") && /MON/i.test(t));
expect(wrongCell).toBeUndefined();
});
it('Asia/Jakarta: no column incorrectly labeled MON 17 regression', async () => {
const cells = await renderAtPivotTime('Asia/Jakarta')
const wrongCell = cells.find(t => t.includes('17') && /MON/i.test(t))
expect(wrongCell).toBeUndefined()
})
it("Asia/Tokyo: Tuesday March 17 column exists (UTC+9)", async () => {
const cells = await renderAtPivotTime("Asia/Tokyo");
const tuesday = cells.find((t) => t.includes("17"));
expect(tuesday).toBeDefined();
expect(tuesday).toMatch(/TUE/i);
});
it('Asia/Tokyo: Tuesday March 17 column exists (UTC+9)', async () => {
const cells = await renderAtPivotTime('Asia/Tokyo')
const tuesday = cells.find(t => t.includes('17'))
expect(tuesday).toBeDefined()
expect(tuesday).toMatch(/TUE/i)
})
it("America/New_York: Monday March 16 column exists (UTC-4, no day shift)", async () => {
const cells = await renderAtPivotTime("America/New_York");
const monday = cells.find((t) => t.includes("16"));
expect(monday).toBeDefined();
expect(monday).toMatch(/MON/i);
});
});
it('America/New_York: Monday March 16 column exists (UTC-4, no day shift)', async () => {
const cells = await renderAtPivotTime('America/New_York')
const monday = cells.find(t => t.includes('16'))
expect(monday).toBeDefined()
expect(monday).toMatch(/MON/i)
})
})
+233 -235
View File
@@ -1,413 +1,411 @@
import CalendarSearch from "@/components/Calendar/CalendarSearch";
import * as CalendarApi from "@/features/Calendars/CalendarApi";
import * as CalendarSlice from "@/features/Calendars/services";
import { searchUsers } from "@/features/User/userAPI";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../utils/Renderwithproviders";
import CalendarSearch from '@/components/Calendar/CalendarSearch'
import * as CalendarApi from '@/features/Calendars/CalendarApi'
import * as CalendarSlice from '@/features/Calendars/services'
import { searchUsers } from '@/features/User/userAPI'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/User/userAPI");
jest.mock("@/features/Calendars/CalendarApi");
jest.mock('@/features/User/userAPI')
jest.mock('@/features/Calendars/CalendarApi')
const mockedSearchUsers = searchUsers as jest.MockedFunction<
typeof searchUsers
>;
const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
const mockedGetCalendars = CalendarApi.getCalendars as jest.MockedFunction<
typeof CalendarApi.getCalendars
>;
>
describe("CalendarSearch", () => {
const mockOnClose = jest.fn();
describe('CalendarSearch', () => {
const mockOnClose = jest.fn()
const mockUser = {
email: "user@example.com",
displayName: "Test User",
avatarUrl: "https://example.com/avatar.jpg",
openpaasId: "user123",
};
email: 'user@example.com',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
openpaasId: 'user123'
}
const mockCalendar = {
"dav:name": "Test Calendar",
"apple:color": "#FF0000",
'dav:name': 'Test Calendar',
'apple:color': '#FF0000',
_links: {
self: {
href: "/calendars/user123/cal1.json",
},
},
};
href: '/calendars/user123/cal1.json'
}
}
}
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
tokens: { accessToken: 'token' }
},
calendars: {
list: {
"user1/cal1": {
name: "My Calendar",
id: "user1/cal1",
color: "#0000FF",
owner: { emails: ["test@test.com"] },
events: {},
},
'user1/cal1': {
name: 'My Calendar',
id: 'user1/cal1',
color: '#0000FF',
owner: { emails: ['test@test.com'] },
events: {}
}
},
pending: false,
},
};
pending: false
}
}
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("searches for users and displays their calendars", async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
it('searches for users and displays their calendars', async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({
_embedded: {
"dav:calendar": [mockCalendar],
},
});
'dav:calendar': [mockCalendar]
}
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(mockedGetCalendars).toHaveBeenCalledWith(
"user123",
"sharedPublic=true&"
);
});
'user123',
'sharedPublic=true&'
)
})
await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument();
expect(screen.getByText("user@example.com")).toBeInTheDocument();
});
});
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
expect(screen.getByText('user@example.com')).toBeInTheDocument()
})
})
it("adds selected calendars on save", async () => {
it('adds selected calendars on save', async () => {
const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation(payload => {
return () => Promise.resolve(payload) as any
})
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({
_embedded: {
"dav:calendar": [mockCalendar],
},
});
'dav:calendar': [mockCalendar]
}
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument();
});
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
})
const addButton = screen.getByRole("button", { name: /add/i });
const addButton = screen.getByRole('button', { name: /add/i })
await act(async () => {
fireEvent.click(addButton);
});
fireEvent.click(addButton)
})
expect(addSharedCalendarSpy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
expect(addSharedCalendarSpy).toHaveBeenCalled()
expect(mockOnClose).toHaveBeenCalled()
})
it("does not add calendars that already exist", async () => {
it('does not add calendars that already exist', async () => {
const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation(payload => {
return () => Promise.resolve(payload) as any
})
const existingCalendar = {
...mockCalendar,
_links: {
self: {
href: "/calendars/user1/cal1.json",
},
},
};
href: '/calendars/user1/cal1.json'
}
}
}
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({
_embedded: {
"dav:calendar": [existingCalendar],
},
});
'dav:calendar': [existingCalendar]
}
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(
screen.getByText("calendar.noMoreCalendarsFor(name=Test User)")
).toBeInTheDocument();
});
screen.getByText('calendar.noMoreCalendarsFor(name=Test User)')
).toBeInTheDocument()
})
const addButton = screen.getByRole("button", { name: /add/i });
const addButton = screen.getByRole('button', { name: /add/i })
await act(async () => {
fireEvent.click(addButton);
});
fireEvent.click(addButton)
})
expect(addSharedCalendarSpy).not.toHaveBeenCalled();
});
expect(addSharedCalendarSpy).not.toHaveBeenCalled()
})
it("displays message when user has no publicly available calendars", async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
mockedGetCalendars.mockResolvedValueOnce({});
it('displays message when user has no publicly available calendars', async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(
screen.getByText("calendar.noPublicCalendarsFor(name=Test User)")
).toBeInTheDocument();
});
});
screen.getByText('calendar.noPublicCalendarsFor(name=Test User)')
).toBeInTheDocument()
})
})
it("changes calendar color", async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
it('changes calendar color', async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({
_embedded: {
"dav:calendar": [mockCalendar],
},
});
'dav:calendar': [mockCalendar]
}
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument();
});
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
})
// ColorPicker would need to be interacted with based on its implementation
// This is a placeholder for color change interaction
const colorPicker = document.querySelector('[data-testid="color-picker"]');
const colorPicker = document.querySelector('[data-testid="color-picker"]')
if (colorPicker) {
await act(async () => {
fireEvent.click(colorPicker);
});
fireEvent.click(colorPicker)
})
}
});
})
it("handles multiple calendars from the same user", async () => {
it('handles multiple calendars from the same user', async () => {
const secondCalendar = {
"dav:name": "Second Calendar",
"apple:color": "#00FF00",
'dav:name': 'Second Calendar',
'apple:color': '#00FF00',
_links: {
self: {
href: "/calendars/user123/cal2.json",
},
},
};
href: '/calendars/user123/cal2.json'
}
}
}
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({
_embedded: {
"dav:calendar": [mockCalendar, secondCalendar],
},
});
'dav:calendar': [mockCalendar, secondCalendar]
}
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument();
expect(screen.getByText(/Second Calendar/i)).toBeInTheDocument();
});
});
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
expect(screen.getByText(/Second Calendar/i)).toBeInTheDocument()
})
})
it("does not call addSharedCalendarAsync when no calendars are selected", async () => {
it('does not call addSharedCalendarAsync when no calendars are selected', async () => {
const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation(payload => {
return () => Promise.resolve(payload) as any
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const addButton = screen.getByRole("button", { name: /add/i });
const addButton = screen.getByRole('button', { name: /add/i })
await act(async () => {
fireEvent.click(addButton);
});
fireEvent.click(addButton)
})
expect(addSharedCalendarSpy).not.toHaveBeenCalled();
});
it("BUGFIX : handles calendar with no apple:color", async () => {
expect(addSharedCalendarSpy).not.toHaveBeenCalled()
})
it('BUGFIX : handles calendar with no apple:color', async () => {
const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation(payload => {
return () => Promise.resolve(payload) as any
})
const mockCalendarNoColor = {
"dav:name": "Test Calendar",
'dav:name': 'Test Calendar',
_links: {
self: {
href: "/calendars/user123/cal2.json",
},
},
};
mockedSearchUsers.mockResolvedValueOnce([mockUser]);
href: '/calendars/user123/cal2.json'
}
}
}
mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({
_embedded: {
"dav:calendar": [mockCalendarNoColor],
},
});
'dav:calendar': [mockCalendarNoColor]
}
})
await act(async () => {
renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState
);
});
)
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", expect.anything());
});
expect(mockedSearchUsers).toHaveBeenCalledWith('Test', expect.anything())
})
const option = await screen.findByText("Test User");
const option = await screen.findByText('Test User')
await act(async () => {
fireEvent.click(option);
});
fireEvent.click(option)
})
await waitFor(() => {
expect(mockedGetCalendars).toHaveBeenCalledWith(
"user123",
"sharedPublic=true&"
);
});
'user123',
'sharedPublic=true&'
)
})
await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument();
expect(screen.getByText("user@example.com")).toBeInTheDocument();
});
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
expect(screen.getByText('user@example.com')).toBeInTheDocument()
})
const addButton = screen.getByRole("button", { name: /add/i });
const addButton = screen.getByRole('button', { name: /add/i })
await act(async () => {
fireEvent.click(addButton);
});
fireEvent.click(addButton)
})
await waitFor(() =>
expect(addSharedCalendarSpy).toHaveBeenCalledWith({
cal: {
cal: {
_links: { self: { href: "/calendars/user123/cal2.json" } },
"dav:name": "Test Calendar",
_links: { self: { href: '/calendars/user123/cal2.json' } },
'dav:name': 'Test Calendar'
},
color: { dark: "#329655", light: "#D0ECDA" },
color: { dark: '#329655', light: '#D0ECDA' },
owner: {
avatarUrl: "https://example.com/avatar.jpg",
displayName: "Test User",
email: "user@example.com",
openpaasId: "user123",
},
avatarUrl: 'https://example.com/avatar.jpg',
displayName: 'Test User',
email: 'user@example.com',
openpaasId: 'user123'
}
},
calId: expect.any(String),
userId: "user1",
userId: 'user1'
})
);
)
expect(mockOnClose).toHaveBeenCalledWith(
expect.arrayContaining(["user123/cal2"])
);
});
});
expect.arrayContaining(['user123/cal2'])
)
})
})
+110 -110
View File
@@ -1,188 +1,188 @@
import CalendarResources from "@/components/Calendar/CalendarResources";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
import CalendarResources from '@/components/Calendar/CalendarResources'
import { getCalendars } from '@/features/Calendars/CalendarApi'
import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/features/Calendars/api/addCalendarResourceAsync");
jest.mock("@/components/Attendees/ResourceSearch", () => ({
jest.mock('@/features/Calendars/CalendarApi')
jest.mock('@/features/Calendars/api/addCalendarResourceAsync')
jest.mock('@/components/Attendees/ResourceSearch', () => ({
ResourceSearch: ({
onChange,
onChange
}: {
onChange: (
event: null,
value: { displayName: string; openpaasId: string }[]
) => void;
) => void
}) => (
<div data-testid="resource-search">
<button
data-testid="mock-resource-search-select"
onClick={() =>
onChange(null, [
{ displayName: "Room A", openpaasId: "room-a-id" },
{ displayName: "Room B", openpaasId: "room-b-id" },
{ displayName: 'Room A', openpaasId: 'room-a-id' },
{ displayName: 'Room B', openpaasId: 'room-b-id' }
])
}
>
Select Resources
</button>
</div>
),
}));
)
}))
const mockedGetCalendars = getCalendars as jest.Mock;
const mockedGetCalendars = getCalendars as jest.Mock
const mockedAddCalendarResourceAsync =
addCalendarResourceAsync as unknown as jest.Mock;
addCalendarResourceAsync as unknown as jest.Mock
describe("CalendarResources", () => {
describe('CalendarResources', () => {
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
const baseUser = {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
};
tokens: { accessToken: 'token' }
}
const setup = (isOpen = true) => {
const onClose = jest.fn();
const onClose = jest.fn()
renderWithProviders(<CalendarResources onClose={onClose} open={isOpen} />, {
user: baseUser,
});
return { onClose };
};
user: baseUser
})
return { onClose }
}
it("renders correctly and closes on cancel", () => {
const { onClose } = setup();
it('renders correctly and closes on cancel', () => {
const { onClose } = setup()
expect(screen.getByText("calendar.browseResources")).toBeInTheDocument();
expect(screen.getByText('calendar.browseResources')).toBeInTheDocument()
const cancelButton = screen.getByRole("button", {
name: "common.cancel",
});
fireEvent.click(cancelButton);
const cancelButton = screen.getByRole('button', {
name: 'common.cancel'
})
fireEvent.click(cancelButton)
expect(onClose).toHaveBeenCalled();
});
expect(onClose).toHaveBeenCalled()
})
it("does not render when isOpen is false", () => {
setup(false);
it('does not render when isOpen is false', () => {
setup(false)
expect(
screen.queryByText("calendar.browseResources")
).not.toBeInTheDocument();
});
screen.queryByText('calendar.browseResources')
).not.toBeInTheDocument()
})
it("fetches resource calendars and adds them using Promise.allSettled logic", async () => {
it('fetches resource calendars and adds them using Promise.allSettled logic', async () => {
const mockCalendarDataA = {
_embedded: {
"dav:calendar": [
'dav:calendar': [
{
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } },
"dav:name": "Room A Calendar",
"caldav:description": "Main room A calendar",
color: "red",
},
],
},
};
_links: { self: { href: '/calendars/room-a-id/cal-a.json' } },
'dav:name': 'Room A Calendar',
'caldav:description': 'Main room A calendar',
color: 'red'
}
]
}
}
mockedGetCalendars.mockImplementation((userId) => {
if (userId === "room-a-id") return Promise.resolve(mockCalendarDataA);
mockedGetCalendars.mockImplementation(userId => {
if (userId === 'room-a-id') return Promise.resolve(mockCalendarDataA)
// Simulate failure for room-b
if (userId === "room-b-id")
return Promise.reject(new Error("Failed fetching Room B"));
return Promise.resolve([]);
});
if (userId === 'room-b-id')
return Promise.reject(new Error('Failed fetching Room B'))
return Promise.resolve([])
})
const mockDispatchResult = {
unwrap: () => Promise.resolve({ type: "success" }),
};
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult);
unwrap: () => Promise.resolve({ type: 'success' })
}
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult)
const { onClose } = setup();
const { onClose } = setup()
// Trigger resource selection
const mockSelectBtn = screen.getByTestId("mock-resource-search-select");
const mockSelectBtn = screen.getByTestId('mock-resource-search-select')
await act(async () => {
fireEvent.click(mockSelectBtn);
});
fireEvent.click(mockSelectBtn)
})
// After selection, the button should be enabled and say Add
const addButton = await screen.findByRole("button", {
name: "actions.add",
});
expect(addButton).not.toBeDisabled();
const addButton = await screen.findByRole('button', {
name: 'actions.add'
})
expect(addButton).not.toBeDisabled()
// Click add to trigger addCalendarResourceAsync
await act(async () => {
fireEvent.click(addButton);
});
fireEvent.click(addButton)
})
await waitFor(() => {
// It should only have been called for Room A because Room B failed (Promise.allSettled)
expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1);
expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1)
const payload = mockedAddCalendarResourceAsync.mock.calls[0][0];
const payload = mockedAddCalendarResourceAsync.mock.calls[0][0]
expect(payload).toEqual(
expect.objectContaining({
userId: "user1",
userId: 'user1',
calId: expect.any(String),
cal: expect.objectContaining({
cal: mockCalendarDataA._embedded["dav:calendar"][0],
}),
cal: mockCalendarDataA._embedded['dav:calendar'][0]
})
})
);
});
)
})
expect(onClose).toHaveBeenCalled();
});
expect(onClose).toHaveBeenCalled()
})
it("adds existing user details fallback logic within submit handler", async () => {
it('adds existing user details fallback logic within submit handler', async () => {
const mockCalendarDataA = {
_embedded: {
"dav:calendar": [
'dav:calendar': [
{
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } },
"dav:name": "Room A Calendar",
"caldav:description": "Main room A calendar",
color: "red",
},
],
},
};
_links: { self: { href: '/calendars/room-a-id/cal-a.json' } },
'dav:name': 'Room A Calendar',
'caldav:description': 'Main room A calendar',
color: 'red'
}
]
}
}
mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA);
mockedGetCalendars.mockResolvedValueOnce([]); // Mock array of size 2 (for both A and B but second resolves empty)
mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA)
mockedGetCalendars.mockResolvedValueOnce([]) // Mock array of size 2 (for both A and B but second resolves empty)
// Simulate successful API response
const mockDispatchResult = {
unwrap: () => Promise.resolve(),
};
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult);
unwrap: () => Promise.resolve()
}
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult)
const { onClose } = setup();
const { onClose } = setup()
// Trigger resource selection
const mockSelectBtn = screen.getByTestId("mock-resource-search-select");
const mockSelectBtn = screen.getByTestId('mock-resource-search-select')
await act(async () => {
fireEvent.click(mockSelectBtn);
});
fireEvent.click(mockSelectBtn)
})
const addButton = await screen.findByRole("button", {
name: "actions.add",
});
const addButton = await screen.findByRole('button', {
name: 'actions.add'
})
await act(async () => {
fireEvent.click(addButton);
});
fireEvent.click(addButton)
})
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
expect(onClose).toHaveBeenCalled()
})
})
})
+149 -149
View File
@@ -1,80 +1,80 @@
import CalendarSelection from "@/components/Calendar/CalendarSelection";
import * as calendarThunks from "@/features/Calendars/services";
import "@testing-library/jest-dom";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../utils/Renderwithproviders";
import CalendarSelection from '@/components/Calendar/CalendarSelection'
import * as calendarThunks from '@/features/Calendars/services'
import '@testing-library/jest-dom'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../utils/Renderwithproviders'
describe("CalendarSelection", () => {
describe('CalendarSelection', () => {
beforeEach(() => {
localStorage.clear();
});
localStorage.clear()
})
const baseUser = {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
};
tokens: { accessToken: 'token' }
}
const calendarsMock = {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: "#FF0000",
owner: { emails: ["alice@example.com"], lastname: "alice" },
'user1/cal1': {
name: 'Calendar personal',
id: 'user1/cal1',
color: '#FF0000',
owner: { emails: ['alice@example.com'], lastname: 'alice' }
},
"user2/cal1": {
name: "Calendar delegated",
'user2/cal1': {
name: 'Calendar delegated',
delegated: true,
id: "user2/cal1",
color: "#00FF00",
id: 'user2/cal1',
color: '#00FF00',
owner: {
firstname: "Bob",
lastname: "Builder",
emails: ["bob@example.com"],
},
firstname: 'Bob',
lastname: 'Builder',
emails: ['bob@example.com']
}
},
"user3/cal1": {
name: "Calendar shared",
id: "user3/cal1",
color: "#0000FF",
'user3/cal1': {
name: 'Calendar shared',
id: 'user3/cal1',
color: '#0000FF',
owner: {
firstname: "Charlie",
lastname: "Chaplin",
emails: ["charlie@example.com"],
},
},
};
firstname: 'Charlie',
lastname: 'Chaplin',
emails: ['charlie@example.com']
}
}
}
beforeAll(() => {
jest.clearAllMocks();
cleanup();
});
it("renders personal, delegated and calendar.other", () => {
jest.clearAllMocks()
cleanup()
})
it('renders personal, delegated and calendar.other', () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={["user1/cal1"]}
selectedCalendars={['user1/cal1']}
setSelectedCalendars={jest.fn()}
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
expect(screen.getByText("calendar.personal")).toBeInTheDocument();
expect(screen.getByText("calendar.delegated")).toBeInTheDocument();
expect(screen.getByText("calendar.other")).toBeInTheDocument();
expect(screen.getByText('calendar.personal')).toBeInTheDocument()
expect(screen.getByText('calendar.delegated')).toBeInTheDocument()
expect(screen.getByText('calendar.other')).toBeInTheDocument()
expect(screen.getByLabelText("Calendar personal")).toBeChecked();
expect(screen.getByLabelText("Calendar delegated")).not.toBeChecked();
expect(screen.getByLabelText("Calendar shared")).not.toBeChecked();
});
expect(screen.getByLabelText('Calendar personal')).toBeChecked()
expect(screen.getByLabelText('Calendar delegated')).not.toBeChecked()
expect(screen.getByLabelText('Calendar shared')).not.toBeChecked()
})
it("toggles a calendar selection on click", () => {
const setSelectedCalendars = jest.fn();
it('toggles a calendar selection on click', () => {
const setSelectedCalendars = jest.fn()
renderWithProviders(
<CalendarSelection
@@ -83,41 +83,41 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const checkbox = screen.getByLabelText("Calendar personal");
fireEvent.click(checkbox);
const checkbox = screen.getByLabelText('Calendar personal')
fireEvent.click(checkbox)
expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function));
expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function))
const updater = setSelectedCalendars.mock.calls[0][0];
expect(updater([])).toEqual(["user1/cal1"]);
});
const updater = setSelectedCalendars.mock.calls[0][0]
expect(updater([])).toEqual(['user1/cal1'])
})
it("removes calendar from selection if already selected", () => {
const setSelectedCalendars = jest.fn();
it('removes calendar from selection if already selected', () => {
const setSelectedCalendars = jest.fn()
renderWithProviders(
<CalendarSelection
selectedCalendars={["user1/cal1"]}
selectedCalendars={['user1/cal1']}
setSelectedCalendars={setSelectedCalendars}
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const checkbox = screen.getByLabelText("Calendar personal");
fireEvent.click(checkbox);
const checkbox = screen.getByLabelText('Calendar personal')
fireEvent.click(checkbox)
const updater = setSelectedCalendars.mock.calls[0][0];
expect(updater(["user1/cal1"])).toEqual([]);
});
const updater = setSelectedCalendars.mock.calls[0][0]
expect(updater(['user1/cal1'])).toEqual([])
})
it("opens CalendarPopover modal when personal Add button is clicked", async () => {
it('opens CalendarPopover modal when personal Add button is clicked', async () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -125,26 +125,26 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const addButtons = screen.getAllByRole("button");
fireEvent.click(addButtons[1]);
const addButtons = screen.getAllByRole('button')
fireEvent.click(addButtons[1])
await waitFor(() =>
expect(
screen.getByText("calendarPopover.tabs.addNew")
screen.getByText('calendarPopover.tabs.addNew')
).toBeInTheDocument()
);
});
)
})
it("Navigates to deletion dialog and deletes personal cal", async () => {
it('Navigates to deletion dialog and deletes personal cal', async () => {
const spy = jest
.spyOn(calendarThunks, "removeCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.spyOn(calendarThunks, 'removeCalendarAsync')
.mockImplementation(payload => {
return () => Promise.resolve(payload) as any
})
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -152,31 +152,31 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const addButtons = screen.getAllByTestId("MoreHorizIcon");
fireEvent.click(addButtons[0]);
const addButtons = screen.getAllByTestId('MoreHorizIcon')
fireEvent.click(addButtons[0])
userEvent.click(screen.getByText(/delete/i));
userEvent.click(screen.getByText(/delete/i))
await waitFor(() =>
expect(
screen.getByText("calendar.delete.title(name=Calendar personal)")
screen.getByText('calendar.delete.title(name=Calendar personal)')
).toBeInTheDocument()
);
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
)
fireEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => expect(spy).toHaveBeenCalled());
});
await waitFor(() => expect(spy).toHaveBeenCalled())
})
it("Navigates to deletion dialog and deletes other cal", async () => {
it('Navigates to deletion dialog and deletes other cal', async () => {
const spy = jest
.spyOn(calendarThunks, "removeCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.spyOn(calendarThunks, 'removeCalendarAsync')
.mockImplementation(payload => {
return () => Promise.resolve(payload) as any
})
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -184,26 +184,26 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const addButtons = screen.getAllByTestId("MoreHorizIcon");
fireEvent.click(addButtons[1]);
const addButtons = screen.getAllByTestId('MoreHorizIcon')
fireEvent.click(addButtons[1])
userEvent.click(screen.getByText(/remove/i));
userEvent.click(screen.getByText(/remove/i))
await waitFor(() =>
expect(
screen.getByText("calendar.delete.title(name=Calendar delegated)")
screen.getByText('calendar.delete.title(name=Calendar delegated)')
).toBeInTheDocument()
);
fireEvent.click(screen.getByRole("button", { name: /remove/i }));
)
fireEvent.click(screen.getByRole('button', { name: /remove/i }))
await waitFor(() => expect(spy).toHaveBeenCalled());
});
await waitFor(() => expect(spy).toHaveBeenCalled())
})
it("opens CalendarSearch modal when Other Add button is clicked", () => {
it('opens CalendarSearch modal when Other Add button is clicked', () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -211,19 +211,19 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const addButtons = screen.getAllByTestId("AddIcon");
fireEvent.click(addButtons[1]); // seccond Add button (other)
const addButtons = screen.getAllByTestId('AddIcon')
fireEvent.click(addButtons[1]) // seccond Add button (other)
expect(
screen.getByText("calendar.browseOtherCalendars")
).toBeInTheDocument();
});
screen.getByText('calendar.browseOtherCalendars')
).toBeInTheDocument()
})
it("when only calendar.personal are in the state, only calendar.personal and the title for other to be added are shown", () => {
it('when only calendar.personal are in the state, only calendar.personal and the title for other to be added are shown', () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -233,19 +233,19 @@ describe("CalendarSelection", () => {
user: baseUser,
calendars: {
list: {
"user1/cal1": calendarsMock["user1/cal1"],
'user1/cal1': calendarsMock['user1/cal1']
},
pending: false,
},
pending: false
}
}
);
)
expect(screen.getByText("calendar.personal")).toBeInTheDocument();
expect(screen.queryByText("calendar.delegated")).not.toBeInTheDocument();
expect(screen.queryByText("calendar.other")).toBeInTheDocument();
});
expect(screen.getByText('calendar.personal')).toBeInTheDocument()
expect(screen.queryByText('calendar.delegated')).not.toBeInTheDocument()
expect(screen.queryByText('calendar.other')).toBeInTheDocument()
})
it("renders nothing when no calendars are present", () => {
it('renders nothing when no calendars are present', () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -253,14 +253,14 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: {}, pending: false },
calendars: { list: {}, pending: false }
}
);
)
expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument();
});
expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument()
})
it("expands and collapses accordions when clicked", () => {
it('expands and collapses accordions when clicked', () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
@@ -268,31 +268,31 @@ describe("CalendarSelection", () => {
/>,
{
user: baseUser,
calendars: { list: calendarsMock, pending: false },
calendars: { list: calendarsMock, pending: false }
}
);
)
const delegatedAccordionSummary = screen
.getByText("calendar.delegated")
.closest(".MuiAccordionSummary-root");
.getByText('calendar.delegated')
.closest('.MuiAccordionSummary-root')
fireEvent.click(delegatedAccordionSummary!);
expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "false");
fireEvent.click(delegatedAccordionSummary!)
expect(delegatedAccordionSummary).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(delegatedAccordionSummary!);
expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true");
});
fireEvent.click(delegatedAccordionSummary!)
expect(delegatedAccordionSummary).toHaveAttribute('aria-expanded', 'true')
})
it("renders owner name caption for non-personal, non-default calendars", () => {
it('renders owner name caption for non-personal, non-default calendars', () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
setSelectedCalendars={jest.fn()}
/>,
{ user: baseUser, calendars: { list: calendarsMock, pending: false } }
);
expect(screen.getByText("Bob Builder")).toBeInTheDocument();
expect(screen.getByText("Charlie Chaplin")).toBeInTheDocument();
)
expect(screen.getByText('Bob Builder')).toBeInTheDocument()
expect(screen.getByText('Charlie Chaplin')).toBeInTheDocument()
// personal calendar should NOT show a caption
expect(screen.queryByText("alice")).not.toBeInTheDocument();
});
});
expect(screen.queryByText('alice')).not.toBeInTheDocument()
})
})
+208 -220
View File
@@ -1,343 +1,331 @@
import {
DateTimeFields,
DateTimeFieldsProps,
} from "@/components/Event/components/DateTimeFields";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
DateTimeFieldsProps
} from '@/components/Event/components/DateTimeFields'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
jest.mock("twake-i18n", () => ({
jest.mock('twake-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
lang: "en",
}),
}));
lang: 'en'
})
}))
describe("DateTimeFields", () => {
describe('DateTimeFields', () => {
const mockHandlers = {
onStartDateChange: jest.fn(),
onStartTimeChange: jest.fn(),
onEndDateChange: jest.fn(),
onEndTimeChange: jest.fn(),
onToggleEndDate: jest.fn(),
};
onToggleEndDate: jest.fn()
}
const defaultProps: DateTimeFieldsProps = {
startDate: "2025-07-18",
startTime: "09:00",
endDate: "2025-07-18",
endTime: "10:00",
startDate: '2025-07-18',
startTime: '09:00',
endDate: '2025-07-18',
endTime: '10:00',
allday: false,
showMore: true,
hasEndDateChanged: false,
showEndDate: false,
validation: {
errors: {
dateTime: "",
},
dateTime: ''
}
},
...mockHandlers,
};
...mockHandlers
}
const renderField = async (props: Partial<DateTimeFieldsProps> = {}) => {
await act(async () =>
render(<DateTimeFields {...defaultProps} {...props} />)
);
};
)
}
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("moves END forward when START moves after END (normal mode)", async () => {
it('moves END forward when START moves after END (normal mode)', async () => {
await renderField({
startDate: "2025-01-01",
startTime: "10:00",
endDate: "2025-01-01",
endTime: "11:00",
showMore: true,
});
startDate: '2025-01-01',
startTime: '10:00',
endDate: '2025-01-01',
endTime: '11:00',
showMore: true
})
const startTimeInput = screen.getByTestId("start-time-input");
const startTimeInput = screen.getByTestId('start-time-input')
fireEvent.change(startTimeInput, { target: { value: "12:00" } });
fireEvent.blur(startTimeInput);
fireEvent.change(startTimeInput, { target: { value: '12:00' } })
fireEvent.blur(startTimeInput)
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00")
);
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('12:00')
)
await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("13:00")
);
});
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith('13:00')
)
})
it("moves END forward by full duration when START date jumps after END date", async () => {
it('moves END forward by full duration when START date jumps after END date', async () => {
await renderField({
startDate: "2025-01-01",
startTime: "09:00",
endDate: "2025-01-01",
endTime: "10:00",
showMore: true,
});
startDate: '2025-01-01',
startTime: '09:00',
endDate: '2025-01-01',
endTime: '10:00',
showMore: true
})
await userEvent.click(screen.getByTestId("start-date-input"));
const dayButton = screen.getByRole("gridcell", { name: "3" });
await userEvent.click(dayButton);
await userEvent.click(screen.getByTestId('start-date-input'))
const dayButton = screen.getByRole('gridcell', { name: '3' })
await userEvent.click(dayButton)
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03")
);
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-01-03')
)
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03")
);
});
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-01-03')
)
})
it("does NOT move START backward when END moves before START (normal mode)", async () => {
it('does NOT move START backward when END moves before START (normal mode)', async () => {
await renderField({
startDate: "2025-01-01",
startTime: "10:00",
endDate: "2025-01-01",
endTime: "11:00",
showMore: true,
});
startDate: '2025-01-01',
startTime: '10:00',
endDate: '2025-01-01',
endTime: '11:00',
showMore: true
})
const endTimeInput = screen.getByTestId("end-time-input");
const endTimeInput = screen.getByTestId('end-time-input')
fireEvent.change(endTimeInput, { target: { value: "08:00" } });
fireEvent.blur(endTimeInput);
fireEvent.change(endTimeInput, { target: { value: '08:00' } })
fireEvent.blur(endTimeInput)
await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00")
);
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith('08:00')
)
// Start time should NOT be automatically adjusted when end time changes
expect(mockHandlers.onStartTimeChange).not.toHaveBeenCalled();
expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled();
});
expect(mockHandlers.onStartTimeChange).not.toHaveBeenCalled()
expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled()
})
it("moves START backward properly when END date jumps before START date", async () => {
it('moves START backward properly when END date jumps before START date', async () => {
await renderField({
startDate: "2025-01-05",
startTime: "09:00",
endDate: "2025-01-05",
endTime: "10:00",
showMore: true,
});
startDate: '2025-01-05',
startTime: '09:00',
endDate: '2025-01-05',
endTime: '10:00',
showMore: true
})
await userEvent.click(screen.getByTestId("end-date-input"));
const dayButton = screen.getByRole("gridcell", { name: "3" });
await userEvent.click(dayButton);
await userEvent.click(screen.getByTestId('end-date-input'))
const dayButton = screen.getByRole('gridcell', { name: '3' })
await userEvent.click(dayButton)
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03")
);
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-01-03')
)
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03")
);
});
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-01-03')
)
})
it("pushes END forward in whole days for allday events", async () => {
it('pushes END forward in whole days for allday events', async () => {
await renderField({
allday: true,
startDate: "2025-02-01",
endDate: "2025-02-03",
showEndDate: true,
});
startDate: '2025-02-01',
endDate: '2025-02-03',
showEndDate: true
})
await userEvent.click(screen.getByTestId("start-date-input"));
const dayButton = screen.getByRole("gridcell", { name: "10" });
await userEvent.click(dayButton);
await userEvent.click(screen.getByTestId('start-date-input'))
const dayButton = screen.getByRole('gridcell', { name: '10' })
await userEvent.click(dayButton)
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-02-10")
);
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-02-10')
)
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-02-12")
);
});
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-02-12')
)
})
it("moves START backward in whole days for allday when END moves earlier", async () => {
it('moves START backward in whole days for allday when END moves earlier', async () => {
await renderField({
allday: true,
startDate: "2025-05-10",
endDate: "2025-05-15",
showEndDate: true,
});
startDate: '2025-05-10',
endDate: '2025-05-15',
showEndDate: true
})
await userEvent.click(screen.getByTestId("end-date-input"));
const dayButton = screen.getByRole("gridcell", { name: "1" });
await userEvent.click(dayButton);
await userEvent.click(screen.getByTestId('end-date-input'))
const dayButton = screen.getByRole('gridcell', { name: '1' })
await userEvent.click(dayButton)
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-05-01")
);
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-05-01')
)
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-04-26")
);
});
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-04-26')
)
})
it("does not call handlers if invalid (null) date value", async () => {
it('does not call handlers if invalid (null) date value', async () => {
await renderField({
showMore: true,
});
showMore: true
})
const startDateInput = screen.getByTestId("start-date-input");
const startDateInput = screen.getByTestId('start-date-input')
fireEvent.change(startDateInput, { target: { value: "" } });
fireEvent.change(startDateInput, { target: { value: '' } })
expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled();
expect(mockHandlers.onEndDateChange).not.toHaveBeenCalled();
});
expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled()
expect(mockHandlers.onEndDateChange).not.toHaveBeenCalled()
})
it("shift to preserve original duration (normal case)", async () => {
it('shift to preserve original duration (normal case)', async () => {
await renderField({
startDate: "2025-01-01",
startTime: "09:00",
endDate: "2025-01-01",
endTime: "10:00",
showMore: true,
});
startDate: '2025-01-01',
startTime: '09:00',
endDate: '2025-01-01',
endTime: '10:00',
showMore: true
})
const startTimeInput = screen.getByTestId("start-time-input");
const startTimeInput = screen.getByTestId('start-time-input')
fireEvent.change(startTimeInput, { target: { value: "09:30" } });
fireEvent.blur(startTimeInput);
fireEvent.change(startTimeInput, { target: { value: '09:30' } })
fireEvent.blur(startTimeInput)
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30")
);
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('09:30')
)
await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30")
);
});
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith('10:30')
)
})
it("preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45", async () => {
it('preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45', async () => {
await renderField({
startDate: "2025-01-15",
startTime: "22:30",
endDate: "2025-01-15",
endTime: "23:30",
showMore: true,
});
startDate: '2025-01-15',
startTime: '22:30',
endDate: '2025-01-15',
endTime: '23:30',
showMore: true
})
const startTimeInput = screen.getByTestId("start-time-input");
const startTimeInput = screen.getByTestId('start-time-input')
// Change start time from 22:30 to 23:45
fireEvent.change(startTimeInput, { target: { value: "23:45" } });
fireEvent.blur(startTimeInput);
fireEvent.change(startTimeInput, { target: { value: '23:45' } })
fireEvent.blur(startTimeInput)
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45")
);
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('23:45')
)
// End date should move to the next day
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith(
"2025-01-16",
"00:45"
'2025-01-16',
'00:45'
)
);
});
)
})
it("should have aria-label for accessibility and testing", async () => {
it('should have aria-label for accessibility and testing', async () => {
await renderField({
startDate: "2025-01-01",
startTime: "10:00",
endDate: "2025-01-01",
endTime: "11:00",
showMore: true,
});
startDate: '2025-01-01',
startTime: '10:00',
endDate: '2025-01-01',
endTime: '11:00',
showMore: true
})
const startDateInput = screen.getByTestId("start-date-input");
const startTimeInput = screen.getByTestId("start-time-input");
const endDateInput = screen.getByTestId("end-date-input");
const endTimeInput = screen.getByTestId("end-time-input");
const startDateInput = screen.getByTestId('start-date-input')
const startTimeInput = screen.getByTestId('start-time-input')
const endDateInput = screen.getByTestId('end-date-input')
const endTimeInput = screen.getByTestId('end-time-input')
expect(startDateInput).toHaveAttribute(
"aria-label",
"dateTimeFields.startDate"
);
'aria-label',
'dateTimeFields.startDate'
)
expect(startTimeInput).toHaveAttribute(
"aria-label",
"dateTimeFields.startTime"
);
expect(endDateInput).toHaveAttribute(
"aria-label",
"dateTimeFields.endDate"
);
expect(endTimeInput).toHaveAttribute(
"aria-label",
"dateTimeFields.endTime"
);
});
'aria-label',
'dateTimeFields.startTime'
)
expect(endDateInput).toHaveAttribute('aria-label', 'dateTimeFields.endDate')
expect(endTimeInput).toHaveAttribute('aria-label', 'dateTimeFields.endTime')
})
describe("DateTimeFields - Drag and Drop Display Logic", () => {
describe('DateTimeFields - Drag and Drop Display Logic', () => {
// Test 1.1: Display 2 fields when drag from allday slot (multiple days)
it("displays only 2 date fields when allday=true and multiple days", async () => {
it('displays only 2 date fields when allday=true and multiple days', async () => {
await renderField({
allday: true,
hasEndDateChanged: false,
startDate: "2025-07-18",
endDate: "2025-07-20",
startDate: '2025-07-18',
endDate: '2025-07-20',
showEndDate: true,
showMore: false,
});
showMore: false
})
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
expect(screen.getByTestId("end-date-input")).toBeInTheDocument();
expect(screen.queryByTestId("start-time-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("end-time-input")).not.toBeInTheDocument();
});
expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.queryByTestId('start-time-input')).not.toBeInTheDocument()
expect(screen.queryByTestId('end-time-input')).not.toBeInTheDocument()
})
// Test 1.2: Display 4 fields when drag from week view (multiple days)
it("displays 4 fields when allday=false, hasEndDateChanged=true, and multiple days", async () => {
it('displays 4 fields when allday=false, hasEndDateChanged=true, and multiple days', async () => {
await renderField({
allday: false,
hasEndDateChanged: true,
startDate: "2025-07-18",
endDate: "2025-07-20",
startTime: "09:00",
endTime: "10:00",
startDate: '2025-07-18',
endDate: '2025-07-20',
startTime: '09:00',
endTime: '10:00',
showEndDate: true,
showMore: false,
});
showMore: false
})
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
expect(screen.getByTestId("end-date-input")).toBeInTheDocument();
expect(screen.getByTestId("end-time-input")).toBeInTheDocument();
});
expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
})
// Test 1.3: Display single date + time fields
it("displays single date field with time fields for single day event", async () => {
it('displays single date field with time fields for single day event', async () => {
await renderField({
allday: false,
hasEndDateChanged: false,
startDate: "2025-07-18",
endDate: "2025-07-18",
startTime: "09:00",
endTime: "10:00",
startDate: '2025-07-18',
endDate: '2025-07-18',
startTime: '09:00',
endTime: '10:00',
showEndDate: false,
showMore: false,
});
showMore: false
})
const startDateInput = screen.getByTestId("start-date-input");
const startDateInput = screen.getByTestId('start-date-input')
expect(startDateInput).toHaveAttribute(
"aria-label",
"dateTimeFields.date"
);
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
expect(screen.getByTestId("end-time-input")).toBeInTheDocument();
expect(screen.queryByTestId("end-date-input")).not.toBeInTheDocument();
});
});
});
'aria-label',
'dateTimeFields.date'
)
expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
expect(screen.queryByTestId('end-date-input')).not.toBeInTheDocument()
})
})
})
+84 -84
View File
@@ -1,93 +1,93 @@
import { RootState } from "@/app/store";
import EventDuplication from "@/components/Event/EventDuplicate";
import EventPreviewModal from "@/features/Events/EventPreview";
import EventPopover from "@/features/Events/EventModal";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
import { RootState } from '@/app/store'
import EventDuplication from '@/components/Event/EventDuplicate'
import EventPreviewModal from '@/features/Events/EventPreview'
import EventPopover from '@/features/Events/EventModal'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
const day = new Date();
const day = new Date()
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "mailto:test@test.com",
},
cn: 'test',
cal_address: 'mailto:test@test.com'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Calendar",
color: "#FF0000",
owner: { emails: ["test@test.com"] },
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'Calendar',
color: '#FF0000',
owner: { emails: ['test@test.com'] },
events: {
event1: {
uid: "event1",
URL: "calendars/667037022b752d0026472254/cal1/event1.ics",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
uid: 'event1',
URL: 'calendars/667037022b752d0026472254/cal1/event1.ics',
title: 'Test Event',
calId: '667037022b752d0026472254/cal1',
start: day.toISOString(),
end: day.toISOString(),
timezone: "UTC",
organizer: { cn: "test", cal_address: "test@test.com" },
timezone: 'UTC',
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [
{
cn: "test",
cal_address: "test@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cn: 'test',
cal_address: 'test@test.com',
partstat: 'NEEDS-ACTION',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL'
},
{
cn: "John",
cal_address: "john@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
],
},
},
},
cn: 'John',
cal_address: 'john@test.com',
partstat: 'NEEDS-ACTION',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL'
}
]
}
}
}
},
pending: false,
},
} as unknown as RootState;
pending: false
}
} as unknown as RootState
describe("EventDuplication", () => {
it("calls onOpenDuplicate when button clicked", () => {
const handleClose = jest.fn();
const onOpenDuplicate = jest.fn();
describe('EventDuplication', () => {
it('calls onOpenDuplicate when button clicked', () => {
const handleClose = jest.fn()
const onOpenDuplicate = jest.fn()
renderWithProviders(
<EventDuplication
event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events
preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1
}
onClose={handleClose}
onOpenDuplicate={onOpenDuplicate}
/>,
preloadedState
);
)
fireEvent.click(
screen.getByRole("menuitem", { name: "eventDuplication.duplicateEvent" })
);
screen.getByRole('menuitem', { name: 'eventDuplication.duplicateEvent' })
)
expect(onOpenDuplicate).toHaveBeenCalled();
});
});
expect(onOpenDuplicate).toHaveBeenCalled()
})
})
describe("EventPopover", () => {
it("renders with event data", () => {
describe('EventPopover', () => {
it('renders with event data', () => {
renderWithProviders(
<EventPopover
anchorEl={null}
@@ -97,18 +97,18 @@ describe("EventPopover", () => {
setSelectedRange={jest.fn()}
calendarRef={{ current: null }}
event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events
preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1
}
/>,
preloadedState
);
)
expect(screen.getByDisplayValue(/Test Event/i)).toBeInTheDocument();
});
expect(screen.getByDisplayValue(/Test Event/i)).toBeInTheDocument()
})
it("saves duplicated event when Save is clicked", () => {
const onClose = jest.fn();
it('saves duplicated event when Save is clicked', () => {
const onClose = jest.fn()
renderWithProviders(
<EventPopover
@@ -119,24 +119,24 @@ describe("EventPopover", () => {
setSelectedRange={jest.fn()}
calendarRef={{ current: null }}
event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events
preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1
}
/>,
preloadedState
);
)
fireEvent.change(screen.getByLabelText(/Title/i), {
target: { value: "Duplicated Event" },
});
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
target: { value: 'Duplicated Event' }
})
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
waitFor(() => expect(onClose).toHaveBeenCalled())
})
})
describe("EventDisplayModal", () => {
it("shows duplication button and opens duplication form", () => {
describe('EventDisplayModal', () => {
it('shows duplication button and opens duplication form', () => {
renderWithProviders(
<EventPreviewModal
eventId="event1"
@@ -145,20 +145,20 @@ describe("EventDisplayModal", () => {
onClose={jest.fn()}
/>,
preloadedState
);
)
fireEvent.click(screen.getByTestId("MoreVertIcon"));
fireEvent.click(screen.getByTestId('MoreVertIcon'))
fireEvent.click(
screen.getByRole("menuitem", { name: "eventDuplication.duplicateEvent" })
);
screen.getByRole('menuitem', { name: 'eventDuplication.duplicateEvent' })
)
expect(
screen.getAllByText("eventDuplication.duplicateEvent")[1]
).toBeInTheDocument();
screen.getAllByText('eventDuplication.duplicateEvent')[1]
).toBeInTheDocument()
expect(
screen.getByDisplayValue(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events
preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1.title as string
)
).toBeInTheDocument();
});
});
).toBeInTheDocument()
})
})
@@ -3,176 +3,174 @@ import {
convertFormDateTimeToISO,
DATETIME_WITH_SECONDS_LENGTH,
DATETIME_FORMAT_WITH_SECONDS,
DATETIME_FORMAT_WITHOUT_SECONDS,
} from "@/components/Event/utils/dateTimeHelpers";
DATETIME_FORMAT_WITHOUT_SECONDS
} from '@/components/Event/utils/dateTimeHelpers'
describe("dateTimeHelpers", () => {
describe("Constants", () => {
it("should have correct constant values", () => {
expect(DATETIME_WITH_SECONDS_LENGTH).toBe(19);
expect(DATETIME_FORMAT_WITH_SECONDS).toBe("YYYY-MM-DDTHH:mm:ss");
expect(DATETIME_FORMAT_WITHOUT_SECONDS).toBe("YYYY-MM-DDTHH:mm");
});
});
describe('dateTimeHelpers', () => {
describe('Constants', () => {
it('should have correct constant values', () => {
expect(DATETIME_WITH_SECONDS_LENGTH).toBe(19)
expect(DATETIME_FORMAT_WITH_SECONDS).toBe('YYYY-MM-DDTHH:mm:ss')
expect(DATETIME_FORMAT_WITHOUT_SECONDS).toBe('YYYY-MM-DDTHH:mm')
})
})
describe("detectDateTimeFormat", () => {
it("should return format with seconds for length >= 19", () => {
expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe(
describe('detectDateTimeFormat', () => {
it('should return format with seconds for length >= 19', () => {
expect(detectDateTimeFormat('2024-01-15T10:30:45')).toBe(
DATETIME_FORMAT_WITH_SECONDS
);
expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe(
"YYYY-MM-DDTHH:mm:ss"
);
});
)
expect(detectDateTimeFormat('2024-01-15T10:30:45')).toBe(
'YYYY-MM-DDTHH:mm:ss'
)
})
it("should return format without seconds for length < 19", () => {
expect(detectDateTimeFormat("2024-01-15T10:30")).toBe(
it('should return format without seconds for length < 19', () => {
expect(detectDateTimeFormat('2024-01-15T10:30')).toBe(
DATETIME_FORMAT_WITHOUT_SECONDS
);
expect(detectDateTimeFormat("2024-01-15T10:30")).toBe("YYYY-MM-DDTHH:mm");
});
)
expect(detectDateTimeFormat('2024-01-15T10:30')).toBe('YYYY-MM-DDTHH:mm')
})
it("should return format with seconds for length exactly 19", () => {
const datetime = "2024-01-15T10:30:45";
expect(datetime.length).toBe(19);
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS);
});
it('should return format with seconds for length exactly 19', () => {
const datetime = '2024-01-15T10:30:45'
expect(datetime.length).toBe(19)
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS)
})
it("should return format without seconds for length 16", () => {
const datetime = "2024-01-15T10:30";
expect(datetime.length).toBe(16);
it('should return format without seconds for length 16', () => {
const datetime = '2024-01-15T10:30'
expect(datetime.length).toBe(16)
expect(detectDateTimeFormat(datetime)).toBe(
DATETIME_FORMAT_WITHOUT_SECONDS
);
});
)
})
it("should return format with seconds for length > 19", () => {
const datetime = "2024-01-15T10:30:45.123";
expect(datetime.length).toBeGreaterThan(19);
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS);
});
it('should return format with seconds for length > 19', () => {
const datetime = '2024-01-15T10:30:45.123'
expect(datetime.length).toBeGreaterThan(19)
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS)
})
it("should handle empty string", () => {
expect(detectDateTimeFormat("")).toBe(DATETIME_FORMAT_WITHOUT_SECONDS);
});
it('should handle empty string', () => {
expect(detectDateTimeFormat('')).toBe(DATETIME_FORMAT_WITHOUT_SECONDS)
})
it("should handle very short strings", () => {
expect(detectDateTimeFormat("2024")).toBe(
DATETIME_FORMAT_WITHOUT_SECONDS
);
});
});
it('should handle very short strings', () => {
expect(detectDateTimeFormat('2024')).toBe(DATETIME_FORMAT_WITHOUT_SECONDS)
})
})
describe("convertFormDateTimeToISO", () => {
const originalConsoleWarn = console.warn;
let consoleWarnSpy: jest.SpyInstance;
describe('convertFormDateTimeToISO', () => {
const originalConsoleWarn = console.warn
let consoleWarnSpy: jest.SpyInstance
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
});
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
})
afterEach(() => {
consoleWarnSpy.mockRestore();
});
consoleWarnSpy.mockRestore()
})
it("should convert valid datetime without seconds to ISO string", () => {
it('should convert valid datetime without seconds to ISO string', () => {
const result = convertFormDateTimeToISO(
"2024-01-15T10:30",
"America/New_York"
);
expect(result).toBeTruthy();
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
'2024-01-15T10:30',
'America/New_York'
)
expect(result).toBeTruthy()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
it("should convert valid datetime with seconds to ISO string", () => {
it('should convert valid datetime with seconds to ISO string', () => {
const result = convertFormDateTimeToISO(
"2024-01-15T10:30:45",
"America/New_York"
);
expect(result).toBeTruthy();
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
'2024-01-15T10:30:45',
'America/New_York'
)
expect(result).toBeTruthy()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
it("should use Etc/UTC as default timezone when timezone is empty", () => {
const result = convertFormDateTimeToISO("2024-01-15T10:30", "");
expect(result).toBeTruthy();
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('should use Etc/UTC as default timezone when timezone is empty', () => {
const result = convertFormDateTimeToISO('2024-01-15T10:30', '')
expect(result).toBeTruthy()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
})
it("should return empty string for empty datetime input", () => {
const result = convertFormDateTimeToISO("", "America/New_York");
expect(result).toBe("");
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return empty string for empty datetime input', () => {
const result = convertFormDateTimeToISO('', 'America/New_York')
expect(result).toBe('')
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
it("should return empty string and log warning for invalid datetime", () => {
it('should return empty string and log warning for invalid datetime', () => {
const result = convertFormDateTimeToISO(
"invalid-date",
"America/New_York"
);
expect(result).toBe("");
'invalid-date',
'America/New_York'
)
expect(result).toBe('')
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("[convertFormDateTimeToISO] Invalid datetime:")
);
expect.stringContaining('[convertFormDateTimeToISO] Invalid datetime:')
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('"invalid-date"')
);
});
)
})
it("should return empty string and log warning for invalid format", () => {
it('should return empty string and log warning for invalid format', () => {
const result = convertFormDateTimeToISO(
"2024-13-45T25:99:99",
"America/New_York"
);
expect(result).toBe("");
expect(consoleWarnSpy).toHaveBeenCalled();
});
'2024-13-45T25:99:99',
'America/New_York'
)
expect(result).toBe('')
expect(consoleWarnSpy).toHaveBeenCalled()
})
it("should handle different timezones correctly", () => {
it('should handle different timezones correctly', () => {
const result1 = convertFormDateTimeToISO(
"2024-01-15T10:30",
"America/New_York"
);
'2024-01-15T10:30',
'America/New_York'
)
const result2 = convertFormDateTimeToISO(
"2024-01-15T10:30",
"Europe/London"
);
expect(result1).toBeTruthy();
expect(result2).toBeTruthy();
expect(result1).not.toBe(result2);
});
'2024-01-15T10:30',
'Europe/London'
)
expect(result1).toBeTruthy()
expect(result2).toBeTruthy()
expect(result1).not.toBe(result2)
})
it("should handle edge case with null/undefined timezone", () => {
it('should handle edge case with null/undefined timezone', () => {
const result = convertFormDateTimeToISO(
"2024-01-15T10:30",
'2024-01-15T10:30',
// @ts-ignore - testing edge case
null
);
expect(result).toBeTruthy();
});
)
expect(result).toBeTruthy()
})
it("should convert correctly for UTC timezone", () => {
const result = convertFormDateTimeToISO("2024-01-15T10:30", "Etc/UTC");
expect(result).toBeTruthy();
expect(result).toContain("T10:30:00");
});
it('should convert correctly for UTC timezone', () => {
const result = convertFormDateTimeToISO('2024-01-15T10:30', 'Etc/UTC')
expect(result).toBeTruthy()
expect(result).toContain('T10:30:00')
})
it("should handle datetime at boundary (exactly 19 characters)", () => {
const datetime = "2024-01-15T10:30:45";
expect(datetime.length).toBe(19);
const result = convertFormDateTimeToISO(datetime, "Etc/UTC");
expect(result).toBeTruthy();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should handle datetime at boundary (exactly 19 characters)', () => {
const datetime = '2024-01-15T10:30:45'
expect(datetime.length).toBe(19)
const result = convertFormDateTimeToISO(datetime, 'Etc/UTC')
expect(result).toBeTruthy()
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
it("should handle datetime at boundary (exactly 16 characters)", () => {
const datetime = "2024-01-15T10:30";
expect(datetime.length).toBe(16);
const result = convertFormDateTimeToISO(datetime, "Etc/UTC");
expect(result).toBeTruthy();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});
});
it('should handle datetime at boundary (exactly 16 characters)', () => {
const datetime = '2024-01-15T10:30'
expect(datetime.length).toBe(16)
const result = convertFormDateTimeToISO(datetime, 'Etc/UTC')
expect(result).toBeTruthy()
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
})
})
+60 -62
View File
@@ -1,102 +1,100 @@
import CalendarLayout from "@/components/Calendar/CalendarLayout";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
import CalendarLayout from '@/components/Calendar/CalendarLayout'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
describe("Event Error Handling", () => {
describe('Event Error Handling', () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
localStorage.clear()
jest.clearAllMocks()
})
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
const end = new Date(today);
end.setHours(11, 0, 0, 0);
const today = new Date()
const start = new Date(today)
start.setHours(10, 0, 0, 0)
const end = new Date(today)
end.setHours(11, 0, 0, 0)
const erroredState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
tokens: { accessToken: 'token' }
},
calendars: {
list: {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
'user1/cal1': {
name: 'Calendar personal',
id: 'user1/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user1/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user1/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: start.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
}
},
pending: false,
},
};
pending: false
}
}
it("BUGFIX: does not re-report errors after clearing error snackbar", async () => {
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
it('BUGFIX: does not re-report errors after clearing error snackbar', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
await act(async () =>
renderWithProviders(<CalendarLayout />, erroredState)
);
await act(async () => renderWithProviders(<CalendarLayout />, erroredState))
await waitFor(
() => {
expect(screen.getByText("Test Event"));
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText('Test Event'))
expect(screen.getByRole('alert')).toBeInTheDocument()
},
{ timeout: 10000 }
);
const closeButton = screen.queryByRole("button", { name: "common.ok" });
)
const closeButton = screen.queryByRole('button', { name: 'common.ok' })
if (closeButton) {
const initialWarnCount = consoleWarnSpy.mock.calls.length;
const initialWarnCount = consoleWarnSpy.mock.calls.length
await act(async () => {
fireEvent.click(closeButton);
});
fireEvent.click(closeButton)
})
await waitFor(
() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
},
{ timeout: 5000 }
);
)
const afterCloseWarnCount = consoleWarnSpy.mock.calls.length;
const afterCloseWarnCount = consoleWarnSpy.mock.calls.length
expect(afterCloseWarnCount).toBe(initialWarnCount);
expect(afterCloseWarnCount).toBe(initialWarnCount)
}
consoleWarnSpy.mockRestore();
}, 15000);
});
consoleWarnSpy.mockRestore()
}, 15000)
})
+350 -360
View File
@@ -1,350 +1,340 @@
import * as appHooks from "@/app/hooks";
import { AppDispatch } from "@/app/store";
import CalendarApp from "@/components/Calendar/Calendar";
import * as appHooks from '@/app/hooks'
import { AppDispatch } from '@/app/store'
import CalendarApp from '@/components/Calendar/Calendar'
import {
createEventHandlers,
EventHandlersProps,
} from "@/components/Calendar/handlers/eventHandlers";
import * as eventThunks from "@/features/Calendars/services";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { CalendarApi } from "@fullcalendar/core";
import { jest } from "@jest/globals";
import "@testing-library/jest-dom";
import {
act,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
EventHandlersProps
} from '@/components/Calendar/handlers/eventHandlers'
import * as eventThunks from '@/features/Calendars/services'
import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { CalendarApi } from '@fullcalendar/core'
import { jest } from '@jest/globals'
import '@testing-library/jest-dom'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
describe("CalendarApp integration", () => {
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
const end = new Date(today);
end.setHours(11, 0, 0, 0);
describe('CalendarApp integration', () => {
const today = new Date()
const start = new Date(today)
start.setHours(10, 0, 0, 0)
const end = new Date(today)
end.setHours(11, 0, 0, 0)
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
const renderCalendar = () => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
tokens: { accessToken: "token" }, // required to avoid redirect
tokens: { accessToken: 'token' } // required to avoid redirect
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
id: "667037022b752d0026472254/cal1",
color: { light: "#FFFFFF", dark: "#000000" },
owner: { emails: ["alice@example.com"] },
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
id: '667037022b752d0026472254/cal1',
color: { light: '#FFFFFF', dark: '#000000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "667037022b752d0026472254/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: '667037022b752d0026472254/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
}
},
pending: false,
},
};
pending: false
}
}
const mockCalendarRef = { current: null };
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
preloadedState
);
};
)
}
it("renders the event on the calendar and calendarRef works", async () => {
it('renders the event on the calendar and calendarRef works', async () => {
const dispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}),
unwrap: () => Promise.resolve({})
})
) as unknown as AppDispatch;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
) as unknown as AppDispatch
jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch)
renderCalendar();
renderCalendar()
const calendarRef: React.RefObject<CalendarApi | null> = (window as any)
.__calendarRef;
.__calendarRef
const calendarApi = calendarRef.current;
const calendarApi = calendarRef.current
// Wait for the FullCalendar DOM to populate
const eventEl = await screen.findByText(
"Test Event",
{},
{ timeout: 3000 }
);
expect(eventEl).toBeInTheDocument();
const eventEl = await screen.findByText('Test Event', {}, { timeout: 3000 })
expect(eventEl).toBeInTheDocument()
act(() => {
if (calendarApi) {
const fcEvent = calendarApi.getEventById("event1");
expect(fcEvent?.title).toBe("Test Event");
const oldEnd = new Date(today.getTime() + 3600000); // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min
const fcEvent = calendarApi.getEventById('event1')
expect(fcEvent?.title).toBe('Test Event')
const oldEnd = new Date(today.getTime() + 3600000) // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000) // +30 min
fcEvent?.setEnd(newEnd);
fcEvent?.setEnd(newEnd)
waitFor(() => expect(dispatch).toHaveBeenCalled());
waitFor(() => expect(dispatch).toHaveBeenCalled())
}
});
});
})
})
const createPreloadedState = (eventProps = {}) => ({
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
tokens: {
accessToken: "token",
},
accessToken: 'token'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
id: "667037022b752d0026472254/cal1",
color: { light: "#FFFFFF", dark: "#000000" },
owner: { emails: ["alice@example.com"] },
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
id: '667037022b752d0026472254/cal1',
color: { light: '#FFFFFF', dark: '#000000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "667037022b752d0026472254/cal1",
uid: "event1",
id: 'event1',
calId: '667037022b752d0026472254/cal1',
uid: 'event1',
start: new Date().toISOString(),
end: new Date(Date.now() + 3600000).toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
],
...eventProps,
},
},
},
...eventProps
}
}
}
},
pending: false,
},
});
pending: false
}
})
it("renders lock icon for private events on the calendar", async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
it('renders lock icon for private events on the calendar', async () => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: 120,
});
value: 120
})
const preloadedState = createPreloadedState({
class: "PRIVATE",
title: "Private Event",
});
const mockCalendarRef = { current: null };
class: 'PRIVATE',
title: 'Private Event'
})
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
preloadedState
);
const card = screen.getByTestId("event-card-event1");
const lockIcon = within(card).getByTestId("LockOutlineIcon");
expect(lockIcon).toBeInTheDocument();
});
)
const card = screen.getByTestId('event-card-event1')
const lockIcon = within(card).getByTestId('LockOutlineIcon')
expect(lockIcon).toBeInTheDocument()
})
it("renders lock icon for confidential events on the calendar", async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
it('renders lock icon for confidential events on the calendar', async () => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: 120,
});
value: 120
})
const preloadedState = createPreloadedState({
class: "CONFIDENTIAL",
title: "Confidential Event",
});
const mockCalendarRef = { current: null };
class: 'CONFIDENTIAL',
title: 'Confidential Event'
})
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
preloadedState
);
)
const card = screen.getByTestId("event-card-event1");
const lockIcon = within(card).getByTestId("LockOutlineIcon");
expect(lockIcon).toBeInTheDocument();
});
const card = screen.getByTestId('event-card-event1')
const lockIcon = within(card).getByTestId('LockOutlineIcon')
expect(lockIcon).toBeInTheDocument()
})
it("does NOT render a lock icon for public events on the calendar", async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
it('does NOT render a lock icon for public events on the calendar', async () => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: 120,
});
value: 120
})
const preloadedState = createPreloadedState({
class: "PUBLIC",
title: "Public Event",
});
const mockCalendarRef = { current: null };
class: 'PUBLIC',
title: 'Public Event'
})
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
preloadedState
);
)
const card = screen.getByTestId("event-card-event1");
const lockIcon = within(card).queryByTestId("LockOutlineIcon");
expect(lockIcon).not.toBeInTheDocument();
});
const card = screen.getByTestId('event-card-event1')
const lockIcon = within(card).queryByTestId('LockOutlineIcon')
expect(lockIcon).not.toBeInTheDocument()
})
it("does render a title for events without any attendees or user as organizer", async () => {
const mockCalendarRef = { current: null };
it('does render a title for events without any attendees or user as organizer', async () => {
const mockCalendarRef = { current: null }
renderWithProviders(<CalendarApp calendarRef={mockCalendarRef} />, {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
tokens: {
accessToken: "token",
},
accessToken: 'token'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
id: "667037022b752d0026472254/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
id: '667037022b752d0026472254/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "667037022b752d0026472254/cal1",
uid: "event1",
id: 'event1',
calId: '667037022b752d0026472254/cal1',
uid: 'event1',
start: new Date().toISOString(),
end: new Date(Date.now() + 3600000).toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
class: "PUBLIC",
title: "Public Event",
},
},
},
class: 'PUBLIC',
title: 'Public Event'
}
}
}
},
pending: false,
},
});
pending: false
}
})
expect(screen.getByText("Public Event")).toBeInTheDocument();
});
describe("BUGFIX", () => {
expect(screen.getByText('Public Event')).toBeInTheDocument()
})
describe('BUGFIX', () => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
tokens: {
accessToken: "token",
},
accessToken: 'token'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
id: "667037022b752d0026472254/cal1",
color: { light: "#FFFFFF", dark: "#000000" },
owner: { emails: ["alice@example.com"] },
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
id: '667037022b752d0026472254/cal1',
color: { light: '#FFFFFF', dark: '#000000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "667037022b752d0026472254/cal1",
uid: "event1",
title: "Original Event",
start: new Date("2025-11-14T10:31:00.000Z").toISOString(),
end: new Date("2025-11-14T11:31:00.000Z").toISOString(),
class: "PUBLIC",
partstat: "ACCEPTED",
id: 'event1',
calId: '667037022b752d0026472254/cal1',
uid: 'event1',
title: 'Original Event',
start: new Date('2025-11-14T10:31:00.000Z').toISOString(),
end: new Date('2025-11-14T11:31:00.000Z').toISOString(),
class: 'PUBLIC',
partstat: 'ACCEPTED',
sequence: 2,
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "CHAIR",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'CHAIR',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
},
{
cn: "Bob",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "bob@example.com",
},
],
},
},
},
cn: 'Bob',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'bob@example.com'
}
]
}
}
}
},
pending: false,
},
};
pending: false
}
}
it("keeps all attendees event participation on title update", async () => {
it('keeps all attendees event participation on title update', async () => {
const updateSpy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
const onClose = jest.fn();
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const onClose = jest.fn()
renderWithProviders(
<EventUpdateModal
eventId="event1"
@@ -354,56 +344,56 @@ describe("CalendarApp integration", () => {
typeOfAction="solo"
/>,
preloadedState
);
)
const titleInput = await screen.findByDisplayValue("Original Event");
const titleInput = await screen.findByDisplayValue('Original Event')
await act(async () => {
fireEvent.change(titleInput, "Updated Event");
});
fireEvent.change(titleInput, 'Updated Event')
})
const saveButton = screen.getByRole("button", { name: /save/i });
const saveButton = screen.getByRole('button', { name: /save/i })
await act(async () => {
saveButton.click();
});
saveButton.click()
})
await waitFor(() => expect(updateSpy).toHaveBeenCalled());
await waitFor(() => expect(updateSpy).toHaveBeenCalled())
const dispatchedCalls = updateSpy.mock.calls;
expect(dispatchedCalls.length).toBeGreaterThan(0);
const updatedEvent = dispatchedCalls[0][0].newEvent;
const dispatchedCalls = updateSpy.mock.calls
expect(dispatchedCalls.length).toBeGreaterThan(0)
const updatedEvent = dispatchedCalls[0][0].newEvent
// Ensure organizer attendee info is preserved
const organizerAttendee = updatedEvent?.attendee?.find(
(a: any) => a.cal_address === "alice@example.com"
);
(a: any) => a.cal_address === 'alice@example.com'
)
expect(organizerAttendee).toBeTruthy();
expect(organizerAttendee?.partstat).toBe("ACCEPTED");
expect(organizerAttendee?.role).toBe("CHAIR");
expect(organizerAttendee).toBeTruthy()
expect(organizerAttendee?.partstat).toBe('ACCEPTED')
expect(organizerAttendee?.role).toBe('CHAIR')
// Ensure normal attendee info is preserved too
const normalAttendee = updatedEvent?.attendee?.find(
(a: any) => a.cal_address === "bob@example.com"
);
(a: any) => a.cal_address === 'bob@example.com'
)
expect(normalAttendee).toBeTruthy();
expect(normalAttendee?.partstat).toBe("ACCEPTED");
expect(normalAttendee?.role).toBe("REQ-PARTICIPANT");
expect(normalAttendee).toBeTruthy()
expect(normalAttendee?.partstat).toBe('ACCEPTED')
expect(normalAttendee?.role).toBe('REQ-PARTICIPANT')
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
expect(updatedEvent?.sequence).toBe(3) // 2 + 1
})
it("changes normal attendee to need action on time update and no organizer changes", async () => {
it('changes normal attendee to need action on time update and no organizer changes', async () => {
const updateSpy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
const onClose = jest.fn();
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const onClose = jest.fn()
renderWithProviders(
<EventUpdateModal
eventId="event1"
@@ -413,78 +403,78 @@ describe("CalendarApp integration", () => {
typeOfAction="solo"
/>,
preloadedState
);
)
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
const startDateInput = await screen.findByTestId("start-time-input");
screen.getByRole('button', { name: 'common.moreOptions' })
)
const startDateInput = await screen.findByTestId('start-time-input')
await act(async () => {
fireEvent.change(startDateInput, {
target: { value: "08:00" },
});
fireEvent.blur(startDateInput);
});
target: { value: '08:00' }
})
fireEvent.blur(startDateInput)
})
// Wait for blur handler to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await new Promise(resolve => setTimeout(resolve, 150))
})
const saveButton = screen.getByRole("button", { name: /save/i });
const saveButton = screen.getByRole('button', { name: /save/i })
await act(async () => {
saveButton.click();
});
saveButton.click()
})
await waitFor(() => expect(updateSpy).toHaveBeenCalled());
await waitFor(() => expect(updateSpy).toHaveBeenCalled())
const dispatchedCalls = updateSpy.mock.calls;
expect(dispatchedCalls.length).toBeGreaterThan(0);
const updatedEvent = dispatchedCalls[0][0].newEvent;
const dispatchedCalls = updateSpy.mock.calls
expect(dispatchedCalls.length).toBeGreaterThan(0)
const updatedEvent = dispatchedCalls[0][0].newEvent
// Ensure organizer attendee info is preserved
const organizerAttendee = updatedEvent?.attendee?.find(
(a: any) => a.cal_address === "alice@example.com"
);
(a: any) => a.cal_address === 'alice@example.com'
)
expect(organizerAttendee).toBeTruthy();
expect(organizerAttendee?.partstat).toBe("ACCEPTED");
expect(organizerAttendee?.role).toBe("CHAIR");
expect(organizerAttendee).toBeTruthy()
expect(organizerAttendee?.partstat).toBe('ACCEPTED')
expect(organizerAttendee?.role).toBe('CHAIR')
// Ensure normal attendee partstat is updated
const normalAttendee = updatedEvent?.attendee?.find(
(a: any) => a.cal_address === "bob@example.com"
);
(a: any) => a.cal_address === 'bob@example.com'
)
expect(normalAttendee).toBeTruthy();
expect(normalAttendee?.partstat).toBe("NEEDS-ACTION");
expect(normalAttendee?.role).toBe("REQ-PARTICIPANT");
expect(normalAttendee).toBeTruthy()
expect(normalAttendee?.partstat).toBe('NEEDS-ACTION')
expect(normalAttendee?.role).toBe('REQ-PARTICIPANT')
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
expect(updatedEvent?.sequence).toBe(3) // 2 + 1
})
it("update event attendees on drag", async () => {
it('update event attendees on drag', async () => {
// Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch.
const mockDispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}),
unwrap: () => Promise.resolve({})
})
) as unknown as AppDispatch;
) as unknown as AppDispatch
jest
.spyOn(appHooks, "useAppDispatch")
.mockReturnValue(mockDispatch as unknown as AppDispatch);
.spyOn(appHooks, 'useAppDispatch')
.mockReturnValue(mockDispatch as unknown as AppDispatch)
const updateSpy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const eventHandlers = createEventHandlers({
setSelectedRange: jest.fn(),
@@ -499,70 +489,70 @@ describe("CalendarApp integration", () => {
calendars: preloadedState.calendars.list,
setSelectedEvent: jest.fn(),
setAfterChoiceFunc: jest.fn(),
setOpenEditModePopup: jest.fn(),
} as unknown as EventHandlersProps);
setOpenEditModePopup: jest.fn()
} as unknown as EventHandlersProps)
const mockArg = {
event: {
_def: {
extendedProps: {
uid: "event1",
calId: "667037022b752d0026472254/cal1",
},
},
uid: 'event1',
calId: '667037022b752d0026472254/cal1'
}
}
},
// drag event → move by 1 day
delta: { years: 0, months: 0, days: 1, milliseconds: 0 },
};
delta: { years: 0, months: 0, days: 1, milliseconds: 0 }
}
renderCalendar();
eventHandlers.handleEventDrop(mockArg);
renderCalendar()
eventHandlers.handleEventDrop(mockArg)
expect(updateSpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalled()
// Extract the dispatched update event
const dispatched = updateSpy.mock.calls[0];
const dispatched = updateSpy.mock.calls[0]
expect(dispatched).toBeTruthy();
expect(dispatched).toBeTruthy()
const updatePayload = dispatched[0];
const updatedEvent = updatePayload.newEvent;
const updatePayload = dispatched[0]
const updatedEvent = updatePayload.newEvent
// Organizer should remain unchanged
const organizer = updatedEvent.attendee.find(
(a: any) => a.cal_address === "alice@example.com"
);
expect(organizer?.partstat).toBe("ACCEPTED");
(a: any) => a.cal_address === 'alice@example.com'
)
expect(organizer?.partstat).toBe('ACCEPTED')
// Normal attendee must become NEEDS-ACTION
const normal = updatedEvent.attendee.find(
(a: any) => a.cal_address === "bob@example.com"
);
expect(normal?.partstat).toBe("NEEDS-ACTION");
(a: any) => a.cal_address === 'bob@example.com'
)
expect(normal?.partstat).toBe('NEEDS-ACTION')
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
expect(updatedEvent?.sequence).toBe(3) // 2 + 1
})
it("update event attendees on resize", async () => {
it('update event attendees on resize', async () => {
// Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch.
const mockDispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}),
unwrap: () => Promise.resolve({})
})
) as unknown as AppDispatch;
) as unknown as AppDispatch
jest
.spyOn(appHooks, "useAppDispatch")
.mockReturnValue(mockDispatch as unknown as AppDispatch);
.spyOn(appHooks, 'useAppDispatch')
.mockReturnValue(mockDispatch as unknown as AppDispatch)
const updateSpy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const eventHandlers = createEventHandlers({
setSelectedRange: jest.fn(),
@@ -577,49 +567,49 @@ describe("CalendarApp integration", () => {
calendars: preloadedState.calendars.list,
setSelectedEvent: jest.fn(),
setAfterChoiceFunc: jest.fn(),
setOpenEditModePopup: jest.fn(),
} as unknown as EventHandlersProps);
setOpenEditModePopup: jest.fn()
} as unknown as EventHandlersProps)
const mockArg = {
event: {
_def: {
extendedProps: {
uid: "event1",
calId: "667037022b752d0026472254/cal1",
},
},
uid: 'event1',
calId: '667037022b752d0026472254/cal1'
}
}
},
startDelta: { years: 0, months: 0, days: 0, milliseconds: 0 },
endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 }, // 1 hour
};
endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 } // 1 hour
}
renderCalendar();
eventHandlers.handleEventResize(mockArg);
renderCalendar()
eventHandlers.handleEventResize(mockArg)
expect(updateSpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalled()
// Extract the dispatched update event
const dispatched = updateSpy.mock.calls[0];
const dispatched = updateSpy.mock.calls[0]
expect(dispatched).toBeTruthy();
expect(dispatched).toBeTruthy()
const updatePayload = dispatched[0];
const updatedEvent = updatePayload.newEvent;
const updatePayload = dispatched[0]
const updatedEvent = updatePayload.newEvent
// Organizer should remain unchanged
const organizer = updatedEvent.attendee.find(
(a: any) => a.cal_address === "alice@example.com"
);
expect(organizer?.partstat).toBe("ACCEPTED");
(a: any) => a.cal_address === 'alice@example.com'
)
expect(organizer?.partstat).toBe('ACCEPTED')
// Normal attendee must become NEEDS-ACTION
const normal = updatedEvent.attendee.find(
(a: any) => a.cal_address === "bob@example.com"
);
expect(normal?.partstat).toBe("NEEDS-ACTION");
(a: any) => a.cal_address === 'bob@example.com'
)
expect(normal?.partstat).toBe('NEEDS-ACTION')
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
});
});
expect(updatedEvent?.sequence).toBe(3) // 2 + 1
})
})
})
File diff suppressed because it is too large Load Diff
+44 -44
View File
@@ -1,65 +1,65 @@
import * as appHooks from "@/app/hooks";
import { AppDispatch } from "@/app/store";
import CalendarApp from "@/components/Calendar/Calendar";
import { jest } from "@jest/globals";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
import * as appHooks from '@/app/hooks'
import { AppDispatch } from '@/app/store'
import CalendarApp from '@/components/Calendar/Calendar'
import { jest } from '@jest/globals'
import { screen } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
describe("MiniCalendar", () => {
const day = new Date();
describe('MiniCalendar', () => {
const day = new Date()
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
const dispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}),
unwrap: () => Promise.resolve({})
})
) as unknown as AppDispatch;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
jest.useFakeTimers().clearAllTimers();
});
) as unknown as AppDispatch
jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch)
jest.useFakeTimers().clearAllTimers()
})
const renderCalendar = () => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["test@test.com"] },
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['test@test.com'] },
events: {
event1: {
calId: "667037022b752d0026472254/cal1",
id: "event1",
title: "Test Event",
start: day.toISOString(),
},
},
},
calId: '667037022b752d0026472254/cal1',
id: 'event1',
title: 'Test Event',
start: day.toISOString()
}
}
}
},
pending: false,
},
};
const mockCalendarRef = { current: null };
pending: false
}
}
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
preloadedState
);
};
)
}
it("renders mini calendar with today in orange", async () => {
renderCalendar();
const today = new Date();
const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
it('renders mini calendar with today in orange', async () => {
renderCalendar()
const today = new Date()
const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
const todayTile = screen.getByTestId(dateTestId);
expect(todayTile).toHaveClass("today");
});
});
const todayTiles = screen.getAllByTestId(dateTestId)
expect(todayTiles[0]).toHaveClass('today')
})
})
+241 -243
View File
@@ -1,412 +1,410 @@
import { PeopleSearch, User } from "@/components/Attendees/PeopleSearch";
import { searchUsers } from "@/features/User/userAPI";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../utils/Renderwithproviders";
import { PeopleSearch, User } from '@/components/Attendees/PeopleSearch'
import { searchUsers } from '@/features/User/userAPI'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/User/userAPI");
const mockedSearchUsers = searchUsers as jest.MockedFunction<
typeof searchUsers
>;
jest.mock('@/features/User/userAPI')
const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
describe("PeopleSearch", () => {
describe('PeopleSearch', () => {
const baseUser: User = {
email: "test@example.com",
displayName: "Test User",
avatarUrl: "https://example.com/avatar.png",
openpaasId: "1234567890",
};
email: 'test@example.com',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.png',
openpaasId: '1234567890'
}
function setup(
selectedUsers: User[] = [],
props?: Partial<React.ComponentProps<typeof PeopleSearch>>
) {
const onChange = jest.fn();
const onChange = jest.fn()
renderWithProviders(
<PeopleSearch
objectTypes={["user"]}
objectTypes={['user']}
selectedUsers={selectedUsers}
onChange={onChange}
{...props}
/>
);
return { onChange };
)
return { onChange }
}
beforeEach(() => {
jest.useFakeTimers();
mockedSearchUsers.mockReset();
});
jest.useFakeTimers()
mockedSearchUsers.mockReset()
})
afterEach(() => {
jest.useRealTimers();
});
jest.useRealTimers()
})
it("calls searchUsers after debounce when typing", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup();
it('calls searchUsers after debounce when typing', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
});
});
expect(mockedSearchUsers).toHaveBeenCalledWith('Test', ['user'])
})
})
it("renders search results and allows selection", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
const { onChange } = setup();
it('renders search results and allows selection', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser])
const { onChange } = setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
const option = await screen.findByText("Test User");
await userEvent.click(option);
const option = await screen.findByText('Test User')
await userEvent.click(option)
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
expect(onChange).toHaveBeenCalled()
})
})
it("does not show already selected users in options", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup([baseUser]);
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
it('does not show already selected users in options', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup([baseUser])
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
await waitFor(() => {
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
});
});
expect(screen.queryByText('test@example.com')).not.toBeInTheDocument()
})
})
it("triggers onToggleEventPreview on Enter key press", () => {
const onToggleEventPreview = jest.fn();
setup([], { onToggleEventPreview });
it('triggers onToggleEventPreview on Enter key press', () => {
const onToggleEventPreview = jest.fn()
setup([], { onToggleEventPreview })
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "Enter" });
const input = screen.getByRole('combobox')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onToggleEventPreview).toHaveBeenCalled();
});
expect(onToggleEventPreview).toHaveBeenCalled()
})
it("respects disabled state", () => {
setup([], { disabled: true });
expect(screen.getByRole("combobox")).toBeDisabled();
});
it('respects disabled state', () => {
setup([], { disabled: true })
expect(screen.getByRole('combobox')).toBeDisabled()
})
it("no options doesn't show dropdown when input is empty", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup();
const input = screen.getByRole("combobox");
mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup()
const input = screen.getByRole('combobox')
userEvent.type(input, "Test");
userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
userEvent.clear(input);
userEvent.clear(input)
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
it("shows 'No results' when search succeeds but returns empty array", async () => {
mockedSearchUsers.mockResolvedValueOnce([]);
setup();
mockedSearchUsers.mockResolvedValueOnce([])
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
await Promise.resolve()
})
const noResults = await screen.findByText(
"peopleSearch.noResults",
'peopleSearch.noResults',
{},
{ timeout: 5000 }
);
expect(noResults).toBeInTheDocument();
});
)
expect(noResults).toBeInTheDocument()
})
it("does not clear options when search fails and shows error snackbar", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup();
it('does not clear options when search fails and shows error snackbar', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("Test User")).toBeInTheDocument();
});
expect(screen.getByText('Test User')).toBeInTheDocument()
})
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error"));
await userEvent.clear(input);
await userEvent.type(input, "Error");
mockedSearchUsers.mockRejectedValueOnce(new Error('Network error'))
await userEvent.clear(input)
await userEvent.type(input, 'Error')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
})
const errorMessage = await screen.findByText("peopleSearch.searchError");
expect(errorMessage).toBeInTheDocument();
const errorMessage = await screen.findByText('peopleSearch.searchError')
expect(errorMessage).toBeInTheDocument()
expect(screen.queryByText("Test User")).not.toBeInTheDocument();
expect(screen.queryByText('Test User')).not.toBeInTheDocument()
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
await userEvent.clear(input);
await userEvent.type(input, "Test");
mockedSearchUsers.mockResolvedValueOnce([baseUser])
await userEvent.clear(input)
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("Test User")).toBeInTheDocument();
});
});
expect(screen.getByText('Test User')).toBeInTheDocument()
})
})
it("shows loading text when searching", async () => {
let resolveSearch: (value: User[]) => void;
const searchPromise = new Promise<User[]>((resolve) => {
resolveSearch = resolve;
});
mockedSearchUsers.mockReturnValueOnce(searchPromise);
setup();
it('shows loading text when searching', async () => {
let resolveSearch: (value: User[]) => void
const searchPromise = new Promise<User[]>(resolve => {
resolveSearch = resolve
})
mockedSearchUsers.mockReturnValueOnce(searchPromise)
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Test')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
const loadingText = await screen.findByText(
"peopleSearch.loading",
'peopleSearch.loading',
{},
{ timeout: 5000 }
);
expect(loadingText).toBeInTheDocument();
)
expect(loadingText).toBeInTheDocument()
await act(async () => {
resolveSearch!([baseUser]);
await searchPromise;
});
});
resolveSearch!([baseUser])
await searchPromise
})
})
describe("paste multiple attendees", () => {
describe('paste multiple attendees', () => {
function setupFreeSolo(selectedUsers: User[] = []) {
const onChange = jest.fn();
const onChange = jest.fn()
renderWithProviders(
<PeopleSearch
objectTypes={["user"]}
objectTypes={['user']}
selectedUsers={selectedUsers}
onChange={onChange}
freeSolo
/>
);
return { onChange };
)
return { onChange }
}
it("splits pasted comma-separated emails into individual attendees", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
it('splits pasted comma-separated emails into individual attendees', async () => {
const { onChange } = setupFreeSolo()
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com, bob@example.com",
},
});
getData: () => 'alice@example.com, bob@example.com'
}
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: 'bob@example.com' })
])
);
});
});
)
})
})
it("splits pasted semicolon-separated emails", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
it('splits pasted semicolon-separated emails', async () => {
const { onChange } = setupFreeSolo()
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com;bob@example.com",
},
});
getData: () => 'alice@example.com;bob@example.com'
}
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: 'bob@example.com' })
])
);
});
});
)
})
})
it("splits pasted newline-separated emails", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
it('splits pasted newline-separated emails', async () => {
const { onChange } = setupFreeSolo()
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com\nbob@example.com",
},
});
getData: () => 'alice@example.com\nbob@example.com'
}
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: 'bob@example.com' })
])
);
});
});
)
})
})
it("splits pasted space-separated emails", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
it('splits pasted space-separated emails', async () => {
const { onChange } = setupFreeSolo()
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com bob@example.com",
},
});
getData: () => 'alice@example.com bob@example.com'
}
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: 'bob@example.com' })
])
);
});
});
)
})
})
it("skips duplicate emails already in selectedUsers", async () => {
it('skips duplicate emails already in selectedUsers', async () => {
const existing: User = {
email: "alice@example.com",
displayName: "Alice",
};
const { onChange } = setupFreeSolo([existing]);
const input = screen.getByRole("combobox");
email: 'alice@example.com',
displayName: 'Alice'
}
const { onChange } = setupFreeSolo([existing])
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com, bob@example.com",
},
});
getData: () => 'alice@example.com, bob@example.com'
}
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
existing,
expect.objectContaining({ email: "bob@example.com" }),
expect.objectContaining({ email: 'bob@example.com' })
])
);
)
// Should NOT have alice duplicated
const call = onChange.mock.calls[0][1];
const call = onChange.mock.calls[0][1]
const aliceCount = call.filter(
(u: User) => u.email === "alice@example.com"
).length;
expect(aliceCount).toBe(1);
});
});
(u: User) => u.email === 'alice@example.com'
).length
expect(aliceCount).toBe(1)
})
})
it("leaves invalid text in input when some emails are invalid", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
it('leaves invalid text in input when some emails are invalid', async () => {
const { onChange } = setupFreeSolo()
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com, not-an-email, bob@example.com",
},
});
getData: () => 'alice@example.com, not-an-email, bob@example.com'
}
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: 'bob@example.com' })
])
);
});
});
)
})
})
it("does not intercept paste of a single email", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
it('does not intercept paste of a single email', async () => {
const { onChange } = setupFreeSolo()
const input = screen.getByRole('combobox')
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com",
},
});
getData: () => 'alice@example.com'
}
})
// Single email should NOT trigger multi-paste handler
expect(onChange).not.toHaveBeenCalled();
});
});
expect(onChange).not.toHaveBeenCalled()
})
})
it("retains input value when field loses focus (blur)", async () => {
let resolveSearch: (value: User[]) => void;
const searchPromise = new Promise<User[]>((resolve) => {
resolveSearch = resolve;
});
mockedSearchUsers.mockReturnValueOnce(searchPromise);
it('retains input value when field loses focus (blur)', async () => {
let resolveSearch: (value: User[]) => void
const searchPromise = new Promise<User[]>(resolve => {
resolveSearch = resolve
})
mockedSearchUsers.mockReturnValueOnce(searchPromise)
await act(async () => {
setup();
});
setup()
})
const input = screen.getByRole("combobox");
const input = screen.getByRole('combobox')
await act(async () => {
userEvent.type(input, "Test");
});
userEvent.type(input, 'Test')
})
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
});
expect(mockedSearchUsers).toHaveBeenCalledWith('Test', ['user'])
})
expect(input).toHaveValue("Test");
expect(input).toHaveValue('Test')
await act(async () => {
input.blur();
});
input.blur()
})
await waitFor(() => {
expect(input).toHaveValue("Test");
});
});
});
expect(input).toHaveValue('Test')
})
})
})
+254 -258
View File
@@ -1,69 +1,69 @@
import { RootState } from "@/app/store";
import RepeatEvent from "@/components/Event/EventRepeat";
import * as eventThunks from "@/features/Calendars/services";
import EventPopover from "@/features/Events/EventModal";
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
import { RootState } from '@/app/store'
import RepeatEvent from '@/components/Event/EventRepeat'
import * as eventThunks from '@/features/Calendars/services'
import EventPopover from '@/features/Events/EventModal'
import { RepetitionObject } from '@/features/Events/EventsTypes'
import { CalendarApi, DateSelectArg } from '@fullcalendar/core'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
const baseRepetition: RepetitionObject = {
freq: "",
freq: '',
interval: 1,
occurrences: 0,
endDate: "",
};
endDate: ''
}
const mockOnClose = jest.fn();
const mockSetSelectedRange = jest.fn();
const mockOnClose = jest.fn()
const mockSetSelectedRange = jest.fn()
const mockCalendarRef = {
current: { select: jest.fn() } as unknown as CalendarApi,
};
current: { select: jest.fn() } as unknown as CalendarApi
}
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
cn: 'test',
cal_address: 'test@test.com'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Calendar 1",
color: "#FF0000",
},
"667037022b752d0026472254/cal2": {
id: "667037022b752d0026472254/cal2",
name: "Calendar 2",
color: "#00FF00",
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'Calendar 1',
color: '#FF0000'
},
'667037022b752d0026472254/cal2': {
id: '667037022b752d0026472254/cal2',
name: 'Calendar 2',
color: '#00FF00'
}
},
pending: false,
},
};
pending: false
}
}
const defaultSelectedRange = {
startStr: "2025-07-18T09:00",
endStr: "2025-07-18T10:00",
start: new Date("2025-07-18T09:00"),
end: new Date("2025-07-18T10:00"),
startStr: '2025-07-18T09:00',
endStr: '2025-07-18T10:00',
start: new Date('2025-07-18T09:00'),
end: new Date('2025-07-18T10:00'),
allDay: false,
resource: undefined,
} as unknown as DateSelectArg;
resource: undefined
} as unknown as DateSelectArg
function setupRepeatEvent(
props?: Partial<RepetitionObject>,
state?: RootState
) {
const setRepetition = jest.fn();
const setRepetition = jest.fn()
renderWithProviders(
<RepeatEvent
repetition={{ ...baseRepetition, ...props }}
@@ -72,20 +72,20 @@ function setupRepeatEvent(
isOwn={true}
/>,
state
);
return { setRepetition };
)
return { setRepetition }
}
async function setupEventPopover() {
jest
.spyOn(crypto, "randomUUID")
.mockReturnValue("fixed-uuid-with-correct-format");
.spyOn(crypto, 'randomUUID')
.mockReturnValue('fixed-uuid-with-correct-format')
const originalDateResolvedOptions =
new Intl.DateTimeFormat().resolvedOptions();
jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
new Intl.DateTimeFormat().resolvedOptions()
jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
...originalDateResolvedOptions,
timeZone: "UTC",
});
timeZone: 'UTC'
})
renderWithProviders(
<EventPopover
@@ -97,324 +97,320 @@ async function setupEventPopover() {
calendarRef={mockCalendarRef}
/>,
preloadedState
);
)
// Fill in title
const titleInput = screen.getByLabelText("event.form.title");
fireEvent.change(titleInput, { target: { value: "Meeting" } });
const titleInput = screen.getByLabelText('event.form.title')
fireEvent.change(titleInput, { target: { value: 'Meeting' } })
// Click More options to expand the dialog
const showMoreButton = screen.getByRole("button", {
name: "common.moreOptions",
});
fireEvent.click(showMoreButton);
const showMoreButton = screen.getByRole('button', {
name: 'common.moreOptions'
})
fireEvent.click(showMoreButton)
// Check Repeat checkbox to show repeat options
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
fireEvent.click(repeatCheckbox);
const repeatCheckbox = screen.getByLabelText('event.form.repeat')
fireEvent.click(repeatCheckbox)
// Wait for RepeatEvent component to be rendered
await waitFor(() => {
expect(
screen.getByText("event.repeat.frequency.weeks")
).toBeInTheDocument();
});
expect(screen.getByText('event.repeat.frequency.weeks')).toBeInTheDocument()
})
}
async function expectRRule(expected: Partial<RepetitionObject>) {
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
const saveButton = screen.getByRole("button", { name: /save/i });
act(() => fireEvent.click(saveButton));
await waitFor(() => expect(spy).toHaveBeenCalled());
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const saveButton = screen.getByRole('button', { name: /save/i })
act(() => fireEvent.click(saveButton))
await waitFor(() => expect(spy).toHaveBeenCalled())
const received = spy.mock.calls[0][0];
expect(received.newEvent.repetition).toMatchObject(expected);
const received = spy.mock.calls[0][0]
expect(received.newEvent.repetition).toMatchObject(expected)
}
describe("RepeatEvent Component", () => {
describe('RepeatEvent Component', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
jest.clearAllMocks()
jest.restoreAllMocks()
})
it("renders with no repetition by default", () => {
setupRepeatEvent();
it('renders with no repetition by default', () => {
setupRepeatEvent()
// Check that interval input shows default value
const intervalInput = screen.getByTestId("repeat-interval");
expect(intervalInput).toBeInTheDocument();
const intervalInput = screen.getByTestId('repeat-interval')
expect(intervalInput).toBeInTheDocument()
// Check that frequency dropdown shows default value
const frequencySelect = screen.getByRole("combobox");
expect(frequencySelect).toBeInTheDocument();
});
const frequencySelect = screen.getByRole('combobox')
expect(frequencySelect).toBeInTheDocument()
})
it("allows selecting repetition frequency", () => {
const { setRepetition } = setupRepeatEvent();
it('allows selecting repetition frequency', () => {
const { setRepetition } = setupRepeatEvent()
// Click on frequency dropdown
const frequencySelect = screen.getByRole("combobox");
fireEvent.mouseDown(frequencySelect);
const frequencySelect = screen.getByRole('combobox')
fireEvent.mouseDown(frequencySelect)
// Select Week(s)
const weeklyOption = screen.getByText("event.repeat.frequency.weeks");
fireEvent.click(weeklyOption);
const weeklyOption = screen.getByText('event.repeat.frequency.weeks')
fireEvent.click(weeklyOption)
expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ freq: "weekly" })
);
});
expect.objectContaining({ freq: 'weekly' })
)
})
it("renders interval input when frequency is selected", () => {
setupRepeatEvent({ freq: "daily" });
it('renders interval input when frequency is selected', () => {
setupRepeatEvent({ freq: 'daily' })
const intervalInput = screen.getByTestId("repeat-interval");
expect(intervalInput).toBeInTheDocument();
});
const intervalInput = screen.getByTestId('repeat-interval')
expect(intervalInput).toBeInTheDocument()
})
it("updates interval value", () => {
const { setRepetition } = setupRepeatEvent();
it('updates interval value', () => {
const { setRepetition } = setupRepeatEvent()
const intervalInput = screen.getByTestId("repeat-interval");
fireEvent.change(intervalInput, { target: { value: "3" } });
const intervalInput = screen.getByTestId('repeat-interval')
fireEvent.change(intervalInput, { target: { value: '3' } })
expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ interval: 3 })
);
});
)
})
it("toggles day selection for weekly frequency", () => {
const { setRepetition } = setupRepeatEvent({ freq: "weekly" });
it('toggles day selection for weekly frequency', () => {
const { setRepetition } = setupRepeatEvent({ freq: 'weekly' })
const mondayChip = screen.getByLabelText("event.repeat.days.monday");
fireEvent.click(mondayChip);
const mondayChip = screen.getByLabelText('event.repeat.days.monday')
fireEvent.click(mondayChip)
expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ byday: ["MO"] })
);
});
});
expect.objectContaining({ byday: ['MO'] })
)
})
})
describe("Repeat Event Integration Tests", () => {
describe('Repeat Event Integration Tests', () => {
// Increase timeout for all tests in this describe block
jest.setTimeout(30000);
jest.setTimeout(30000)
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
jest.clearAllMocks()
jest.restoreAllMocks()
})
it("sends correct CalendarEvent payload for daily repeat", async () => {
await setupEventPopover();
it('sends correct CalendarEvent payload for daily repeat', async () => {
await setupEventPopover()
// When Repeat checkbox is checked, repetition is set to empty object
// We need to set the frequency manually
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const dailyOption = screen.getByRole("option", {
name: "event.repeat.frequency.days",
});
fireEvent.click(dailyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.days'
})
fireEvent.click(dailyOption)
await expectRRule({ freq: "daily", interval: 1 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'daily', interval: 1 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat daily with 2 day interval", async () => {
await setupEventPopover();
it('sends correct API payload for repeat daily with 2 day interval', async () => {
await setupEventPopover()
// Ensure frequency is daily
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const dailyOption = screen.getByRole("option", {
name: "event.repeat.frequency.days",
});
fireEvent.click(dailyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.days'
})
fireEvent.click(dailyOption)
// Set interval to 2
const intervalInput = screen.getByTestId("repeat-interval");
fireEvent.change(intervalInput, { target: { value: "2" } });
const intervalInput = screen.getByTestId('repeat-interval')
fireEvent.change(intervalInput, { target: { value: '2' } })
await expectRRule({ freq: "daily", interval: 2 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'daily', interval: 2 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat daily for 5 repetitions", async () => {
await setupEventPopover();
it('sends correct API payload for repeat daily for 5 repetitions', async () => {
await setupEventPopover()
// Ensure frequency is daily
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const dailyOption = screen.getByRole("option", {
name: "event.repeat.frequency.days",
});
fireEvent.click(dailyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.days'
})
fireEvent.click(dailyOption)
// Select "After" end option
const afterRadio = screen.getByLabelText(/after/i);
fireEvent.click(afterRadio);
const afterRadio = screen.getByLabelText(/after/i)
fireEvent.click(afterRadio)
// Set occurrences to 5
const occurrencesInput = screen.getByTestId("occurrences-input");
fireEvent.change(occurrencesInput, { target: { value: "5" } });
const occurrencesInput = screen.getByTestId('occurrences-input')
fireEvent.change(occurrencesInput, { target: { value: '5' } })
await expectRRule({ freq: "daily", interval: 1, occurrences: 5 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'daily', interval: 1, occurrences: 5 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat daily until specific date", async () => {
await setupEventPopover();
it('sends correct API payload for repeat daily until specific date', async () => {
await setupEventPopover()
// Ensure frequency is daily
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const dailyOption = screen.getByRole("option", {
name: "event.repeat.frequency.days",
});
fireEvent.click(dailyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.days'
})
fireEvent.click(dailyOption)
// Select "On" end option
const onRadio = screen
.getAllByLabelText(/on/i)
.find((el) => el.type === "radio");
fireEvent.click(onRadio!);
.find(el => el.type === 'radio')
fireEvent.click(onRadio!)
// End date is set by UI to some valid date string (YYYY-MM-DD)
await expectRRule({
freq: "daily",
freq: 'daily',
interval: 1,
endDate: expect.any(String),
});
expect(mockOnClose).toHaveBeenCalledWith(true);
});
endDate: expect.any(String)
})
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat weekly on specific days", async () => {
await setupEventPopover();
it('sends correct API payload for repeat weekly on specific days', async () => {
await setupEventPopover()
// Select Week(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const weeklyOption = screen.getByRole("option", {
name: "event.repeat.frequency.weeks",
});
fireEvent.click(weeklyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const weeklyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.weeks'
})
fireEvent.click(weeklyOption)
// Select Thursday
const thursdayCheckbox = screen.getByLabelText(
"event.repeat.days.thursday"
);
fireEvent.click(thursdayCheckbox);
const thursdayCheckbox = screen.getByLabelText('event.repeat.days.thursday')
fireEvent.click(thursdayCheckbox)
await expectRRule({
freq: "weekly",
freq: 'weekly',
interval: 1,
byday: ["FR", "TH"],
});
expect(mockOnClose).toHaveBeenCalledWith(true);
});
byday: ['FR', 'TH']
})
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat weekly with 3 week interval", async () => {
await setupEventPopover();
it('sends correct API payload for repeat weekly with 3 week interval', async () => {
await setupEventPopover()
// Select Week(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const weeklyOption = screen.getByRole("option", {
name: "event.repeat.frequency.weeks",
});
fireEvent.click(weeklyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const weeklyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.weeks'
})
fireEvent.click(weeklyOption)
// Set interval to 3
const intervalInput = screen.getByTestId("repeat-interval");
fireEvent.change(intervalInput, { target: { value: "3" } });
const intervalInput = screen.getByTestId('repeat-interval')
fireEvent.change(intervalInput, { target: { value: '3' } })
await expectRRule({ freq: "weekly", interval: 3 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'weekly', interval: 3 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat monthly", async () => {
await setupEventPopover();
it('sends correct API payload for repeat monthly', async () => {
await setupEventPopover()
// Select Month(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const monthlyOption = screen.getByRole("option", {
name: "event.repeat.frequency.months",
});
fireEvent.click(monthlyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const monthlyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.months'
})
fireEvent.click(monthlyOption)
await expectRRule({ freq: "monthly", interval: 1 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'monthly', interval: 1 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat monthly and end after 5 occurrences", async () => {
await setupEventPopover();
it('sends correct API payload for repeat monthly and end after 5 occurrences', async () => {
await setupEventPopover()
// Select Month(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const monthlyOption = screen.getByRole("option", {
name: "event.repeat.frequency.months",
});
fireEvent.click(monthlyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const monthlyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.months'
})
fireEvent.click(monthlyOption)
// Select "After" end option
const afterRadio = screen.getByLabelText(/after/i);
fireEvent.click(afterRadio);
const afterRadio = screen.getByLabelText(/after/i)
fireEvent.click(afterRadio)
// Set occurrences to 5
const occurrencesInput = screen.getByTestId("occurrences-input");
fireEvent.change(occurrencesInput, { target: { value: "5" } });
const occurrencesInput = screen.getByTestId('occurrences-input')
fireEvent.change(occurrencesInput, { target: { value: '5' } })
await expectRRule({ freq: "monthly", interval: 1, occurrences: 5 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'monthly', interval: 1, occurrences: 5 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat yearly", async () => {
await setupEventPopover();
it('sends correct API payload for repeat yearly', async () => {
await setupEventPopover()
// Select Year(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const yearlyOption = screen.getByRole("option", {
name: "event.repeat.frequency.years",
});
fireEvent.click(yearlyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const yearlyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.years'
})
fireEvent.click(yearlyOption)
await expectRRule({ freq: "yearly", interval: 1 });
expect(mockOnClose).toHaveBeenCalledWith(true);
});
await expectRRule({ freq: 'yearly', interval: 1 })
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("sends correct API payload for repeat yearly with end option changes", async () => {
await setupEventPopover();
it('sends correct API payload for repeat yearly with end option changes', async () => {
await setupEventPopover()
// Select Year(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks");
fireEvent.mouseDown(frequencySelect);
const yearlyOption = screen.getByRole("option", {
name: "event.repeat.frequency.years",
});
fireEvent.click(yearlyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect)
const yearlyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.years'
})
fireEvent.click(yearlyOption)
// First choose "After" with 5 occurrences
const afterRadio = screen.getByLabelText(/after/i);
fireEvent.click(afterRadio);
const occurrencesInput = screen.getByTestId("occurrences-input");
fireEvent.change(occurrencesInput, { target: { value: "5" } });
const afterRadio = screen.getByLabelText(/after/i)
fireEvent.click(afterRadio)
const occurrencesInput = screen.getByTestId('occurrences-input')
fireEvent.change(occurrencesInput, { target: { value: '5' } })
// Then change mind and choose "Never"
const neverRadio = screen.getByLabelText(/never/i);
fireEvent.click(neverRadio);
const neverRadio = screen.getByLabelText(/never/i)
fireEvent.click(neverRadio)
await expectRRule({
freq: "yearly",
interval: 1,
});
expect(mockOnClose).toHaveBeenCalledWith(true);
});
});
freq: 'yearly',
interval: 1
})
expect(mockOnClose).toHaveBeenCalledWith(true)
})
})
+134 -139
View File
@@ -1,224 +1,219 @@
import {
ResourceSearch,
Resource,
} from "@/components/Attendees/ResourceSearch";
import { searchUsers } from "@/features/User/userAPI";
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../utils/Renderwithproviders";
import { ResourceSearch, Resource } from '@/components/Attendees/ResourceSearch'
import { searchUsers } from '@/features/User/userAPI'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/User/userAPI");
const mockedSearchUsers = searchUsers as jest.MockedFunction<
typeof searchUsers
>;
jest.mock('@/features/User/userAPI')
const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
describe("ResourceSearch", () => {
describe('ResourceSearch', () => {
const baseResource: Resource = {
displayName: "Projector Room",
};
displayName: 'Projector Room'
}
function setup(
selectedResources: Resource[] = [],
props?: Partial<React.ComponentProps<typeof ResourceSearch>>
) {
const onChange = jest.fn();
const onChange = jest.fn()
renderWithProviders(
<ResourceSearch
objectTypes={["resource"]}
objectTypes={['resource']}
selectedResources={selectedResources}
onChange={onChange}
{...props}
/>
);
return { onChange };
)
return { onChange }
}
beforeEach(() => {
jest.useFakeTimers();
mockedSearchUsers.mockReset();
});
jest.useFakeTimers()
mockedSearchUsers.mockReset()
})
afterEach(() => {
jest.useRealTimers();
});
jest.useRealTimers()
})
it("calls searchUsers after debounce when typing", async () => {
it('calls searchUsers after debounce when typing', async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup();
baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Room", ["resource"]);
});
});
expect(mockedSearchUsers).toHaveBeenCalledWith('Room', ['resource'])
})
})
it("renders search results and allows selection", async () => {
it('renders search results and allows selection', async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
const { onChange } = setup();
baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>)
const { onChange } = setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
const option = await screen.findByText("Projector Room");
await userEvent.click(option);
const option = await screen.findByText('Projector Room')
await userEvent.click(option)
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
expect(onChange).toHaveBeenCalled()
})
})
it("does not show already selected resources in options", async () => {
it('does not show already selected resources in options', async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup([baseResource]);
const input = screen.getByRole("combobox");
await userEvent.type(input, "Projector");
baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup([baseResource])
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Projector')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
await waitFor(() => {
// It shouldn't be in the dropdown options anymore
const options = screen.queryAllByRole("option");
const options = screen.queryAllByRole('option')
expect(
options.find((opt) => opt.textContent === "Projector Room")
).toBeUndefined();
});
});
options.find(opt => opt.textContent === 'Projector Room')
).toBeUndefined()
})
})
it("respects disabled state", () => {
setup([], { disabled: true });
expect(screen.getByRole("combobox")).toBeDisabled();
});
it('respects disabled state', () => {
setup([], { disabled: true })
expect(screen.getByRole('combobox')).toBeDisabled()
})
it("no options doesn't show dropdown when input is empty", async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup();
const input = screen.getByRole("combobox");
baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup()
const input = screen.getByRole('combobox')
await userEvent.type(input, "Room");
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
await userEvent.clear(input);
await userEvent.clear(input)
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
it("shows 'No results' when search succeeds but returns empty array", async () => {
mockedSearchUsers.mockResolvedValueOnce([]);
setup();
mockedSearchUsers.mockResolvedValueOnce([])
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
await Promise.resolve()
})
const noResults = await screen.findByText(
"resourceSearch.noResults",
'resourceSearch.noResults',
{},
{ timeout: 5000 }
);
expect(noResults).toBeInTheDocument();
});
)
expect(noResults).toBeInTheDocument()
})
it("clears options when search fails and shows error snackbar", async () => {
it('clears options when search fails and shows error snackbar', async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup();
baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("Projector Room")).toBeInTheDocument();
});
expect(screen.getByText('Projector Room')).toBeInTheDocument()
})
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error"));
await userEvent.clear(input);
await userEvent.type(input, "Error");
mockedSearchUsers.mockRejectedValueOnce(new Error('Network error'))
await userEvent.clear(input)
await userEvent.type(input, 'Error')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
})
const errorMessage = await screen.findByText("resourceSearch.searchError");
expect(errorMessage).toBeInTheDocument();
const errorMessage = await screen.findByText('resourceSearch.searchError')
expect(errorMessage).toBeInTheDocument()
expect(screen.queryByText("Projector Room")).not.toBeInTheDocument();
expect(screen.queryByText('Projector Room')).not.toBeInTheDocument()
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
await userEvent.clear(input);
await userEvent.type(input, "Room");
baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>)
await userEvent.clear(input)
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
jest.advanceTimersByTime(300)
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("Projector Room")).toBeInTheDocument();
});
});
expect(screen.getByText('Projector Room')).toBeInTheDocument()
})
})
it("shows loading text when searching", async () => {
let resolveSearch: (value: Resource[]) => void;
const searchPromise = new Promise<Resource[]>((resolve) => {
resolveSearch = resolve;
});
it('shows loading text when searching', async () => {
let resolveSearch: (value: Resource[]) => void
const searchPromise = new Promise<Resource[]>(resolve => {
resolveSearch = resolve
})
mockedSearchUsers.mockReturnValueOnce(
searchPromise as unknown as ReturnType<typeof searchUsers>
);
setup();
)
setup()
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
const input = screen.getByRole('combobox')
await userEvent.type(input, 'Room')
await act(async () => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
const loadingText = await screen.findByText(
"resourceSearch.loading",
'resourceSearch.loading',
{},
{ timeout: 5000 }
);
expect(loadingText).toBeInTheDocument();
)
expect(loadingText).toBeInTheDocument()
await act(async () => {
resolveSearch!([baseResource]);
await searchPromise;
});
});
});
resolveSearch!([baseResource])
await searchPromise
})
})
})
+125 -125
View File
@@ -1,33 +1,33 @@
import { ResponsiveDialog } from "@/components/Dialog";
import { Button, TextField, TwakeMuiThemeProvider } from "@linagora/twake-mui";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { ResponsiveDialog } from '@/components/Dialog'
import { Button, TextField, TwakeMuiThemeProvider } from '@linagora/twake-mui'
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
describe("ResponsiveDialog", () => {
const mockOnClose = jest.fn();
const mockOnExpandToggle = jest.fn();
describe('ResponsiveDialog', () => {
const mockOnClose = jest.fn()
const mockOnExpandToggle = jest.fn()
const renderWithTheme = (ui: React.ReactElement) => {
return render(<TwakeMuiThemeProvider>{ui}</TwakeMuiThemeProvider>);
};
return render(<TwakeMuiThemeProvider>{ui}</TwakeMuiThemeProvider>)
}
beforeEach(() => {
mockOnClose.mockClear();
mockOnExpandToggle.mockClear();
});
mockOnClose.mockClear()
mockOnExpandToggle.mockClear()
})
it("renders in normal mode by default", () => {
it('renders in normal mode by default', () => {
renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test Dialog">
<TextField label="Name" />
</ResponsiveDialog>
);
)
expect(screen.getByText("Test Dialog")).toBeInTheDocument();
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
});
expect(screen.getByText('Test Dialog')).toBeInTheDocument()
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
})
it("renders title in normal mode", () => {
it('renders title in normal mode', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -37,13 +37,13 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("My Title")).toBeInTheDocument();
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
});
expect(screen.getByText('My Title')).toBeInTheDocument()
expect(screen.queryByLabelText('show less')).not.toBeInTheDocument()
})
it("renders back arrow in extended mode", () => {
it('renders back arrow in extended mode', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -54,13 +54,13 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
expect(screen.queryByText("My Title")).not.toBeInTheDocument();
expect(screen.getByLabelText("show less")).toBeInTheDocument();
});
expect(screen.queryByText('My Title')).not.toBeInTheDocument()
expect(screen.getByLabelText('show less')).toBeInTheDocument()
})
it("calls onExpandToggle when back arrow is clicked", () => {
it('calls onExpandToggle when back arrow is clicked', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -71,15 +71,15 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
const backButton = screen.getByLabelText("show less");
fireEvent.click(backButton);
const backButton = screen.getByLabelText('show less')
fireEvent.click(backButton)
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1);
});
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1)
})
it("renders actions when provided", () => {
it('renders actions when provided', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -89,38 +89,38 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Custom Action")).toBeInTheDocument();
});
expect(screen.getByText('Custom Action')).toBeInTheDocument()
})
it("does not render actions when not provided", () => {
it('does not render actions when not provided', () => {
const { container } = renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
<div>Content</div>
</ResponsiveDialog>
);
)
const dialogActions = container.querySelector(".MuiDialogActions-root");
expect(dialogActions).not.toBeInTheDocument();
});
const dialogActions = container.querySelector('.MuiDialogActions-root')
expect(dialogActions).not.toBeInTheDocument()
})
it("calls onClose when backdrop is clicked", () => {
it('calls onClose when backdrop is clicked', () => {
renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
<div>Content</div>
</ResponsiveDialog>
);
)
const backdrop = document.querySelector(".MuiBackdrop-root");
const backdrop = document.querySelector('.MuiBackdrop-root')
if (backdrop) {
fireEvent.click(backdrop);
fireEvent.click(backdrop)
}
expect(mockOnClose).toHaveBeenCalled();
});
expect(mockOnClose).toHaveBeenCalled()
})
it("applies custom normalMaxWidth", () => {
it('applies custom normalMaxWidth', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -130,24 +130,24 @@ describe("ResponsiveDialog", () => {
>
<div>Normal Width Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Normal Width Content")).toBeInTheDocument();
});
expect(screen.getByText('Normal Width Content')).toBeInTheDocument()
})
it("wraps children in Stack component", () => {
it('wraps children in Stack component', () => {
renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
<TextField label="Field 1" />
<TextField label="Field 2" />
</ResponsiveDialog>
);
)
expect(screen.getByLabelText("Field 1")).toBeInTheDocument();
expect(screen.getByLabelText("Field 2")).toBeInTheDocument();
});
expect(screen.getByLabelText('Field 1')).toBeInTheDocument()
expect(screen.getByLabelText('Field 2')).toBeInTheDocument()
})
it("uses correct spacing in normal mode", () => {
it('uses correct spacing in normal mode', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -158,12 +158,12 @@ describe("ResponsiveDialog", () => {
>
<div>Normal Spacing Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Normal Spacing Content")).toBeInTheDocument();
});
expect(screen.getByText('Normal Spacing Content')).toBeInTheDocument()
})
it("uses correct spacing in extended mode", () => {
it('uses correct spacing in extended mode', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -174,12 +174,12 @@ describe("ResponsiveDialog", () => {
>
<div>Extended Spacing Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Extended Spacing Content")).toBeInTheDocument();
});
expect(screen.getByText('Extended Spacing Content')).toBeInTheDocument()
})
it("applies contentSx custom styles", () => {
it('applies contentSx custom styles', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -189,28 +189,28 @@ describe("ResponsiveDialog", () => {
>
<div>Custom Styled Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Custom Styled Content")).toBeInTheDocument();
});
expect(screen.getByText('Custom Styled Content')).toBeInTheDocument()
})
it("applies titleSx custom styles", () => {
it('applies titleSx custom styles', () => {
const { container } = renderWithTheme(
<ResponsiveDialog
open={true}
onClose={mockOnClose}
title="Test"
titleSx={{ color: "red" }}
titleSx={{ color: 'red' }}
>
<div>Content</div>
</ResponsiveDialog>
);
)
const title = screen.getByText("Test");
expect(title).toBeInTheDocument();
});
const title = screen.getByText('Test')
expect(title).toBeInTheDocument()
})
it("shows dividers when dividers prop is true", () => {
it('shows dividers when dividers prop is true', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -220,12 +220,12 @@ describe("ResponsiveDialog", () => {
>
<div>Content with Dividers</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Content with Dividers")).toBeInTheDocument();
});
expect(screen.getByText('Content with Dividers')).toBeInTheDocument()
})
it("does not show back arrow when onExpandToggle is not provided", () => {
it('does not show back arrow when onExpandToggle is not provided', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -235,13 +235,13 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
expect(screen.queryByLabelText('show less')).not.toBeInTheDocument()
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it("accepts custom headerHeight", () => {
it('accepts custom headerHeight', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -252,12 +252,12 @@ describe("ResponsiveDialog", () => {
>
<div>Custom Header Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Custom Header Content")).toBeInTheDocument();
});
expect(screen.getByText('Custom Header Content')).toBeInTheDocument()
})
it("renders with custom expandedContentMaxWidth", () => {
it('renders with custom expandedContentMaxWidth', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -268,22 +268,22 @@ describe("ResponsiveDialog", () => {
>
<div>Wide Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Wide Content")).toBeInTheDocument();
});
expect(screen.getByText('Wide Content')).toBeInTheDocument()
})
it("does not render dialog content when open is false", () => {
it('does not render dialog content when open is false', () => {
renderWithTheme(
<ResponsiveDialog open={false} onClose={mockOnClose} title="Test">
<div>Test Content</div>
</ResponsiveDialog>
);
)
expect(screen.queryByText("Test Content")).not.toBeInTheDocument();
});
expect(screen.queryByText('Test Content')).not.toBeInTheDocument()
})
it("renders correctly in extended mode", () => {
it('renders correctly in extended mode', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -294,13 +294,13 @@ describe("ResponsiveDialog", () => {
>
<div>Extended Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByText("Extended Content")).toBeInTheDocument();
expect(screen.getByLabelText("show less")).toBeInTheDocument();
});
expect(screen.getByText('Extended Content')).toBeInTheDocument()
expect(screen.getByLabelText('show less')).toBeInTheDocument()
})
it("renders expand and close icons in normal mode when showHeaderActions is true", () => {
it('renders expand and close icons in normal mode when showHeaderActions is true', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -312,13 +312,13 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
expect(screen.getByLabelText("expand")).toBeInTheDocument();
expect(screen.getByLabelText("close")).toBeInTheDocument();
});
expect(screen.getByLabelText('expand')).toBeInTheDocument()
expect(screen.getByLabelText('close')).toBeInTheDocument()
})
it("does not render header icons when showHeaderActions is false", () => {
it('does not render header icons when showHeaderActions is false', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -330,14 +330,14 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
expect(screen.queryByLabelText("expand")).not.toBeInTheDocument();
expect(screen.queryByLabelText("close")).not.toBeInTheDocument();
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
expect(screen.queryByLabelText('expand')).not.toBeInTheDocument()
expect(screen.queryByLabelText('close')).not.toBeInTheDocument()
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it("calls onClose when close icon is clicked", () => {
it('calls onClose when close icon is clicked', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -348,15 +348,15 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
const closeButton = screen.getByLabelText("close");
fireEvent.click(closeButton);
const closeButton = screen.getByLabelText('close')
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it("calls onExpandToggle when expand icon is clicked", () => {
it('calls onExpandToggle when expand icon is clicked', () => {
renderWithTheme(
<ResponsiveDialog
open={true}
@@ -368,11 +368,11 @@ describe("ResponsiveDialog", () => {
>
<div>Content</div>
</ResponsiveDialog>
);
)
const expandButton = screen.getByLabelText("expand");
fireEvent.click(expandButton);
const expandButton = screen.getByLabelText('expand')
fireEvent.click(expandButton)
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1);
});
});
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1)
})
})
+68 -68
View File
@@ -1,102 +1,102 @@
import { renderHook, act } from "@testing-library/react";
import { useUserSearch } from "@/components/Attendees/useUserSearch";
import { searchUsers } from "@/features/User/userAPI";
import { renderHook, act } from '@testing-library/react'
import { useUserSearch } from '@/components/Attendees/useUserSearch'
import { searchUsers } from '@/features/User/userAPI'
jest.mock("@/features/User/userAPI", () => ({
searchUsers: jest.fn(),
}));
jest.mock('@/features/User/userAPI', () => ({
searchUsers: jest.fn()
}))
describe("useUserSearch", () => {
const mockSearchUsers = searchUsers as jest.Mock;
describe('useUserSearch', () => {
const mockSearchUsers = searchUsers as jest.Mock
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});
jest.useFakeTimers()
jest.clearAllMocks()
})
afterEach(() => {
jest.useRealTimers();
});
jest.useRealTimers()
})
it("should initialize with default values", () => {
it('should initialize with default values', () => {
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
);
useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' })
)
expect(result.current.query).toBe("");
expect(result.current.loading).toBe(false);
expect(result.current.options).toEqual([]);
expect(result.current.hasSearched).toBe(false);
expect(result.current.isOpen).toBe(false);
expect(result.current.inputError).toBeNull();
expect(result.current.snackbarOpen).toBe(false);
expect(result.current.snackbarMessage).toBe("");
});
expect(result.current.query).toBe('')
expect(result.current.loading).toBe(false)
expect(result.current.options).toEqual([])
expect(result.current.hasSearched).toBe(false)
expect(result.current.isOpen).toBe(false)
expect(result.current.inputError).toBeNull()
expect(result.current.snackbarOpen).toBe(false)
expect(result.current.snackbarMessage).toBe('')
})
it("should debounce and fetch users when query changes", async () => {
const mockUsers = [{ displayName: "John Doe", email: "john@example.com" }];
mockSearchUsers.mockResolvedValueOnce(mockUsers);
it('should debounce and fetch users when query changes', async () => {
const mockUsers = [{ displayName: 'John Doe', email: 'john@example.com' }]
mockSearchUsers.mockResolvedValueOnce(mockUsers)
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
);
useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' })
)
act(() => {
result.current.setQuery("John");
});
result.current.setQuery('John')
})
expect(result.current.loading).toBe(false); // Before debounce
expect(result.current.loading).toBe(false) // Before debounce
// Wait for the mock promise to resolve within act
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve(); // allow microtasks to flush
});
jest.advanceTimersByTime(300)
await Promise.resolve() // allow microtasks to flush
})
expect(mockSearchUsers).toHaveBeenCalledWith("John", ["user"]);
expect(result.current.loading).toBe(false);
expect(result.current.options).toEqual(mockUsers);
expect(result.current.hasSearched).toBe(true);
});
expect(mockSearchUsers).toHaveBeenCalledWith('John', ['user'])
expect(result.current.loading).toBe(false)
expect(result.current.options).toEqual(mockUsers)
expect(result.current.hasSearched).toBe(true)
})
it("should clear options and handle empty query", () => {
it('should clear options and handle empty query', () => {
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
);
useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' })
)
act(() => {
result.current.setQuery(" "); // empty query with spaces
});
result.current.setQuery(' ') // empty query with spaces
})
act(() => {
jest.advanceTimersByTime(300);
});
jest.advanceTimersByTime(300)
})
expect(mockSearchUsers).not.toHaveBeenCalled();
expect(result.current.options).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.hasSearched).toBe(false);
});
expect(mockSearchUsers).not.toHaveBeenCalled()
expect(result.current.options).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.hasSearched).toBe(false)
})
it("should handle search errors and show the custom error message", async () => {
mockSearchUsers.mockRejectedValueOnce(new Error("API Error"));
it('should handle search errors and show the custom error message', async () => {
mockSearchUsers.mockRejectedValueOnce(new Error('API Error'))
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Custom Error" })
);
useUserSearch({ objectTypes: ['user'], errorMessage: 'Custom Error' })
)
act(() => {
result.current.setQuery("FailedSearch");
});
result.current.setQuery('FailedSearch')
})
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve(); // allow microtasks to flush
});
jest.advanceTimersByTime(300)
await Promise.resolve() // allow microtasks to flush
})
expect(result.current.hasSearched).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.snackbarOpen).toBe(true);
expect(result.current.snackbarMessage).toBe("Custom Error");
});
});
expect(result.current.hasSearched).toBe(false)
expect(result.current.loading).toBe(false)
expect(result.current.snackbarOpen).toBe(true)
expect(result.current.snackbarMessage).toBe('Custom Error')
})
})
+124 -124
View File
@@ -6,197 +6,197 @@ import {
getSecretLink,
postCalendar,
proppatchCalendar,
removeCalendar,
} from "@/features/Calendars/CalendarApi";
import { clientConfig } from "@/features/User/oidcAuth";
import { api } from "@/utils/apiUtils";
clientConfig.url = "https://example.com";
removeCalendar
} from '@/features/Calendars/CalendarApi'
import { clientConfig } from '@/features/User/oidcAuth'
import { api } from '@/utils/apiUtils'
clientConfig.url = 'https://example.com'
jest.mock("@/utils/apiUtils");
jest.mock('@/utils/apiUtils')
describe("Calendar API", () => {
describe('Calendar API', () => {
afterEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("fetches calendar list for a user", async () => {
const mockUserId = "user123";
const mockResponse = [{ id: "calendar1" }, { id: "calendar2" }];
it('fetches calendar list for a user', async () => {
const mockUserId = 'user123'
const mockResponse = [{ id: 'calendar1' }, { id: 'calendar2' }]
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResponse),
});
;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResponse)
})
const calendars = await getCalendars(mockUserId);
const calendars = await getCalendars(mockUserId)
expect(api.get).toHaveBeenCalledWith(
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`,
{
headers: { Accept: "application/calendar+json" },
headers: { Accept: 'application/calendar+json' }
}
);
expect(calendars).toEqual(mockResponse);
});
)
expect(calendars).toEqual(mockResponse)
})
it("fetches calendar events for a given ID and match window", async () => {
const calendarId = "calendar1";
const match = { start: "2025-07-01", end: "2025-07-31" };
const mockCalendarData = { events: ["event1", "event2"] };
it('fetches calendar events for a given ID and match window', async () => {
const calendarId = 'calendar1'
const match = { start: '2025-07-01', end: '2025-07-31' }
const mockCalendarData = { events: ['event1', 'event2'] }
(api as unknown as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockCalendarData),
});
;(api as unknown as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockCalendarData)
})
const result = await getCalendar(calendarId, match);
const result = await getCalendar(calendarId, match)
expect(api).toHaveBeenCalledWith(`dav/calendars/${calendarId}.json`, {
method: "REPORT",
method: 'REPORT',
headers: {
Accept: "application/json, text/plain, */*",
Accept: 'application/json, text/plain, */*'
},
body: JSON.stringify({ match }),
});
body: JSON.stringify({ match })
})
expect(result).toEqual(mockCalendarData);
});
it("postCalendar", async () => {
const calId = "calId";
const userId = "userId";
const color = { light: "calId" };
const name = "new cal";
const desc = "desc";
expect(result).toEqual(mockCalendarData)
})
it('postCalendar', async () => {
const calId = 'calId'
const userId = 'userId'
const color = { light: 'calId' }
const name = 'new cal'
const desc = 'desc'
const result = await postCalendar(userId, calId, color, name, desc);
const result = await postCalendar(userId, calId, color, name, desc)
expect(api.post).toHaveBeenCalledWith(`dav/calendars/${userId}.json`, {
headers: {
Accept: "application/json, text/plain, */*",
Accept: 'application/json, text/plain, */*'
},
body: JSON.stringify({
id: "calId",
"dav:name": "new cal",
"apple:color": "calId",
"caldav:description": "desc",
}),
});
});
it("patch Calendar", async () => {
const calId = "calId";
const calLink = "/calendars/calId.json";
const color = { light: "calIdLight", dark: "calIdDark" };
const name = "new cal";
const desc = "desc";
id: 'calId',
'dav:name': 'new cal',
'apple:color': 'calId',
'caldav:description': 'desc'
})
})
})
it('patch Calendar', async () => {
const calId = 'calId'
const calLink = '/calendars/calId.json'
const color = { light: 'calIdLight', dark: 'calIdDark' }
const name = 'new cal'
const desc = 'desc'
const result = await proppatchCalendar(calLink, { color, name, desc });
const result = await proppatchCalendar(calLink, { color, name, desc })
expect(api).toHaveBeenCalledWith(`dav${calLink}`, {
method: "PROPPATCH",
method: 'PROPPATCH',
headers: {
Accept: "application/json, text/plain, */*",
Accept: 'application/json, text/plain, */*'
},
body: JSON.stringify({
"dav:name": "new cal",
"caldav:description": "desc",
"apple:color": "calIdLight",
}),
});
});
'dav:name': 'new cal',
'caldav:description': 'desc',
'apple:color': 'calIdLight'
})
})
})
it("remove Calendar", async () => {
const calLink = "/calendars/calId.json";
const result = await removeCalendar(calLink);
it('remove Calendar', async () => {
const calLink = '/calendars/calId.json'
const result = await removeCalendar(calLink)
expect(api).toHaveBeenCalledWith(`dav${calLink}`, {
method: "DELETE",
method: 'DELETE',
headers: {
Accept: "application/json, text/plain, */*",
},
});
});
Accept: 'application/json, text/plain, */*'
}
})
})
it("get secret link without reset", async () => {
const calLink = "/calendars/calId.json";
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue("link"),
});
it('get secret link without reset', async () => {
const calLink = '/calendars/calId.json'
;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue('link')
})
const noreset = await getSecretLink(calLink, false);
const noreset = await getSecretLink(calLink, false)
expect(api.get).toHaveBeenCalledWith(
`calendar/api${calLink}/secret-link?shouldResetLink=false`,
{
headers: {
Accept: "application/json, text/plain, */*",
},
Accept: 'application/json, text/plain, */*'
}
}
);
});
it("get secret link with reset", async () => {
const calLink = "/calendars/calId.json";
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue("link"),
});
const reset = await getSecretLink(calLink, true);
)
})
it('get secret link with reset', async () => {
const calLink = '/calendars/calId.json'
;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue('link')
})
const reset = await getSecretLink(calLink, true)
expect(api.get).toHaveBeenCalledWith(
`calendar/api${calLink}/secret-link?shouldResetLink=true`,
{
headers: {
Accept: "application/json, text/plain, */*",
},
Accept: 'application/json, text/plain, */*'
}
}
);
});
)
})
it("get export data ", async () => {
const calLink = "/calendars/calId.json";
(api.get as jest.Mock).mockReturnValue({
text: jest.fn().mockResolvedValue("data"),
});
const data = await exportCalendar(calLink);
it('get export data ', async () => {
const calLink = '/calendars/calId.json'
;(api.get as jest.Mock).mockReturnValue({
text: jest.fn().mockResolvedValue('data')
})
const data = await exportCalendar(calLink)
expect(api.get).toHaveBeenCalledWith(`dav${calLink}?export`, {
headers: {
Accept: "application/calendar",
},
});
});
Accept: 'application/calendar'
}
})
})
it("When adding a sharedCal with #default #default is preserved", async () => {
const mockApiPost = jest.spyOn(api, "post");
it('When adding a sharedCal with #default #default is preserved', async () => {
const mockApiPost = jest.spyOn(api, 'post')
const calData = {
cal: {
id: "cal123",
"dav:name": "#default",
"apple:color": "#FF5733",
"caldav:description": "Default calendar",
id: 'cal123',
'dav:name': '#default',
'apple:color': '#FF5733',
'caldav:description': 'Default calendar',
acl: [],
invite: [],
_links: {
self: {
href: "/calendars/owner123/cal123.json",
},
},
href: '/calendars/owner123/cal123.json'
}
}
},
owner: {
displayName: "John Doe",
email: "john.doe@example.com",
openpaasId: "owner123",
displayName: 'John Doe',
email: 'john.doe@example.com',
openpaasId: 'owner123'
},
color: "#FF5733",
};
color: '#FF5733'
}
await addSharedCalendar("currentUserId", "newCalId123", calData);
await addSharedCalendar('currentUserId', 'newCalId123', calData)
expect(mockApiPost).toHaveBeenCalledWith(
"dav/calendars/currentUserId.json",
'dav/calendars/currentUserId.json',
expect.objectContaining({
body: expect.stringContaining('"dav:name":"#default"'),
body: expect.stringContaining('"dav:name":"#default"')
})
);
)
const callBody = JSON.parse(String(mockApiPost.mock.calls[0][1]?.body));
expect(callBody["dav:name"]).toBe("#default");
});
});
const callBody = JSON.parse(String(mockApiPost.mock.calls[0][1]?.body))
expect(callBody['dav:name']).toBe('#default')
})
})
@@ -1,103 +1,103 @@
import { AccessTab } from "@/components/Calendar/AccessTab";
import { AccessTab } from '@/components/Calendar/AccessTab'
import {
CalendarAccessRights,
UserWithAccess,
} from "@/components/Calendar/CalendarAccessRights";
import CalendarPopover from "@/components/Calendar/CalendarModal";
import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar";
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
import * as eventThunks from "@/features/Calendars/services";
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { getUserDetails } from "@/features/User/userAPI";
import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
import { api } from "@/utils/apiUtils";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
UserWithAccess
} from '@/components/Calendar/CalendarAccessRights'
import CalendarPopover from '@/components/Calendar/CalendarModal'
import { updateDelegationCalendar } from '@/features/Calendars/api/updateDelegationCalendar'
import { AccessRight, Calendar } from '@/features/Calendars/CalendarTypes'
import * as eventThunks from '@/features/Calendars/services'
import * as delegationThunks from '@/features/Calendars/services/updateDelegationCalendarAsync'
import { getUserDetails } from '@/features/User/userAPI'
import { accessRightToDavProp } from '@/utils/accessRightToDavProp'
import { api } from '@/utils/apiUtils'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils", () => ({
api: { post: jest.fn() },
}));
jest.mock('@/utils/apiUtils', () => ({
api: { post: jest.fn() }
}))
jest.mock("@/features/User/userAPI", () => ({
getUserDetails: jest.fn(),
}));
jest.mock('@/features/User/userAPI', () => ({
getUserDetails: jest.fn()
}))
jest.mock("@/features/Calendars/CalendarApi", () => ({
getSecretLink: jest.fn().mockReturnValue(""),
exportCalendar: jest.fn(),
}));
jest.mock('@/features/Calendars/CalendarApi', () => ({
getSecretLink: jest.fn().mockReturnValue(''),
exportCalendar: jest.fn()
}))
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
jest.fn().mockImplementation(() => {
const result = Object.assign(Promise.resolve(resolvedValue), {
unwrap: () => Promise.resolve(resolvedValue),
});
return jest.fn().mockReturnValue(result);
});
unwrap: () => Promise.resolve(resolvedValue)
})
return jest.fn().mockReturnValue(result)
})
describe("accessRightToDavProp", () => {
it("maps 5 (ADMIN) → dav:administration", () => {
expect(accessRightToDavProp(5)).toBe("dav:administration");
});
describe('accessRightToDavProp', () => {
it('maps 5 (ADMIN) → dav:administration', () => {
expect(accessRightToDavProp(5)).toBe('dav:administration')
})
it("maps 3 (EDITOR) → dav:read-write", () => {
expect(accessRightToDavProp(3)).toBe("dav:read-write");
});
it('maps 3 (EDITOR) → dav:read-write', () => {
expect(accessRightToDavProp(3)).toBe('dav:read-write')
})
it("maps 2 (VIEW) → dav:read", () => {
expect(accessRightToDavProp(2)).toBe("dav:read");
});
it('maps 2 (VIEW) → dav:read', () => {
expect(accessRightToDavProp(2)).toBe('dav:read')
})
it("defaults to dav:read for unknown values (covered by default case)", () => {
expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read");
});
});
it('defaults to dav:read for unknown values (covered by default case)', () => {
expect(accessRightToDavProp(999 as AccessRight)).toBe('dav:read')
})
})
describe("updateDelegationCalendar", () => {
beforeEach(() => jest.clearAllMocks());
describe('updateDelegationCalendar', () => {
beforeEach(() => jest.clearAllMocks())
it("posts to the correct DAV endpoint with the share body", async () => {
(api.post as jest.Mock).mockResolvedValue({ ok: true });
it('posts to the correct DAV endpoint with the share body', async () => {
;(api.post as jest.Mock).mockResolvedValue({ ok: true })
const share = {
set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }],
remove: [],
};
set: [{ 'dav:href': 'mailto:alice@example.com', 'dav:read': true }],
remove: []
}
await updateDelegationCalendar("/calendars/user/cal1.json", share);
await updateDelegationCalendar('/calendars/user/cal1.json', share)
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith(
"dav/calendars/user/cal1.json",
'dav/calendars/user/cal1.json',
expect.objectContaining({
body: JSON.stringify({ share }),
body: JSON.stringify({ share })
})
)
);
});
});
)
})
})
const baseCalendar: Calendar = {
id: "user1/cal1",
name: "My Calendar",
description: "",
color: { color: "#0062FF", dark: "#FFF" },
link: "/calendars/user1/cal1.json",
visibility: "public",
id: 'user1/cal1',
name: 'My Calendar',
description: '',
color: { color: '#0062FF', dark: '#FFF' },
link: '/calendars/user1/cal1.json',
visibility: 'public',
events: {},
invite: [],
owner: { emails: ["user1@example.com"] },
owner: { emails: ['user1@example.com'] },
access: { write: true } as any,
delegated: true,
};
delegated: true
}
describe("CalendarAccessRights", () => {
const mockOnChange = jest.fn();
const mockOnInvitesLoaded = jest.fn();
describe('CalendarAccessRights', () => {
const mockOnChange = jest.fn()
const mockOnInvitesLoaded = jest.fn()
beforeEach(() => jest.clearAllMocks());
beforeEach(() => jest.clearAllMocks())
it("renders the grant access rights section", () => {
it('renders the grant access rights section', () => {
renderWithProviders(
<CalendarAccessRights
calendar={baseCalendar}
@@ -106,22 +106,22 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
expect(
screen.getByText("calendarPopover.access.grantAccessRights")
).toBeInTheDocument();
});
screen.getByText('calendarPopover.access.grantAccessRights')
).toBeInTheDocument()
})
it("shows a list of users already passed in via value prop", () => {
it('shows a list of users already passed in via value prop', () => {
const users: UserWithAccess[] = [
{
openpaasId: "user1",
displayName: "Alice",
email: "alice@example.com",
accessRight: 2,
},
];
openpaasId: 'user1',
displayName: 'Alice',
email: 'alice@example.com',
accessRight: 2
}
]
renderWithProviders(
<CalendarAccessRights
@@ -130,21 +130,21 @@ describe("CalendarAccessRights", () => {
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
)
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
it("calls onChange with user removed when remove button is clicked", () => {
it('calls onChange with user removed when remove button is clicked', () => {
const users: UserWithAccess[] = [
{
openpaasId: "user1",
displayName: "Alice",
email: "alice@example.com",
accessRight: 5,
},
];
openpaasId: 'user1',
displayName: 'Alice',
email: 'alice@example.com',
accessRight: 5
}
]
renderWithProviders(
<CalendarAccessRights
@@ -154,31 +154,31 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
fireEvent.click(screen.getByLabelText(/remove/i));
expect(mockOnChange).toHaveBeenCalledWith([]);
});
fireEvent.click(screen.getByLabelText(/remove/i))
expect(mockOnChange).toHaveBeenCalledWith([])
})
it("loads invited users from calendar.invite on mount", async () => {
it('loads invited users from calendar.invite on mount', async () => {
const calendarWithInvite: Calendar = {
...baseCalendar,
invite: [
{
href: "mailto:bob@example.com",
principal: "/principals/users/bob123",
href: 'mailto:bob@example.com',
principal: '/principals/users/bob123',
access: 3,
inviteStatus: 1,
},
],
};
inviteStatus: 1
}
]
}
(getUserDetails as jest.Mock).mockResolvedValue({
preferredEmail: "bob@example.com",
firstname: "Bob",
lastname: "Smith",
emails: ["bob@example.com"],
});
;(getUserDetails as jest.Mock).mockResolvedValue({
preferredEmail: 'bob@example.com',
firstname: 'Bob',
lastname: 'Smith',
emails: ['bob@example.com']
})
renderWithProviders(
<CalendarAccessRights
@@ -188,35 +188,35 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123"));
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith('bob123'))
await waitFor(() =>
expect(mockOnInvitesLoaded).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
email: "bob@example.com",
accessRight: 3,
}),
email: 'bob@example.com',
accessRight: 3
})
])
)
);
});
)
})
it("skips invite entries where getUserDetails throws", async () => {
it('skips invite entries where getUserDetails throws', async () => {
const calendarWithInvite: Calendar = {
...baseCalendar,
invite: [
{
href: "mailto:ghost@example.com",
principal: "/principals/users/ghost",
href: 'mailto:ghost@example.com',
principal: '/principals/users/ghost',
access: 2,
inviteStatus: 1,
},
],
};
inviteStatus: 1
}
]
}
(getUserDetails as jest.Mock).mockRejectedValue(new Error("Not found"));
;(getUserDetails as jest.Mock).mockRejectedValue(new Error('Not found'))
renderWithProviders(
<CalendarAccessRights
@@ -226,28 +226,28 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]));
});
await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]))
})
it("shows a loading spinner while invite users are being fetched", async () => {
it('shows a loading spinner while invite users are being fetched', async () => {
const calendarWithInvite: Calendar = {
...baseCalendar,
invite: [
{
href: "mailto:carol@example.com",
principal: "/principals/users/carol",
href: 'mailto:carol@example.com',
principal: '/principals/users/carol',
access: 2,
inviteStatus: 1,
},
],
};
inviteStatus: 1
}
]
}
// Never resolves during the assertion window
(getUserDetails as jest.Mock).mockImplementation(
;(getUserDetails as jest.Mock).mockImplementation(
() => new Promise(() => {})
);
)
renderWithProviders(
<CalendarAccessRights
@@ -257,12 +257,12 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
});
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument()
})
it("displays the calendar owner information", () => {
it('displays the calendar owner information', () => {
renderWithProviders(
<CalendarAccessRights
calendar={baseCalendar}
@@ -271,46 +271,44 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
expect(screen.getAllByText("user1@example.com").length).toBeGreaterThan(0);
expect(
screen.getByText("calendarPopover.access.owner")
).toBeInTheDocument();
});
expect(screen.getAllByText('user1@example.com').length).toBeGreaterThan(0)
expect(screen.getByText('calendarPopover.access.owner')).toBeInTheDocument()
})
it("fetches and displays resource administrators for resource calendars", async () => {
it('fetches and displays resource administrators for resource calendars', async () => {
const resourceCalendar: any = {
...baseCalendar,
owner: {
_id: "resource1",
_id: 'resource1',
resource: true,
emails: ["resource1@example.com"],
emails: ['resource1@example.com'],
administrators: [
{ id: "admin1" },
{ id: "admin2" },
{ id: "resource1" }, // Owner shouldn't be loaded as an admin
],
},
};
{ id: 'admin1' },
{ id: 'admin2' },
{ id: 'resource1' } // Owner shouldn't be loaded as an admin
]
}
}
(getUserDetails as jest.Mock).mockImplementation((id: string) => {
if (id === "admin1") {
;(getUserDetails as jest.Mock).mockImplementation((id: string) => {
if (id === 'admin1') {
return Promise.resolve({
preferredEmail: "admin1@example.com",
firstname: "Admin",
lastname: "One",
});
preferredEmail: 'admin1@example.com',
firstname: 'Admin',
lastname: 'One'
})
}
if (id === "admin2") {
if (id === 'admin2') {
return Promise.resolve({
preferredEmail: "admin2@example.com",
firstname: "Admin",
lastname: "Two",
});
preferredEmail: 'admin2@example.com',
firstname: 'Admin',
lastname: 'Two'
})
}
return Promise.resolve(null);
});
return Promise.resolve(null)
})
renderWithProviders(
<CalendarAccessRights
@@ -320,37 +318,37 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...userState }
);
)
await waitFor(() => {
expect(getUserDetails).toHaveBeenCalledWith("admin1");
expect(getUserDetails).toHaveBeenCalledWith("admin2");
});
expect(getUserDetails).toHaveBeenCalledWith('admin1')
expect(getUserDetails).toHaveBeenCalledWith('admin2')
})
expect(getUserDetails).not.toHaveBeenCalledWith("resource1");
expect(getUserDetails).not.toHaveBeenCalledWith('resource1')
expect(await screen.findByText("Admin One")).toBeInTheDocument();
expect(screen.getByText("admin1@example.com")).toBeInTheDocument();
expect(screen.getByText("Admin Two")).toBeInTheDocument();
expect(screen.getByText("admin2@example.com")).toBeInTheDocument();
expect(await screen.findByText('Admin One')).toBeInTheDocument()
expect(screen.getByText('admin1@example.com')).toBeInTheDocument()
expect(screen.getByText('Admin Two')).toBeInTheDocument()
expect(screen.getByText('admin2@example.com')).toBeInTheDocument()
expect(
screen.getAllByText("calendarPopover.access.administrator")
).toHaveLength(2);
});
});
screen.getAllByText('calendarPopover.access.administrator')
).toHaveLength(2)
})
})
const userState = {
user: {
userData: { openpaasId: "user1", email: "user1@example.com" },
},
};
userData: { openpaasId: 'user1', email: 'user1@example.com' }
}
}
describe("AccessTab conditional rendering of CalendarAccessRights", () => {
const noop = jest.fn();
describe('AccessTab conditional rendering of CalendarAccessRights', () => {
const noop = jest.fn()
afterEach(() => jest.clearAllMocks());
afterEach(() => jest.clearAllMocks())
it("shows CalendarAccessRights when the current user owns the calendar", () => {
it('shows CalendarAccessRights when the current user owns the calendar', () => {
renderWithProviders(
<AccessTab
calendar={baseCalendar}
@@ -360,21 +358,21 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
/>,
{
...userState,
calendars: { list: { "user1/cal1": baseCalendar } },
calendars: { list: { 'user1/cal1': baseCalendar } }
}
);
)
expect(
screen.getByText("calendarPopover.access.grantAccessRights")
).toBeInTheDocument();
});
screen.getByText('calendarPopover.access.grantAccessRights')
).toBeInTheDocument()
})
it("hides CalendarAccessRights textfield for a non-owner without admin delegation", () => {
it('hides CalendarAccessRights textfield for a non-owner without admin delegation', () => {
const foreignCalendar: Calendar = {
...baseCalendar,
id: "otherUser/cal1",
invite: [],
};
id: 'otherUser/cal1',
invite: []
}
renderWithProviders(
<AccessTab
@@ -385,29 +383,29 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
/>,
{
...userState,
calendars: { list: { "otherUser/cal1": foreignCalendar } },
calendars: { list: { 'otherUser/cal1': foreignCalendar } }
}
);
)
expect(screen.queryByText("peopleSearch.label")).not.toBeInTheDocument();
expect(screen.queryByText('peopleSearch.label')).not.toBeInTheDocument()
expect(
screen.getByText("calendarPopover.access.accessRights")
).toBeInTheDocument();
});
screen.getByText('calendarPopover.access.accessRights')
).toBeInTheDocument()
})
it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => {
it('shows CalendarAccessRights when the user has access=5 (admin) delegation', () => {
const delegatedCalendar: Calendar = {
...baseCalendar,
id: "otherUser/cal1",
id: 'otherUser/cal1',
invite: [
{
href: "mailto:user1@example.com",
principal: "/principals/users/user1",
href: 'mailto:user1@example.com',
principal: '/principals/users/user1',
access: 5,
inviteStatus: 1,
},
],
};
inviteStatus: 1
}
]
}
renderWithProviders(
<AccessTab
@@ -418,78 +416,78 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
/>,
{
...userState,
calendars: { list: { "otherUser/cal1": delegatedCalendar } },
calendars: { list: { 'otherUser/cal1': delegatedCalendar } }
}
);
)
expect(
screen.getByText("calendarPopover.access.grantAccessRights")
).toBeInTheDocument();
});
});
screen.getByText('calendarPopover.access.grantAccessRights')
).toBeInTheDocument()
})
})
const existingCalendar: Calendar = {
id: "user1/cal1",
name: "Existing Cal",
description: "Desc",
color: { color: "#0062FF", dark: "#FFF" },
link: "/calendars/user/cal1",
visibility: "public",
id: 'user1/cal1',
name: 'Existing Cal',
description: 'Desc',
color: { color: '#0062FF', dark: '#FFF' },
link: '/calendars/user/cal1',
visibility: 'public',
events: {},
invite: [],
owner: { emails: ["user1@example.com"] },
};
owner: { emails: ['user1@example.com'] }
}
describe("CalendarModal updateDelegationCalendarAsync integration", () => {
const mockOnClose = jest.fn();
describe('CalendarModal updateDelegationCalendarAsync integration', () => {
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
jest
.spyOn(eventThunks, "patchACLCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'patchACLCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
.spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
})
it("does NOT call updateDelegationCalendarAsync when no users are added or removed", async () => {
it('does NOT call updateDelegationCalendarAsync when no users are added or removed', async () => {
renderWithProviders(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ ...userState, calendars: { list: { "user1/cal1": existingCalendar } } }
);
{ ...userState, calendars: { list: { 'user1/cal1': existingCalendar } } }
)
fireEvent.click(screen.getByRole("button", { name: /save/i }));
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
await waitFor(() => expect(mockOnClose).toHaveBeenCalled())
expect(
delegationThunks.updateDelegationCalendarAsync
).not.toHaveBeenCalled();
});
});
).not.toHaveBeenCalled()
})
})
describe("CalendarModal cancel button", () => {
const mockOnClose = jest.fn();
describe('CalendarModal cancel button', () => {
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
.spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
})
it("calls onClose without saving when Cancel is clicked", async () => {
it('calls onClose without saving when Cancel is clicked', async () => {
renderWithProviders(
<CalendarPopover
open={true}
@@ -497,14 +495,14 @@ describe("CalendarModal cancel button", () => {
calendar={existingCalendar}
/>,
{ ...userState }
);
)
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
await waitFor(() => expect(mockOnClose).toHaveBeenCalled())
expect(
delegationThunks.updateDelegationCalendarAsync
).not.toHaveBeenCalled();
expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled();
});
});
).not.toHaveBeenCalled()
expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled()
})
})
+258 -259
View File
@@ -1,141 +1,141 @@
import CalendarPopover from "@/components/Calendar/CalendarModal";
import { getSecretLink } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import * as eventThunks from "@/features/Calendars/services";
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import CalendarPopover from '@/components/Calendar/CalendarModal'
import { getSecretLink } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import * as eventThunks from '@/features/Calendars/services'
import * as delegationThunks from '@/features/Calendars/services/updateDelegationCalendarAsync'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/features/Calendars/CalendarApi", () => ({
getSecretLink: jest.fn(),
}));
jest.mock('@/features/Calendars/CalendarApi', () => ({
getSecretLink: jest.fn()
}))
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
jest.fn().mockImplementation(() => {
const dispatchResult = Object.assign(Promise.resolve(resolvedValue), {
unwrap: () => Promise.resolve(resolvedValue),
});
return jest.fn().mockReturnValue(dispatchResult);
});
unwrap: () => Promise.resolve(resolvedValue)
})
return jest.fn().mockReturnValue(dispatchResult)
})
describe("CalendarPopover", () => {
const mockOnClose = jest.fn();
describe('CalendarPopover', () => {
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
.spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
})
const renderPopover = (open = true) => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
},
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
}
},
calendars: { list: {}, pending: true },
};
calendars: { list: {}, pending: true }
}
renderWithProviders(
<CalendarPopover open={open} onClose={mockOnClose} />,
preloadedState
);
};
)
}
it("renders popover and inputs", () => {
renderPopover();
it('renders popover and inputs', () => {
renderPopover()
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
expect(screen.getByText("event.form.addDescription")).toBeInTheDocument();
});
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument()
expect(screen.getByText('event.form.addDescription')).toBeInTheDocument()
})
it("updates name and description fields", () => {
renderPopover();
it('updates name and description fields', () => {
renderPopover()
const nameInput = screen.getByLabelText(/Name/i);
fireEvent.change(nameInput, { target: { value: "My Calendar" } });
expect(nameInput).toHaveValue("My Calendar");
const nameInput = screen.getByLabelText(/Name/i)
fireEvent.change(nameInput, { target: { value: 'My Calendar' } })
expect(nameInput).toHaveValue('My Calendar')
fireEvent.click(screen.getByText("event.form.addDescription"));
const descInput = screen.getByLabelText(/Description/i);
fireEvent.change(descInput, { target: { value: "Test description" } });
expect(descInput).toHaveValue("Test description");
});
fireEvent.click(screen.getByText('event.form.addDescription'))
const descInput = screen.getByLabelText(/Description/i)
fireEvent.change(descInput, { target: { value: 'Test description' } })
expect(descInput).toHaveValue('Test description')
})
it("dispatches createCalendar and calls onClose when Save clicked", async () => {
it('dispatches createCalendar and calls onClose when Save clicked', async () => {
jest
.spyOn(eventThunks, "createCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'createCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
renderPopover();
renderPopover()
fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Test Calendar" },
});
fireEvent.click(screen.getByText("event.form.addDescription"));
target: { value: 'Test Calendar' }
})
fireEvent.click(screen.getByText('event.form.addDescription'))
fireEvent.change(screen.getByLabelText(/Description/i), {
target: { value: "Test Description" },
});
target: { value: 'Test Description' }
})
const colorButtons = screen.getAllByRole("button", {
name: /select color/i,
});
fireEvent.click(colorButtons[0]);
const colorButtons = screen.getAllByRole('button', {
name: /select color/i
})
fireEvent.click(colorButtons[0])
fireEvent.click(screen.getByRole("button", { name: /Create/i }));
fireEvent.click(screen.getByRole('button', { name: /Create/i }))
await waitFor(() =>
expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
);
)
await waitFor(() =>
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick")
);
});
expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick')
)
})
it("calls onClose when Cancel clicked", () => {
renderPopover();
it('calls onClose when Cancel clicked', () => {
renderPopover()
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }))
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
});
expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick')
})
})
describe("CalendarPopover (editing mode)", () => {
const mockOnClose = jest.fn();
describe('CalendarPopover (editing mode)', () => {
const mockOnClose = jest.fn()
const baseUser = {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
},
};
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
}
}
const existingCalendar: Calendar = {
id: "user1/cal1",
link: "/calendars/user/cal1",
name: "Work Calendar",
description: "Team meetings",
color: { light: "#33B679" },
owner: { firstname: "alice", emails: ["alice@example.com"] },
visibility: "public",
events: {},
};
id: 'user1/cal1',
link: '/calendars/user/cal1',
name: 'Work Calendar',
description: 'Team meetings',
color: { light: '#33B679' },
owner: { firstname: 'alice', emails: ['alice@example.com'] },
visibility: 'public',
events: {}
}
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
.spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
})
it("prefills fields when calendar prop is given", () => {
it('prefills fields when calendar prop is given', () => {
renderWithProviders(
<CalendarPopover
open={true}
@@ -143,34 +143,34 @@ describe("CalendarPopover (editing mode)", () => {
calendar={existingCalendar}
/>,
{ user: baseUser }
);
)
expect(screen.getByLabelText(/Name/i)).toHaveValue("Work Calendar");
expect(screen.getByLabelText(/Description/i)).toHaveValue("Team meetings");
});
expect(screen.getByLabelText(/Name/i)).toHaveValue('Work Calendar')
expect(screen.getByLabelText(/Description/i)).toHaveValue('Team meetings')
})
test("Save button is disabled when name is empty or whitespace only", () => {
test('Save button is disabled when name is empty or whitespace only', () => {
renderWithProviders(<CalendarPopover open={true} onClose={jest.fn()} />, {
user: baseUser,
});
user: baseUser
})
const saveButton = screen.getByRole("button", { name: /create/i });
expect(saveButton).toBeDisabled();
const saveButton = screen.getByRole('button', { name: /create/i })
expect(saveButton).toBeDisabled()
// only spaces
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: " " } });
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: ' ' } })
expect(saveButton).toBeDisabled();
expect(saveButton).toBeDisabled()
// valid name
fireEvent.change(nameInput, { target: { value: "Work Calendar" } });
expect(saveButton).toBeEnabled();
});
fireEvent.change(nameInput, { target: { value: 'Work Calendar' } })
expect(saveButton).toBeEnabled()
})
it("allows modifying and saving existing calendar", async () => {
it('allows modifying and saving existing calendar', async () => {
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
renderWithProviders(
<CalendarPopover
@@ -179,87 +179,87 @@ describe("CalendarPopover (editing mode)", () => {
calendar={existingCalendar}
/>,
{ user: baseUser }
);
)
// Change name
fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Updated Calendar" },
});
target: { value: 'Updated Calendar' }
})
// Save
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() =>
expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({
calId: "user1/cal1",
calLink: "/calendars/user/cal1",
calId: 'user1/cal1',
calLink: '/calendars/user/cal1',
patch: {
color: { light: "#33B679" },
desc: "Team meetings",
name: "Updated Calendar",
},
color: { light: '#33B679' },
desc: 'Team meetings',
name: 'Updated Calendar'
}
})
)
);
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
});
});
)
await waitFor(() => expect(mockOnClose).toHaveBeenCalled())
})
})
describe("CalendarPopover - Tabs Scenarios", () => {
const mockOnClose = jest.fn();
describe('CalendarPopover - Tabs Scenarios', () => {
const mockOnClose = jest.fn()
const baseUser = {
userData: {
openpaasId: "user1",
},
};
openpaasId: 'user1'
}
}
const writeText = jest.fn();
const writeText = jest.fn()
Object.assign(navigator, {
clipboard: {
writeText,
},
});
writeText
}
})
const existingCalendar: Calendar = {
id: "user1/cal1",
link: "/calendars/user1/cal1.json",
name: "Work Calendar",
description: "Team meetings",
color: { light: "#33B679" },
owner: { firstname: "alice", emails: ["alice@example.com"] },
visibility: "public",
events: {},
};
id: 'user1/cal1',
link: '/calendars/user1/cal1.json',
name: 'Work Calendar',
description: 'Team meetings',
color: { light: '#33B679' },
owner: { firstname: 'alice', emails: ['alice@example.com'] },
visibility: 'public',
events: {}
}
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
.spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
})
it("resets state after closing and reopening", () => {
it('resets state after closing and reopening', () => {
const { rerender } = renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />,
{ user: baseUser }
);
)
// Enter some data
fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Temp Calendar" },
});
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
target: { value: 'Temp Calendar' }
})
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }))
expect(mockOnClose).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled()
// Reopen: state should be reset
rerender(<CalendarPopover open={true} onClose={mockOnClose} />);
expect(screen.getByLabelText(/Name/i)).toHaveValue("");
});
rerender(<CalendarPopover open={true} onClose={mockOnClose} />)
expect(screen.getByLabelText(/Name/i)).toHaveValue('')
})
it("shows Access tab only when editing an existing calendar", () => {
it('shows Access tab only when editing an existing calendar', () => {
renderWithProviders(
<CalendarPopover
open={true}
@@ -267,29 +267,29 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar}
/>,
{ user: baseUser }
);
)
expect(screen.getByRole("tab", { name: /Access/i })).toBeInTheDocument();
});
expect(screen.getByRole('tab', { name: /Access/i })).toBeInTheDocument()
})
it("does not show Access tab when creating new calendar", () => {
it('does not show Access tab when creating new calendar', () => {
renderWithProviders(<CalendarPopover open={true} onClose={mockOnClose} />, {
user: baseUser,
});
user: baseUser
})
expect(
screen.queryByRole("tab", { name: /Access/i })
).not.toBeInTheDocument();
});
screen.queryByRole('tab', { name: /Access/i })
).not.toBeInTheDocument()
})
it("patches ACL when visibility changes", async () => {
it('patches ACL when visibility changes', async () => {
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
jest
.spyOn(eventThunks, "patchACLCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'patchACLCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
renderWithProviders(
<CalendarPopover
@@ -298,42 +298,42 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar}
/>,
{ user: baseUser }
);
)
// By default: "All" (public) is selected
const publicButton = screen.getByRole("button", { name: /All/i });
const privateButton = screen.getByRole("button", { name: /You/i });
const publicButton = screen.getByRole('button', { name: /All/i })
const privateButton = screen.getByRole('button', { name: /You/i })
expect(publicButton).toHaveAttribute("aria-pressed", "true");
expect(privateButton).toHaveAttribute("aria-pressed", "false");
expect(publicButton).toHaveAttribute('aria-pressed', 'true')
expect(privateButton).toHaveAttribute('aria-pressed', 'false')
// Change to private
fireEvent.click(privateButton);
fireEvent.click(privateButton)
expect(privateButton).toHaveAttribute("aria-pressed", "true");
expect(publicButton).toHaveAttribute("aria-pressed", "false");
expect(privateButton).toHaveAttribute('aria-pressed', 'true')
expect(publicButton).toHaveAttribute('aria-pressed', 'false')
// Save
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() =>
expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({
calId: "user1/cal1",
request: "",
calId: 'user1/cal1',
request: ''
})
)
);
});
)
})
it("copies CalDAV link from Access tab", async () => {
window.DAV_BASE_URL = "https://cal.example.org";
it('copies CalDAV link from Access tab', async () => {
window.DAV_BASE_URL = 'https://cal.example.org'
Object.assign(navigator, {
clipboard: { writeText: jest.fn() },
});
(getSecretLink as jest.Mock).mockResolvedValue({
secretLink: "https://example.org/secret/initial",
});
clipboard: { writeText: jest.fn() }
})
;(getSecretLink as jest.Mock).mockResolvedValue({
secretLink: 'https://example.org/secret/initial'
})
renderWithProviders(
<CalendarPopover
@@ -342,79 +342,79 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar}
/>,
{ user: baseUser }
);
)
// Switch to Access tab
fireEvent.click(screen.getByRole("tab", { name: /Access/i }));
fireEvent.click(screen.getByRole('tab', { name: /Access/i }))
// Expect text field with caldav link
const input = screen.getByLabelText("calendar.caldav_access");
expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1");
const input = screen.getByLabelText('calendar.caldav_access')
expect(input).toHaveValue('https://cal.example.org/calendars/user1/cal1')
// Click copy button (find button containing ContentCopyIcon)
const copyIcon = screen.getAllByTestId("ContentCopyIcon")[0];
const copyButton = copyIcon.closest("button");
const copyIcon = screen.getAllByTestId('ContentCopyIcon')[0]
const copyButton = copyIcon.closest('button')
if (copyButton) {
fireEvent.click(copyButton);
fireEvent.click(copyButton)
}
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"https://cal.example.org/calendars/user1/cal1"
);
'https://cal.example.org/calendars/user1/cal1'
)
// Snackbar should appear
await waitFor(() =>
expect(screen.getByText("common.link_copied")).toBeInTheDocument()
);
});
expect(screen.getByText('common.link_copied')).toBeInTheDocument()
)
})
describe("Import flow", () => {
const file = new File(["test"], "events.ics", { type: "text/calendar" });
describe('Import flow', () => {
const file = new File(['test'], 'events.ics', { type: 'text/calendar' })
it("creates a new calendar and imports events when Import with 'new' target", async () => {
jest
.spyOn(eventThunks, "createCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'createCalendarAsync')
.mockImplementation(mockThunkWithUnwrap())
jest
.spyOn(eventThunks, "importEventFromFileAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'importEventFromFileAsync')
.mockImplementation(mockThunkWithUnwrap())
renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />,
{
user: baseUser,
user: baseUser
}
);
)
// Switch to Import tab
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
fireEvent.click(screen.getByRole('tab', { name: /Import/i }))
// Provide new calendar params
fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Imported Calendar" },
});
const fileInput = screen.getByLabelText("common.select_file");
fireEvent.change(fileInput, { target: { files: [file] } });
target: { value: 'Imported Calendar' }
})
const fileInput = screen.getByLabelText('common.select_file')
fireEvent.change(fileInput, { target: { files: [file] } })
// Click Import
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.import' }))
await waitFor(() =>
expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
);
)
await waitFor(() =>
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled()
);
});
)
})
it("imports into an existing calendar when target is set", async () => {
it('imports into an existing calendar when target is set', async () => {
jest
.spyOn(eventThunks, "importEventFromFileAsync")
.mockImplementation(mockThunkWithUnwrap());
.spyOn(eventThunks, 'importEventFromFileAsync')
.mockImplementation(mockThunkWithUnwrap())
const calendars = {
"user1/cal1": existingCalendar,
};
'user1/cal1': existingCalendar
}
renderWithProviders(
<CalendarPopover
@@ -423,51 +423,50 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar}
/>,
{ user: baseUser, calendars: { list: calendars } }
);
)
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
const fileInput = screen.getByLabelText("common.select_file");
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(screen.getByRole('tab', { name: /Import/i }))
const fileInput = screen.getByLabelText('common.select_file')
fireEvent.change(fileInput, { target: { files: [file] } })
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.import' }))
await waitFor(() =>
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith(
expect.objectContaining({
calLink: "/calendars/user1/cal1.json",
file,
calLink: '/calendars/user1/cal1.json',
file
})
)
);
});
)
})
it("disables Import button until a file is uploaded", () => {
it('disables Import button until a file is uploaded', () => {
renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />,
{
user: baseUser,
user: baseUser
}
);
)
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
fireEvent.click(screen.getByRole('tab', { name: /Import/i }))
const importButton = screen.getByRole("button", {
name: "actions.import",
});
expect(importButton).toBeDisabled();
});
});
const importButton = screen.getByRole('button', {
name: 'actions.import'
})
expect(importButton).toBeDisabled()
})
})
it("fetches and resets the secret link", async () => {
window.DAV_BASE_URL = "https://cal.example.org";
(getSecretLink as jest.Mock)
it('fetches and resets the secret link', async () => {
window.DAV_BASE_URL = 'https://cal.example.org'
;(getSecretLink as jest.Mock)
.mockResolvedValueOnce({
secretLink: "https://example.org/secret/initial",
secretLink: 'https://example.org/secret/initial'
})
.mockResolvedValueOnce({
secretLink: "https://example.org/secret/new",
});
secretLink: 'https://example.org/secret/new'
})
renderWithProviders(
<CalendarPopover
@@ -476,27 +475,27 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar}
/>,
{ user: baseUser }
);
)
fireEvent.click(screen.getByRole("tab", { name: /Access/i }));
fireEvent.click(screen.getByRole('tab', { name: /Access/i }))
await waitFor(() =>
expect(
screen.getByDisplayValue("https://example.org/secret/initial")
screen.getByDisplayValue('https://example.org/secret/initial')
).toBeInTheDocument()
);
)
fireEvent.click(screen.getByRole("button", { name: /reset/i }));
fireEvent.click(screen.getByRole('button', { name: /reset/i }))
await waitFor(() =>
expect(
screen.getByDisplayValue("https://example.org/secret/new")
screen.getByDisplayValue('https://example.org/secret/new')
).toBeInTheDocument()
);
)
expect(getSecretLink).toHaveBeenCalledWith(
existingCalendar.link.replace(".json", ""),
existingCalendar.link.replace('.json', ''),
true
);
});
});
)
})
})
+355 -355
View File
@@ -1,12 +1,12 @@
import * as calAPI from "@/features/Calendars/CalendarApi";
import * as calAPI from '@/features/Calendars/CalendarApi'
import reducer, {
addEvent,
createCalendar,
removeEvent,
removeTempCal,
updateEventLocal,
} from "@/features/Calendars/CalendarSlice";
import { Calendar } from "@/features/Calendars/CalendarTypes";
updateEventLocal
} from '@/features/Calendars/CalendarSlice'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
addSharedCalendarAsync,
createCalendarAsync,
@@ -14,523 +14,523 @@ import {
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";
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");
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", () => {
describe('CalendarSlice', () => {
const initialState = {
list: {},
templist: {},
pending: false,
error: null,
};
error: null
}
beforeEach(() => {
jest.resetAllMocks();
});
jest.resetAllMocks()
})
describe("reducers", () => {
it("createCalendar adds new calendar", () => {
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");
});
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;
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 },
};
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" })
);
});
)
expect(state.list[calId].events['event1']).toEqual(
expect.objectContaining({ uid: 'event1' })
)
})
it("removeEvent deletes event", () => {
const calId = "user/cal";
it('removeEvent deletes event', () => {
const calId = 'user/cal'
const stateWithEvent = {
...initialState,
list: {
[calId]: {
id: calId,
events: { e1: { uid: "e1" } },
} as unknown as Calendar,
},
};
events: { e1: { uid: 'e1' } }
} as unknown as Calendar
}
}
const state = reducer(
stateWithEvent,
removeEvent({ calendarUid: calId, eventUid: "e1" })
);
expect(state.list[calId].events).toEqual({});
});
removeEvent({ calendarUid: calId, eventUid: 'e1' })
)
expect(state.list[calId].events).toEqual({})
})
it("updateEventLocal updates an event", () => {
const calId = "user/cal";
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,
},
};
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");
});
updateEventLocal({ calId, event: { uid: 'e1', title: 'New' } as any })
)
expect(state.list[calId].events.e1.title).toBe('New')
})
it("removeTempCal deletes temp calendar", () => {
it('removeTempCal deletes temp calendar', () => {
const stateWithTemp = {
...initialState,
templist: { temp1: { id: "temp1" } as any },
};
const state = reducer(stateWithTemp, removeTempCal("temp1"));
expect(state.templist).toEqual({});
});
});
templist: { temp1: { id: 'temp1' } as any }
}
const state = reducer(stateWithTemp, removeTempCal('temp1'))
expect(state.templist).toEqual({})
})
})
describe("extraReducers (thunks)", () => {
describe('extraReducers (thunks)', () => {
const storeFactory = () =>
configureStore({
reducer: { calendars: reducer, user: userReducer },
});
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"],
});
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({});
});
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 () => {
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/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/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: [],
},
];
_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 },
});
;(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;
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock
.mockResolvedValueOnce({
firstname: "Alice",
lastname: "Smith",
emails: ["alice@example.com"],
firstname: 'Alice',
lastname: 'Smith',
emails: ['alice@example.com']
})
.mockResolvedValueOnce({
firstname: "Bob",
lastname: "Jones",
emails: ["bob@example.com"],
firstname: 'Bob',
lastname: 'Jones',
emails: ['bob@example.com']
})
.mockResolvedValueOnce({
firstname: "Charlie",
lastname: "Brown",
emails: ["charlie@example.com"],
});
firstname: 'Charlie',
lastname: 'Brown',
emails: ['charlie@example.com']
})
const store = storeFactory();
await store.dispatch(getCalendarsListAsync() as any);
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");
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");
});
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 () => {
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/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/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: [],
},
];
_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 },
});
;(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;
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock.mockResolvedValue({
firstname: "Alice",
lastname: "Smith",
emails: ["alice@example.com"],
});
firstname: 'Alice',
lastname: 'Smith',
emails: ['alice@example.com']
})
const store = storeFactory();
await store.dispatch(getCalendarsListAsync() as any);
const store = storeFactory()
await store.dispatch(getCalendarsListAsync() as any)
expect(getUserDetailsMock).toHaveBeenCalledTimes(1);
expect(getUserDetailsMock).toHaveBeenCalledWith("u1");
});
expect(getUserDetailsMock).toHaveBeenCalledTimes(1)
expect(getUserDetailsMock).toHaveBeenCalledWith('u1')
})
it("getCalendarsListAsync processes owners in batches of 20", async () => {
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: [],
}));
'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 },
});
;(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;
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock.mockImplementation((ownerId: string) =>
Promise.resolve({
firstname: "User",
firstname: 'User',
lastname: ownerId,
emails: [`${ownerId}@example.com`],
emails: [`${ownerId}@example.com`]
})
);
)
const store = storeFactory();
await store.dispatch(getCalendarsListAsync() as any);
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);
});
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 () => {
it('getCalendarsListAsync doesnt call getUserDetails if userdata exist in store', async () => {
const existingCalendars = {
"u1/cal1": {
id: "u1/cal1",
name: "Existing Calendar",
events: {},
} as Calendar,
};
'u1/cal1': {
id: 'u1/cal1',
name: 'Existing Calendar',
events: {}
} as Calendar
}
const store = storeFactory();
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;
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);
await store.dispatch(getCalendarsListAsync() as any)
expect(getUserDetailsMock).not.toHaveBeenCalled();
expect(getUserDetailsMock).not.toHaveBeenCalled()
const state = store.getState().calendars;
expect(state.list).toEqual(existingCalendars);
});
const state = store.getState().calendars
expect(state.list).toEqual(existingCalendars)
})
it("getCalendarsListAsync handles errors in getUserDetails gracefully", async () => {
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/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/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 },
});
;(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;
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock
.mockResolvedValueOnce({
firstname: "Alice",
lastname: "Smith",
emails: ["alice@example.com"],
firstname: 'Alice',
lastname: 'Smith',
emails: ['alice@example.com']
})
.mockRejectedValueOnce(new Error("Failed to fetch user"));
.mockRejectedValueOnce(new Error('Failed to fetch user'))
const store = storeFactory();
const result = await store.dispatch(getCalendarsListAsync() as any);
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();
});
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", () => {
it('patchACLCalendarAsync.fulfilled sets visibility', () => {
const prev = {
...initialState,
list: { c1: { id: "c1", visibility: "public" } as any },
};
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: "" }
{ calId: 'c1', calLink: 'l', request: '' },
'req3',
{ calId: 'c1', calLink: 'l', request: '' }
)
);
expect(state.list.c1.visibility).toBe("private");
});
)
expect(state.list.c1.visibility).toBe('private')
})
it("createCalendarAsync.fulfilled adds a new calendar", () => {
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",
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",
};
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"] },
};
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");
});
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", () => {
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"] },
};
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",
},
};
_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,
addSharedCalendarAsync.fulfilled(payload, 'req5', {
userId: 'u1',
calId: 'c1',
cal: mockCal
})
);
expect(state.list["c1"].name).toBe("Shared");
});
)
expect(state.list['c1'].name).toBe('Shared')
})
it("getTempCalendarsListAsync.fulfilled updates templist", () => {
it('getTempCalendarsListAsync.fulfilled updates templist', () => {
const payload = {
t1: {
id: "t1",
name: "Temp",
color: { "apple:color": "#aaa" },
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,
};
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",
getTempCalendarsListAsync.fulfilled(payload, 'req7', {
openpaasId: 'u1',
color: { 'apple:color': '#aaa' },
displayName: 'test',
avatarUrl: '',
email: 'test@test.com'
})
);
expect(state.templist.t1.name).toBe("Temp");
});
)
expect(state.templist.t1.name).toBe('Temp')
})
it("getEventAsync.fulfilled adds single event", () => {
const payload = { calId: "c1", event: { uid: "e1" } as any };
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");
});
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[] };
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,
},
['c1']: {
id: 'c1',
events: {}
} as unknown as Calendar
}
},
getCalendarDetailAsync.fulfilled(payload, "req11", {
calId: "c1",
match: { start: "", end: "" },
getCalendarDetailAsync.fulfilled(payload, 'req11', {
calId: 'c1',
match: { start: '', end: '' }
})
);
expect(state.list.c1.events.e1.uid).toBe("e1");
});
)
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";
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;
title: 'Existing Recurring Event',
recurrenceId: null
} as unknown as CalendarEvent
const newEventInstance = {
uid: baseUid,
title: "Fetched Master Event",
recurrenceId: "20240115",
} as CalendarEvent;
title: 'Fetched Master Event',
recurrenceId: '20240115'
} as CalendarEvent
const stateWithEvent = {
...initialState,
list: {
c1: {
id: "c1",
events: { [`${baseUid}/20240115`]: existingEvent },
} as unknown as Calendar,
},
};
id: 'c1',
events: { [`${baseUid}/20240115`]: existingEvent }
} as unknown as Calendar
}
}
const payload = { calId: "c1", event: newEventInstance };
const payload = { calId: 'c1', event: newEventInstance }
const state = reducer(
stateWithEvent,
getEventAsync.fulfilled(payload, "req", newEventInstance)
);
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();
});
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 eventUid = 'new-event-uid'
const newEvent = {
uid: eventUid,
title: "New Event",
recurrenceId: null,
} as unknown as CalendarEvent;
title: 'New Event',
recurrenceId: null
} as unknown as CalendarEvent
const stateWithoutEvent = {
...initialState,
list: {
c1: {
id: "c1",
events: {},
} as unknown as Calendar,
},
};
id: 'c1',
events: {}
} as unknown as Calendar
}
}
const payload = { calId: "c1", event: newEvent };
const payload = { calId: 'c1', event: newEvent }
const state = reducer(
stateWithoutEvent,
getEventAsync.fulfilled(payload, "req", newEvent)
);
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");
});
});
});
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')
})
})
})
@@ -1,196 +1,196 @@
import CalendarApp from "@/components/Calendar/Calendar";
import * as calendarUtils from "@/components/Calendar/utils/calendarUtils";
import { updateSlotLabelVisibility } from "@/components/Calendar/utils/calendarUtils";
import EventPreviewModal from "@/features/Events/EventPreview";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import * as SettingsSlice from "@/features/Settings/SettingsSlice";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import CalendarApp from '@/components/Calendar/Calendar'
import * as calendarUtils from '@/components/Calendar/utils/calendarUtils'
import { updateSlotLabelVisibility } from '@/components/Calendar/utils/calendarUtils'
import EventPreviewModal from '@/features/Events/EventPreview'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import * as SettingsSlice from '@/features/Settings/SettingsSlice'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("Calendar - Timezone Integration", () => {
const mockCalendarRef = { current: null };
describe('Calendar - Timezone Integration', () => {
const mockCalendarRef = { current: null }
const baseState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "testSid",
openpaasId: "user1",
},
sub: 'test',
email: 'test@test.com',
sid: 'testSid',
openpaasId: 'user1'
}
},
calendars: {
list: {
"user1/cal1": {
id: "user1/cal1",
link: "/calendars/user1/cal1.json",
name: "Test Calendar",
description: "",
color: "#33B679",
owner: { firstname: "user1", emails: ["test@test.com"] },
visibility: "public",
events: {},
},
'user1/cal1': {
id: 'user1/cal1',
link: '/calendars/user1/cal1.json',
name: 'Test Calendar',
description: '',
color: '#33B679',
owner: { firstname: 'user1', emails: ['test@test.com'] },
visibility: 'public',
events: {}
}
},
timeZone: "America/New_York",
pending: false,
},
};
timeZone: 'America/New_York',
pending: false
}
}
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("renders TimezoneSelector in week view", async () => {
it('renders TimezoneSelector in week view', async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
baseState
);
)
// Look for timezone selector button (should show offset)
await waitFor(() => {
expect(screen.getByText(/UTC/)).toBeInTheDocument();
});
});
expect(screen.getByText(/UTC/)).toBeInTheDocument()
})
})
it("dispatches setTimeZone action when timezone is changed", async () => {
const setTimeZoneSpy = jest.spyOn(SettingsSlice, "setTimeZone");
it('dispatches setTimeZone action when timezone is changed', async () => {
const setTimeZoneSpy = jest.spyOn(SettingsSlice, 'setTimeZone')
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
baseState
);
)
// Find and click timezone selector
await waitFor(() => {
const timezoneButton = screen.getByText(/UTC/i);
fireEvent.click(timezoneButton);
const timezoneButton = screen.getByText(/UTC/i)
fireEvent.click(timezoneButton)
// Select a different timezone
const autocomplete = screen.getByRole("combobox");
fireEvent.change(autocomplete, { target: { value: "Tokyo" } });
});
const option = await screen.findByText(/Tokyo/i);
fireEvent.click(option);
const autocomplete = screen.getByRole('combobox')
fireEvent.change(autocomplete, { target: { value: 'Tokyo' } })
})
const option = await screen.findByText(/Tokyo/i)
fireEvent.click(option)
expect(setTimeZoneSpy).toHaveBeenCalledWith("Asia/Tokyo");
});
});
expect(setTimeZoneSpy).toHaveBeenCalledWith('Asia/Tokyo')
})
})
describe("Calendar - Timezone Slot Label Visibility", () => {
it("hides slot labels within 15 minutes of current time", () => {
const currentTime = new Date("2025-01-15T14:30:00Z");
const timezone = "UTC";
describe('Calendar - Timezone Slot Label Visibility', () => {
it('hides slot labels within 15 minutes of current time', () => {
const currentTime = new Date('2025-01-15T14:30:00Z')
const timezone = 'UTC'
jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay")
.mockImplementation(() => true);
.spyOn(calendarUtils, 'checkIfCurrentWeekOrDay')
.mockImplementation(() => true)
// 14:25 - within 15 minutes
const slot1425 = { text: "14:25" };
const slot1425 = { text: '14:25' }
expect(updateSlotLabelVisibility(currentTime, slot1425, timezone)).toBe(
"timegrid-slot-label-hidden"
);
'timegrid-slot-label-hidden'
)
// 14:30 - exact match
const slot1430 = { text: "14:30" };
const slot1430 = { text: '14:30' }
expect(updateSlotLabelVisibility(currentTime, slot1430, timezone)).toBe(
"timegrid-slot-label-hidden"
);
'timegrid-slot-label-hidden'
)
// 14:35 - within 15 minutes
const slot1435 = { text: "14:35" };
const slot1435 = { text: '14:35' }
expect(updateSlotLabelVisibility(currentTime, slot1435, timezone)).toBe(
"timegrid-slot-label-hidden"
);
});
'timegrid-slot-label-hidden'
)
})
it("shows slot labels more than 15 minutes from current time", () => {
it('shows slot labels more than 15 minutes from current time', () => {
jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay")
.mockImplementation(() => true);
.spyOn(calendarUtils, 'checkIfCurrentWeekOrDay')
.mockImplementation(() => true)
const currentTime = new Date("2025-01-15T14:30:00");
const timezone = "UTC";
const currentTime = new Date('2025-01-15T14:30:00')
const timezone = 'UTC'
// 14:00 - 30 minutes before
const slot1400 = { text: "14:00" };
const slot1400 = { text: '14:00' }
expect(updateSlotLabelVisibility(currentTime, slot1400, timezone)).toBe(
"fc-timegrid-slot-label"
);
'fc-timegrid-slot-label'
)
// 15:00 - 30 minutes after
const slot1500 = { text: "15:00" };
const slot1500 = { text: '15:00' }
expect(updateSlotLabelVisibility(currentTime, slot1500, timezone)).toBe(
"fc-timegrid-slot-label"
);
});
'fc-timegrid-slot-label'
)
})
it("returns visible class when not in current week/day", () => {
it('returns visible class when not in current week/day', () => {
jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay")
.mockImplementation(() => false);
const currentTime = new Date("2025-01-15T14:30:00");
const timezone = "UTC";
const slot = { text: "14:30" };
.spyOn(calendarUtils, 'checkIfCurrentWeekOrDay')
.mockImplementation(() => false)
const currentTime = new Date('2025-01-15T14:30:00')
const timezone = 'UTC'
const slot = { text: '14:30' }
// Should return visible class when not in current view
const result = updateSlotLabelVisibility(currentTime, slot, timezone);
expect(result).toBe("fc-timegrid-slot-label");
});
});
const result = updateSlotLabelVisibility(currentTime, slot, timezone)
expect(result).toBe('fc-timegrid-slot-label')
})
})
describe("EventDisplayPreview - Timezone Display", () => {
const mockOnClose = jest.fn();
describe('EventDisplayPreview - Timezone Display', () => {
const mockOnClose = jest.fn()
const baseEvent: CalendarEvent = {
uid: "event1",
title: "Team Meeting",
start: new Date("2025-01-15T14:00:00Z"),
end: new Date("2025-01-15T15:00:00Z"),
calendarId: "user1/cal1",
allday: false,
};
uid: 'event1',
title: 'Team Meeting',
start: new Date('2025-01-15T14:00:00Z'),
end: new Date('2025-01-15T15:00:00Z'),
calendarId: 'user1/cal1',
allday: false
}
const allDayEvent = {
...baseEvent,
allday: true,
start: new Date("2025-01-15"),
end: new Date("2025-01-16"),
};
start: new Date('2025-01-15'),
end: new Date('2025-01-16')
}
const baseState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "testSid",
openpaasId: "user1",
},
sub: 'test',
email: 'test@test.com',
sid: 'testSid',
openpaasId: 'user1'
}
},
calendars: {
list: {
"user1/cal1": {
id: "user1/cal1",
name: "Test Calendar",
color: "#33B679",
owner: { firstname: "user1", emails: ["test@test.com"] },
visibility: "public",
'user1/cal1': {
id: 'user1/cal1',
name: 'Test Calendar',
color: '#33B679',
owner: { firstname: 'user1', emails: ['test@test.com'] },
visibility: 'public',
events: {
event1: baseEvent,
allDayEvent,
},
},
allDayEvent
}
}
},
timeZone: "America/New_York",
pending: false,
},
};
timeZone: 'America/New_York',
pending: false
}
}
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("does not show timezone offset for all-day events", async () => {
it('does not show timezone offset for all-day events', async () => {
renderWithProviders(
<EventPreviewModal
open={true}
@@ -199,33 +199,33 @@ describe("EventDisplayPreview - Timezone Display", () => {
calId="user1/cal1"
/>,
baseState
);
)
await waitFor(() => {
const title = screen.getByText(/Team Meeting/i);
expect(title).toBeInTheDocument();
});
const title = screen.getByText(/Team Meeting/i)
expect(title).toBeInTheDocument()
})
// Should not show UTC offset
expect(screen.queryByText(/UTC[+-]\d+/i)).not.toBeInTheDocument();
});
expect(screen.queryByText(/UTC[+-]\d+/i)).not.toBeInTheDocument()
})
it("displays correct timezone offset for different timezones", async () => {
it('displays correct timezone offset for different timezones', async () => {
const timezones = [
{ tz: "America/New_York", expectedOffset: /UTC[-][45]/ },
{ tz: "Europe/Paris", expectedOffset: /UTC\+[12]/ },
{ tz: "Asia/Tokyo", expectedOffset: /UTC\+9/ },
{ tz: "Australia/Sydney", expectedOffset: /UTC\+1[01]/ },
{ tz: "Asia/Kolkata", expectedOffset: /UTC\+5:30/ },
];
{ tz: 'America/New_York', expectedOffset: /UTC[-][45]/ },
{ tz: 'Europe/Paris', expectedOffset: /UTC\+[12]/ },
{ tz: 'Asia/Tokyo', expectedOffset: /UTC\+9/ },
{ tz: 'Australia/Sydney', expectedOffset: /UTC\+1[01]/ },
{ tz: 'Asia/Kolkata', expectedOffset: /UTC\+5:30/ }
]
for (const { tz, expectedOffset } of timezones) {
const state = {
...baseState,
settings: {
timeZone: tz,
},
};
timeZone: tz
}
}
renderWithProviders(
<EventPreviewModal
@@ -235,12 +235,12 @@ describe("EventDisplayPreview - Timezone Display", () => {
onClose={mockOnClose}
/>,
state
);
)
await waitFor(() => {
const content = document.body.textContent || "";
expect(content).toMatch(expectedOffset);
});
const content = document.body.textContent || ''
expect(content).toMatch(expectedOffset)
})
}
});
});
})
})
@@ -1,134 +1,134 @@
import { TimezoneSelector } from "@/components/Calendar/TimezoneSelector";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import { TimezoneSelector } from '@/components/Calendar/TimezoneSelector'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("TimezoneSelector", () => {
const mockOnChange = jest.fn();
describe('TimezoneSelector', () => {
const mockOnChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("renders with initial timezone value", () => {
it('renders with initial timezone value', () => {
renderWithProviders(
<TimezoneSelector
referenceDate={new Date()}
value="America/New_York"
onChange={mockOnChange}
/>
);
)
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent(/UTC[-][45]/i); // New York offset
});
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button).toHaveTextContent(/UTC[-][45]/i) // New York offset
})
it("opens popover when button is clicked", async () => {
it('opens popover when button is clicked', async () => {
renderWithProviders(
<TimezoneSelector
referenceDate={new Date()}
value="Europe/Paris"
onChange={mockOnChange}
/>
);
)
const button = screen.getByRole("button");
fireEvent.click(button);
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
});
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
})
it("calls onChange when a new timezone is selected", async () => {
it('calls onChange when a new timezone is selected', async () => {
renderWithProviders(
<TimezoneSelector
referenceDate={new Date()}
value="Europe/Paris"
onChange={mockOnChange}
/>
);
)
const button = screen.getByRole("button");
fireEvent.click(button);
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
const autocomplete = screen.getByRole("combobox");
fireEvent.change(autocomplete, { target: { value: "Los Angeles" } });
const autocomplete = screen.getByRole('combobox')
fireEvent.change(autocomplete, { target: { value: 'Los Angeles' } })
// Find and click the Los Angeles option
const option = await screen.findByText(/Los Angeles/i);
fireEvent.click(option);
const option = await screen.findByText(/Los Angeles/i)
fireEvent.click(option)
expect(mockOnChange).toHaveBeenCalledWith("America/Los_Angeles");
});
expect(mockOnChange).toHaveBeenCalledWith('America/Los_Angeles')
})
it("closes popover after timezone selection", async () => {
it('closes popover after timezone selection', async () => {
renderWithProviders(
<TimezoneSelector
referenceDate={new Date()}
value="Europe/Paris"
onChange={mockOnChange}
/>
);
)
const button = screen.getByRole("button");
fireEvent.click(button);
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
const autocomplete = screen.getByRole("combobox");
fireEvent.change(autocomplete, { target: { value: "Tokyo" } });
const autocomplete = screen.getByRole('combobox')
fireEvent.change(autocomplete, { target: { value: 'Tokyo' } })
const option = await screen.findByText(/Tokyo/i);
fireEvent.click(option);
const option = await screen.findByText(/Tokyo/i)
fireEvent.click(option)
await waitFor(() => {
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
});
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
})
it("displays timezones with half-hour offsets correctly", () => {
it('displays timezones with half-hour offsets correctly', () => {
renderWithProviders(
<TimezoneSelector
referenceDate={new Date()}
value="Asia/Kolkata"
onChange={mockOnChange}
/>
);
)
const button = screen.getByRole("button");
expect(button).toHaveTextContent("UTC+5:30"); // India offset
});
const button = screen.getByRole('button')
expect(button).toHaveTextContent('UTC+5:30') // India offset
})
it("shows correct offset for Europe/Paris depending on daylight saving time", () => {
it('shows correct offset for Europe/Paris depending on daylight saving time', () => {
// Summer date (DST on)
const summerDate = new Date("2025-07-15T12:00:00Z");
const summerDate = new Date('2025-07-15T12:00:00Z')
renderWithProviders(
<TimezoneSelector
value="Europe/Paris"
onChange={mockOnChange}
referenceDate={summerDate}
/>
);
let button = screen.getByRole("button");
expect(button).toHaveTextContent(/UTC\+2\b/);
cleanup();
)
let button = screen.getByRole('button')
expect(button).toHaveTextContent(/UTC\+2\b/)
cleanup()
// Rerender with a winter date (DST off)
const winterDate = new Date("2025-01-15T12:00:00Z");
const winterDate = new Date('2025-01-15T12:00:00Z')
renderWithProviders(
<TimezoneSelector
value="Europe/Paris"
onChange={mockOnChange}
referenceDate={winterDate}
/>
);
button = screen.getByRole("button");
expect(button).toHaveTextContent(/UTC\+1\b/);
});
});
)
button = screen.getByRole('button')
expect(button).toHaveTextContent(/UTC\+1\b/)
})
})
@@ -1,152 +1,150 @@
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { addSharedCalendar } from "@/features/Calendars/CalendarApi";
import { fetchOwnerOfResource } from "@/features/Calendars/services/helpers";
import { toRejectedError } from "@/utils/errorUtils";
import { configureStore } from "@reduxjs/toolkit";
import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync'
import { addSharedCalendar } from '@/features/Calendars/CalendarApi'
import { fetchOwnerOfResource } from '@/features/Calendars/services/helpers'
import { toRejectedError } from '@/utils/errorUtils'
import { configureStore } from '@reduxjs/toolkit'
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/features/Calendars/services/helpers");
jest.mock("@/utils/errorUtils");
jest.mock('@/features/Calendars/CalendarApi')
jest.mock('@/features/Calendars/services/helpers')
jest.mock('@/utils/errorUtils')
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock;
const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock;
const mockedToRejectedError = toRejectedError as jest.Mock;
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock
const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock
const mockedToRejectedError = toRejectedError as jest.Mock
describe("addCalendarResourceAsync thunk", () => {
let store: ReturnType<typeof configureStore>;
const dispatch = jest.fn();
describe('addCalendarResourceAsync thunk', () => {
let store: ReturnType<typeof configureStore>
const dispatch = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllMocks()
store = configureStore({
reducer: () => ({}),
});
});
reducer: () => ({})
})
})
const mockPayload = {
userId: "user-123",
calId: "cal-123",
userId: 'user-123',
calId: 'cal-123',
cal: {
color: {
background: "#000000",
foreground: "#FFFFFF",
background: '#000000',
foreground: '#FFFFFF'
},
cal: {
"dav:name": "Resource Room A",
"caldav:description": "A meeting room",
'dav:name': 'Resource Room A',
'caldav:description': 'A meeting room',
_links: {
self: {
href: "/calendars/res-456/cal-123.json",
},
},
},
},
};
href: '/calendars/res-456/cal-123.json'
}
}
}
}
}
const mockResolvedResourceData = {
_id: "res-456",
id: "res-456",
name: "Resource Room A",
description: "A meeting room",
creator: "user-789",
_id: 'res-456',
id: 'res-456',
name: 'Resource Room A',
description: 'A meeting room',
creator: 'user-789',
deleted: false,
_rev: "1",
};
_rev: '1'
}
it("should add shared calendar, fetch resource details, map userData", async () => {
it('should add shared calendar, fetch resource details, map userData', async () => {
mockedFetchOwnerOfResource.mockResolvedValueOnce({
firstname: "Creator",
lastname: "User",
emails: ["creator@example.com"],
});
mockedAddSharedCalendar.mockResolvedValueOnce({});
firstname: 'Creator',
lastname: 'User',
emails: ['creator@example.com']
})
mockedAddSharedCalendar.mockResolvedValueOnce({})
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined);
)(dispatch, store.getState, undefined)
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId,
mockPayload.calId,
mockPayload.cal
);
expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456");
)
expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith('res-456')
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
expect(result.type).toBe('calendars/addCalendarResource/fulfilled')
expect(result.payload).toEqual({
calId: "res-456/cal-123",
color: { background: "#000000", foreground: "#FFFFFF" },
desc: "A meeting room",
link: "/calendars/user-123/cal-123.json",
name: "Resource Room A",
calId: 'res-456/cal-123',
color: { background: '#000000', foreground: '#FFFFFF' },
desc: 'A meeting room',
link: '/calendars/user-123/cal-123.json',
name: 'Resource Room A',
owner: {
firstname: "Creator",
lastname: "User",
emails: ["creator@example.com"],
resource: true,
},
});
});
firstname: 'Creator',
lastname: 'User',
emails: ['creator@example.com'],
resource: true
}
})
})
it("should fallback to name if resource details fetch fails", async () => {
mockedAddSharedCalendar.mockResolvedValueOnce({});
const errorDetails = new Error("Fetch failed");
mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails);
it('should fallback to name if resource details fetch fails', async () => {
mockedAddSharedCalendar.mockResolvedValueOnce({})
const errorDetails = new Error('Fetch failed')
mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails)
// Silence expected console error in tests
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined);
)(dispatch, store.getState, undefined)
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId,
mockPayload.calId,
mockPayload.cal
);
expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456");
)
expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith('res-456')
consoleSpy.mockRestore();
consoleSpy.mockRestore()
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
expect(result.type).toBe('calendars/addCalendarResource/fulfilled')
expect(result.payload).toEqual({
calId: "res-456/cal-123",
color: { background: "#000000", foreground: "#FFFFFF" },
desc: "A meeting room",
link: "/calendars/user-123/cal-123.json",
name: "Resource Room A",
calId: 'res-456/cal-123',
color: { background: '#000000', foreground: '#FFFFFF' },
desc: 'A meeting room',
link: '/calendars/user-123/cal-123.json',
name: 'Resource Room A',
owner: {
firstname: "",
lastname: "Resource Room A",
firstname: '',
lastname: 'Resource Room A',
emails: [],
resource: true,
},
});
});
resource: true
}
})
})
it("should handle error if addSharedCalendar fails", async () => {
const errorAdd = new Error("Add failed");
mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd);
const mockRejectedErrorResult = { message: "Add failed" };
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult);
it('should handle error if addSharedCalendar fails', async () => {
const errorAdd = new Error('Add failed')
mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd)
const mockRejectedErrorResult = { message: 'Add failed' }
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult)
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined);
)(dispatch, store.getState, undefined)
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId,
mockPayload.calId,
mockPayload.cal
);
expect(mockedFetchOwnerOfResource).not.toHaveBeenCalled();
)
expect(mockedFetchOwnerOfResource).not.toHaveBeenCalled()
expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd);
expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd)
expect(result.type).toBe("calendars/addCalendarResource/rejected");
expect(result.payload).toEqual(mockRejectedErrorResult);
});
});
expect(result.type).toBe('calendars/addCalendarResource/rejected')
expect(result.payload).toEqual(mockRejectedErrorResult)
})
})
@@ -1,234 +1,234 @@
import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync";
import { getOpenPaasUser } from "@/features/User/userAPI";
import { fetchOwnerData } from "@/features/Calendars/services/helpers";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { formatReduxError } from "@/utils/errorUtils";
import { normalizeCalendar } from "@/features/Calendars/utils/normalizeCalendar";
import { getCalendarsListAsync } from '@/features/Calendars/services/getCalendarsListAsync'
import { getOpenPaasUser } from '@/features/User/userAPI'
import { fetchOwnerData } from '@/features/Calendars/services/helpers'
import { getCalendars } from '@/features/Calendars/CalendarApi'
import { formatReduxError } from '@/utils/errorUtils'
import { normalizeCalendar } from '@/features/Calendars/utils/normalizeCalendar'
jest.mock("@/features/User/userAPI");
jest.mock("@/features/Calendars/services/helpers");
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/utils/errorUtils");
jest.mock("@/features/Calendars/utils/normalizeCalendar");
jest.mock("@/utils/getAccessiblePair", () => ({
getAccessiblePair: jest.fn().mockReturnValue("#FFF"),
}));
jest.mock('@/features/User/userAPI')
jest.mock('@/features/Calendars/services/helpers')
jest.mock('@/features/Calendars/CalendarApi')
jest.mock('@/utils/errorUtils')
jest.mock('@/features/Calendars/utils/normalizeCalendar')
jest.mock('@/utils/getAccessiblePair', () => ({
getAccessiblePair: jest.fn().mockReturnValue('#FFF')
}))
jest.mock("@mui/material/styles", () => ({
createTheme: jest.fn().mockReturnValue({}),
}));
jest.mock('@mui/material/styles', () => ({
createTheme: jest.fn().mockReturnValue({})
}))
const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock;
const mockedFetchOwnerData = fetchOwnerData as jest.Mock;
const mockedGetCalendars = getCalendars as jest.Mock;
const mockedFormatReduxError = formatReduxError as jest.Mock;
const mockedNormalizeCalendar = normalizeCalendar as jest.Mock;
const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock
const mockedFetchOwnerData = fetchOwnerData as jest.Mock
const mockedGetCalendars = getCalendars as jest.Mock
const mockedFormatReduxError = formatReduxError as jest.Mock
const mockedNormalizeCalendar = normalizeCalendar as jest.Mock
describe("getCalendarsListAsync", () => {
let dispatch: jest.Mock;
let getState: jest.Mock;
describe('getCalendarsListAsync', () => {
let dispatch: jest.Mock
let getState: jest.Mock
beforeEach(() => {
dispatch = jest.fn();
getState = jest.fn();
jest.clearAllMocks();
dispatch = jest.fn()
getState = jest.fn()
jest.clearAllMocks()
mockedFormatReduxError.mockImplementation((err) => {
if (err?.message) return err.message;
if (typeof err === "string") return err;
return JSON.stringify(err);
});
});
mockedFormatReduxError.mockImplementation(err => {
if (err?.message) return err.message
if (typeof err === 'string') return err
return JSON.stringify(err)
})
})
it("should handle successful execution and merge with existing calendars", async () => {
it('should handle successful execution and merge with existing calendars', async () => {
getState.mockReturnValue({
calendars: {
list: {
"cal-existing": {
id: "cal-existing",
color: { light: "red", dark: "#FFF" },
events: { "event-1": {} },
},
},
'cal-existing': {
id: 'cal-existing',
color: { light: 'red', dark: '#FFF' },
events: { 'event-1': {} }
}
}
},
user: {
userData: { openpaasId: "user-123" },
},
});
userData: { openpaasId: 'user-123' }
}
})
const mockCalendarsResponse = {
_embedded: {
"dav:calendar": [{ id: "cal-existing" }, { id: "cal-new" }],
},
};
mockedGetCalendars.mockResolvedValue(mockCalendarsResponse);
'dav:calendar': [{ id: 'cal-existing' }, { id: 'cal-new' }]
}
}
mockedGetCalendars.mockResolvedValue(mockCalendarsResponse)
mockedNormalizeCalendar
.mockReturnValueOnce({
cal: { "dav:name": "Existing Cal", "apple:color": "blue" },
id: "cal-existing",
ownerId: "user-123",
description: "old cal",
cal: { 'dav:name': 'Existing Cal', 'apple:color': 'blue' },
id: 'cal-existing',
ownerId: 'user-123',
description: 'old cal',
delegated: false,
link: "/link/1",
link: '/link/1',
visibility: 1,
access: 3,
access: 3
})
.mockReturnValueOnce({
cal: { "dav:name": "New Cal" },
id: "cal-new",
ownerId: "user-456",
description: "new cal",
cal: { 'dav:name': 'New Cal' },
id: 'cal-new',
ownerId: 'user-456',
description: 'new cal',
delegated: true,
link: "/link/2",
link: '/link/2',
visibility: 2,
access: 2,
invite: [{ href: "", principal: "", access: 3, inviteStatus: 1 }],
});
invite: [{ href: '', principal: '', access: 3, inviteStatus: 1 }]
})
mockedFetchOwnerData
.mockResolvedValueOnce({
firstname: "John",
lastname: "Doe",
emails: ["john@example.com"],
firstname: 'John',
lastname: 'Doe',
emails: ['john@example.com']
})
.mockResolvedValueOnce({
firstname: "Jane",
lastname: "Smith",
emails: ["jane@example.com"],
});
firstname: 'Jane',
lastname: 'Smith',
emails: ['jane@example.com']
})
const thunk = getCalendarsListAsync();
const result = await thunk(dispatch, getState, undefined);
const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined)
expect(result.type).toBe("calendars/getCalendars/fulfilled");
expect(result.type).toBe('calendars/getCalendars/fulfilled')
const payload = result.payload as {
importedCalendars: any;
errors: string;
};
importedCalendars: any
errors: string
}
expect(payload.errors).toBe("");
expect(Object.keys(payload.importedCalendars)).toHaveLength(2);
expect(payload.errors).toBe('')
expect(Object.keys(payload.importedCalendars)).toHaveLength(2)
expect(payload.importedCalendars["cal-existing"]).toMatchObject({
id: "cal-existing",
color: { light: "blue", dark: "#FFF" },
events: { "event-1": {} },
});
expect(payload.importedCalendars['cal-existing']).toMatchObject({
id: 'cal-existing',
color: { light: 'blue', dark: '#FFF' },
events: { 'event-1': {} }
})
expect(payload.importedCalendars["cal-new"]).toMatchObject({
id: "cal-new",
name: "New Cal",
owner: { firstname: "Jane", lastname: "Smith" },
});
expect(payload.importedCalendars['cal-new']).toMatchObject({
id: 'cal-new',
name: 'New Cal',
owner: { firstname: 'Jane', lastname: 'Smith' }
})
expect(mockedGetOpenPaasUser).not.toHaveBeenCalled(); // User ID existed in state
expect(mockedGetCalendars).toHaveBeenCalledWith("user-123");
});
expect(mockedGetOpenPaasUser).not.toHaveBeenCalled() // User ID existed in state
expect(mockedGetCalendars).toHaveBeenCalledWith('user-123')
})
it("should fetch user using getOpenPaasUser if openpaasId is not in state", async () => {
getState.mockReturnValue({ calendars: {}, user: {} });
mockedGetOpenPaasUser.mockResolvedValue({ id: "fetched-user-123" });
mockedGetCalendars.mockResolvedValue({ _embedded: { "dav:calendar": [] } });
it('should fetch user using getOpenPaasUser if openpaasId is not in state', async () => {
getState.mockReturnValue({ calendars: {}, user: {} })
mockedGetOpenPaasUser.mockResolvedValue({ id: 'fetched-user-123' })
mockedGetCalendars.mockResolvedValue({ _embedded: { 'dav:calendar': [] } })
const thunk = getCalendarsListAsync();
await thunk(dispatch, getState, undefined);
const thunk = getCalendarsListAsync()
await thunk(dispatch, getState, undefined)
expect(mockedGetOpenPaasUser).toHaveBeenCalled();
expect(mockedGetCalendars).toHaveBeenCalledWith("fetched-user-123");
});
expect(mockedGetOpenPaasUser).toHaveBeenCalled()
expect(mockedGetCalendars).toHaveBeenCalledWith('fetched-user-123')
})
it("should handle error when API call fails", async () => {
getState.mockReturnValue({ calendars: {}, user: {} });
mockedGetOpenPaasUser.mockResolvedValue({ id: "fetched-user-123" });
it('should handle error when API call fails', async () => {
getState.mockReturnValue({ calendars: {}, user: {} })
mockedGetOpenPaasUser.mockResolvedValue({ id: 'fetched-user-123' })
mockedGetCalendars.mockRejectedValue({
response: { status: 500 },
message: "Server Error",
});
message: 'Server Error'
})
// toRejectedError is imported from a mocked module; provide the expected return
const { toRejectedError } = jest.requireMock("@/utils/errorUtils") as {
toRejectedError: jest.Mock;
};
const { toRejectedError } = jest.requireMock('@/utils/errorUtils') as {
toRejectedError: jest.Mock
}
toRejectedError.mockReturnValueOnce({
status: 500,
message: "Server Error",
});
message: 'Server Error'
})
const thunk = getCalendarsListAsync();
const result = await thunk(dispatch, getState, undefined);
const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined)
expect(result.type).toBe("calendars/getCalendars/rejected");
expect(result.type).toBe('calendars/getCalendars/rejected')
expect(result.payload).toEqual({
status: 500,
message: "Server Error",
});
});
message: 'Server Error'
})
})
it("should handle error when fetching owner data fails", async () => {
it('should handle error when fetching owner data fails', async () => {
getState.mockReturnValue({
calendars: {},
user: { userData: { openpaasId: "user-123" } },
});
user: { userData: { openpaasId: 'user-123' } }
})
mockedGetCalendars.mockResolvedValue({
_embedded: { "dav:calendar": [{ id: "cal-1" }] },
});
_embedded: { 'dav:calendar': [{ id: 'cal-1' }] }
})
mockedNormalizeCalendar.mockReturnValue({
cal: { "dav:name": "Error Cal" },
id: "cal-1",
ownerId: "error-123",
});
cal: { 'dav:name': 'Error Cal' },
id: 'cal-1',
ownerId: 'error-123'
})
// fetchOwnerData fails
mockedFetchOwnerData.mockRejectedValueOnce(new Error("Network Error"));
mockedFetchOwnerData.mockRejectedValueOnce(new Error('Network Error'))
const thunk = getCalendarsListAsync();
const result = await thunk(dispatch, getState, undefined);
const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined)
const payload = result.payload as any;
expect(mockedFetchOwnerData).toHaveBeenCalledWith("error-123");
expect(payload.importedCalendars["cal-1"].owner).toEqual({
firstname: "",
lastname: "Unknown User",
emails: [],
});
const payload = result.payload as any
expect(mockedFetchOwnerData).toHaveBeenCalledWith('error-123')
expect(payload.importedCalendars['cal-1'].owner).toEqual({
firstname: '',
lastname: 'Unknown User',
emails: []
})
// Errors array should contain the error
expect(payload.errors).toContain("Network Error");
});
expect(payload.errors).toContain('Network Error')
})
it("should return owner data mapping properly (including resource: true)", async () => {
it('should return owner data mapping properly (including resource: true)', async () => {
getState.mockReturnValue({
calendars: {},
user: { userData: { openpaasId: "user-123" } },
});
user: { userData: { openpaasId: 'user-123' } }
})
mockedGetCalendars.mockResolvedValue({
_embedded: { "dav:calendar": [{ id: "cal-1" }] },
});
_embedded: { 'dav:calendar': [{ id: 'cal-1' }] }
})
mockedNormalizeCalendar.mockReturnValue({
cal: { "dav:name": "Resource Cal" },
id: "cal-1",
ownerId: "resource-123",
});
cal: { 'dav:name': 'Resource Cal' },
id: 'cal-1',
ownerId: 'resource-123'
})
// fetchOwnerData succeeds and returns a resource config structure
mockedFetchOwnerData.mockResolvedValueOnce({
firstname: "Creator",
lastname: "User",
firstname: 'Creator',
lastname: 'User',
emails: [],
resource: true,
});
resource: true
})
const thunk = getCalendarsListAsync();
const result = await thunk(dispatch, getState, undefined);
const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined)
const payload = result.payload as any;
expect(mockedFetchOwnerData).toHaveBeenCalledWith("resource-123");
expect(payload.importedCalendars["cal-1"].owner).toEqual({
firstname: "Creator",
lastname: "User",
const payload = result.payload as any
expect(mockedFetchOwnerData).toHaveBeenCalledWith('resource-123')
expect(payload.importedCalendars['cal-1'].owner).toEqual({
firstname: 'Creator',
lastname: 'User',
emails: [],
resource: true,
});
});
});
resource: true
})
})
})
@@ -1,90 +1,90 @@
import { fetchOwnerData } from "@/features/Calendars/services/helpers";
import { getResourceDetails, getUserDetails } from "@/features/User/userAPI";
import { fetchOwnerData } from '@/features/Calendars/services/helpers'
import { getResourceDetails, getUserDetails } from '@/features/User/userAPI'
jest.mock("@/features/User/userAPI");
jest.mock('@/features/User/userAPI')
const mockedGetUserDetails = getUserDetails as jest.Mock;
const mockedGetResourceDetails = getResourceDetails as jest.Mock;
const mockedGetUserDetails = getUserDetails as jest.Mock
const mockedGetResourceDetails = getResourceDetails as jest.Mock
describe("helpers", () => {
describe('helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
describe("fetchOwnerData", () => {
it("should return user details successfully", async () => {
describe('fetchOwnerData', () => {
it('should return user details successfully', async () => {
const mockUser = {
firstname: "John",
lastname: "Doe",
emails: ["john@example.com"],
};
mockedGetUserDetails.mockResolvedValueOnce(mockUser);
firstname: 'John',
lastname: 'Doe',
emails: ['john@example.com']
}
mockedGetUserDetails.mockResolvedValueOnce(mockUser)
const result = await fetchOwnerData("user-123");
const result = await fetchOwnerData('user-123')
expect(mockedGetUserDetails).toHaveBeenCalledWith("user-123");
expect(mockedGetResourceDetails).not.toHaveBeenCalled();
expect(result).toEqual(mockUser);
});
expect(mockedGetUserDetails).toHaveBeenCalledWith('user-123')
expect(mockedGetResourceDetails).not.toHaveBeenCalled()
expect(result).toEqual(mockUser)
})
it("should fetch resource details and its creator when user is not found", async () => {
const mockResource = { creator: "creator-456" };
it('should fetch resource details and its creator when user is not found', async () => {
const mockResource = { creator: 'creator-456' }
const mockCreator = {
firstname: "Creator",
lastname: "User",
emails: ["creator@example.com"],
};
firstname: 'Creator',
lastname: 'User',
emails: ['creator@example.com']
}
// Mock getUserDetails to fail with 404 for the initial call
mockedGetUserDetails.mockRejectedValueOnce({
response: { status: 404 },
});
response: { status: 404 }
})
// Mock getResourceDetails to succeed and return a creator ID
mockedGetResourceDetails.mockResolvedValueOnce(mockResource);
mockedGetResourceDetails.mockResolvedValueOnce(mockResource)
// Mock getUserDetails to succeed when called for the creator
mockedGetUserDetails.mockResolvedValueOnce(mockCreator);
mockedGetUserDetails.mockResolvedValueOnce(mockCreator)
const result = await fetchOwnerData("resource-123");
const result = await fetchOwnerData('resource-123')
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, "resource-123");
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, "creator-456");
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, 'resource-123')
expect(mockedGetResourceDetails).toHaveBeenCalledWith('resource-123')
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, 'creator-456')
expect(result).toEqual({
...mockCreator,
resource: true,
administrators: undefined,
resourceIcon: undefined,
});
});
resourceIcon: undefined
})
})
it("should throw error when getUserDetails fails with non-404 error", async () => {
const mockError = { response: { status: 500 } };
mockedGetUserDetails.mockRejectedValueOnce(mockError);
it('should throw error when getUserDetails fails with non-404 error', async () => {
const mockError = { response: { status: 500 } }
mockedGetUserDetails.mockRejectedValueOnce(mockError)
await expect(fetchOwnerData("user-123")).rejects.toEqual(mockError);
await expect(fetchOwnerData('user-123')).rejects.toEqual(mockError)
expect(mockedGetUserDetails).toHaveBeenCalledWith("user-123");
expect(mockedGetResourceDetails).not.toHaveBeenCalled();
});
expect(mockedGetUserDetails).toHaveBeenCalledWith('user-123')
expect(mockedGetResourceDetails).not.toHaveBeenCalled()
})
it("should throw error when getResourceDetails fails", async () => {
const mockError = new Error("Resource not found");
it('should throw error when getResourceDetails fails', async () => {
const mockError = new Error('Resource not found')
// Mock getUserDetails to fail with 404 for the initial call
mockedGetUserDetails.mockRejectedValueOnce({
response: { status: 404 },
});
response: { status: 404 }
})
// Mock getResourceDetails to fail
mockedGetResourceDetails.mockRejectedValueOnce(mockError);
mockedGetResourceDetails.mockRejectedValueOnce(mockError)
await expect(fetchOwnerData("resource-123")).rejects.toEqual(mockError);
await expect(fetchOwnerData('resource-123')).rejects.toEqual(mockError)
expect(mockedGetUserDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetUserDetails).toHaveBeenCalledTimes(1); // Only called once
});
});
});
expect(mockedGetUserDetails).toHaveBeenCalledWith('resource-123')
expect(mockedGetResourceDetails).toHaveBeenCalledWith('resource-123')
expect(mockedGetUserDetails).toHaveBeenCalledTimes(1) // Only called once
})
})
})
File diff suppressed because it is too large Load Diff
+26 -26
View File
@@ -1,35 +1,35 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
describe("EventErrorHandler", () => {
it("calls the callback when a new error is reported", () => {
const handler = new EventErrorHandler();
const callback = jest.fn();
describe('EventErrorHandler', () => {
it('calls the callback when a new error is reported', () => {
const handler = new EventErrorHandler()
const callback = jest.fn()
handler.setErrorCallback(callback);
handler.reportError("123", "Something broke");
handler.setErrorCallback(callback)
handler.reportError('123', 'Something broke')
expect(callback).toHaveBeenCalledWith(["Something broke"]);
});
expect(callback).toHaveBeenCalledWith(['Something broke'])
})
it("does not duplicate errors for same eventId", () => {
const handler = new EventErrorHandler();
const callback = jest.fn();
it('does not duplicate errors for same eventId', () => {
const handler = new EventErrorHandler()
const callback = jest.fn()
handler.setErrorCallback(callback);
handler.reportError("A", "Error 1");
handler.reportError("A", "Error 1 again");
handler.setErrorCallback(callback)
handler.reportError('A', 'Error 1')
handler.reportError('A', 'Error 1 again')
expect(callback).toHaveBeenCalledTimes(1);
});
expect(callback).toHaveBeenCalledTimes(1)
})
it("clears errors properly", () => {
const handler = new EventErrorHandler();
const callback = jest.fn();
it('clears errors properly', () => {
const handler = new EventErrorHandler()
const callback = jest.fn()
handler.setErrorCallback(callback);
handler.reportError("A", "Error 1");
handler.clearAll();
handler.setErrorCallback(callback)
handler.reportError('A', 'Error 1')
handler.clearAll()
expect(callback).toHaveBeenLastCalledWith([]);
});
});
expect(callback).toHaveBeenLastCalledWith([])
})
})
@@ -1,183 +1,181 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AttendanceValidation } from "@/features/Events/AttendanceValidation/AttendanceValidation";
import { ContextualizedEvent } from "@/features/Events/EventsTypes";
import { userAttendee } from "@/features/User/models/attendee";
import { userData } from "@/features/User/userDataTypes";
import { render } from "@testing-library/react";
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { AttendanceValidation } from '@/features/Events/AttendanceValidation/AttendanceValidation'
import { ContextualizedEvent } from '@/features/Events/EventsTypes'
import { userAttendee } from '@/features/User/models/attendee'
import { userData } from '@/features/User/userDataTypes'
import { render } from '@testing-library/react'
jest.mock("twake-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
jest.mock('twake-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
jest.mock("@/app/hooks", () => ({
jest.mock('@/app/hooks', () => ({
useAppDispatch: () => jest.fn(),
useAppSelector: jest.fn(),
}));
useAppSelector: jest.fn()
}))
const makeUser = (email: string): userData => ({
email,
given_name: "Alice",
family_name: "User",
name: "Alice User",
sid: "user1",
sub: "user1",
});
given_name: 'Alice',
family_name: 'User',
name: 'Alice User',
sid: 'user1',
sub: 'user1'
})
const makeContext = (
overrides: Partial<ContextualizedEvent> = {}
): ContextualizedEvent =>
({
event: {
uid: "event-1",
calId: "user1/cal1",
title: "Test",
start: "2024-01-15T10:00:00",
end: "2024-01-15T11:00:00",
organizer: { cal_address: "owner@example.com" },
attendee: [
{ cal_address: "owner@example.com", partstat: "NEEDS-ACTION" },
],
uid: 'event-1',
calId: 'user1/cal1',
title: 'Test',
start: '2024-01-15T10:00:00',
end: '2024-01-15T11:00:00',
organizer: { cal_address: 'owner@example.com' },
attendee: [{ cal_address: 'owner@example.com', partstat: 'NEEDS-ACTION' }]
},
calendar: {
id: "user1/cal1",
name: "Test",
id: 'user1/cal1',
name: 'Test',
delegated: false,
owner: { emails: ["alice@example.com"] },
events: {},
owner: { emails: ['alice@example.com'] },
events: {}
},
currentUserAttendee: {
cal_address: "alice@example.com",
partstat: "NEEDS-ACTION",
cal_address: 'alice@example.com',
partstat: 'NEEDS-ACTION'
},
isOwn: true,
isOrganizer: false,
isRecurring: false,
...overrides,
}) as unknown as ContextualizedEvent;
...overrides
}) as unknown as ContextualizedEvent
const noopSetFunc = jest.fn();
const noopSetFunc = jest.fn()
describe("AttendanceValidation - delegation", () => {
describe("non-delegated calendar", () => {
it("renders when isOwn and currentUserAttendee exists", () => {
describe('AttendanceValidation - delegation', () => {
describe('non-delegated calendar', () => {
it('renders when isOwn and currentUserAttendee exists', () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={makeContext({ isOwn: true })}
user={makeUser("alice@example.com")}
user={makeUser('alice@example.com')}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(container.firstChild).not.toBeNull();
});
)
expect(container.firstChild).not.toBeNull()
})
it("returns null when not isOwn and not delegated", () => {
it('returns null when not isOwn and not delegated', () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={makeContext({ isOwn: false })}
user={makeUser("other@example.com")}
user={makeUser('other@example.com')}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(container.firstChild).toBeNull();
});
});
)
expect(container.firstChild).toBeNull()
})
})
describe("delegated calendar", () => {
describe('delegated calendar', () => {
const delegatedContext = makeContext({
isOwn: false, // logged-in user is not the owner
calendar: {
id: "user2/cal1",
name: "Delegated Cal",
id: 'user2/cal1',
name: 'Delegated Cal',
delegated: true,
owner: { emails: ["owner@example.com"] },
events: {},
owner: { emails: ['owner@example.com'] },
events: {}
} as Calendar,
currentUserAttendee: {
cal_address: "owner@example.com",
partstat: "NEEDS-ACTION",
cal_address: 'owner@example.com',
partstat: 'NEEDS-ACTION'
} as userAttendee,
event: { class: "PUBLIC" },
});
event: { class: 'PUBLIC' }
})
it("renders even when isOwn is false", () => {
it('renders even when isOwn is false', () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={delegatedContext}
user={makeUser("alice@example.com")}
user={makeUser('alice@example.com')}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(container.firstChild).not.toBeNull();
});
)
expect(container.firstChild).not.toBeNull()
})
it("renders regardless of currentUserAttendee presence", () => {
it('renders regardless of currentUserAttendee presence', () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={{
...delegatedContext,
currentUserAttendee: undefined,
currentUserAttendee: undefined
}}
user={makeUser("alice@example.com")}
user={makeUser('alice@example.com')}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
)
// delegated flag alone should keep it visible
expect(container.firstChild).not.toBeNull();
});
});
expect(container.firstChild).not.toBeNull()
})
})
describe("resource calendar", () => {
describe('resource calendar', () => {
const resourceContext = makeContext({
isOwn: false,
calendar: {
id: "res1/cal1",
name: "Resource Cal",
id: 'res1/cal1',
name: 'Resource Cal',
delegated: false,
owner: {
emails: ["resource@example.com"],
emails: ['resource@example.com'],
resource: true,
administrators: [{ id: "admin1" }],
administrators: [{ id: 'admin1' }]
},
events: {},
} as Calendar,
});
events: {}
} as Calendar
})
it("renders when user is an administrator of the resource", () => {
it('renders when user is an administrator of the resource', () => {
const { getByText } = render(
<AttendanceValidation
contextualizedEvent={resourceContext}
user={
{
...makeUser("admin@example.com"),
openpaasId: "admin1",
...makeUser('admin@example.com'),
openpaasId: 'admin1'
} as userData
}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(getByText("eventPreview.authorizeQuestion")).toBeTruthy();
});
)
expect(getByText('eventPreview.authorizeQuestion')).toBeTruthy()
})
it("returns null when user is not an administrator of the resource", () => {
it('returns null when user is not an administrator of the resource', () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={resourceContext}
user={
{
...makeUser("other@example.com"),
openpaasId: "other1",
...makeUser('other@example.com'),
openpaasId: 'other1'
} as userData
}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(container.firstChild).toBeNull();
});
});
});
)
expect(container.firstChild).toBeNull()
})
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,131 +1,131 @@
import * as eventThunks from "@/features/Calendars/services";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as eventThunks from '@/features/Calendars/services'
import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("Event Full Display — delegated calendar move", () => {
const mockOnClose = jest.fn();
describe('Event Full Display — delegated calendar move', () => {
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
const testDate = new Date("2025-01-15T10:00:00.000Z");
const testEndDate = new Date("2025-01-15T11:00:00.000Z");
const testDate = new Date('2025-01-15T10:00:00.000Z')
const testEndDate = new Date('2025-01-15T11:00:00.000Z')
const baseUserState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: 'user1'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
},
};
cn: 'test',
cal_address: 'test@test.com'
}
}
}
const preloadedDelegatedCals = {
...baseUserState,
calendars: {
pending: false,
list: {
"user1/cal1": {
id: "user1/cal1",
name: "Calendar One",
color: "#FF0000",
'user1/cal1': {
id: 'user1/cal1',
name: 'Calendar One',
color: '#FF0000',
events: {
event1: {
uid: "event1",
title: "Test Event",
calId: "user1/cal1",
uid: 'event1',
title: 'Test Event',
calId: 'user1/cal1',
start: testDate.toISOString(),
end: testEndDate.toISOString(),
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: "/calendar/user1/cal1/event1.ics",
},
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: '/calendar/user1/cal1/event1.ics'
}
},
link: "/calendar/user1/cal1.json",
link: '/calendar/user1/cal1.json'
},
"user2/cal2": {
id: "user2/cal2",
name: "Delegated Calendar",
color: "#00FF00",
'user2/cal2': {
id: 'user2/cal2',
name: 'Delegated Calendar',
color: '#00FF00',
delegated: true,
access: { write: true },
owner: {
emails: ["delegate@test.com"],
name: "Delegate User",
emails: ['delegate@test.com'],
name: 'Delegate User'
},
events: {},
link: "/calendar/user2/cal2.json",
},
},
},
};
link: '/calendar/user2/cal2.json'
}
}
}
}
it("calls deleteEventAsync and putEventAsync — and NOT moveEventAsync — when moving to a delegated calendar", async () => {
it('calls deleteEventAsync and putEventAsync — and NOT moveEventAsync — when moving to a delegated calendar', async () => {
const spyPut = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const spyDelete = jest
.spyOn(eventThunks, "deleteEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'deleteEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const spyMove = jest
.spyOn(eventThunks, "moveEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'moveEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
await act(async () =>
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"user1/cal1"}
eventId={"event1"}
calId={'user1/cal1'}
eventId={'event1'}
/>,
preloadedDelegatedCals
)
);
)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const calendarSelect = screen.getByLabelText("event.form.calendar");
await act(async () => fireEvent.mouseDown(calendarSelect));
const option = await screen.findByText("Delegated Calendar");
fireEvent.click(option);
const calendarSelect = screen.getByLabelText('event.form.calendar')
await act(async () => fireEvent.mouseDown(calendarSelect))
const option = await screen.findByText('Delegated Calendar')
fireEvent.click(option)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
});
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
})
await waitFor(
() => {
expect(spyPut).toHaveBeenCalled();
expect(spyPut).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
await waitFor(
() => {
expect(spyDelete).toHaveBeenCalled();
expect(spyDelete).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
expect(spyMove).not.toHaveBeenCalled();
});
});
expect(spyMove).not.toHaveBeenCalled()
})
})
+233 -233
View File
@@ -1,422 +1,422 @@
import { InfoRow } from "@/components/Event/InfoRow";
import { LONG_DATE_FORMAT } from "@/components/Event/utils/dateTimeFormatters";
import * as eventThunks from "@/features/Calendars/services";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import dayjs from "dayjs";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import { InfoRow } from '@/components/Event/InfoRow'
import { LONG_DATE_FORMAT } from '@/components/Event/utils/dateTimeFormatters'
import * as eventThunks from '@/features/Calendars/services'
import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import dayjs from 'dayjs'
import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("Event Full Display", () => {
const mockOnClose = jest.fn();
const day = new Date("2025-01-15T10:00:00.000Z"); // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC
describe('Event Full Display', () => {
const mockOnClose = jest.fn()
const day = new Date('2025-01-15T10:00:00.000Z') // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
cn: 'test',
cal_address: 'test@test.com'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "First Calendar",
color: "#FF0000",
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'First Calendar',
color: '#FF0000',
events: {
event1: {
uid: "event1",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
uid: 'event1',
title: 'Test Event',
calId: '667037022b752d0026472254/cal1',
start: day.toISOString(),
end: day.toISOString(),
organizer: { cn: "test", cal_address: "test@test.com" },
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [
{
cn: "test",
cal_address: "test@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cn: 'test',
cal_address: 'test@test.com',
partstat: 'NEEDS-ACTION',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL'
},
{
cn: "John",
cal_address: "john@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
],
},
},
cn: 'John',
cal_address: 'john@test.com',
partstat: 'NEEDS-ACTION',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL'
}
]
}
}
},
"otherCal/cal": {
id: "otherCal/cal",
name: "Calendar 1",
color: "#FF0000",
'otherCal/cal': {
id: 'otherCal/cal',
name: 'Calendar 1',
color: '#FF0000',
events: {
event1: {
uid: "event1",
calId: "otherCal/cal",
title: "Test Event Other cal",
uid: 'event1',
calId: 'otherCal/cal',
title: 'Test Event Other cal',
start: day.toISOString(),
end: day.toISOString(),
organizer: { cn: "john", cal_address: "john@test.com" },
},
},
},
},
},
};
organizer: { cn: 'john', cal_address: 'john@test.com' }
}
}
}
}
}
}
it("renders correctly event data with fixed timezone", () => {
it('renders correctly event data with fixed timezone', () => {
// Use fixed timezone UTC for consistent test results across all environments
const fixedDate = new Date("2025-01-15T10:00:00.000Z"); // 10AM UTC
const endDate = new Date(fixedDate.getTime() + 3600000); // 11AM UTC
const fixedDate = new Date('2025-01-15T10:00:00.000Z') // 10AM UTC
const endDate = new Date(fixedDate.getTime() + 3600000) // 11AM UTC
// With UTC timezone set, formatLocalDateTime produces predictable values
const expectedStart = "2025-01-15T10:00"; // 10:00 AM UTC
const expectedEnd = "2025-01-15T11:00"; // 11:00 AM UTC
const expectedStart = '2025-01-15T10:00' // 10:00 AM UTC
const expectedEnd = '2025-01-15T11:00' // 11:00 AM UTC
const stateWithFixedDate = {
...preloadedState,
calendars: {
pending: false,
list: {
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
'667037022b752d0026472254/cal1': {
...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
'667037022b752d0026472254/cal1'
].events.event1,
start: fixedDate.toISOString(),
end: endDate.toISOString(),
timezone: "UTC",
},
},
},
},
},
};
timezone: 'UTC'
}
}
}
}
}
}
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
stateWithFixedDate
);
)
expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument();
expect(screen.getByDisplayValue('Test Event')).toBeInTheDocument()
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const startDateInput = screen.getByTestId("start-date-input");
const startTimeInput = screen.getByTestId("start-time-input");
const endTimeInput = screen.getByTestId("end-time-input");
const startDateInput = screen.getByTestId('start-date-input')
const startTimeInput = screen.getByTestId('start-time-input')
const endTimeInput = screen.getByTestId('end-time-input')
expect(startDateInput).toBeInTheDocument();
expect(startTimeInput).toBeInTheDocument();
expect(endTimeInput).toBeInTheDocument();
expect(startDateInput).toBeInTheDocument()
expect(startTimeInput).toBeInTheDocument()
expect(endTimeInput).toBeInTheDocument()
expect(startDateInput).toHaveValue(
dayjs(expectedStart).format(LONG_DATE_FORMAT)
);
expect(startTimeInput).toHaveValue(dayjs(expectedStart).format("HH:mm"));
)
expect(startTimeInput).toHaveValue(dayjs(expectedStart).format('HH:mm'))
expect(endTimeInput).toHaveValue(dayjs(expectedEnd).format("HH:mm"));
expect(endTimeInput).toHaveValue(dayjs(expectedEnd).format('HH:mm'))
expect(screen.getByText("First Calendar")).toBeInTheDocument();
});
it("calls onClose when Cancel clicked", () => {
expect(screen.getByText('First Calendar')).toBeInTheDocument()
})
it('calls onClose when Cancel clicked', () => {
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
preloadedState
);
fireEvent.click(screen.getAllByTestId("CloseIcon")[0]);
)
fireEvent.click(screen.getAllByTestId('CloseIcon')[0])
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("toggle Show More reveals extra fields", async () => {
expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick')
})
it('toggle Show More reveals extra fields', async () => {
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
preloadedState
);
)
act(() => {
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
});
screen.getByRole('button', { name: 'common.moreOptions' })
)
})
await waitFor(() => {
expect(screen.getByText("event.form.notification")).toBeInTheDocument();
});
expect(screen.getByText('event.form.notification')).toBeInTheDocument()
})
// EventDisplay modal doesn't have Repeat checkbox, only RepeatEvent component
// which shows repetition settings when repetition data exists
// Since test event has no repetition data, RepeatEvent component won't show Repeat checkbox
fireEvent.click(screen.getByRole("button", { name: /Show Less/i }));
});
fireEvent.click(screen.getByRole('button', { name: /Show Less/i }))
})
it("can edit title when user is organizer", () => {
it('can edit title when user is organizer', () => {
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
preloadedState
);
const titleField = screen.getByLabelText("event.form.title");
fireEvent.change(titleField, { target: { value: "New Title" } });
expect(screen.getByDisplayValue("New Title")).toBeInTheDocument();
});
it("toggle all-day updates end date correctly", () => {
)
const titleField = screen.getByLabelText('event.form.title')
fireEvent.change(titleField, { target: { value: 'New Title' } })
expect(screen.getByDisplayValue('New Title')).toBeInTheDocument()
})
it('toggle all-day updates end date correctly', () => {
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
preloadedState
);
)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
fireEvent.click(allDayCheckbox);
expect(allDayCheckbox).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const allDayCheckbox = screen.getByLabelText('event.form.allDay')
fireEvent.click(allDayCheckbox)
expect(allDayCheckbox).toBeChecked()
const expectedDate = dayjs(day).format(LONG_DATE_FORMAT);
const expectedDate = dayjs(day).format(LONG_DATE_FORMAT)
const startDateInput = screen.getByTestId("start-date-input");
const endDateInput = screen.getByTestId("end-date-input");
const startDateInput = screen.getByTestId('start-date-input')
const endDateInput = screen.getByTestId('end-date-input')
expect(startDateInput).toHaveValue(expectedDate);
expect(endDateInput).toHaveValue(expectedDate);
});
expect(startDateInput).toHaveValue(expectedDate)
expect(endDateInput).toHaveValue(expectedDate)
})
it("saves event and moves it when calendar is changed", async () => {
it('saves event and moves it when calendar is changed', async () => {
const spyPut = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const spyMove = jest
.spyOn(eventThunks, "moveEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'moveEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
const testDate = new Date("2025-01-15T10:00:00.000Z");
const testEndDate = new Date("2025-01-15T11:00:00.000Z");
const testDate = new Date('2025-01-15T10:00:00.000Z')
const testEndDate = new Date('2025-01-15T11:00:00.000Z')
const preloadedTwoCals = {
...preloadedState,
calendars: {
pending: false,
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Calendar One",
color: "#FF0000",
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'Calendar One',
color: '#FF0000',
events: {
event1: {
uid: "event1",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
uid: 'event1',
title: 'Test Event',
calId: '667037022b752d0026472254/cal1',
start: testDate.toISOString(),
end: testEndDate.toISOString(),
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
},
},
},
"667037022b752d0026472254/cal2": {
id: "667037022b752d0026472254/cal2",
name: "Calendar Two",
color: "#00FF00",
events: {},
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }]
}
}
},
'667037022b752d0026472254/cal2': {
id: '667037022b752d0026472254/cal2',
name: 'Calendar Two',
color: '#00FF00',
events: {}
}
},
pending: false,
},
};
pending: false
}
}
await act(async () =>
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
preloadedTwoCals
)
);
)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
const calendarSelect = screen.getByLabelText("event.form.calendar");
await act(async () => fireEvent.mouseDown(calendarSelect));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const calendarSelect = screen.getByLabelText('event.form.calendar')
await act(async () => fireEvent.mouseDown(calendarSelect))
const option = await screen.findByText("Calendar Two");
fireEvent.click(option);
const option = await screen.findByText('Calendar Two')
fireEvent.click(option)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
});
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
})
await waitFor(
() => {
expect(spyPut).toHaveBeenCalled();
expect(spyPut).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
await waitFor(
() => {
expect(spyMove).toHaveBeenCalled();
expect(spyMove).toHaveBeenCalled()
},
{ timeout: 3000 }
);
});
)
})
it("edit modal displays event time in original event timezone", () => {
it('edit modal displays event time in original event timezone', () => {
// GIVEN user timezone is UTC+2
// WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok)
// THEN the update modal displays the time as 2PM in Asia/Bangkok timezone
const eventDateUTC7 = new Date("2025-01-15T07:00:00.000Z"); // 7AM UTC = 2PM UTC+7
const eventDateUTC7 = new Date('2025-01-15T07:00:00.000Z') // 7AM UTC = 2PM UTC+7
const stateWithTimezone = {
...preloadedState,
calendars: {
pending: false,
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Test Calendar",
color: "#FF0000",
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'Test Calendar',
color: '#FF0000',
events: {
event1: {
uid: "event1",
title: "Timezone Edit Test",
calId: "667037022b752d0026472254/cal1",
uid: 'event1',
title: 'Timezone Edit Test',
calId: '667037022b752d0026472254/cal1',
start: eventDateUTC7.toISOString(),
end: new Date(eventDateUTC7.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok",
timezone: 'Asia/Bangkok',
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
},
},
},
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }]
}
}
}
},
pending: false,
},
};
pending: false
}
}
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
stateWithTimezone
);
)
// Expand to show timezone selector (normal mode hides it)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// The timezone select should have Asia/Bangkok selected
const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i);
expect(timeZone).toBeInTheDocument();
});
const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i)
expect(timeZone).toBeInTheDocument()
})
it("InfoRow renders error style when error prop is true", () => {
renderWithProviders(<InfoRow icon={<span>i</span>} text="Bad" error />);
expect(screen.getByText("Bad")).toBeInTheDocument();
});
it('InfoRow renders error style when error prop is true', () => {
renderWithProviders(<InfoRow icon={<span>i</span>} text="Bad" error />)
expect(screen.getByText('Bad')).toBeInTheDocument()
})
it("can remove an attendee with the close button", () => {
it('can remove an attendee with the close button', () => {
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
preloadedState
);
)
const removeBtn = screen.getAllByTestId("CloseIcon").pop()!;
fireEvent.click(removeBtn);
const removeBtn = screen.getAllByTestId('CloseIcon').pop()!
fireEvent.click(removeBtn)
expect(screen.queryByText(/John/)).not.toBeInTheDocument();
});
expect(screen.queryByText(/John/)).not.toBeInTheDocument()
})
it("renders video conference info when x_openpass_videoconference exists", () => {
it('renders video conference info when x_openpass_videoconference exists', () => {
const videoState = {
...preloadedState,
calendars: {
pending: false,
list: {
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
'667037022b752d0026472254/cal1': {
...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
'667037022b752d0026472254/cal1'
].events.event1,
x_openpass_videoconference: "https://meet.test/video",
},
},
},
},
},
};
x_openpass_videoconference: 'https://meet.test/video'
}
}
}
}
}
}
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'event1'}
/>,
videoState
);
)
expect(
screen.getByText("event.form.joinVisioConference")
).toBeInTheDocument();
});
});
screen.getByText('event.form.joinVisioConference')
).toBeInTheDocument()
})
})
+370 -372
View File
@@ -1,115 +1,115 @@
import * as calendarsApi from "@/features/Calendars/CalendarApi";
import * as eventThunks from "@/features/Calendars/services";
import EventPopover from "@/features/Events/EventModal";
import { api } from "@/utils/apiUtils";
import { DateSelectArg } from "@fullcalendar/core";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as calendarsApi from '@/features/Calendars/CalendarApi'
import * as eventThunks from '@/features/Calendars/services'
import EventPopover from '@/features/Events/EventModal'
import { api } from '@/utils/apiUtils'
import { DateSelectArg } from '@fullcalendar/core'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils");
jest.mock('@/utils/apiUtils')
describe("EventPopover", () => {
describe('EventPopover', () => {
beforeEach(() => {
sessionStorage.clear();
});
sessionStorage.clear()
})
const mockOnClose = jest.fn();
const mockSetSelectedRange = jest.fn();
const mockCalendarRef = { current: { select: jest.fn() } } as any;
const mockOnClose = jest.fn()
const mockSetSelectedRange = jest.fn()
const mockCalendarRef = { current: { select: jest.fn() } } as any
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
cn: 'test',
cal_address: 'test@test.com'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Calendar 1",
color: "#FF0000",
},
"667037022b752d0026472254/cal2": {
id: "667037022b752d0026472254/cal2",
name: "Calendar 2",
color: "#00FF00",
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'Calendar 1',
color: '#FF0000'
},
'667037022b752d0026472254/cal2': {
id: '667037022b752d0026472254/cal2',
name: 'Calendar 2',
color: '#00FF00'
}
},
pending: false,
},
};
pending: false
}
}
const mockUsers = [
{
id: "john@example.com",
objectType: "user",
id: 'john@example.com',
objectType: 'user',
emailAddresses: [
{
value: "john@example.com",
type: "default",
},
value: 'john@example.com',
type: 'default'
}
],
names: [
{
displayName: "John Doe",
type: "default",
},
],
displayName: 'John Doe',
type: 'default'
}
]
},
{
id: "jane@example.com",
objectType: "user",
id: 'jane@example.com',
objectType: 'user',
emailAddresses: [
{
value: "jane@example.com",
type: "default",
},
value: 'jane@example.com',
type: 'default'
}
],
names: [
{
displayName: "Jane Smith",
type: "default",
},
],
displayName: 'Jane Smith',
type: 'default'
}
]
},
{
id: "room1@example.com",
objectType: "resource",
id: 'room1@example.com',
objectType: 'resource',
emailAddresses: [
{
value: "room1@example.com",
type: "default",
},
value: 'room1@example.com',
type: 'default'
}
],
names: [
{
displayName: "Room 1",
type: "default",
},
],
},
];
(api.post as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUsers),
});
displayName: 'Room 1',
type: 'default'
}
]
}
]
;(api.post as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUsers)
})
const defaultSelectedRange = {
startStr: "2025-07-18T09:00",
endStr: "2025-07-18T10:00",
start: new Date("2025-07-18T09:00"),
end: new Date("2025-07-18T10:00"),
startStr: '2025-07-18T09:00',
endStr: '2025-07-18T10:00',
start: new Date('2025-07-18T09:00'),
end: new Date('2025-07-18T10:00'),
allDay: false,
resource: undefined,
} as unknown as DateSelectArg;
resource: undefined
} as unknown as DateSelectArg
const renderPopover = (selectedRange = defaultSelectedRange) => {
renderWithProviders(
@@ -122,461 +122,459 @@ describe("EventPopover", () => {
calendarRef={mockCalendarRef}
/>,
preloadedState
);
};
)
}
it("renders correctly with inputs and calendar options", () => {
renderPopover();
it('renders correctly with inputs and calendar options', () => {
renderPopover()
// Check dialog title
expect(screen.getByText("event.createEvent")).toBeInTheDocument();
expect(screen.getByText('event.createEvent')).toBeInTheDocument()
// Check inputs exist by their roles
const titleInput = screen.getByRole("textbox", { name: /title/i });
expect(titleInput).toBeInTheDocument();
const titleInput = screen.getByRole('textbox', { name: /title/i })
expect(titleInput).toBeInTheDocument()
// Description input is only visible after clicking "Add description" button
const addDescriptionButton = screen.getByRole("button", {
name: "event.form.addDescription",
});
expect(addDescriptionButton).toBeInTheDocument();
const addDescriptionButton = screen.getByRole('button', {
name: 'event.form.addDescription'
})
expect(addDescriptionButton).toBeInTheDocument()
// In normal mode calendar is a SectionPreviewRow (button), not combobox
expect(
screen.getByRole("button", { name: "Calendar 1" })
).toBeInTheDocument();
screen.getByRole('button', { name: 'Calendar 1' })
).toBeInTheDocument()
// Check button
expect(
screen.getByRole("button", { name: "common.moreOptions" })
).toBeInTheDocument();
screen.getByRole('button', { name: 'common.moreOptions' })
).toBeInTheDocument()
// Extended mode
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// Back button appears
expect(screen.getByLabelText("show less")).toBeInTheDocument();
expect(screen.getByLabelText('show less')).toBeInTheDocument()
// Extended labels appear
expect(screen.getAllByText("event.form.repeat")).toHaveLength(1);
expect(screen.getAllByText("event.form.notification")).toHaveLength(1);
expect(screen.getAllByText("event.form.visibleTo")).toHaveLength(1);
expect(screen.getAllByText("event.form.showMeAs")).toHaveLength(1);
});
expect(screen.getAllByText('event.form.repeat')).toHaveLength(1)
expect(screen.getAllByText('event.form.notification')).toHaveLength(1)
expect(screen.getAllByText('event.form.visibleTo')).toHaveLength(1)
expect(screen.getAllByText('event.form.showMeAs')).toHaveLength(1)
})
it("fills start from selectedRange", () => {
it('fills start from selectedRange', () => {
renderPopover({
startStr: "2026-07-20T10:00",
endStr: "2026-07-20T12:00",
start: new Date("2026-07-20T10:00"),
end: new Date("2026-07-20T12:00"),
allDay: false,
} as unknown as DateSelectArg);
startStr: '2026-07-20T10:00',
endStr: '2026-07-20T12:00',
start: new Date('2026-07-20T10:00'),
end: new Date('2026-07-20T12:00'),
allDay: false
} as unknown as DateSelectArg)
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
})
it("updates inputs on change", () => {
renderPopover();
it('updates inputs on change', () => {
renderPopover()
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "My Event" },
});
expect(screen.getByLabelText("event.form.title")).toHaveValue("My Event");
fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: 'My Event' }
})
expect(screen.getByLabelText('event.form.title')).toHaveValue('My Event')
// Click "Add description" button first
fireEvent.click(
screen.getByRole("button", { name: "event.form.addDescription" })
);
screen.getByRole('button', { name: 'event.form.addDescription' })
)
fireEvent.change(screen.getByLabelText("event.form.description"), {
target: { value: "Event Description" },
});
expect(screen.getByLabelText("event.form.description")).toHaveValue(
"Event Description"
);
fireEvent.change(screen.getByLabelText('event.form.description'), {
target: { value: 'Event Description' }
})
expect(screen.getByLabelText('event.form.description')).toHaveValue(
'Event Description'
)
// Expand location section (normal mode shows SectionPreviewRow)
fireEvent.click(
screen.getByRole("button", { name: "event.form.locationPlaceholder" })
);
fireEvent.change(screen.getByLabelText("event.form.location"), {
target: { value: "Conference Room" },
});
expect(screen.getByLabelText("event.form.location")).toHaveValue(
"Conference Room"
);
});
screen.getByRole('button', { name: 'event.form.locationPlaceholder' })
)
fireEvent.change(screen.getByLabelText('event.form.location'), {
target: { value: 'Conference Room' }
})
expect(screen.getByLabelText('event.form.location')).toHaveValue(
'Conference Room'
)
})
it("changes selected calendar", async () => {
renderPopover();
it('changes selected calendar', async () => {
renderPopover()
// Expand to show calendar combobox (normal mode shows SectionPreviewRow)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
const select = screen.getByLabelText("event.form.calendar");
fireEvent.mouseDown(select); // Open menu
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const select = screen.getByLabelText('event.form.calendar')
fireEvent.mouseDown(select) // Open menu
const option = await screen.findByText("Calendar 2");
fireEvent.click(option);
const option = await screen.findByText('Calendar 2')
fireEvent.click(option)
const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i });
expect(calendarSelect).toHaveTextContent("Calendar 2");
});
it("adds a attendee", async () => {
jest.useFakeTimers();
const calendarSelect = screen.getByRole('combobox', { name: /Calendar/i })
expect(calendarSelect).toHaveTextContent('Calendar 2')
})
it('adds a attendee', async () => {
jest.useFakeTimers()
try {
jest
.spyOn(calendarsApi, "getCalendars")
.mockReturnValue({ json: jest.fn() });
renderPopover();
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "newEvent" },
});
const select = screen.getByLabelText("peopleSearch.label");
.spyOn(calendarsApi, 'getCalendars')
.mockReturnValue({ json: jest.fn() })
renderPopover()
fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: 'newEvent' }
})
const select = screen.getByLabelText('peopleSearch.label')
act(() => {
select.focus();
fireEvent.mouseDown(select);
userEvent.type(select, "john");
});
select.focus()
fireEvent.mouseDown(select)
userEvent.type(select, 'john')
})
await act(async () => {
jest.advanceTimersByTime(400);
});
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1));
jest.advanceTimersByTime(400)
})
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1))
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
await act(async () => {
userEvent.click(screen.getByText("John Doe"));
});
userEvent.click(screen.getByText('John Doe'))
})
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
expect(spy).toHaveBeenCalled()
})
const receivedPayload = spy.mock.calls[0][0];
const receivedPayload = spy.mock.calls[0][0]
expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
preloadedState.calendars.list['667037022b752d0026472254/cal1']
)
expect(receivedPayload.newEvent.attendee).toHaveLength(2);
expect(receivedPayload.newEvent.attendee).toHaveLength(2)
expect(receivedPayload.newEvent.attendee).toStrictEqual([
{
cn: "test",
cal_address: "test@test.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
cn: 'test',
cal_address: 'test@test.com',
partstat: 'ACCEPTED',
rsvp: 'FALSE',
role: 'CHAIR',
cutype: 'INDIVIDUAL'
},
{
cn: "John Doe",
cal_address: "john@example.com",
partstat: "NEEDS-ACTION",
rsvp: "FALSE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
]);
cn: 'John Doe',
cal_address: 'john@example.com',
partstat: 'NEEDS-ACTION',
rsvp: 'FALSE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL'
}
])
} finally {
jest.useRealTimers();
jest.useRealTimers()
}
});
})
it("adds a resource", async () => {
jest.useFakeTimers();
it('adds a resource', async () => {
jest.useFakeTimers()
try {
renderPopover();
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "newEventWithResource" },
});
renderPopover()
fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: 'newEventWithResource' }
})
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
const resourceCombobox = screen.getByPlaceholderText(
"resourceSearch.placeholder"
);
'resourceSearch.placeholder'
)
act(() => {
resourceCombobox.focus();
fireEvent.mouseDown(resourceCombobox);
});
await userEvent.type(resourceCombobox, "room");
resourceCombobox.focus()
fireEvent.mouseDown(resourceCombobox)
})
await userEvent.type(resourceCombobox, 'room')
await act(async () => {
jest.advanceTimersByTime(400);
});
jest.advanceTimersByTime(400)
})
await waitFor(() => {
expect(screen.getByText("Room 1")).toBeInTheDocument();
});
expect(screen.getByText('Room 1')).toBeInTheDocument()
})
await userEvent.click(screen.getByText("Room 1"));
await userEvent.click(screen.getByText('Room 1'))
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
expect(spy).toHaveBeenCalled()
})
const receivedPayload = spy.mock.calls[0][0];
const receivedPayload = spy.mock.calls[0][0]
expect(receivedPayload.newEvent.attendee).toHaveLength(2); // Organizer + 1 resource
expect(receivedPayload.newEvent.attendee).toHaveLength(2) // Organizer + 1 resource
expect(receivedPayload.newEvent.attendee[1]).toStrictEqual({
cn: "Room 1",
cal_address: "room1@example.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
});
cn: 'Room 1',
cal_address: 'room1@example.com',
partstat: 'NEEDS-ACTION',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'RESOURCE'
})
} finally {
jest.useRealTimers();
jest.useRealTimers()
}
});
})
it("dispatches putEventAsync and calls onClose when Save is clicked", async () => {
renderPopover();
it('dispatches putEventAsync and calls onClose when Save is clicked', async () => {
renderPopover()
const newEvent = {
title: "Meeting",
title: 'Meeting',
allday: false,
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8",
description: "Discuss project",
location: "Zoom",
organizer: { cn: "test", cal_address: "test@test.com" },
timezone: "Europe/Paris",
transp: "TRANSPARENT",
};
uid: '6045c603-11ab-43c5-bc30-0641420bb3a8',
description: 'Discuss project',
location: 'Zoom',
organizer: { cn: 'test', cal_address: 'test@test.com' },
timezone: 'Europe/Paris',
transp: 'TRANSPARENT'
}
// Fill inputs
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: newEvent.title },
});
fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: newEvent.title }
})
// Click "Add description" button first
fireEvent.click(
screen.getByRole("button", { name: "event.form.addDescription" })
);
fireEvent.change(screen.getByLabelText("event.form.description"), {
target: { value: newEvent.description },
});
screen.getByRole('button', { name: 'event.form.addDescription' })
)
fireEvent.change(screen.getByLabelText('event.form.description'), {
target: { value: newEvent.description }
})
fireEvent.click(
screen.getByRole("button", { name: "event.form.locationPlaceholder" })
);
fireEvent.change(screen.getByLabelText("event.form.location"), {
target: { value: newEvent.location },
});
screen.getByRole('button', { name: 'event.form.locationPlaceholder' })
)
fireEvent.change(screen.getByLabelText('event.form.location'), {
target: { value: newEvent.location }
})
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
.spyOn(eventThunks, 'putEventAsync')
.mockImplementation(payload => {
const promise = Promise.resolve(payload)
;(promise as any).unwrap = () => promise
return () => promise as any
})
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
expect(spy).toHaveBeenCalled()
})
const receivedPayload = spy.mock.calls[0][0];
const receivedPayload = spy.mock.calls[0][0]
expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
preloadedState.calendars.list['667037022b752d0026472254/cal1']
)
expect(receivedPayload.newEvent.title).toBe(newEvent.title);
expect(receivedPayload.newEvent.description).toBe(newEvent.description);
expect(receivedPayload.newEvent.location).toBe(newEvent.location);
expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer);
expect(receivedPayload.newEvent.title).toBe(newEvent.title)
expect(receivedPayload.newEvent.description).toBe(newEvent.description)
expect(receivedPayload.newEvent.location).toBe(newEvent.location)
expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer)
expect(receivedPayload.newEvent.color).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].color
);
preloadedState.calendars.list['667037022b752d0026472254/cal1'].color
)
// onClose should be called
expect(mockOnClose).toHaveBeenCalledWith(true);
});
expect(mockOnClose).toHaveBeenCalledWith(true)
})
it("calls onClose with refresh = false when Cancel clicked", () => {
renderPopover();
it('calls onClose with refresh = false when Cancel clicked', () => {
renderPopover()
// Cancel button only appears in expanded mode
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }))
expect(mockOnClose).toHaveBeenCalledWith(false);
});
expect(mockOnClose).toHaveBeenCalledWith(false)
})
it("BUGFIX: Prefill Calendar field", async () => {
renderPopover();
it('BUGFIX: Prefill Calendar field', async () => {
renderPopover()
// In normal mode calendar is a SectionPreviewRow (button) with calendar name
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Calendar 1" })
).toHaveTextContent("Calendar 1")
);
});
screen.getByRole('button', { name: 'Calendar 1' })
).toHaveTextContent('Calendar 1')
)
})
describe("EventModal - Drag and Drop Scenarios", () => {
describe('EventModal - Drag and Drop Scenarios', () => {
// Test 2.1: Drag from allday slot (single day)
it("sets allday=true when drag from allday slot (single day)", async () => {
it('sets allday=true when drag from allday slot (single day)', async () => {
const selectedRange = {
startStr: "2025-07-18",
endStr: "2025-07-19",
start: new Date("2025-07-18"),
end: new Date("2025-07-19"),
allDay: true,
} as unknown as DateSelectArg;
startStr: '2025-07-18',
endStr: '2025-07-19',
start: new Date('2025-07-18'),
end: new Date('2025-07-19'),
allDay: true
} as unknown as DateSelectArg
renderPopover(selectedRange);
renderPopover(selectedRange)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
const allDayCheckbox = screen.getByLabelText('event.form.allDay')
await waitFor(() => {
expect(allDayCheckbox).toBeChecked();
});
});
expect(allDayCheckbox).toBeChecked()
})
})
// Test 2.2: Drag from allday slot (multiple days)
it("sets allday=true and shows 2 date fields when drag from allday slot (multiple days)", async () => {
it('sets allday=true and shows 2 date fields when drag from allday slot (multiple days)', async () => {
const selectedRange = {
startStr: "2025-07-18",
endStr: "2025-07-21",
start: new Date("2025-07-18"),
end: new Date("2025-07-21"),
allDay: true,
} as unknown as DateSelectArg;
startStr: '2025-07-18',
endStr: '2025-07-21',
start: new Date('2025-07-18'),
end: new Date('2025-07-21'),
allDay: true
} as unknown as DateSelectArg
renderPopover(selectedRange);
renderPopover(selectedRange)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
const allDayCheckbox = screen.getByLabelText('event.form.allDay')
await waitFor(() => {
expect(allDayCheckbox).toBeChecked();
});
expect(allDayCheckbox).toBeChecked()
})
// Verify only date fields are shown
await waitFor(() => {
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
expect(screen.getByTestId("end-date-input")).toBeInTheDocument();
expect(
screen.queryByTestId("start-time-input")
).not.toBeInTheDocument();
expect(screen.queryByTestId("end-time-input")).not.toBeInTheDocument();
});
});
expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.queryByTestId('start-time-input')).not.toBeInTheDocument()
expect(screen.queryByTestId('end-time-input')).not.toBeInTheDocument()
})
})
// Test 2.3: Drag from week view (single day)
it("keeps allday=false when drag from week view (single day)", async () => {
it('keeps allday=false when drag from week view (single day)', async () => {
const selectedRange = {
startStr: "2025-07-18T09:00",
endStr: "2025-07-18T10:00",
start: new Date("2025-07-18T09:00"),
end: new Date("2025-07-18T10:00"),
allDay: false,
} as unknown as DateSelectArg;
startStr: '2025-07-18T09:00',
endStr: '2025-07-18T10:00',
start: new Date('2025-07-18T09:00'),
end: new Date('2025-07-18T10:00'),
allDay: false
} as unknown as DateSelectArg
renderPopover(selectedRange);
renderPopover(selectedRange)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
const allDayCheckbox = screen.getByLabelText('event.form.allDay')
await waitFor(() => {
expect(allDayCheckbox).not.toBeChecked();
});
expect(allDayCheckbox).not.toBeChecked()
})
// Verify time fields are shown
await waitFor(() => {
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
expect(screen.getByTestId("end-time-input")).toBeInTheDocument();
});
});
expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
})
})
// Test 2.4: Drag from week view (multiple days) - CRITICAL TEST
it("keeps allday=false and shows 4 fields when drag from week view (multiple days)", async () => {
it('keeps allday=false and shows 4 fields when drag from week view (multiple days)', async () => {
const selectedRange = {
startStr: "2025-07-18T09:00",
endStr: "2025-07-20T10:00",
start: new Date("2025-07-18T09:00"),
end: new Date("2025-07-20T10:00"),
allDay: false,
} as unknown as DateSelectArg;
startStr: '2025-07-18T09:00',
endStr: '2025-07-20T10:00',
start: new Date('2025-07-18T09:00'),
end: new Date('2025-07-20T10:00'),
allDay: false
} as unknown as DateSelectArg
renderPopover(selectedRange);
renderPopover(selectedRange)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
const allDayCheckbox = screen.getByLabelText('event.form.allDay')
await waitFor(() => {
expect(allDayCheckbox).not.toBeChecked(); // CRITICAL: should NOT be checked
});
expect(allDayCheckbox).not.toBeChecked() // CRITICAL: should NOT be checked
})
// Verify all 4 fields are shown
await waitFor(() => {
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
expect(screen.getByTestId("end-date-input")).toBeInTheDocument();
expect(screen.getByTestId("end-time-input")).toBeInTheDocument();
});
});
expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
})
})
// Test 2.5: Drag from week view (multiple days) - fallback path
it("handles drag from week view (multiple days) with Date objects only", async () => {
it('handles drag from week view (multiple days) with Date objects only', async () => {
const selectedRange = {
start: new Date("2025-07-18T09:00"),
end: new Date("2025-07-20T10:00"),
allDay: false,
} as unknown as DateSelectArg;
start: new Date('2025-07-18T09:00'),
end: new Date('2025-07-20T10:00'),
allDay: false
} as unknown as DateSelectArg
renderPopover(selectedRange);
renderPopover(selectedRange)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
const allDayCheckbox = screen.getByLabelText('event.form.allDay')
await waitFor(() => {
expect(allDayCheckbox).not.toBeChecked();
});
expect(allDayCheckbox).not.toBeChecked()
})
// Verify all 4 fields are shown
await waitFor(() => {
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
expect(screen.getByTestId("end-date-input")).toBeInTheDocument();
expect(screen.getByTestId("end-time-input")).toBeInTheDocument();
});
});
});
});
expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
})
})
})
})
@@ -1,157 +1,157 @@
import { makeRecurrenceString } from "@/features/Events/EventPreview/utils/makeRecurrenceString";
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { makeRecurrenceString } from '@/features/Events/EventPreview/utils/makeRecurrenceString'
import { RepetitionObject } from '@/features/Events/EventsTypes'
describe("makeRecurrenceString", () => {
describe('makeRecurrenceString', () => {
const mockT = jest.fn((key: string, params?: any) => {
if (params) {
if (key === "eventPreview.everyInterval") {
return `Every ${params.interval} ${params.unit}`;
if (key === 'eventPreview.everyInterval') {
return `Every ${params.interval} ${params.unit}`
}
if (key === "eventPreview.recurrenceOnDays") {
return `on ${params.days}`;
if (key === 'eventPreview.recurrenceOnDays') {
return `on ${params.days}`
}
if (key === "eventPreview.forOccurrences") {
return `for ${params.count} times`;
if (key === 'eventPreview.forOccurrences') {
return `for ${params.count} times`
}
if (key === "eventPreview.until") {
return `until ${params.date}`;
if (key === 'eventPreview.until') {
return `until ${params.date}`
}
}
const translations: Record<string, string> = {
"event.repeat.frequency.days": "days",
"event.repeat.frequency.weeks": "weeks",
"event.repeat.frequency.months": "months",
"event.repeat.frequency.years": "years",
"eventPreview.onDays.MO": "Monday",
"eventPreview.onDays.TU": "Tuesday",
"eventPreview.onDays.WE": "Wednesday",
"eventPreview.onDays.TH": "Thursday",
"eventPreview.onDays.FR": "Friday",
"eventPreview.onDays.SA": "Saturday",
"eventPreview.onDays.SU": "Sunday",
locale: "en-US",
};
'event.repeat.frequency.days': 'days',
'event.repeat.frequency.weeks': 'weeks',
'event.repeat.frequency.months': 'months',
'event.repeat.frequency.years': 'years',
'eventPreview.onDays.MO': 'Monday',
'eventPreview.onDays.TU': 'Tuesday',
'eventPreview.onDays.WE': 'Wednesday',
'eventPreview.onDays.TH': 'Thursday',
'eventPreview.onDays.FR': 'Friday',
'eventPreview.onDays.SA': 'Saturday',
'eventPreview.onDays.SU': 'Sunday',
locale: 'en-US'
}
return translations[key] || key;
});
return translations[key] || key
})
const startText = "Repeats";
const startText = 'Repeats'
beforeEach(() => {
mockT.mockClear();
});
mockT.mockClear()
})
it("formats string for interval === 1 when enableStrForOneTimeInterval is true", () => {
const repetition: RepetitionObject = { freq: "daily", interval: 1 };
it('formats string for interval === 1 when enableStrForOneTimeInterval is true', () => {
const repetition: RepetitionObject = { freq: 'daily', interval: 1 }
const result = makeRecurrenceString({
repetition,
t: mockT,
startText,
enableStrForOneTimeInterval: true,
});
enableStrForOneTimeInterval: true
})
expect(result).toBe("Repeats, days");
});
expect(result).toBe('Repeats, days')
})
it("formats string when interval is undefined", () => {
const repetition: RepetitionObject = { freq: "daily" };
it('formats string when interval is undefined', () => {
const repetition: RepetitionObject = { freq: 'daily' }
const result = makeRecurrenceString({
repetition,
t: mockT,
startText,
enableStrForOneTimeInterval: true,
});
enableStrForOneTimeInterval: true
})
expect(result).toBe("Repeats, days");
});
expect(result).toBe('Repeats, days')
})
it("formats string for interval > 1", () => {
const repetition: RepetitionObject = { freq: "weekly", interval: 2 };
const result = makeRecurrenceString({ repetition, t: mockT, startText });
it('formats string for interval > 1', () => {
const repetition: RepetitionObject = { freq: 'weekly', interval: 2 }
const result = makeRecurrenceString({ repetition, t: mockT, startText })
expect(result).toBe("Repeats, Every 2 weeks");
expect(mockT).toHaveBeenCalledWith("eventPreview.everyInterval", {
expect(result).toBe('Repeats, Every 2 weeks')
expect(mockT).toHaveBeenCalledWith('eventPreview.everyInterval', {
interval: 2,
unit: "weeks",
});
});
unit: 'weeks'
})
})
it("formats string with specific days and sorts them according to WEEK_DAYS order", () => {
it('formats string with specific days and sorts them according to WEEK_DAYS order', () => {
const repetition: RepetitionObject = {
freq: "weekly",
freq: 'weekly',
interval: 1,
byday: ["WE", "MO", "FR"],
};
const result = makeRecurrenceString({ repetition, t: mockT, startText });
byday: ['WE', 'MO', 'FR']
}
const result = makeRecurrenceString({ repetition, t: mockT, startText })
// Expected order: MO, WE, FR
expect(result).toBe("Repeats, on Monday, Wednesday, Friday");
});
expect(result).toBe('Repeats, on Monday, Wednesday, Friday')
})
it("formats string with occurrences count", () => {
it('formats string with occurrences count', () => {
const repetition: RepetitionObject = {
freq: "daily",
freq: 'daily',
interval: 1,
occurrences: 5,
};
const result = makeRecurrenceString({ repetition, t: mockT, startText });
occurrences: 5
}
const result = makeRecurrenceString({ repetition, t: mockT, startText })
expect(result).toBe("Repeats, for 5 times");
});
expect(result).toBe('Repeats, for 5 times')
})
it("formats string with end date", () => {
const endDate = "2025-12-31T00:00:00.000Z";
it('formats string with end date', () => {
const endDate = '2025-12-31T00:00:00.000Z'
const repetition: RepetitionObject = {
freq: "daily",
freq: 'daily',
interval: 1,
endDate,
};
const result = makeRecurrenceString({ repetition, t: mockT, startText });
endDate
}
const result = makeRecurrenceString({ repetition, t: mockT, startText })
const formattedDate = new Date(endDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const formattedDate = new Date(endDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
expect(result).toBe(`Repeats, until ${formattedDate}`);
});
expect(result).toBe(`Repeats, until ${formattedDate}`)
})
it("combines multiple properties", () => {
const endDate = "2025-12-31T00:00:00.000Z";
it('combines multiple properties', () => {
const endDate = '2025-12-31T00:00:00.000Z'
const repetition: RepetitionObject = {
freq: "monthly",
freq: 'monthly',
interval: 3,
byday: ["TU", "TH"],
endDate,
};
const result = makeRecurrenceString({ repetition, t: mockT, startText });
byday: ['TU', 'TH'],
endDate
}
const result = makeRecurrenceString({ repetition, t: mockT, startText })
const formattedDate = new Date(endDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const formattedDate = new Date(endDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
expect(result).toBe(
`Repeats, Every 3 months, on Tuesday, Thursday, until ${formattedDate}`
);
});
)
})
it("uses custom joinChar when provided", () => {
it('uses custom joinChar when provided', () => {
const repetition: RepetitionObject = {
freq: "weekly",
freq: 'weekly',
interval: 2,
occurrences: 10,
};
occurrences: 10
}
const result = makeRecurrenceString({
repetition,
t: mockT,
startText,
joinChar: ";",
});
joinChar: ';'
})
expect(result).toBe("Repeats; Every 2 weeks; for 10 times");
});
});
expect(result).toBe('Repeats; Every 2 weeks; for 10 times')
})
})
File diff suppressed because it is too large Load Diff
@@ -1,81 +1,81 @@
import * as CalendarApi from "@/features/Calendars/CalendarApi";
import * as eventThunks from "@/features/Calendars/services";
import * as EventApi from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as CalendarApi from '@/features/Calendars/CalendarApi'
import * as eventThunks from '@/features/Calendars/services'
import * as EventApi from '@/features/Events/EventApi'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/features/Events/EventApi");
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/features/Events/api/updateSeries");
jest.mock('@/features/Events/EventApi')
jest.mock('@/features/Calendars/CalendarApi')
jest.mock('@/features/Events/api/updateSeries')
describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
const mockOnClose = jest.fn();
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
sessionStorage.clear();
});
jest.clearAllMocks()
jest.restoreAllMocks()
sessionStorage.clear()
})
const baseUID = "recurring-event-base";
const calId = "667037022b752d0026472254/cal1";
const baseUID = 'recurring-event-base'
const calId = '667037022b752d0026472254/cal1'
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "test-sid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'test-sid',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
cn: 'test',
cal_address: 'test@test.com'
}
},
calendars: {
list: {
[calId]: {
id: calId,
name: "Test Calendar",
color: "#FF0000",
name: 'Test Calendar',
color: '#FF0000',
events: {},
owner: { emails: ["test@test.com"] },
},
owner: { emails: ['test@test.com'] }
}
},
pending: false,
},
};
pending: false
}
}
describe("Master Event Display", () => {
describe('Master Event Display', () => {
it("should fetch and display master event when editing 'all events' of a recurring series", async () => {
// Given a recurring series with modified instances
const masterEvent = {
uid: baseUID,
title: "Master Event Title",
description: "Master Description",
title: 'Master Event Title',
description: 'Master Description',
calId,
start: "2025-01-15T10:00:00.000Z",
end: "2025-01-15T11:00:00.000Z",
repetition: { freq: "daily", interval: 1 },
start: '2025-01-15T10:00:00.000Z',
end: '2025-01-15T11:00:00.000Z',
repetition: { freq: 'daily', interval: 1 },
allday: false,
timezone: "America/New_York",
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`,
} as CalendarEvent;
timezone: 'America/New_York',
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent
// Instance that was moved to different time
const modifiedInstance = {
...masterEvent,
uid: `${baseUID}/20250116`,
title: "Modified Instance Title", // Different title
start: "2025-01-16T14:00:00.000Z", // Different time (2PM instead of 10AM)
end: "2025-01-16T15:00:00.000Z",
};
title: 'Modified Instance Title', // Different title
start: '2025-01-16T14:00:00.000Z', // Different time (2PM instead of 10AM)
end: '2025-01-16T15:00:00.000Z'
}
const stateWithRecurringEvent = {
...preloadedState,
@@ -86,17 +86,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
...preloadedState.calendars.list[calId],
events: {
[baseUID]: masterEvent,
[`${baseUID}/20250116`]: modifiedInstance,
},
},
},
},
};
[`${baseUID}/20250116`]: modifiedInstance
}
}
}
}
}
// Mock getEvent to return master event
const mockGetEvent = jest
.spyOn(EventApi, "getEvent")
.mockResolvedValue(masterEvent);
.spyOn(EventApi, 'getEvent')
.mockResolvedValue(masterEvent)
renderWithProviders(
<EventUpdateModal
@@ -107,51 +107,51 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all"
/>,
stateWithRecurringEvent
);
)
// Wait for master event to be fetched
await waitFor(() => {
expect(mockGetEvent).toHaveBeenCalledWith(
expect.objectContaining({
uid: baseUID, // Should fetch base UID, not instance
uid: baseUID // Should fetch base UID, not instance
}),
true
);
});
)
})
// Should display master event data, NOT modified instance data
await waitFor(() => {
const titleInput = screen.getByDisplayValue("Master Event Title");
expect(titleInput).toBeInTheDocument();
});
const titleInput = screen.getByDisplayValue('Master Event Title')
expect(titleInput).toBeInTheDocument()
})
// Should NOT show modified instance title
expect(
screen.queryByDisplayValue("Modified Instance Title")
).not.toBeInTheDocument();
screen.queryByDisplayValue('Modified Instance Title')
).not.toBeInTheDocument()
// Expand more options to show timezone (normal mode shows summary first)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
screen.getByRole('button', { name: 'common.moreOptions' })
)
// Verify timezone dropdown shows master timezone
await waitFor(() => {
expect(screen.getByDisplayValue(/New York/i)).toBeDefined();
});
});
expect(screen.getByDisplayValue(/New York/i)).toBeDefined()
})
})
it("should use master event directly if clicked event is already the master", async () => {
it('should use master event directly if clicked event is already the master', async () => {
const masterEvent = {
uid: baseUID, // No recurrence-id
title: "Master Event",
title: 'Master Event',
calId,
start: "2025-01-15T10:00:00.000Z",
end: "2025-01-15T11:00:00.000Z",
repetition: { freq: "weekly", interval: 1 },
start: '2025-01-15T10:00:00.000Z',
end: '2025-01-15T11:00:00.000Z',
repetition: { freq: 'weekly', interval: 1 },
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
URL: `/calendars/${calId}/${baseUID}.ics`,
};
organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`
}
const stateWithMaster = {
...preloadedState,
@@ -161,14 +161,14 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
[calId]: {
...preloadedState.calendars.list[calId],
events: {
[baseUID]: masterEvent,
},
},
},
},
};
[baseUID]: masterEvent
}
}
}
}
}
const mockGetEvent = jest.spyOn(EventApi, "getEvent");
const mockGetEvent = jest.spyOn(EventApi, 'getEvent')
renderWithProviders(
<EventUpdateModal
@@ -179,28 +179,28 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all"
/>,
stateWithMaster
);
)
await waitFor(() => {
expect(screen.getByDisplayValue("Master Event")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Master Event')).toBeInTheDocument()
})
// Should NOT fetch from API since we already have master
expect(mockGetEvent).not.toHaveBeenCalled();
});
expect(mockGetEvent).not.toHaveBeenCalled()
})
it("should fallback to instance data if master event fetch fails", async () => {
it('should fallback to instance data if master event fetch fails', async () => {
const instance = {
uid: `${baseUID}/20250115`,
title: "Instance Event",
title: 'Instance Event',
calId,
start: "2025-01-15T10:00:00.000Z",
end: "2025-01-15T11:00:00.000Z",
repetition: { freq: "daily", interval: 1 },
start: '2025-01-15T10:00:00.000Z',
end: '2025-01-15T11:00:00.000Z',
repetition: { freq: 'daily', interval: 1 },
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
URL: `/calendars/${calId}/${baseUID}.ics`,
} as CalendarEvent;
organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent
const stateWithInstance = {
...preloadedState,
@@ -210,19 +210,19 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
[calId]: {
...preloadedState.calendars.list[calId],
events: {
[`${baseUID}/20250115`]: instance,
},
},
},
},
};
[`${baseUID}/20250115`]: instance
}
}
}
}
}
// Mock getEvent to fail
jest
.spyOn(EventApi, "getEvent")
.mockRejectedValue(new Error("Network error"));
.spyOn(EventApi, 'getEvent')
.mockRejectedValue(new Error('Network error'))
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
renderWithProviders(
<EventUpdateModal
@@ -233,48 +233,48 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all"
/>,
stateWithInstance
);
)
// Should still display the instance data as fallback
await waitFor(() => {
expect(screen.getByDisplayValue("Instance Event")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Instance Event')).toBeInTheDocument()
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to fetch master event:",
'Failed to fetch master event:',
expect.any(Error)
);
)
consoleErrorSpy.mockRestore();
});
});
consoleErrorSpy.mockRestore()
})
})
describe("Update All Events - Using Base UID", () => {
it("should use base UID (not instance UID) when updating series with property changes", async () => {
describe('Update All Events - Using Base UID', () => {
it('should use base UID (not instance UID) when updating series with property changes', async () => {
const masterEvent = {
uid: baseUID,
title: "Weekly Meeting",
description: "Original description",
title: 'Weekly Meeting',
description: 'Original description',
calId,
start: "2025-01-15T10:00:00.000Z",
end: "2025-01-15T11:00:00.000Z",
repetition: { freq: "weekly", interval: 1 },
start: '2025-01-15T10:00:00.000Z',
end: '2025-01-15T11:00:00.000Z',
repetition: { freq: 'weekly', interval: 1 },
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
URL: `/calendars/${calId}/${baseUID}.ics`,
} as CalendarEvent;
organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent
const instance1 = {
...masterEvent,
uid: `${baseUID}/20250115`,
};
uid: `${baseUID}/20250115`
}
const instance2 = {
...masterEvent,
uid: `${baseUID}/20250122`,
start: "2025-01-22T10:00:00.000Z",
end: "2025-01-22T11:00:00.000Z",
};
start: '2025-01-22T10:00:00.000Z',
end: '2025-01-22T11:00:00.000Z'
}
const stateWithSeries = {
...preloadedState,
@@ -286,16 +286,16 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
events: {
[baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1,
[`${baseUID}/20250122`]: instance2,
},
},
},
},
};
[`${baseUID}/20250122`]: instance2
}
}
}
}
}
// Mock getEvent to return master
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync");
jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent)
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, 'updateSeriesAsync')
renderWithProviders(
<EventUpdateModal
@@ -306,61 +306,61 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all"
/>,
stateWithSeries
);
)
await waitFor(() => {
expect(screen.getByDisplayValue("Weekly Meeting")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Weekly Meeting')).toBeInTheDocument()
})
// Change only the title (property change, not recurrence rules)
const titleInput = screen.getByDisplayValue("Weekly Meeting");
fireEvent.change(titleInput, { target: { value: "Updated Meeting" } });
const titleInput = screen.getByDisplayValue('Weekly Meeting')
fireEvent.change(titleInput, { target: { value: 'Updated Meeting' } })
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
});
fireEvent.click(saveButton)
})
// Verify updateSeriesAsync was dispatched with base UID
await waitFor(() => {
expect(updateSeriesAsyncSpy).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
uid: "recurring-event-base",
recurrenceId: undefined,
uid: 'recurring-event-base',
recurrenceId: undefined
}),
removeOverrides: false,
removeOverrides: false
})
);
});
});
)
})
})
it("should use base UID when updating series with recurrence rule changes", async () => {
it('should use base UID when updating series with recurrence rule changes', async () => {
const masterEvent = {
uid: baseUID,
title: "Daily Standup",
title: 'Daily Standup',
calId,
start: "2025-01-15T09:00:00.000Z",
end: "2025-01-15T09:30:00.000Z",
repetition: { freq: "daily", interval: 1 }, // Daily
start: '2025-01-15T09:00:00.000Z',
end: '2025-01-15T09:30:00.000Z',
repetition: { freq: 'daily', interval: 1 }, // Daily
allday: false,
timezone: "America/New_York",
organizer: { cn: "test", cal_address: "test@test.com" },
URL: `/calendars/${calId}/${baseUID}.ics`,
} as CalendarEvent;
timezone: 'America/New_York',
organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent
const instance1 = {
...masterEvent,
uid: `${baseUID}/20250115`,
};
uid: `${baseUID}/20250115`
}
const instance2 = {
...masterEvent,
uid: `${baseUID}/20250116`,
start: "2025-01-16T09:00:00.000Z",
end: "2025-01-16T09:30:00.000Z",
};
start: '2025-01-16T09:00:00.000Z',
end: '2025-01-16T09:30:00.000Z'
}
const stateWithSeries = {
...preloadedState,
@@ -372,17 +372,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
events: {
[baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1,
[`${baseUID}/20250116`]: instance2,
},
},
},
},
};
[`${baseUID}/20250116`]: instance2
}
}
}
}
}
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
_embedded: { "dav:item": [] },
} as any);
jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent)
jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({
_embedded: { 'dav:item': [] }
} as any)
const { store } = renderWithProviders(
<EventUpdateModal
@@ -393,72 +393,72 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all"
/>,
stateWithSeries
);
)
await waitFor(() => {
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Daily Standup')).toBeInTheDocument()
})
// Click More Options to access repeat settings
const moreOptionsButton = screen.getByText("common.moreOptions");
fireEvent.click(moreOptionsButton);
const moreOptionsButton = screen.getByText('common.moreOptions')
fireEvent.click(moreOptionsButton)
await waitFor(() => {
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
expect(repeatCheckbox).toBeInTheDocument();
expect(repeatCheckbox).toBeChecked();
});
const repeatCheckbox = screen.getByLabelText('event.form.repeat')
expect(repeatCheckbox).toBeInTheDocument()
expect(repeatCheckbox).toBeChecked()
})
// Change from daily to weekly
const frequencySelect = screen.getByText("event.repeat.frequency.days");
fireEvent.mouseDown(frequencySelect);
const weeklyOption = screen.getByRole("option", {
name: "event.repeat.frequency.weeks",
});
fireEvent.click(weeklyOption);
const frequencySelect = screen.getByText('event.repeat.frequency.days')
fireEvent.mouseDown(frequencySelect)
const weeklyOption = screen.getByRole('option', {
name: 'event.repeat.frequency.weeks'
})
fireEvent.click(weeklyOption)
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
await new Promise((resolve) => setTimeout(resolve, 100));
});
fireEvent.click(saveButton)
await new Promise(resolve => setTimeout(resolve, 100))
})
// Verify all old instances were removed from store
await waitFor(() => {
const state = store.getState();
const calendar = state.calendars.list[calId];
const state = store.getState()
const calendar = state.calendars.list[calId]
expect(calendar.events[baseUID]).toBeUndefined();
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined();
expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined();
});
});
});
expect(calendar.events[baseUID]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined()
})
})
})
describe("Master Event Date Preservation", () => {
describe('Master Event Date Preservation', () => {
it("should preserve master event date when updating time in 'all events' mode", async () => {
// Master event starts on Jan 15 at 10:00 AM
const masterEvent = {
uid: baseUID,
title: "Daily Standup",
title: 'Daily Standup',
calId,
start: "2025-01-15T10:00:00.000Z",
end: "2025-01-15T10:30:00.000Z",
repetition: { freq: "daily", interval: 1 },
start: '2025-01-15T10:00:00.000Z',
end: '2025-01-15T10:30:00.000Z',
repetition: { freq: 'daily', interval: 1 },
allday: false,
timezone: "UTC",
organizer: { cn: "test", cal_address: "test@test.com" },
URL: `/calendars/${calId}/${baseUID}.ics`,
} as CalendarEvent;
timezone: 'UTC',
organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent
// Instance on Jan 17 (different date)
const instance = {
...masterEvent,
uid: `${baseUID}/20250117`,
start: "2025-01-17T10:00:00.000Z",
end: "2025-01-17T10:30:00.000Z",
};
start: '2025-01-17T10:00:00.000Z',
end: '2025-01-17T10:30:00.000Z'
}
const stateWithSeries = {
...preloadedState,
@@ -469,19 +469,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
...preloadedState.calendars.list[calId],
events: {
[baseUID]: masterEvent,
[`${baseUID}/20250117`]: instance,
},
},
},
},
};
[`${baseUID}/20250117`]: instance
}
}
}
}
}
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
jest
.spyOn(EventApi, "putEvent")
.mockResolvedValue({ status: 201 } as any);
jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent)
jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201 } as any)
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync");
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, 'updateSeriesAsync')
renderWithProviders(
<EventUpdateModal
@@ -492,40 +490,40 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all"
/>,
stateWithSeries
);
)
await waitFor(() => {
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Daily Standup')).toBeInTheDocument()
})
// Expand more options to show date/time inputs (normal mode shows DateTimeSummary first)
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
const input = await waitFor(() => screen.getByTestId("start-time-input"));
screen.getByRole('button', { name: 'common.moreOptions' })
)
const input = await waitFor(() => screen.getByTestId('start-time-input'))
// Change time to 2:00 PM (14:00)
await userEvent.click(input);
await userEvent.clear(input);
await userEvent.type(input, "14:00{enter}");
await userEvent.click(input)
await userEvent.clear(input)
await userEvent.type(input, '14:00{enter}')
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
await new Promise((resolve) => setTimeout(resolve, 100));
});
fireEvent.click(saveButton)
await new Promise(resolve => setTimeout(resolve, 100))
})
// Verify the API was called with master date (Jan 15) + new time (14:00)
await waitFor(() => {
expect(updateSeriesAsyncSpy).toHaveBeenCalled();
const callArgs = updateSeriesAsyncSpy.mock.calls[0][0];
const updatedEvent = callArgs.event;
expect(updateSeriesAsyncSpy).toHaveBeenCalled()
const callArgs = updateSeriesAsyncSpy.mock.calls[0][0]
const updatedEvent = callArgs.event
// Should preserve Jan 15 date (master), not Jan 17 (clicked instance)
expect(updatedEvent.start).toContain("2025-01-15");
expect(updatedEvent.start).toContain('2025-01-15')
// But with new time 14:00 (in UTC, this is 14:00)
expect(updatedEvent.start).toContain("14:00");
});
});
});
});
expect(updatedEvent.start).toContain('14:00')
})
})
})
})
+317 -317
View File
@@ -1,329 +1,329 @@
import * as CalendarApi from "@/features/Calendars/CalendarApi";
import * as EventApi from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as CalendarApi from '@/features/Calendars/CalendarApi'
import * as EventApi from '@/features/Events/EventApi'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/features/Events/EventApi");
jest.mock("@/features/Calendars/CalendarApi");
jest.mock('@/features/Events/EventApi')
jest.mock('@/features/Calendars/CalendarApi')
describe("EventUpdateModal Timezone Handling", () => {
const mockOnClose = jest.fn();
describe('EventUpdateModal Timezone Handling', () => {
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
});
jest.clearAllMocks()
sessionStorage.clear()
})
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "test-sid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'test-sid',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
cn: 'test',
cal_address: 'test@test.com'
}
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Test Calendar",
color: "#FF0000",
events: {},
},
'667037022b752d0026472254/cal1': {
id: '667037022b752d0026472254/cal1',
name: 'Test Calendar',
color: '#FF0000',
events: {}
}
},
pending: false,
},
};
pending: false
}
}
it("displays event time in original timezone when editing event", async () => {
it('displays event time in original timezone when editing event', async () => {
// GIVEN user timezone is UTC+2
// WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok)
// THEN the update modal prompts him date with 2PM UTC+7
// Create event at 2PM UTC+7 (Asia/Bangkok)
// 2PM UTC+7 = 14:00 in Bangkok = 07:00 UTC
const eventDateUTC = new Date("2025-01-15T07:00:00.000Z");
const eventDateUTC = new Date('2025-01-15T07:00:00.000Z')
const eventData = {
uid: "test-event-1",
title: "Timezone Event",
calId: "667037022b752d0026472254/cal1",
uid: 'test-event-1',
title: 'Timezone Event',
calId: '667037022b752d0026472254/cal1',
start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok",
timezone: 'Asia/Bangkok',
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: "/calendars/667037022b752d0026472254/cal1/test-event-1.ics",
} as CalendarEvent;
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: '/calendars/667037022b752d0026472254/cal1/test-event-1.ics'
} as CalendarEvent
const stateWithEvent = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
'667037022b752d0026472254/cal1': {
...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: {
"test-event-1": eventData,
},
},
},
},
};
'test-event-1': eventData
}
}
}
}
}
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"test-event-1"}
calId={'667037022b752d0026472254/cal1'}
eventId={'test-event-1'}
eventData={eventData}
/>,
stateWithEvent
);
)
// Verify title is displayed
const titleInput = screen.getByDisplayValue("Timezone Event");
expect(titleInput).toBeInTheDocument();
const titleInput = screen.getByDisplayValue('Timezone Event')
expect(titleInput).toBeInTheDocument()
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// Verify the start date and time inputs exist
await waitFor(() => {
const startDateInput = screen.getByTestId("start-date-input");
const startTimeInput = screen.getByTestId("start-time-input");
const startDateInput = screen.getByTestId('start-date-input')
const startTimeInput = screen.getByTestId('start-time-input')
expect(startDateInput).toBeInTheDocument();
expect(startTimeInput).toBeInTheDocument();
expect(startDateInput).toBeInTheDocument()
expect(startTimeInput).toBeInTheDocument()
// MUI DatePicker/TimePicker values are stored differently - just check elements exist
// The actual values are verified through the form submission
});
});
})
})
it("preserves original timezone when editing event fields", async () => {
const eventDateUTC = new Date("2025-01-15T07:00:00.000Z");
it('preserves original timezone when editing event fields', async () => {
const eventDateUTC = new Date('2025-01-15T07:00:00.000Z')
const eventData = {
uid: "test-event-2",
title: "Original Event",
calId: "667037022b752d0026472254/cal1",
uid: 'test-event-2',
title: 'Original Event',
calId: '667037022b752d0026472254/cal1',
start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok",
timezone: 'Asia/Bangkok',
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: "/calendars/667037022b752d0026472254/cal1/test-event-2.ics",
} as CalendarEvent;
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: '/calendars/667037022b752d0026472254/cal1/test-event-2.ics'
} as CalendarEvent
const stateWithEvent = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
'667037022b752d0026472254/cal1': {
...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: {
"test-event-2": eventData,
},
},
},
},
};
'test-event-2': eventData
}
}
}
}
}
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"test-event-2"}
calId={'667037022b752d0026472254/cal1'}
eventId={'test-event-2'}
eventData={eventData}
/>,
stateWithEvent
);
)
// Edit the title
const titleInput = screen.getByDisplayValue("Original Event");
fireEvent.change(titleInput, { target: { value: "Updated Event" } });
const titleInput = screen.getByDisplayValue('Original Event')
fireEvent.change(titleInput, { target: { value: 'Updated Event' } })
// Verify the timezone is still preserved (should be Asia/Bangkok)
expect(titleInput).toHaveValue("Updated Event");
});
expect(titleInput).toHaveValue('Updated Event')
})
it("preserves resources when editing an event", async () => {
const eventDateUTC = new Date("2025-01-15T07:00:00.000Z");
it('preserves resources when editing an event', async () => {
const eventDateUTC = new Date('2025-01-15T07:00:00.000Z')
const eventData = {
uid: "test-event-resource",
title: "Resource Event",
calId: "667037022b752d0026472254/cal1",
uid: 'test-event-resource',
title: 'Resource Event',
calId: '667037022b752d0026472254/cal1',
start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok",
timezone: 'Asia/Bangkok',
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [
{ cn: "test", cal_address: "test@test.com" },
{ cn: 'test', cal_address: 'test@test.com' },
{
cn: "Conference Room",
cal_address: "room@test.com",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
},
cn: 'Conference Room',
cal_address: 'room@test.com',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'RESOURCE'
}
],
URL: "/calendars/667037022b752d0026472254/cal1/test-event-resource.ics",
} as CalendarEvent;
URL: '/calendars/667037022b752d0026472254/cal1/test-event-resource.ics'
} as CalendarEvent
const stateWithEvent = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
'667037022b752d0026472254/cal1': {
...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: {
"test-event-resource": eventData,
},
},
},
},
};
'test-event-resource': eventData
}
}
}
}
}
const mockPutEvent = jest.spyOn(EventApi, "putEvent").mockResolvedValue({
const mockPutEvent = jest.spyOn(EventApi, 'putEvent').mockResolvedValue({
status: 201,
url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics`,
} as any);
url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics`
} as any)
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"test-event-resource"}
calId={'667037022b752d0026472254/cal1'}
eventId={'test-event-resource'}
eventData={eventData}
/>,
stateWithEvent
);
)
// Edit the title
const titleInput = screen.getByDisplayValue("Resource Event");
const titleInput = screen.getByDisplayValue('Resource Event')
fireEvent.change(titleInput, {
target: { value: "Updated Resource Event" },
});
target: { value: 'Updated Resource Event' }
})
// Click Save
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
});
fireEvent.click(saveButton)
})
await waitFor(() => {
expect(mockPutEvent).toHaveBeenCalled();
});
expect(mockPutEvent).toHaveBeenCalled()
})
const putEventCall = mockPutEvent.mock.calls[0][0];
expect(putEventCall.title).toBe("Updated Resource Event");
const putEventCall = mockPutEvent.mock.calls[0][0]
expect(putEventCall.title).toBe('Updated Resource Event')
// Check that the resource is still in the attendee list!
const attendees = putEventCall.attendee;
const resource = attendees.find((a: any) => a.cutype === "RESOURCE");
expect(resource).toBeDefined();
expect(resource!.cn).toBe("Conference Room");
expect(resource!.cal_address).toBe("room@test.com");
expect(resource!.partstat).toBe("ACCEPTED");
expect(resource!.rsvp).toBe("TRUE");
expect(resource!.role).toBe("REQ-PARTICIPANT");
expect(resource!.cutype).toBe("RESOURCE");
});
});
const attendees = putEventCall.attendee
const resource = attendees.find((a: any) => a.cutype === 'RESOURCE')
expect(resource).toBeDefined()
expect(resource!.cn).toBe('Conference Room')
expect(resource!.cal_address).toBe('room@test.com')
expect(resource!.partstat).toBe('ACCEPTED')
expect(resource!.rsvp).toBe('TRUE')
expect(resource!.role).toBe('REQ-PARTICIPANT')
expect(resource!.cutype).toBe('RESOURCE')
})
})
describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
const mockOnClose = jest.fn();
describe('EventUpdateModal Recurring to Non-Recurring Conversion', () => {
const mockOnClose = jest.fn()
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
jest.clearAllMocks()
jest.restoreAllMocks()
})
const baseUID = "recurring-event-base";
const calId = "667037022b752d0026472254/cal1";
const baseUID = 'recurring-event-base'
const calId = '667037022b752d0026472254/cal1'
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "test-sid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'test-sid',
openpaasId: '667037022b752d0026472254'
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
cn: 'test',
cal_address: 'test@test.com'
}
},
calendars: {
list: {
[calId]: {
id: calId,
name: "Test Calendar",
color: "#FF0000",
events: {},
},
name: 'Test Calendar',
color: '#FF0000',
events: {}
}
},
pending: false,
},
};
pending: false
}
}
it("converts recurring event to non-recurring when repeat is disabled in edit all mode", async () => {
it('converts recurring event to non-recurring when repeat is disabled in edit all mode', async () => {
// Create recurring event with multiple instances
const eventDate = new Date("2025-01-15T10:00:00.000Z");
const eventDate = new Date('2025-01-15T10:00:00.000Z')
const masterEvent = {
uid: baseUID,
title: "Recurring Meeting",
title: 'Recurring Meeting',
calId,
start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 3600000).toISOString(),
repetition: { freq: "daily", interval: 1 },
repetition: { freq: 'daily', interval: 1 },
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC",
} as CalendarEvent;
timezone: 'UTC'
} as CalendarEvent
const instance1 = {
...masterEvent,
uid: `${baseUID}/20250115`,
start: "2025-01-15T10:00:00.000Z",
end: "2025-01-15T11:00:00.000Z",
};
start: '2025-01-15T10:00:00.000Z',
end: '2025-01-15T11:00:00.000Z'
}
const instance2 = {
...masterEvent,
uid: `${baseUID}/20250116`,
start: "2025-01-16T10:00:00.000Z",
end: "2025-01-16T11:00:00.000Z",
};
start: '2025-01-16T10:00:00.000Z',
end: '2025-01-16T11:00:00.000Z'
}
const instance3 = {
...masterEvent,
uid: `${baseUID}/20250117`,
start: "2025-01-17T10:00:00.000Z",
end: "2025-01-17T11:00:00.000Z",
};
start: '2025-01-17T10:00:00.000Z',
end: '2025-01-17T11:00:00.000Z'
}
const stateWithRecurringEvent = {
...preloadedState,
@@ -336,29 +336,29 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
[baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1,
[`${baseUID}/20250116`]: instance2,
[`${baseUID}/20250117`]: instance3,
},
},
},
},
};
[`${baseUID}/20250117`]: instance3
}
}
}
}
}
// Mock API calls
const mockDeleteEvent = jest
.spyOn(EventApi, "deleteEvent")
.mockResolvedValue({} as any);
const mockPutEvent = jest.spyOn(EventApi, "putEvent").mockResolvedValue({
.spyOn(EventApi, 'deleteEvent')
.mockResolvedValue({} as any)
const mockPutEvent = jest.spyOn(EventApi, 'putEvent').mockResolvedValue({
status: 201,
url: `/calendars/${calId}/new-event.ics`,
} as any);
url: `/calendars/${calId}/new-event.ics`
} as any)
const mockGetMasterEvent = jest
.spyOn(EventApi, "getEvent")
.mockResolvedValue(masterEvent);
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
.spyOn(EventApi, 'getEvent')
.mockResolvedValue(masterEvent)
jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({
_embedded: {
"dav:item": [],
},
} as any);
'dav:item': []
}
} as any)
const { store } = renderWithProviders(
<EventUpdateModal
@@ -369,97 +369,97 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
typeOfAction="all"
/>,
stateWithRecurringEvent
);
)
// Wait for master event to be fetched AND form to be initialized
await waitFor(() => {
expect(mockGetMasterEvent).toHaveBeenCalled();
});
expect(mockGetMasterEvent).toHaveBeenCalled()
})
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// Wait for the repeat checkbox to be checked (indicating form is initialized)
const repeatCheckbox = await waitFor(() => {
const checkbox = screen.getByLabelText("event.form.repeat");
expect(checkbox).toBeChecked();
return checkbox;
});
const checkbox = screen.getByLabelText('event.form.repeat')
expect(checkbox).toBeChecked()
return checkbox
})
// Now uncheck repeat checkbox
await act(async () => {
fireEvent.click(repeatCheckbox);
});
fireEvent.click(repeatCheckbox)
})
// Wait for checkbox to be unchecked
await waitFor(() => {
expect(repeatCheckbox).not.toBeChecked();
});
expect(repeatCheckbox).not.toBeChecked()
})
// Click Save button
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
});
fireEvent.click(saveButton)
})
await waitFor(() => {
expect(mockPutEvent).toHaveBeenCalled();
});
expect(mockPutEvent).toHaveBeenCalled()
})
// Verify API calls - should delete all instances
await waitFor(
() => {
// Should have called deleteEvent for each instance (4 total: base + 3 recurrences)
expect(mockDeleteEvent).toHaveBeenCalled();
expect(mockDeleteEvent).toHaveBeenCalled()
// At least one instance should be deleted
expect(mockDeleteEvent.mock.calls.length).toBeGreaterThan(0);
expect(mockDeleteEvent.mock.calls.length).toBeGreaterThan(0)
},
{ timeout: 3000 }
);
)
// Verify new event was created via putEvent
expect(mockPutEvent).toHaveBeenCalled();
const putEventCall = mockPutEvent.mock.calls[0][0];
expect(putEventCall.title).toBe("Recurring Meeting");
expect(putEventCall.repetition?.freq).toBeFalsy();
expect(putEventCall.uid).not.toContain(baseUID);
expect(mockPutEvent).toHaveBeenCalled()
const putEventCall = mockPutEvent.mock.calls[0][0]
expect(putEventCall.title).toBe('Recurring Meeting')
expect(putEventCall.repetition?.freq).toBeFalsy()
expect(putEventCall.uid).not.toContain(baseUID)
// Verify Redux store state changes
const finalState = store.getState();
const calendar = finalState.calendars.list[calId];
const finalState = store.getState()
const calendar = finalState.calendars.list[calId]
// Old recurring instances should be removed from store
expect(calendar.events[baseUID]).toBeUndefined();
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined();
expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined();
expect(calendar.events[`${baseUID}/20250117`]).toBeUndefined();
expect(calendar.events[baseUID]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250117`]).toBeUndefined()
// Verify modal was closed after completion
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
expect(mockOnClose).toHaveBeenCalled()
})
})
it("gracefully handles deletion errors and continues to create new event", async () => {
const eventDate = new Date("2025-01-15T10:00:00.000Z");
it('gracefully handles deletion errors and continues to create new event', async () => {
const eventDate = new Date('2025-01-15T10:00:00.000Z')
const masterEvent = {
uid: baseUID,
title: "Recurring Meeting",
title: 'Recurring Meeting',
calId,
start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 3600000).toISOString(),
repetition: { freq: "weekly", interval: 1 },
repetition: { freq: 'weekly', interval: 1 },
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC",
} as CalendarEvent;
timezone: 'UTC'
} as CalendarEvent
const instance1 = {
...masterEvent,
uid: `${baseUID}/20250115`,
};
uid: `${baseUID}/20250115`
}
const stateWithRecurringEvent = {
...preloadedState,
@@ -470,24 +470,24 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
...preloadedState.calendars.list[calId],
events: {
[baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1,
},
},
},
},
};
[`${baseUID}/20250115`]: instance1
}
}
}
}
}
// Mock deleteEvent to fail with a non-404 error
const mockDeleteEvent = jest
.spyOn(EventApi, "deleteEvent")
.mockRejectedValue(new Error("Network error"));
.spyOn(EventApi, 'deleteEvent')
.mockRejectedValue(new Error('Network error'))
const mockPutEvent = jest
.spyOn(EventApi, "putEvent")
.mockResolvedValue({ status: 201 } as any);
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
.spyOn(EventApi, 'putEvent')
.mockResolvedValue({ status: 201 } as any)
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const mockGetMasterEvent = jest
.spyOn(EventApi, "getEvent")
.mockResolvedValue(masterEvent);
.spyOn(EventApi, 'getEvent')
.mockResolvedValue(masterEvent)
const { store } = renderWithProviders(
<EventUpdateModal
@@ -498,106 +498,106 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
typeOfAction="all"
/>,
stateWithRecurringEvent
);
)
// Wait for master event to be fetched
await waitFor(() => {
expect(mockGetMasterEvent).toHaveBeenCalled();
});
expect(mockGetMasterEvent).toHaveBeenCalled()
})
// Wait for component to load
await waitFor(() => {
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Recurring Meeting')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// Wait for repeat checkbox to be checked
const repeatCheckbox = await waitFor(() => {
const checkbox = screen.getByLabelText("event.form.repeat");
expect(checkbox).toBeChecked();
return checkbox;
});
const checkbox = screen.getByLabelText('event.form.repeat')
expect(checkbox).toBeChecked()
return checkbox
})
// Uncheck repeat checkbox and save
await act(async () => {
fireEvent.click(repeatCheckbox);
});
fireEvent.click(repeatCheckbox)
})
// Wait for checkbox to be unchecked
await waitFor(() => {
expect(repeatCheckbox).not.toBeChecked();
});
expect(repeatCheckbox).not.toBeChecked()
})
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
});
fireEvent.click(saveButton)
})
// Wait for delete to be attempted
await waitFor(
() => {
expect(mockDeleteEvent).toHaveBeenCalled();
expect(mockDeleteEvent).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
// Verify error was logged for failed deletion
await waitFor(
() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete event file")
);
expect.stringContaining('Failed to delete event file')
)
},
{ timeout: 3000 }
);
)
// New implementation gracefully handles deletion errors:
// Even if some instances fail to delete, we still create the new event
// This ensures user gets their non-recurring event
await waitFor(
() => {
expect(mockPutEvent).toHaveBeenCalled();
expect(mockPutEvent).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
const putEventCall = mockPutEvent.mock.calls[0][0];
expect(putEventCall.title).toBe("Recurring Meeting");
expect(putEventCall.repetition?.freq).toBeFalsy();
const putEventCall = mockPutEvent.mock.calls[0][0]
expect(putEventCall.title).toBe('Recurring Meeting')
expect(putEventCall.repetition?.freq).toBeFalsy()
// Modal should close after operation completes
await waitFor(
() => {
expect(mockOnClose).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
consoleErrorSpy.mockRestore();
});
consoleErrorSpy.mockRestore()
})
it("closes all modals after converting recurring to non-recurring", async () => {
const eventDate = new Date("2025-01-15T10:00:00.000Z");
it('closes all modals after converting recurring to non-recurring', async () => {
const eventDate = new Date('2025-01-15T10:00:00.000Z')
const masterEvent = {
uid: baseUID,
title: "Recurring Meeting",
title: 'Recurring Meeting',
calId,
start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 3600000).toISOString(),
repetition: { freq: "monthly", interval: 1 },
repetition: { freq: 'monthly', interval: 1 },
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC",
} as CalendarEvent;
timezone: 'UTC'
} as CalendarEvent
const instance1 = {
...masterEvent,
uid: `${baseUID}/20250115`,
};
uid: `${baseUID}/20250115`
}
const stateWithRecurringEvent = {
...preloadedState,
@@ -608,24 +608,24 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
...preloadedState.calendars.list[calId],
events: {
[baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1,
},
},
},
},
};
[`${baseUID}/20250115`]: instance1
}
}
}
}
}
// Mock API calls
jest.spyOn(EventApi, "deleteEvent").mockResolvedValue({} as any);
jest.spyOn(EventApi, "putEvent").mockResolvedValue({ status: 201 } as any);
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
jest.spyOn(EventApi, 'deleteEvent').mockResolvedValue({} as any)
jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201 } as any)
jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({
_embedded: {
"dav:item": [],
},
} as any);
'dav:item': []
}
} as any)
// Mock onCloseAll to test closing both modals
const mockOnCloseAll = jest.fn();
const mockOnCloseAll = jest.fn()
renderWithProviders(
<EventUpdateModal
@@ -637,36 +637,36 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
typeOfAction="all"
/>,
stateWithRecurringEvent
);
)
// Wait for component to load
await waitFor(() => {
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Recurring Meeting')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// Uncheck repeat and save
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
const repeatCheckbox = screen.getByLabelText('event.form.repeat')
await act(async () => {
fireEvent.click(repeatCheckbox);
});
fireEvent.click(repeatCheckbox)
})
const saveButton = screen.getByRole("button", { name: "actions.save" });
const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => {
fireEvent.click(saveButton);
});
fireEvent.click(saveButton)
})
// Verify onCloseAll was called to close both preview and update modals
await waitFor(
() => {
expect(mockOnCloseAll).toHaveBeenCalled();
expect(mockOnCloseAll).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
// Verify onClose was NOT called (onCloseAll is used instead)
expect(mockOnClose).not.toHaveBeenCalled();
});
});
expect(mockOnClose).not.toHaveBeenCalled()
})
})
+185 -187
View File
@@ -1,95 +1,93 @@
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";
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('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");
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";
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
);
});
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", () => {
it('returns false when vfreebusy has no freebusy property', () => {
const data = {
data: [
"vcalendar",
'vcalendar',
[],
[
[
"vfreebusy",
[["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"]],
[],
],
],
],
};
expect(hasFreeBusyConflict(data)).toBe(false);
});
'vfreebusy',
[['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z']],
[]
]
]
]
}
expect(hasFreeBusyConflict(data)).toBe(false)
})
it("returns true when vfreebusy contains a freebusy property", () => {
it('returns true when vfreebusy contains a freebusy property', () => {
const data = {
data: [
"vcalendar",
'vcalendar',
[],
[
[
"vfreebusy",
'vfreebusy',
[
["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"],
["freebusy", {}, "period", "2026-03-14T14:00:00Z/PT1H"],
['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
['freebusy', {}, 'period', '2026-03-14T14:00:00Z/PT1H']
],
[],
],
],
],
};
expect(hasFreeBusyConflict(data)).toBe(true);
});
[]
]
]
]
}
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);
});
});
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());
describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
beforeEach(() => jest.clearAllMocks())
const newAttendee = { email: "alice@example.com", userId: "user-alice" };
const newAttendee = { email: 'alice@example.com', userId: 'user-alice' }
it("shows loading then free for a new attendee", async () => {
mockREPORT.mockResolvedValue(false);
it('shows loading then free for a new attendee', async () => {
mockREPORT.mockResolvedValue(false)
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -97,17 +95,17 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee],
start: START,
end: END,
timezone: TZ,
timezone: TZ
})
);
)
expect(result.current[newAttendee.email]).toBe("loading");
expect(result.current[newAttendee.email]).toBe('loading')
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
});
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
})
it("shows busy when REPORT returns true", async () => {
mockREPORT.mockResolvedValue(true);
it('shows busy when REPORT returns true', async () => {
mockREPORT.mockResolvedValue(true)
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -115,15 +113,15 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee],
start: START,
end: END,
timezone: TZ,
timezone: TZ
})
);
)
await waitFor(() => expect(result.current[newAttendee.email]).toBe("busy"));
});
await waitFor(() => expect(result.current[newAttendee.email]).toBe('busy'))
})
it("shows unknown when REPORT throws", async () => {
mockREPORT.mockRejectedValue(new Error("network error"));
it('shows unknown when REPORT throws', async () => {
mockREPORT.mockRejectedValue(new Error('network error'))
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -131,20 +129,20 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee],
start: START,
end: END,
timezone: TZ,
timezone: TZ
})
);
)
await waitFor(() =>
expect(result.current[newAttendee.email]).toBe("unknown")
);
});
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);
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 attendeeWithoutId = { email: 'bob@example.com' }
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -152,42 +150,42 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [attendeeWithoutId],
start: START,
end: END,
timezone: TZ,
timezone: TZ
})
);
)
await waitFor(() =>
expect(result.current[attendeeWithoutId.email]).toBe("free")
);
expect(mockGetUserData).toHaveBeenCalledWith("bob@example.com");
expect(result.current[attendeeWithoutId.email]).toBe('free')
)
expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com')
expect(mockREPORT).toHaveBeenCalledWith(
"resolved-id",
'resolved-id',
expect.any(String),
expect.any(String)
);
});
)
})
it("shows unknown when userId cannot be resolved", async () => {
mockGetUserData.mockResolvedValue([]);
it('shows unknown when userId cannot be resolved', async () => {
mockGetUserData.mockResolvedValue([])
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [{ email: "ghost@example.com" }],
newAttendees: [{ email: 'ghost@example.com' }],
start: START,
end: END,
timezone: TZ,
timezone: TZ
})
);
)
await waitFor(() =>
expect(result.current["ghost@example.com"]).toBe("unknown")
);
expect(mockREPORT).not.toHaveBeenCalled();
});
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);
it('does not re-fetch an attendee already fetched', async () => {
mockREPORT.mockResolvedValue(false)
const { result, rerender } = renderHook(
({ attendees }) =>
@@ -196,19 +194,19 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: attendees,
start: START,
end: END,
timezone: TZ,
timezone: TZ
}),
{ initialProps: { attendees: [newAttendee] } }
);
)
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
rerender({ attendees: [newAttendee] });
expect(mockREPORT).toHaveBeenCalledTimes(1);
});
rerender({ attendees: [newAttendee] })
expect(mockREPORT).toHaveBeenCalledTimes(1)
})
it("removes departed attendee from the map", async () => {
mockREPORT.mockResolvedValue(false);
it('removes departed attendee from the map', async () => {
mockREPORT.mockResolvedValue(false)
const { result, rerender } = renderHook(
({ attendees }) =>
@@ -217,22 +215,22 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: attendees,
start: START,
end: END,
timezone: TZ,
timezone: TZ
}),
{ initialProps: { attendees: [newAttendee] } }
);
)
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
rerender({ attendees: [] });
rerender({ attendees: [] })
await waitFor(() =>
expect(result.current[newAttendee.email]).toBeUndefined()
);
});
)
})
it("invalidates cache and re-fetches when time window changes", async () => {
mockREPORT.mockResolvedValue(false);
it('invalidates cache and re-fetches when time window changes', async () => {
mockREPORT.mockResolvedValue(false)
const { result, rerender } = renderHook(
({ start, end }) =>
@@ -241,65 +239,65 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee],
start,
end,
timezone: TZ,
timezone: TZ
}),
{ initialProps: { start: START, end: END } }
);
)
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
rerender({ start: "2026-03-15T14:00:00", end: "2026-03-15T15:00:00" });
rerender({ start: '2026-03-15T14:00:00', end: '2026-03-15T15:00:00' })
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
expect(mockREPORT).toHaveBeenCalledTimes(2);
});
expect(mockREPORT).toHaveBeenCalledTimes(2)
})
it("passes UTC-converted iCal times to the API", async () => {
mockREPORT.mockResolvedValue(false);
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",
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());
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"
);
});
});
'user-alice',
'20260314T150000',
'20260314T160000'
)
})
})
describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
beforeEach(() => jest.clearAllMocks());
describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
beforeEach(() => jest.clearAllMocks())
const existingAttendee = { email: "carol@example.com", userId: "user-carol" };
const existingAttendee = { email: 'carol@example.com', userId: 'user-carol' }
it("does not fetch when eventUid is missing", () => {
it('does not fetch when eventUid is missing', () => {
renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
timezone: TZ
})
);
)
expect(mockPOST).not.toHaveBeenCalled();
});
expect(mockPOST).not.toHaveBeenCalled()
})
it("shows loading then free for existing attendees", async () => {
mockPOST.mockResolvedValue({ "user-carol": false });
it('shows loading then free for existing attendees', async () => {
mockPOST.mockResolvedValue({ 'user-carol': false })
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -308,19 +306,19 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
eventUid: 'event-123'
})
);
)
expect(result.current[existingAttendee.email]).toBe("loading");
expect(result.current[existingAttendee.email]).toBe('loading')
await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("free")
);
});
expect(result.current[existingAttendee.email]).toBe('free')
)
})
it("shows busy when POST returns busy for userId", async () => {
mockPOST.mockResolvedValue({ "user-carol": true });
it('shows busy when POST returns busy for userId', async () => {
mockPOST.mockResolvedValue({ 'user-carol': true })
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -329,17 +327,17 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
eventUid: 'event-123'
})
);
)
await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("busy")
);
});
expect(result.current[existingAttendee.email]).toBe('busy')
)
})
it("shows unknown when POST throws", async () => {
mockPOST.mockRejectedValue(new Error("server error"));
it('shows unknown when POST throws', async () => {
mockPOST.mockRejectedValue(new Error('server error'))
const { result } = renderHook(() =>
useAttendeesFreeBusy({
@@ -348,17 +346,17 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
eventUid: 'event-123'
})
);
)
await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("unknown")
);
});
expect(result.current[existingAttendee.email]).toBe('unknown')
)
})
it("passes the eventUid to the POST API", async () => {
mockPOST.mockResolvedValue({ "user-carol": false });
it('passes the eventUid to the POST API', async () => {
mockPOST.mockResolvedValue({ 'user-carol': false })
renderHook(() =>
useAttendeesFreeBusy({
@@ -367,35 +365,35 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START,
end: END,
timezone: TZ,
eventUid: "event-abc",
eventUid: 'event-abc'
})
);
)
await waitFor(() => expect(mockPOST).toHaveBeenCalled());
await waitFor(() => expect(mockPOST).toHaveBeenCalled())
expect(mockPOST).toHaveBeenCalledWith(
["user-carol"],
['user-carol'],
expect.any(String),
expect.any(String),
"event-abc"
);
});
});
'event-abc'
)
})
})
describe("useAttendeesFreeBusy — enabled flag", () => {
it("does not fetch when disabled", () => {
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" }],
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,
eventUid: 'event-123',
enabled: false
})
);
)
expect(mockPOST).not.toHaveBeenCalled();
expect(mockREPORT).not.toHaveBeenCalled();
});
});
expect(mockPOST).not.toHaveBeenCalled()
expect(mockREPORT).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
+70 -70
View File
@@ -1,67 +1,67 @@
import ImportAlert from "@/features/Events/ImportAlert";
import { fireEvent, screen } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import ImportAlert from '@/features/Events/ImportAlert'
import { fireEvent, screen } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
// Mock the ErrorSnackbar component since we want to test ImportAlert logic,
// but we can also test integration if we don't mock it.
// Given ImportAlert uses EventErrorSnackbar which uses MUI Snackbar,
// it's better to render it and check for text presence.
describe("ImportAlert", () => {
describe('ImportAlert', () => {
const preloadedState = {
calendars: {
list: {
cal1: {
id: "cal1",
name: "Calendar 1",
id: 'cal1',
name: 'Calendar 1',
events: {
event1: {
uid: "event1",
title: "Event 1",
error: "Error message 1",
uid: 'event1',
title: 'Event 1',
error: 'Error message 1'
},
event2: {
uid: "event2",
title: "Event 2",
uid: 'event2',
title: 'Event 2'
// No error
},
},
}
}
},
cal2: {
id: "cal2",
name: "Calendar 2",
id: 'cal2',
name: 'Calendar 2',
events: {
event3: {
uid: "event3",
title: "Event 3",
error: "Error message 2",
},
},
},
},
},
};
uid: 'event3',
title: 'Event 3',
error: 'Error message 2'
}
}
}
}
}
}
it("renders nothing when there are no errors", () => {
it('renders nothing when there are no errors', () => {
const state = {
calendars: {
list: {
cal1: {
id: "cal1",
id: 'cal1',
events: {
event1: { uid: "event1" },
},
},
},
},
};
event1: { uid: 'event1' }
}
}
}
}
}
const { container } = renderWithProviders(<ImportAlert />, state);
expect(container).toBeEmptyDOMElement();
});
const { container } = renderWithProviders(<ImportAlert />, state)
expect(container).toBeEmptyDOMElement()
})
it("renders snackbar with error message when there is one error", () => {
renderWithProviders(<ImportAlert />, preloadedState);
it('renders snackbar with error message when there is one error', () => {
renderWithProviders(<ImportAlert />, preloadedState)
// EventErrorSnackbar shows "multipleEvents" if > 1, or the message if 1.
// Here we have 2 errors in preloadedState.
@@ -70,24 +70,24 @@ describe("ImportAlert", () => {
calendars: {
list: {
cal1: {
id: "cal1",
id: 'cal1',
events: {
event1: {
uid: "event1",
error: "Single Error",
},
},
},
},
},
};
uid: 'event1',
error: 'Single Error'
}
}
}
}
}
}
renderWithProviders(<ImportAlert />, singleErrorState);
expect(screen.getByText("Single Error")).toBeInTheDocument();
});
renderWithProviders(<ImportAlert />, singleErrorState)
expect(screen.getByText('Single Error')).toBeInTheDocument()
})
it("renders summary message when there are multiple errors", () => {
renderWithProviders(<ImportAlert />, preloadedState);
it('renders summary message when there are multiple errors', () => {
renderWithProviders(<ImportAlert />, preloadedState)
// 2 errors in preloadedState
// "error.multipleEvents" key is used.
// In test environment, t function usually returns the key or formatted string.
@@ -108,34 +108,34 @@ describe("ImportAlert", () => {
// const summary = messages.length === 1 ? messages[0] : t("error.multipleEvents", { count: messages.length });
// We can check if the Alert is present.
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it("dismisses error when close button is clicked", () => {
it('dismisses error when close button is clicked', () => {
const singleErrorState = {
calendars: {
list: {
cal1: {
id: "cal1",
id: 'cal1',
events: {
event1: {
uid: "event1",
error: "Dismiss Me",
},
},
},
},
},
};
uid: 'event1',
error: 'Dismiss Me'
}
}
}
}
}
}
renderWithProviders(<ImportAlert />, singleErrorState);
expect(screen.getByText("Dismiss Me")).toBeInTheDocument();
renderWithProviders(<ImportAlert />, singleErrorState)
expect(screen.getByText('Dismiss Me')).toBeInTheDocument()
const closeButton = screen.getByRole("button", { name: /ok/i }); // EventErrorSnackbar has an "OK" button
fireEvent.click(closeButton);
const closeButton = screen.getByRole('button', { name: /ok/i }) // EventErrorSnackbar has an "OK" button
fireEvent.click(closeButton)
// After clicking, it should disappear (or at least be removed from DOM or hidden)
// Since we use local state to filter dismissed errors, it should re-render with null.
expect(screen.queryByText("Dismiss Me")).not.toBeInTheDocument();
});
});
expect(screen.queryByText('Dismiss Me')).not.toBeInTheDocument()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,213 +1,213 @@
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange";
import { userAttendee } from "@/features/User/models/attendee";
import { userOrganiser } from "@/features/User/userDataTypes";
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange'
import { userAttendee } from '@/features/User/models/attendee'
import { userOrganiser } from '@/features/User/userDataTypes'
describe("updateAttendeesAfterTimeChange", () => {
describe('updateAttendeesAfterTimeChange', () => {
const mockAttendee: userAttendee = {
cal_address: "attendee@example.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cn: "attendee",
};
cal_address: 'attendee@example.com',
partstat: 'ACCEPTED',
rsvp: 'FALSE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cn: 'attendee'
}
const mockOrganizer: userOrganiser = {
cn: "Organizer",
cal_address: "organizer@example.com",
};
cn: 'Organizer',
cal_address: 'organizer@example.com'
}
const organizerAttendee: userAttendee = {
cal_address: "organizer@example.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
cn: "organizer",
};
cal_address: 'organizer@example.com',
partstat: 'ACCEPTED',
rsvp: 'FALSE',
role: 'CHAIR',
cutype: 'INDIVIDUAL',
cn: 'organizer'
}
const baseEvent: CalendarEvent = {
uid: "test-event",
URL: "/calendar/667037022b752d0026472254/cal1/test-event.ics",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
start: "2025-01-15T07:00:00.000Z",
end: "2025-01-15T08:00:00.000Z",
uid: 'test-event',
URL: '/calendar/667037022b752d0026472254/cal1/test-event.ics',
title: 'Test Event',
calId: '667037022b752d0026472254/cal1',
start: '2025-01-15T07:00:00.000Z',
end: '2025-01-15T08:00:00.000Z',
allday: false,
organizer: mockOrganizer,
attendee: [mockAttendee, organizerAttendee],
timezone: "ETC/UTC",
};
timezone: 'ETC/UTC'
}
describe("early returns", () => {
it("should return the event unchanged when attendee list is undefined", () => {
const event = { ...baseEvent, attendee: undefined };
describe('early returns', () => {
it('should return the event unchanged when attendee list is undefined', () => {
const event = { ...baseEvent, attendee: undefined }
const result = updateAttendeesAfterTimeChange(
event as unknown as CalendarEvent
);
expect(result).toBe(event);
});
)
expect(result).toBe(event)
})
it("should NOT bail out early when organizer is undefined", () => {
const event = { ...baseEvent, organizer: undefined };
const result = updateAttendeesAfterTimeChange(event);
expect(result.attendee).toBeDefined();
expect(result.attendee).toHaveLength(2);
});
});
it('should NOT bail out early when organizer is undefined', () => {
const event = { ...baseEvent, organizer: undefined }
const result = updateAttendeesAfterTimeChange(event)
expect(result.attendee).toBeDefined()
expect(result.attendee).toHaveLength(2)
})
})
describe("without provided attendees list", () => {
it("should return attendees unchanged when timeChanged is false", () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false);
expect(result.attendee?.[0].partstat).toBe("ACCEPTED");
expect(result.attendee?.[0].rsvp).toBe("FALSE");
});
describe('without provided attendees list', () => {
it('should return attendees unchanged when timeChanged is false', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false)
expect(result.attendee?.[0].partstat).toBe('ACCEPTED')
expect(result.attendee?.[0].rsvp).toBe('FALSE')
})
it("should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true", () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true);
it('should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true)
const attendee = result.attendee?.find(
(a) => a.cal_address === "attendee@example.com"
);
expect(attendee?.partstat).toBe("NEEDS-ACTION");
expect(attendee?.rsvp).toBe("TRUE");
});
a => a.cal_address === 'attendee@example.com'
)
expect(attendee?.partstat).toBe('NEEDS-ACTION')
expect(attendee?.rsvp).toBe('TRUE')
})
it("should NOT mark the organizer as NEEDS-ACTION when timeChanged is true", () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true);
it('should NOT mark the organizer as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true)
const organizer = result.attendee?.find(
(a) => a.cal_address === "organizer@example.com"
);
expect(organizer?.partstat).toBe("ACCEPTED");
expect(organizer?.rsvp).toBe("FALSE");
});
a => a.cal_address === 'organizer@example.com'
)
expect(organizer?.partstat).toBe('ACCEPTED')
expect(organizer?.rsvp).toBe('FALSE')
})
it("should return attendees unchanged when timeChanged is undefined", () => {
const result = updateAttendeesAfterTimeChange(baseEvent);
expect(result.attendee?.[0].partstat).toBe("ACCEPTED");
});
});
it('should return attendees unchanged when timeChanged is undefined', () => {
const result = updateAttendeesAfterTimeChange(baseEvent)
expect(result.attendee?.[0].partstat).toBe('ACCEPTED')
})
})
describe("with provided attendees list", () => {
describe('with provided attendees list', () => {
const newAttendee: userAttendee = {
cal_address: "new@example.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cn: "new",
};
cal_address: 'new@example.com',
partstat: 'ACCEPTED',
rsvp: 'FALSE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cn: 'new'
}
it("should use existing attendee data when the address already exists in the event", () => {
it('should use existing attendee data when the address already exists in the event', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false, [
mockAttendee,
]);
mockAttendee
])
const attendee = result.attendee?.find(
(a) => a.cal_address === "attendee@example.com"
);
expect(attendee?.partstat).toBe("ACCEPTED");
});
a => a.cal_address === 'attendee@example.com'
)
expect(attendee?.partstat).toBe('ACCEPTED')
})
it("should mark new attendee as NEEDS-ACTION when not found in existing list", () => {
it('should mark new attendee as NEEDS-ACTION when not found in existing list', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false, [
newAttendee,
]);
newAttendee
])
const attendee = result.attendee?.find(
(a) => a.cal_address === "new@example.com"
);
expect(attendee?.partstat).toBe("NEEDS-ACTION");
expect(attendee?.rsvp).toBe("TRUE");
});
a => a.cal_address === 'new@example.com'
)
expect(attendee?.partstat).toBe('NEEDS-ACTION')
expect(attendee?.rsvp).toBe('TRUE')
})
it("should mark all attendees as NEEDS-ACTION when timeChanged is true", () => {
it('should mark all attendees as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true, [
mockAttendee,
newAttendee,
]);
newAttendee
])
const nonOrganizerAttendees = result.attendee?.filter(
(a) => a.cal_address !== "organizer@example.com"
);
nonOrganizerAttendees?.forEach((a) => {
expect(a.partstat).toBe("NEEDS-ACTION");
expect(a.rsvp).toBe("TRUE");
});
});
a => a.cal_address !== 'organizer@example.com'
)
nonOrganizerAttendees?.forEach(a => {
expect(a.partstat).toBe('NEEDS-ACTION')
expect(a.rsvp).toBe('TRUE')
})
})
it("should append organizer entry at the end of the attendee list", () => {
it('should append organizer entry at the end of the attendee list', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false, [
mockAttendee,
]);
const last = result.attendee?.[result.attendee.length - 1];
expect(last?.cal_address).toBe("organizer@example.com");
});
mockAttendee
])
const last = result.attendee?.[result.attendee.length - 1]
expect(last?.cal_address).toBe('organizer@example.com')
})
it("should use existing organizer attendee data when found in event attendees", () => {
it('should use existing organizer attendee data when found in event attendees', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false, [
mockAttendee,
]);
mockAttendee
])
const organizer = result.attendee?.find(
(a) => a.cal_address === "organizer@example.com"
);
expect(organizer?.partstat).toBe("ACCEPTED");
});
a => a.cal_address === 'organizer@example.com'
)
expect(organizer?.partstat).toBe('ACCEPTED')
})
it("should fall back to organizer defaults when organizer is not in existing attendees", () => {
it('should fall back to organizer defaults when organizer is not in existing attendees', () => {
const eventWithoutOrganizerInAttendees: CalendarEvent = {
...baseEvent,
attendee: [mockAttendee], // organizer not in attendee list
};
attendee: [mockAttendee] // organizer not in attendee list
}
const result = updateAttendeesAfterTimeChange(
eventWithoutOrganizerInAttendees,
false,
[mockAttendee]
);
)
const organizer = result.attendee?.find(
(a) => a.cal_address === "organizer@example.com"
);
expect(organizer?.role).toBe("CHAIR");
expect(organizer?.partstat).toBe("NEEDS-ACTION");
});
});
a => a.cal_address === 'organizer@example.com'
)
expect(organizer?.role).toBe('CHAIR')
expect(organizer?.partstat).toBe('NEEDS-ACTION')
})
})
describe("organizer undefined", () => {
describe('organizer undefined', () => {
const eventNoOrganizer: CalendarEvent = {
...baseEvent,
organizer: undefined,
};
organizer: undefined
}
it("should return attendees without bailing out", () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer);
expect(result.attendee).toHaveLength(2);
});
it('should return attendees without bailing out', () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer)
expect(result.attendee).toHaveLength(2)
})
it("should mark attendees as NEEDS-ACTION when timeChanged is true", () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true);
result.attendee?.forEach((a) => {
expect(a.partstat).toBe("NEEDS-ACTION");
expect(a.rsvp).toBe("TRUE");
});
});
it('should mark attendees as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true)
result.attendee?.forEach(a => {
expect(a.partstat).toBe('NEEDS-ACTION')
expect(a.rsvp).toBe('TRUE')
})
})
it("should not mutate attendees when timeChanged is false", () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false);
expect(result.attendee?.[0].partstat).toBe("ACCEPTED");
expect(result.attendee?.[0].rsvp).toBe("FALSE");
});
it('should not mutate attendees when timeChanged is false', () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false)
expect(result.attendee?.[0].partstat).toBe('ACCEPTED')
expect(result.attendee?.[0].rsvp).toBe('FALSE')
})
it("should not append an organizer entry when organizer is undefined and attendees list is provided", () => {
it('should not append an organizer entry when organizer is undefined and attendees list is provided', () => {
const newAttendee: userAttendee = {
cal_address: "new@example.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cn: "new",
};
cal_address: 'new@example.com',
partstat: 'ACCEPTED',
rsvp: 'FALSE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cn: 'new'
}
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false, [
newAttendee,
]);
expect(result.attendee).toHaveLength(1);
expect(result.attendee?.[0].cal_address).toBe("new@example.com");
});
});
});
newAttendee
])
expect(result.attendee).toHaveLength(1)
expect(result.attendee?.[0].cal_address).toBe('new@example.com')
})
})
})
+227 -227
View File
@@ -1,344 +1,344 @@
import SearchBar from "@/components/Menubar/EventSearchBar";
import * as searchThunk from "@/features/Search/SearchSlice";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import SearchBar from '@/components/Menubar/EventSearchBar'
import * as searchThunk from '@/features/Search/SearchSlice'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("EventSearchBar", () => {
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
const end = new Date(today);
end.setHours(11, 0, 0, 0);
describe('EventSearchBar', () => {
const today = new Date()
const start = new Date(today)
start.setHours(10, 0, 0, 0)
const end = new Date(today)
end.setHours(11, 0, 0, 0)
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
tokens: { accessToken: 'token' }
},
calendars: {
list: {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
'user1/cal1': {
name: 'Calendar personal',
id: 'user1/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user1/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user1/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
},
"user2/cal1": {
name: "Calendar delegated",
'user2/cal1': {
name: 'Calendar delegated',
delegated: true,
id: "user2/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
id: 'user2/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user2/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user2/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
},
"user3/cal1": {
name: "Calendar shared",
id: "user3/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
'user3/cal1': {
name: 'Calendar shared',
id: 'user3/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user3/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user3/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
}
},
pending: false,
},
};
pending: false
}
}
it("should render search icon button initially", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should render search icon button initially', () => {
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
expect(searchButton).toBeInTheDocument();
});
const searchButton = screen.getByRole('button')
expect(searchButton).toBeInTheDocument()
})
it("should expand search bar when icon is clicked", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should expand search bar when icon is clicked', () => {
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
expect(searchInput).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('common.search')
expect(searchInput).toBeInTheDocument()
})
it("should unexpand search bar when field is unfocused and empty", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should unexpand search bar when field is unfocused and empty', () => {
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.mouseDown(document.body);
expect(searchInput).not.toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.mouseDown(document.body)
expect(searchInput).not.toBeInTheDocument()
})
it("should not unexpand search bar when field is unfocused but not empty", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should not unexpand search bar when field is unfocused but not empty', () => {
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
userEvent.type(searchInput, "test");
fireEvent.blur(searchInput);
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveValue("test");
});
const searchInput = screen.getByPlaceholderText('common.search')
userEvent.type(searchInput, 'test')
fireEvent.blur(searchInput)
expect(searchInput).toBeInTheDocument()
expect(searchInput).toHaveValue('test')
})
it("should update search value on input", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should update search value on input', () => {
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.change(searchInput, { target: { value: "meeting" } });
const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.change(searchInput, { target: { value: 'meeting' } })
expect(searchInput).toHaveValue("meeting");
});
expect(searchInput).toHaveValue('meeting')
})
it("should open filter popover when tune icon is clicked", async () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should open filter popover when tune icon is clicked', async () => {
renderWithProviders(<SearchBar />, preloadedState)
// Expand search bar
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
// Click tune icon
const tuneButton = screen.getByTestId("TuneIcon");
if (tuneButton) fireEvent.click(tuneButton);
const tuneButton = screen.getByTestId('TuneIcon')
if (tuneButton) fireEvent.click(tuneButton)
await waitFor(() => {
expect(screen.getByText("search.searchIn")).toBeInTheDocument();
});
});
expect(screen.getByText('search.searchIn')).toBeInTheDocument()
})
})
it("should clear search value when clear button is clicked", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('should clear search value when clear button is clicked', () => {
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.change(searchInput, { target: { value: "meeting" } });
const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.change(searchInput, { target: { value: 'meeting' } })
const clearButton = screen.getByTestId("HighlightOffIcon");
fireEvent.click(clearButton);
const clearButton = screen.getByTestId('HighlightOffIcon')
fireEvent.click(clearButton)
expect(searchInput).toHaveValue("");
});
expect(searchInput).toHaveValue('')
})
it("should trigger search on Enter key", async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync");
it('should trigger search on Enter key', async () => {
const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync')
renderWithProviders(<SearchBar />, preloadedState);
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.change(searchInput, { target: { value: 'test' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => {
expect(searchSpy).toHaveBeenCalledWith({
filters: {
keywords: "",
keywords: '',
organizers: [],
attendees: [],
searchIn: ["user1/cal1"],
searchIn: ['user1/cal1']
},
search: "test",
});
});
});
it("should not trigger search on Enter key when search is empty", async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync");
search: 'test'
})
})
})
it('should not trigger search on Enter key when search is empty', async () => {
const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync')
renderWithProviders(<SearchBar />, preloadedState);
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.keyDown(searchInput, { key: "Enter" });
const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => {
expect(searchSpy).not.toHaveBeenCalled();
});
});
expect(searchSpy).not.toHaveBeenCalled()
})
})
it("should not trigger search when filter keywords is empty", async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync");
it('should not trigger search when filter keywords is empty', async () => {
const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync')
renderWithProviders(<SearchBar />, preloadedState);
renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchButton = screen.getByRole('button')
fireEvent.click(searchButton)
// Click tune icon
const tuneButton = screen.getByTestId("TuneIcon");
if (tuneButton) fireEvent.click(tuneButton);
const tuneButton = screen.getByTestId('TuneIcon')
if (tuneButton) fireEvent.click(tuneButton)
fireEvent.click(screen.getByText("common.search"));
fireEvent.click(screen.getByText('common.search'))
await waitFor(() => {
expect(searchSpy).not.toHaveBeenCalled();
});
});
it("keeps search bar expanded when popover opens", () => {
renderWithProviders(<SearchBar />, preloadedState);
expect(searchSpy).not.toHaveBeenCalled()
})
})
it('keeps search bar expanded when popover opens', () => {
renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button"));
fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
.getAllByRole('button')
.find(b => b.querySelector('[data-testid="TuneIcon"]'))
fireEvent.click(tuneBtn!);
fireEvent.click(tuneBtn!)
expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument();
});
expect(screen.getByPlaceholderText('common.search')).toBeInTheDocument()
})
it("does NOT collapse search when clicking inside popover", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('does NOT collapse search when clicking inside popover', () => {
renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button"));
fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
.getAllByRole('button')
.find(b => b.querySelector('[data-testid="TuneIcon"]'))
fireEvent.click(tuneBtn!);
fireEvent.click(tuneBtn!)
fireEvent.mouseDown(screen.getByText("search.searchIn"));
fireEvent.mouseDown(screen.getByText('search.searchIn'))
expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument();
});
expect(screen.getByPlaceholderText('common.search')).toBeInTheDocument()
})
it("clicking Cancel closes popover and search collapses if empty", async () => {
renderWithProviders(<SearchBar />, preloadedState);
it('clicking Cancel closes popover and search collapses if empty', async () => {
renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button"));
fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
.getAllByRole('button')
.find(b => b.querySelector('[data-testid="TuneIcon"]'))
fireEvent.click(tuneBtn!);
fireEvent.click(tuneBtn!)
fireEvent.click(screen.getByText("common.cancel"));
fireEvent.click(screen.getByText('common.cancel'))
await waitFor(() =>
expect(screen.queryByText("search.searchIn")).not.toBeInTheDocument()
);
expect(screen.queryByPlaceholderText("common.search")).toBeNull();
});
expect(screen.queryByText('search.searchIn')).not.toBeInTheDocument()
)
expect(screen.queryByPlaceholderText('common.search')).toBeNull()
})
it("does not collapse when selecting a filter value", async () => {
renderWithProviders(<SearchBar />, preloadedState);
it('does not collapse when selecting a filter value', async () => {
renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button"));
fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
.getAllByRole('button')
.find(b => b.querySelector('[data-testid="TuneIcon"]'))
fireEvent.click(tuneBtn!);
fireEvent.click(tuneBtn!)
fireEvent.mouseDown(screen.getByText("search.searchIn")); // simulate interaction inside menu
fireEvent.mouseDown(screen.getByText('search.searchIn')) // simulate interaction inside menu
expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument();
});
expect(screen.getByPlaceholderText('common.search')).toBeInTheDocument()
})
it("keeps input value after popover closes if not empty", () => {
renderWithProviders(<SearchBar />, preloadedState);
it('keeps input value after popover closes if not empty', () => {
renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button"));
userEvent.type(screen.getByPlaceholderText("common.search"), "hello");
fireEvent.click(screen.getByRole('button'))
userEvent.type(screen.getByPlaceholderText('common.search'), 'hello')
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
.getAllByRole('button')
.find(b => b.querySelector('[data-testid="TuneIcon"]'))
fireEvent.click(tuneBtn!);
fireEvent.click(document.body);
fireEvent.click(tuneBtn!)
fireEvent.click(document.body)
const inputAfter = screen.getByPlaceholderText("common.search");
expect(inputAfter).toHaveValue("hello");
});
});
const inputAfter = screen.getByPlaceholderText('common.search')
expect(inputAfter).toHaveValue('hello')
})
})
@@ -1,224 +1,224 @@
import SearchResultsPage from "@/features/Search/SearchResultsPage";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import SearchResultsPage from '@/features/Search/SearchResultsPage'
import { screen } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("SearchResultsPage", () => {
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
const end = new Date(today);
end.setHours(11, 0, 0, 0);
describe('SearchResultsPage', () => {
const today = new Date()
const start = new Date(today)
start.setHours(10, 0, 0, 0)
const end = new Date(today)
end.setHours(11, 0, 0, 0)
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: 'user1'
},
tokens: { accessToken: "token" },
tokens: { accessToken: 'token' }
},
calendars: {
list: {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
'user1/cal1': {
name: 'Calendar personal',
id: 'user1/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user1/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user1/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
},
"user2/cal1": {
name: "Calendar delegated",
'user2/cal1': {
name: 'Calendar delegated',
delegated: true,
id: "user2/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
id: 'user2/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user2/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user2/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
},
"user3/cal1": {
name: "Calendar shared",
id: "user3/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
'user3/cal1': {
name: 'Calendar shared',
id: 'user3/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: "event1",
calId: "user3/cal1",
uid: "event1",
title: "Test Event",
id: 'event1',
calId: 'user3/cal1',
uid: 'event1',
title: 'Test Event',
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
partstat: 'ACCEPTED',
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
cn: 'Alice',
cal_address: 'alice@example.com'
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
cn: 'Alice',
partstat: 'ACCEPTED',
rsvp: 'TRUE',
role: 'REQ-PARTICIPANT',
cutype: 'INDIVIDUAL',
cal_address: 'alice@example.com'
}
]
}
}
}
},
pending: false,
},
};
pending: false
}
}
it("should show loading spinner when loading", () => {
it('should show loading spinner when loading', () => {
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { loading: true, error: null, hits: 0, results: [] },
});
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
searchResult: { loading: true, error: null, hits: 0, results: [] }
})
expect(screen.getByRole('progressbar')).toBeInTheDocument()
})
it("should show error message when error occurs", () => {
it('should show error message when error occurs', () => {
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: {
loading: false,
error: "Network error",
error: 'Network error',
hits: 0,
results: [],
},
});
expect(screen.getByText("Network error")).toBeInTheDocument();
});
results: []
}
})
expect(screen.getByText('Network error')).toBeInTheDocument()
})
it("should show no results message when no hits", () => {
it('should show no results message when no hits', () => {
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { loading: false, error: null, hits: null, results: [] },
});
expect(screen.getByText("search.noResults")).toBeInTheDocument();
});
searchResult: { loading: false, error: null, hits: null, results: [] }
})
expect(screen.getByText('search.noResults')).toBeInTheDocument()
})
it("should render search results", () => {
it('should render search results', () => {
const mockResults = [
{
data: {
uid: "1",
summary: "Team Meeting",
start: "2025-06-26T15:00:00Z",
organizer: { cn: "John Doe", email: "john@example.com" },
allDay: false,
},
uid: '1',
summary: 'Team Meeting',
start: '2025-06-26T15:00:00Z',
organizer: { cn: 'John Doe', email: 'john@example.com' },
allDay: false
}
},
{
data: {
uid: "2",
summary: "Project Review",
start: "2025-06-27T10:00:00Z",
organizer: { cn: "Jane Smith", email: "jane@example.com" },
allDay: false,
},
},
];
uid: '2',
summary: 'Project Review',
start: '2025-06-27T10:00:00Z',
organizer: { cn: 'Jane Smith', email: 'jane@example.com' },
allDay: false
}
}
]
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { results: mockResults, hits: 2 },
});
expect(screen.getByText("search.resultsTitle")).toBeInTheDocument();
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Project Review")).toBeInTheDocument();
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
});
searchResult: { results: mockResults, hits: 2 }
})
expect(screen.getByText('search.resultsTitle')).toBeInTheDocument()
expect(screen.getByText('Team Meeting')).toBeInTheDocument()
expect(screen.getByText('Project Review')).toBeInTheDocument()
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
})
it("should handle events without organizer", () => {
it('should handle events without organizer', () => {
const mockResults = [
{
data: {
uid: "1",
summary: "Untitled Event",
start: "2025-06-26T15:00:00Z",
allDay: false,
},
},
];
uid: '1',
summary: 'Untitled Event',
start: '2025-06-26T15:00:00Z',
allDay: false
}
}
]
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { results: mockResults, hits: 1 },
});
searchResult: { results: mockResults, hits: 1 }
})
expect(screen.getByText("Untitled Event")).toBeInTheDocument();
});
expect(screen.getByText('Untitled Event')).toBeInTheDocument()
})
it("should format all-day events correctly", () => {
it('should format all-day events correctly', () => {
const mockResults = [
{
data: {
uid: "1",
summary: "All Day Event",
start: "2025-06-26T00:00:00Z",
organizer: { cn: "Organizer" },
allDay: true,
},
},
];
uid: '1',
summary: 'All Day Event',
start: '2025-06-26T00:00:00Z',
organizer: { cn: 'Organizer' },
allDay: true
}
}
]
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { results: mockResults, hits: 1 },
});
searchResult: { results: mockResults, hits: 1 }
})
expect(screen.getByText("All Day Event")).toBeInTheDocument();
});
});
expect(screen.getByText('All Day Event')).toBeInTheDocument()
})
})
+83 -83
View File
@@ -1,126 +1,126 @@
import * as EventApi from "@/features/Events/EventApi";
import * as EventApi from '@/features/Events/EventApi'
import searchResultReducer, {
searchEventsAsync,
setHits,
setResults,
} from "@/features/Search/SearchSlice";
import { configureStore } from "@reduxjs/toolkit";
setResults
} from '@/features/Search/SearchSlice'
import { configureStore } from '@reduxjs/toolkit'
jest.mock("@/features/Events/EventApi");
jest.mock('@/features/Events/EventApi')
describe("SearchSlice", () => {
let store: any;
describe('SearchSlice', () => {
let store: any
beforeEach(() => {
store = configureStore({
reducer: {
searchResult: searchResultReducer,
},
});
});
searchResult: searchResultReducer
}
})
})
describe("initial state", () => {
it("should have correct initial state", () => {
const state = store.getState().searchResult;
describe('initial state', () => {
it('should have correct initial state', () => {
const state = store.getState().searchResult
expect(state).toEqual({
results: [],
hits: 0,
error: null,
loading: false,
});
});
});
loading: false
})
})
})
describe("reducers", () => {
it("should handle setResults", () => {
describe('reducers', () => {
it('should handle setResults', () => {
const mockResults = [
{ uid: "1", summary: "Event 1" },
{ uid: "2", summary: "Event 2" },
];
store.dispatch(setResults(mockResults as any));
expect(store.getState().searchResult.results).toEqual(mockResults);
});
{ uid: '1', summary: 'Event 1' },
{ uid: '2', summary: 'Event 2' }
]
store.dispatch(setResults(mockResults as any))
expect(store.getState().searchResult.results).toEqual(mockResults)
})
it("should handle setHits", () => {
store.dispatch(setHits(42));
expect(store.getState().searchResult.hits).toBe(42);
});
});
it('should handle setHits', () => {
store.dispatch(setHits(42))
expect(store.getState().searchResult.hits).toBe(42)
})
})
describe("searchEventsAsync", () => {
describe('searchEventsAsync', () => {
const mockFilters = {
searchIn: ["user1/calendar1"],
keywords: "meeting",
organizers: ["user@example.com"],
attendees: ["attendee@example.com"],
};
searchIn: ['user1/calendar1'],
keywords: 'meeting',
organizers: ['user@example.com'],
attendees: ['attendee@example.com']
}
it("should handle successful search", async () => {
it('should handle successful search', async () => {
const mockResponse = {
_total_hits: 5,
_embedded: {
events: [
{ data: { uid: "1", summary: "Test Event" } },
{ data: { uid: "2", summary: "Another Event" } },
],
},
};
{ data: { uid: '1', summary: 'Test Event' } },
{ data: { uid: '2', summary: 'Another Event' } }
]
}
}
(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse);
;(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse)
await store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters })
);
searchEventsAsync({ search: 'test', filters: mockFilters })
)
const state = store.getState().searchResult;
expect(state.loading).toBe(false);
expect(state.hits).toBe(5);
expect(state.results).toEqual(mockResponse._embedded.events);
expect(state.error).toBeNull();
});
const state = store.getState().searchResult
expect(state.loading).toBe(false)
expect(state.hits).toBe(5)
expect(state.results).toEqual(mockResponse._embedded.events)
expect(state.error).toBeNull()
})
it("should handle search with no results", async () => {
it('should handle search with no results', async () => {
const mockResponse = {
_total_hits: 0,
_embedded: { events: [] },
};
_embedded: { events: [] }
}
(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse);
;(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse)
await store.dispatch(
searchEventsAsync({ search: "nonexistent", filters: mockFilters })
);
searchEventsAsync({ search: 'nonexistent', filters: mockFilters })
)
const state = store.getState().searchResult;
expect(state.hits).toBe(0);
expect(state.results).toEqual([]);
});
const state = store.getState().searchResult
expect(state.hits).toBe(0)
expect(state.results).toEqual([])
})
it("should handle search error", async () => {
const mockError = new Error("Network error");
(EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError);
it('should handle search error', async () => {
const mockError = new Error('Network error')
;(EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError)
await store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters })
);
searchEventsAsync({ search: 'test', filters: mockFilters })
)
const state = store.getState().searchResult;
expect(state.loading).toBe(false);
expect(state.error).toBeTruthy();
expect(state.results).toEqual([]);
});
const state = store.getState().searchResult
expect(state.loading).toBe(false)
expect(state.error).toBeTruthy()
expect(state.results).toEqual([])
})
it("should set loading state during search", async () => {
(EventApi.searchEvent as jest.Mock).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
it('should set loading state during search', async () => {
;(EventApi.searchEvent as jest.Mock).mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 100))
)
const promise = store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters })
);
searchEventsAsync({ search: 'test', filters: mockFilters })
)
expect(store.getState().searchResult.loading).toBe(true);
await promise;
});
});
});
expect(store.getState().searchResult.loading).toBe(true)
await promise
})
})
})
@@ -1,482 +1,480 @@
import SettingsPage from "@/features/Settings/SettingsPage";
import SettingsPage from '@/features/Settings/SettingsPage'
import settingsReducer, {
setIsBrowserDefaultTimeZone,
setTimeZone,
} from "@/features/Settings/SettingsSlice";
setTimeZone
} from '@/features/Settings/SettingsSlice'
import userReducer, {
getOpenPaasUserDataAsync,
setTimezone as setUserTimeZone,
} from "@/features/User/userSlice";
import { api } from "@/utils/apiUtils";
import { browserDefaultTimeZone } from "@/utils/timezone";
import { configureStore } from "@reduxjs/toolkit";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
setTimezone as setUserTimeZone
} from '@/features/User/userSlice'
import { api } from '@/utils/apiUtils'
import { browserDefaultTimeZone } from '@/utils/timezone'
import { configureStore } from '@reduxjs/toolkit'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils");
jest.mock('@/utils/apiUtils')
describe("Timezone synchronization after getOpenPaasUserDataAsync", () => {
let apiGetSpy: jest.SpyInstance;
describe('Timezone synchronization after getOpenPaasUserDataAsync', () => {
let apiGetSpy: jest.SpyInstance
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
apiGetSpy = jest.spyOn(api, "get");
});
jest.clearAllMocks()
localStorage.clear()
apiGetSpy = jest.spyOn(api, 'get')
})
afterEach(() => {
apiGetSpy.mockRestore();
});
apiGetSpy.mockRestore()
})
it("should sync timezone to both user and settings state after fetching user data", async () => {
it('should sync timezone to both user and settings state after fetching user data', async () => {
const mockUserData = {
id: "667037022b752d0026472254",
firstname: "John",
lastname: "Doe",
preferredEmail: ["test@test.com"],
id: '667037022b752d0026472254',
firstname: 'John',
lastname: 'Doe',
preferredEmail: ['test@test.com'],
configurations: {
modules: [
{
name: "core",
name: 'core',
configurations: [
{ name: "language", value: "fr" },
{ name: "datetime", value: { timeZone: "Europe/Paris" } },
],
},
],
},
};
{ name: 'language', value: 'fr' },
{ name: 'datetime', value: { timeZone: 'Europe/Paris' } }
]
}
]
}
}
// Return a fake fetch-like response
apiGetSpy.mockResolvedValue({
json: async () => mockUserData,
});
json: async () => mockUserData
})
const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer },
preloadedState: {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
name: 'John',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
organiserData: null,
tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } },
coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
loading: false,
error: null,
error: null
},
settings: { language: "en", timeZone: "UTC", view: "calendar" },
},
});
settings: { language: 'en', timeZone: 'UTC', view: 'calendar' }
}
})
const result = await store.dispatch(getOpenPaasUserDataAsync());
const result = await store.dispatch(getOpenPaasUserDataAsync())
expect(result.type).toBe("user/getOpenPaasUserData/fulfilled");
expect(result.type).toBe('user/getOpenPaasUserData/fulfilled')
const state = store.getState();
expect(state.user.coreConfig.datetime.timeZone).toBe("Europe/Paris");
expect(state.settings.timeZone).toBe("Europe/Paris");
expect(localStorage.getItem("timeZone")).toBe("Europe/Paris");
});
const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe('Europe/Paris')
expect(state.settings.timeZone).toBe('Europe/Paris')
expect(localStorage.getItem('timeZone')).toBe('Europe/Paris')
})
it("should keep browser timezone if API response has no timezone configuration", async () => {
it('should keep browser timezone if API response has no timezone configuration', async () => {
const mockUserData = {
id: "667037022b752d0026472254",
firstname: "John",
lastname: "Doe",
preferredEmail: "[test@test.com](mailto:test@test.com)",
id: '667037022b752d0026472254',
firstname: 'John',
lastname: 'Doe',
preferredEmail: '[test@test.com](mailto:test@test.com)',
configurations: {
modules: [
{ name: "core", configurations: [{ name: "language", value: "en" }] },
],
},
};
{ name: 'core', configurations: [{ name: 'language', value: 'en' }] }
]
}
}
apiGetSpy.mockResolvedValue({
json: async () => mockUserData,
});
json: async () => mockUserData
})
const browserTimezone = browserDefaultTimeZone ?? "UTC";
const browserTimezone = browserDefaultTimeZone ?? 'UTC'
const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer },
preloadedState: {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
name: 'John',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
organiserData: null,
tokens: null,
coreConfig: {
language: "en",
datetime: { timeZone: browserTimezone },
language: 'en',
datetime: { timeZone: browserTimezone }
},
loading: false,
error: null,
error: null
},
settings: {
language: "en",
language: 'en',
timeZone: browserTimezone,
view: "calendar",
},
},
});
view: 'calendar'
}
}
})
const result = await store.dispatch(getOpenPaasUserDataAsync());
const result = await store.dispatch(getOpenPaasUserDataAsync())
expect(result.type).toBe("user/getOpenPaasUserData/fulfilled");
expect(result.type).toBe('user/getOpenPaasUserData/fulfilled')
const state = store.getState();
expect(state.user.coreConfig.datetime.timeZone).toBe(null);
expect(state.settings.timeZone).toBe(browserTimezone);
});
});
const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe(null)
expect(state.settings.timeZone).toBe(browserTimezone)
})
})
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
store[key] = value
},
removeItem: (key: string) => {
delete store[key];
delete store[key]
},
clear: () => {
store = {};
},
};
})();
store = {}
}
}
})()
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
})
describe("Timezone Logic - Backend to Frontend Flow", () => {
let store: any;
describe('Timezone Logic - Backend to Frontend Flow', () => {
let store: any
beforeEach(() => {
localStorageMock.clear();
localStorageMock.clear()
store = configureStore({
reducer: {
settings: settingsReducer,
user: userReducer,
user: userReducer
},
preloadedState: {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
name: 'John',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
organiserData: null,
tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } },
coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
loading: false,
error: null,
error: null
},
settings: { language: "en", timeZone: "UTC", view: "calendar" },
},
});
});
settings: { language: 'en', timeZone: 'UTC', view: 'calendar' }
}
})
})
describe("Backend Response Handling", () => {
test("Backend returns NULL => Should use browser default timezone", async () => {
describe('Backend Response Handling', () => {
test('Backend returns NULL => Should use browser default timezone', async () => {
const backendResponse = {
firstname: "John",
lastname: "Doe",
id: "123",
preferredEmail: "john@example.com",
firstname: 'John',
lastname: 'Doe',
id: '123',
preferredEmail: 'john@example.com',
configurations: {
modules: [
{
name: "core",
name: 'core',
configurations: [
{
name: "datetime",
name: 'datetime',
value: {
timeZone: null,
},
},
],
},
],
},
};
timeZone: null
}
}
]
}
]
}
}
await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined)
);
getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined)
)
const settingsState = store.getState().settings;
const userState = store.getState().user;
const settingsState = store.getState().settings
const userState = store.getState().user
expect(settingsState.timeZone).toBe(browserDefaultTimeZone);
expect(settingsState.isBrowserDefaultTimeZone).toBe(true);
expect(userState.coreConfig.datetime.timeZone).toBe(null);
expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone);
});
expect(settingsState.timeZone).toBe(browserDefaultTimeZone)
expect(settingsState.isBrowserDefaultTimeZone).toBe(true)
expect(userState.coreConfig.datetime.timeZone).toBe(null)
expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone)
})
test("Backend returns SPECIFIC VALUE => Should use that value", async () => {
const specificTimezone = "America/New_York";
test('Backend returns SPECIFIC VALUE => Should use that value', async () => {
const specificTimezone = 'America/New_York'
const backendResponse = {
firstname: "Jane",
lastname: "Smith",
id: "456",
preferredEmail: "jane@example.com",
firstname: 'Jane',
lastname: 'Smith',
id: '456',
preferredEmail: 'jane@example.com',
configurations: {
modules: [
{
name: "core",
name: 'core',
configurations: [
{
name: "datetime",
name: 'datetime',
value: {
timeZone: specificTimezone,
},
},
],
},
],
},
};
timeZone: specificTimezone
}
}
]
}
]
}
}
await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined)
);
getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined)
)
const settingsState = store.getState().settings;
const userState = store.getState().user;
const settingsState = store.getState().settings
const userState = store.getState().user
expect(settingsState.timeZone).toBe(specificTimezone);
expect(settingsState.isBrowserDefaultTimeZone).toBe(false);
expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone);
expect(localStorage.getItem("timeZone")).toBe(specificTimezone);
});
expect(settingsState.timeZone).toBe(specificTimezone)
expect(settingsState.isBrowserDefaultTimeZone).toBe(false)
expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone)
expect(localStorage.getItem('timeZone')).toBe(specificTimezone)
})
test("Backend returns NO datetime config => Should use browser default", async () => {
test('Backend returns NO datetime config => Should use browser default', async () => {
const backendResponse = {
firstname: "Bob",
lastname: "Johnson",
id: "789",
preferredEmail: "bob@example.com",
firstname: 'Bob',
lastname: 'Johnson',
id: '789',
preferredEmail: 'bob@example.com',
configurations: {
modules: [
{
name: "core",
name: 'core',
configurations: [
// No datetime config
],
},
],
},
};
]
}
]
}
}
await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined)
);
getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined)
)
const settingsState = store.getState().settings;
const userState = store.getState().user;
const settingsState = store.getState().settings
const userState = store.getState().user
expect(settingsState.timeZone).toBe(browserDefaultTimeZone);
expect(settingsState.isBrowserDefaultTimeZone).toBe(true);
expect(userState.coreConfig.datetime.timeZone).toBe(null);
expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone);
});
});
expect(settingsState.timeZone).toBe(browserDefaultTimeZone)
expect(settingsState.isBrowserDefaultTimeZone).toBe(true)
expect(userState.coreConfig.datetime.timeZone).toBe(null)
expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone)
})
})
test("Settings state ALWAYS has a concrete value (never null)", () => {
test('Settings state ALWAYS has a concrete value (never null)', () => {
// Test 1: Browser default scenario
store.dispatch(setIsBrowserDefaultTimeZone(true));
let settingsState = store.getState().settings;
store.dispatch(setIsBrowserDefaultTimeZone(true))
let settingsState = store.getState().settings
expect(settingsState.timeZone).toBe(browserDefaultTimeZone);
expect(settingsState.timeZone).not.toBe(null);
expect(settingsState.timeZone).toBe(browserDefaultTimeZone)
expect(settingsState.timeZone).not.toBe(null)
// Test 2: Specific timezone scenario
store.dispatch(setTimeZone("Europe/Paris"));
settingsState = store.getState().settings;
store.dispatch(setTimeZone('Europe/Paris'))
settingsState = store.getState().settings
expect(settingsState.timeZone).toBe("Europe/Paris");
expect(settingsState.timeZone).not.toBe(null);
});
expect(settingsState.timeZone).toBe('Europe/Paris')
expect(settingsState.timeZone).not.toBe(null)
})
describe("User Actions - Changing Timezone", () => {
describe('User Actions - Changing Timezone', () => {
beforeEach(() => {
(api.patch as jest.Mock).mockResolvedValue({ status: 204 });
});
test("User enables browser default => Settings gets browser TZ, User gets null", async () => {
;(api.patch as jest.Mock).mockResolvedValue({ status: 204 })
})
test('User enables browser default => Settings gets browser TZ, User gets null', async () => {
const { store } = renderWithProviders(<SettingsPage />, {
user: {
userData: { sub: "test" },
userData: { sub: 'test' },
organiserData: null,
tokens: null,
coreConfig: {
language: "en",
datetime: { timeZone: "America/Los_Angeles" },
language: 'en',
datetime: { timeZone: 'America/Los_Angeles' }
},
loading: false,
error: null,
error: null
},
settings: {
language: "en",
timeZone: "America/Los_Angeles",
language: 'en',
timeZone: 'America/Los_Angeles',
isBrowserDefaultTimeZone: false,
view: "settings",
},
});
view: 'settings'
}
})
const browserDefaultSwitch = screen.getAllByLabelText(
"settings.timeZoneBrowserDefault"
)[0];
expect(browserDefaultSwitch).toBeInTheDocument();
'settings.timeZoneBrowserDefault'
)[0]
expect(browserDefaultSwitch).toBeInTheDocument()
// Enable browser default
fireEvent.click(browserDefaultSwitch);
fireEvent.click(browserDefaultSwitch)
await waitFor(() => {
const state = store.getState();
expect(state.settings.isBrowserDefaultTimeZone).toBe(true);
});
const state = store.getState()
expect(state.settings.isBrowserDefaultTimeZone).toBe(true)
})
await waitFor(() => {
const state = store.getState();
expect(state.settings.timeZone).toBe(browserDefaultTimeZone);
});
const state = store.getState()
expect(state.settings.timeZone).toBe(browserDefaultTimeZone)
})
await waitFor(() => {
const state = store.getState();
expect(state.user.coreConfig.datetime.timeZone).toBe(null);
});
});
const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe(null)
})
})
test("User selects specific timezone => Both states get the value", async () => {
test('User selects specific timezone => Both states get the value', async () => {
const { store } = renderWithProviders(<SettingsPage />, {
user: {
userData: { sub: "test" },
userData: { sub: 'test' },
organiserData: null,
tokens: null,
coreConfig: { language: "en", datetime: { timeZone: null } },
coreConfig: { language: 'en', datetime: { timeZone: null } },
loading: false,
error: null,
error: null
},
settings: {
language: "en",
language: 'en',
timeZone: browserDefaultTimeZone,
isBrowserDefaultTimeZone: true,
view: "settings",
},
});
view: 'settings'
}
})
const browserDefaultSwitch = screen.getAllByLabelText(
"settings.timeZoneBrowserDefault"
)[0];
'settings.timeZoneBrowserDefault'
)[0]
// Disable browser default so manual selector appears
fireEvent.click(browserDefaultSwitch);
fireEvent.click(browserDefaultSwitch)
// Now timezone combobox is visible
const timezoneInput = screen.getAllByRole("combobox")[1];
const timezoneInput = screen.getAllByRole('combobox')[1]
// Type to filter options
fireEvent.change(timezoneInput, {
target: { value: "Australia/Sydney" },
});
target: { value: 'Australia/Sydney' }
})
// Select from autocomplete dropdown
const option = await screen.findByText(/Australia\/Sydney/i);
fireEvent.click(option);
const option = await screen.findByText(/Australia\/Sydney/i)
fireEvent.click(option)
await waitFor(() => {
const state = store.getState();
expect(state.settings.isBrowserDefaultTimeZone).toBe(false);
});
const state = store.getState()
expect(state.settings.isBrowserDefaultTimeZone).toBe(false)
})
await waitFor(() => {
const state = store.getState();
expect(state.settings.timeZone).toBe("Australia/Sydney");
});
const state = store.getState()
expect(state.settings.timeZone).toBe('Australia/Sydney')
})
await waitFor(() => {
const state = store.getState();
expect(state.user.coreConfig.datetime.timeZone).toBe(
"Australia/Sydney"
);
});
});
});
const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe('Australia/Sydney')
})
})
})
describe("LocalStorage Persistence", () => {
test("LocalStorage always stores concrete values (browser TZ or specific TZ)", () => {
describe('LocalStorage Persistence', () => {
test('LocalStorage always stores concrete values (browser TZ or specific TZ)', () => {
// Scenario 1: Browser default
store.dispatch(setTimeZone(browserDefaultTimeZone));
expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone);
expect(localStorage.getItem("timeZone")).not.toBe("null");
store.dispatch(setTimeZone(browserDefaultTimeZone))
expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone)
expect(localStorage.getItem('timeZone')).not.toBe('null')
// Scenario 2: Specific timezone
store.dispatch(setTimeZone("Africa/Cairo"));
expect(localStorage.getItem("timeZone")).toBe("Africa/Cairo");
});
});
store.dispatch(setTimeZone('Africa/Cairo'))
expect(localStorage.getItem('timeZone')).toBe('Africa/Cairo')
})
})
test("Full user journey: Backend null -> User changes -> Backend saves", async () => {
test('Full user journey: Backend null -> User changes -> Backend saves', async () => {
// Step 1: Backend returns null
const backendResponseNull = {
firstname: "Test",
lastname: "User",
id: "999",
preferredEmail: "test@example.com",
firstname: 'Test',
lastname: 'User',
id: '999',
preferredEmail: 'test@example.com',
configurations: {
modules: [
{
name: "core",
name: 'core',
configurations: [
{
name: "datetime",
value: { timeZone: null },
},
],
},
],
},
};
name: 'datetime',
value: { timeZone: null }
}
]
}
]
}
}
await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponseNull, "", undefined)
);
getOpenPaasUserDataAsync.fulfilled(backendResponseNull, '', undefined)
)
// Verify initial state
expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone);
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null);
expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone)
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null)
// Step 2: User changes to specific timezone
const userSelectedTZ = "Europe/London";
store.dispatch(setIsBrowserDefaultTimeZone(false));
store.dispatch(setTimeZone(userSelectedTZ));
store.dispatch(setUserTimeZone(userSelectedTZ));
const userSelectedTZ = 'Europe/London'
store.dispatch(setIsBrowserDefaultTimeZone(false))
store.dispatch(setTimeZone(userSelectedTZ))
store.dispatch(setUserTimeZone(userSelectedTZ))
expect(store.getState().settings.timeZone).toBe(userSelectedTZ);
expect(store.getState().settings.timeZone).toBe(userSelectedTZ)
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(
userSelectedTZ
);
)
// Step 3: User switches back to browser default
store.dispatch(setIsBrowserDefaultTimeZone(true));
store.dispatch(setUserTimeZone(null));
store.dispatch(setTimeZone(browserDefaultTimeZone));
store.dispatch(setIsBrowserDefaultTimeZone(true))
store.dispatch(setUserTimeZone(null))
store.dispatch(setTimeZone(browserDefaultTimeZone))
expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone);
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null);
});
});
expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone)
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null)
})
})
File diff suppressed because it is too large Load Diff
+268 -272
View File
@@ -1,467 +1,463 @@
import SettingsPage from "@/features/Settings/SettingsPage";
import settingsReducer from "@/features/Settings/SettingsSlice";
import SettingsPage from '@/features/Settings/SettingsPage'
import settingsReducer from '@/features/Settings/SettingsSlice'
import userReducer, {
getOpenPaasUserDataAsync,
} from "@/features/User/userSlice";
import { api } from "@/utils/apiUtils";
import { configureStore } from "@reduxjs/toolkit";
import "@testing-library/jest-dom";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
getOpenPaasUserDataAsync
} from '@/features/User/userSlice'
import { api } from '@/utils/apiUtils'
import { configureStore } from '@reduxjs/toolkit'
import '@testing-library/jest-dom'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils");
jest.mock('@/utils/apiUtils')
const basePreloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
name: 'John',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
organiserData: null,
tokens: null,
coreConfig: {
language: "en",
datetime: { timeZone: "UTC" },
language: 'en',
datetime: { timeZone: 'UTC' }
},
alarmEmailsEnabled: false,
loading: false,
error: null,
error: null
},
settings: {
language: "en",
timeZone: "UTC",
language: 'en',
timeZone: 'UTC',
isBrowserDefaultTimeZone: false,
view: "settings",
view: 'settings',
businessHours: {
start: "8:0",
end: "19:0",
daysOfWeek: [1, 2, 3, 4, 5],
start: '8:0',
end: '19:0',
daysOfWeek: [1, 2, 3, 4, 5]
},
workingDays: false,
},
};
workingDays: false
}
}
describe("Working Days and Business Hours Settings", () => {
describe('Working Days and Business Hours Settings', () => {
beforeEach(() => {
jest.clearAllMocks();
(api.patch as jest.Mock).mockResolvedValue({ status: 204 });
});
jest.clearAllMocks()
;(api.patch as jest.Mock).mockResolvedValue({ status: 204 })
})
describe("Initial rendering", () => {
it("renders the working days section with day selector", () => {
renderWithProviders(<SettingsPage />, basePreloadedState);
describe('Initial rendering', () => {
it('renders the working days section with day selector', () => {
renderWithProviders(<SettingsPage />, basePreloadedState)
expect(screen.getByText('settings.chooseWorkingDays')).toBeInTheDocument()
})
it('renders the show only working days toggle', () => {
renderWithProviders(<SettingsPage />, basePreloadedState)
expect(
screen.getByText("settings.chooseWorkingDays")
).toBeInTheDocument();
});
screen.getByRole('switch', { name: /settings.showOnlyWorkingDays/i })
).toBeInTheDocument()
})
it("renders the show only working days toggle", () => {
renderWithProviders(<SettingsPage />, basePreloadedState);
it('shows working days toggle as unchecked when workingDays is false', () => {
renderWithProviders(<SettingsPage />, basePreloadedState)
expect(
screen.getByRole("switch", { name: /settings.showOnlyWorkingDays/i })
).toBeInTheDocument();
});
const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i
}) as HTMLInputElement
expect(toggle.checked).toBe(false)
})
it("shows working days toggle as unchecked when workingDays is false", () => {
renderWithProviders(<SettingsPage />, basePreloadedState);
const toggle = screen.getByRole("switch", {
name: /settings.showOnlyWorkingDays/i,
}) as HTMLInputElement;
expect(toggle.checked).toBe(false);
});
it("shows working days toggle as checked when workingDays is true", () => {
it('shows working days toggle as checked when workingDays is true', () => {
renderWithProviders(<SettingsPage />, {
...basePreloadedState,
settings: { ...basePreloadedState.settings, workingDays: true },
});
settings: { ...basePreloadedState.settings, workingDays: true }
})
const toggle = screen.getByRole("switch", {
name: /settings.showOnlyWorkingDays/i,
}) as HTMLInputElement;
expect(toggle.checked).toBe(true);
});
const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i
}) as HTMLInputElement
expect(toggle.checked).toBe(true)
})
it("shows working days toggle as unchecked when workingDays is null", () => {
it('shows working days toggle as unchecked when workingDays is null', () => {
renderWithProviders(<SettingsPage />, {
...basePreloadedState,
settings: { ...basePreloadedState.settings, workingDays: null },
});
settings: { ...basePreloadedState.settings, workingDays: null }
})
const toggle = screen.getByRole("switch", {
name: /settings.showOnlyWorkingDays/i,
}) as HTMLInputElement;
expect(toggle.checked).toBe(false);
});
});
const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i
}) as HTMLInputElement
expect(toggle.checked).toBe(false)
})
})
describe("workingDays toggle", () => {
it("updates workingDays immediately (optimistic update) and calls API", async () => {
describe('workingDays toggle', () => {
it('updates workingDays immediately (optimistic update) and calls API', async () => {
const { store } = renderWithProviders(
<SettingsPage />,
basePreloadedState
);
)
const toggle = screen.getByRole("switch", {
name: /settings.showOnlyWorkingDays/i,
});
fireEvent.click(toggle);
const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i
})
fireEvent.click(toggle)
await waitFor(() => {
expect(store.getState().settings.workingDays).toBe(true);
});
expect(store.getState().settings.workingDays).toBe(true)
})
await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user",
'api/configurations?scope=user',
expect.objectContaining({
json: expect.arrayContaining([
expect.objectContaining({
name: "linagora.esn.calendar",
name: 'linagora.esn.calendar',
configurations: expect.arrayContaining([
expect.objectContaining({
name: "workingDays",
value: true,
}),
]),
}),
]),
name: 'workingDays',
value: true
})
])
})
])
})
);
});
});
)
})
})
it("rolls back workingDays if API call fails", async () => {
(api.patch as jest.Mock).mockRejectedValue(new Error("API Error"));
it('rolls back workingDays if API call fails', async () => {
;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error'))
const { store } = renderWithProviders(
<SettingsPage />,
basePreloadedState
);
)
const toggle = screen.getByRole("switch", {
name: /settings.showOnlyWorkingDays/i,
});
fireEvent.click(toggle);
const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i
})
fireEvent.click(toggle)
await waitFor(
() => {
expect(store.getState().settings.workingDays).toBe(false);
expect(store.getState().settings.workingDays).toBe(false)
},
{ timeout: 3000 }
);
)
await waitFor(() => {
expect(
screen.getByText("settings.workingDaysUpdateError")
).toBeInTheDocument();
});
});
screen.getByText('settings.workingDaysUpdateError')
).toBeInTheDocument()
})
})
it("toggles workingDays from true to false", async () => {
it('toggles workingDays from true to false', async () => {
const { store } = renderWithProviders(<SettingsPage />, {
...basePreloadedState,
settings: { ...basePreloadedState.settings, workingDays: true },
});
settings: { ...basePreloadedState.settings, workingDays: true }
})
const toggle = screen.getByRole("switch", {
name: /settings.showOnlyWorkingDays/i,
});
fireEvent.click(toggle);
const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i
})
fireEvent.click(toggle)
await waitFor(() => {
expect(store.getState().settings.workingDays).toBe(false);
});
expect(store.getState().settings.workingDays).toBe(false)
})
await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user",
'api/configurations?scope=user',
expect.objectContaining({
json: expect.arrayContaining([
expect.objectContaining({
name: "linagora.esn.calendar",
name: 'linagora.esn.calendar',
configurations: expect.arrayContaining([
expect.objectContaining({
name: "workingDays",
value: false,
}),
]),
}),
]),
name: 'workingDays',
value: false
})
])
})
])
})
);
});
});
});
)
})
})
})
describe("businessHours day selector", () => {
it("updates businessHours in Redux immediately when a day is clicked", async () => {
describe('businessHours day selector', () => {
it('updates businessHours in Redux immediately when a day is clicked', async () => {
const { store } = renderWithProviders(
<SettingsPage />,
basePreloadedState
);
)
// Sunday (0) is not in initial daysOfWeek [1,2,3,4,5], click it to add
const sundayButton = screen.getByRole("button", {
name: /sunday/i,
});
fireEvent.click(sundayButton);
const sundayButton = screen.getByRole('button', {
name: /sunday/i
})
fireEvent.click(sundayButton)
await waitFor(() => {
expect(store.getState().settings.businessHours?.daysOfWeek).toContain(
0
);
});
});
expect(store.getState().settings.businessHours?.daysOfWeek).toContain(0)
})
})
it("removes a day from businessHours when an already selected day is clicked", async () => {
it('removes a day from businessHours when an already selected day is clicked', async () => {
const { store } = renderWithProviders(
<SettingsPage />,
basePreloadedState
);
)
// Monday (1) is in initial daysOfWeek, click to remove
const mondayButton = screen.getByRole("button", { name: /monday/i });
fireEvent.click(mondayButton);
const mondayButton = screen.getByRole('button', { name: /monday/i })
fireEvent.click(mondayButton)
await waitFor(() => {
expect(
store.getState().settings.businessHours?.daysOfWeek
).not.toContain(1);
});
});
).not.toContain(1)
})
})
it("debounces API call when multiple days are clicked rapidly", async () => {
jest.useFakeTimers();
it('debounces API call when multiple days are clicked rapidly', async () => {
jest.useFakeTimers()
renderWithProviders(<SettingsPage />, basePreloadedState);
renderWithProviders(<SettingsPage />, basePreloadedState)
const sundayButton = screen.getByRole("button", { name: /sunday/i });
const saturdayButton = screen.getByRole("button", { name: /saturday/i });
const sundayButton = screen.getByRole('button', { name: /sunday/i })
const saturdayButton = screen.getByRole('button', { name: /saturday/i })
fireEvent.click(sundayButton);
fireEvent.click(saturdayButton);
fireEvent.click(sundayButton);
fireEvent.click(sundayButton)
fireEvent.click(saturdayButton)
fireEvent.click(sundayButton)
// API should not have been called yet
expect(api.patch).not.toHaveBeenCalled();
expect(api.patch).not.toHaveBeenCalled()
// Advance timers past debounce threshold
jest.runAllTimers();
jest.runAllTimers()
await waitFor(() => {
expect(api.patch).toHaveBeenCalledTimes(1);
});
expect(api.patch).toHaveBeenCalledTimes(1)
})
jest.useRealTimers();
});
jest.useRealTimers()
})
it("sends businessHours as array to API", async () => {
jest.useFakeTimers();
it('sends businessHours as array to API', async () => {
jest.useFakeTimers()
renderWithProviders(<SettingsPage />, basePreloadedState);
renderWithProviders(<SettingsPage />, basePreloadedState)
const sundayButton = screen.getByRole("button", { name: /sunday/i });
fireEvent.click(sundayButton);
const sundayButton = screen.getByRole('button', { name: /sunday/i })
fireEvent.click(sundayButton)
jest.runAllTimers();
jest.runAllTimers()
await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user",
'api/configurations?scope=user',
expect.objectContaining({
json: expect.arrayContaining([
expect.objectContaining({
name: "core",
name: 'core',
configurations: expect.arrayContaining([
expect.objectContaining({
name: "businessHours",
name: 'businessHours',
value: expect.arrayContaining([
expect.objectContaining({
start: "8:0",
end: "19:0",
daysOfWeek: expect.arrayContaining([0]),
}),
]),
}),
]),
}),
]),
start: '8:0',
end: '19:0',
daysOfWeek: expect.arrayContaining([0])
})
])
})
])
})
])
})
);
});
)
})
jest.useRealTimers();
});
jest.useRealTimers()
})
it("rolls back businessHours if API call fails", async () => {
jest.useFakeTimers();
(api.patch as jest.Mock).mockRejectedValue(new Error("API Error"));
it('rolls back businessHours if API call fails', async () => {
jest.useFakeTimers()
;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error'))
const { store } = renderWithProviders(
<SettingsPage />,
basePreloadedState
);
)
const sundayButton = screen.getByRole("button", { name: /sunday/i });
fireEvent.click(sundayButton);
const sundayButton = screen.getByRole('button', { name: /sunday/i })
fireEvent.click(sundayButton)
jest.runAllTimers();
jest.runAllTimers()
await waitFor(
() => {
expect(
store.getState().settings.businessHours?.daysOfWeek
).not.toContain(0);
).not.toContain(0)
},
{ timeout: 3000 }
);
)
await waitFor(() => {
expect(
screen.getByText("settings.workingDaysUpdateError")
).toBeInTheDocument();
});
screen.getByText('settings.workingDaysUpdateError')
).toBeInTheDocument()
})
jest.useRealTimers();
});
});
jest.useRealTimers()
})
})
describe("getOpenPaasUserDataAsync integration", () => {
it("populates businessHours from core module configurations", async () => {
describe('getOpenPaasUserDataAsync integration', () => {
it('populates businessHours from core module configurations', async () => {
const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer },
preloadedState: {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
name: 'John',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
organiserData: null,
tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } },
coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
alarmEmailsEnabled: null,
loading: false,
error: null,
error: null
},
settings: {
language: "en",
timeZone: "UTC",
language: 'en',
timeZone: 'UTC',
isBrowserDefaultTimeZone: false,
view: "calendar",
view: 'calendar',
businessHours: null,
workingDays: null,
},
},
});
workingDays: null
}
}
})
const mockResponse = {
id: "667037022b752d0026472254",
firstname: "John",
lastname: "Doe",
preferredEmail: "test@test.com",
id: '667037022b752d0026472254',
firstname: 'John',
lastname: 'Doe',
preferredEmail: 'test@test.com',
configurations: {
modules: [
{
name: "core",
name: 'core',
configurations: [
{
name: "businessHours",
name: 'businessHours',
value: [
{ start: "8:0", end: "19:0", daysOfWeek: [1, 2, 3, 4, 5] },
],
},
],
{ start: '8:0', end: '19:0', daysOfWeek: [1, 2, 3, 4, 5] }
]
}
]
},
{
name: "linagora.esn.calendar",
configurations: [{ name: "workingDays", value: true }],
},
],
},
};
name: 'linagora.esn.calendar',
configurations: [{ name: 'workingDays', value: true }]
}
]
}
}
await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined)
);
getOpenPaasUserDataAsync.fulfilled(mockResponse, '', undefined)
)
const state = store.getState();
const state = store.getState()
expect(state.settings.businessHours).toEqual({
start: "8:0",
end: "19:0",
daysOfWeek: [1, 2, 3, 4, 5],
});
expect(state.settings.workingDays).toBe(true);
});
start: '8:0',
end: '19:0',
daysOfWeek: [1, 2, 3, 4, 5]
})
expect(state.settings.workingDays).toBe(true)
})
it("sets businessHours to null when not present in API response", async () => {
it('sets businessHours to null when not present in API response', async () => {
const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer },
preloadedState: {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
name: 'John',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
organiserData: null,
tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } },
coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
alarmEmailsEnabled: null,
loading: false,
error: null,
error: null
},
settings: {
language: "en",
timeZone: "UTC",
language: 'en',
timeZone: 'UTC',
isBrowserDefaultTimeZone: false,
view: "calendar",
view: 'calendar',
businessHours: null,
workingDays: null,
},
},
});
workingDays: null
}
}
})
const mockResponse = {
id: "667037022b752d0026472254",
firstname: "John",
lastname: "Doe",
preferredEmail: "test@test.com",
id: '667037022b752d0026472254',
firstname: 'John',
lastname: 'Doe',
preferredEmail: 'test@test.com',
configurations: {
modules: [
{
name: "core",
configurations: [],
name: 'core',
configurations: []
},
{
name: "linagora.esn.calendar",
configurations: [{ name: "workingDays", value: null }],
},
],
},
};
name: 'linagora.esn.calendar',
configurations: [{ name: 'workingDays', value: null }]
}
]
}
}
await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined)
);
getOpenPaasUserDataAsync.fulfilled(mockResponse, '', undefined)
)
const state = store.getState();
expect(state.settings.businessHours).toBeNull();
expect(state.settings.workingDays).toBeNull();
});
});
});
const state = store.getState()
expect(state.settings.businessHours).toBeNull()
expect(state.settings.workingDays).toBeNull()
})
})
})
+88 -88
View File
@@ -1,45 +1,45 @@
import * as appHooks from "@/app/hooks";
import { AppDispatch, setupStore } from "@/app/store";
import HandleLogin from "@/features/User/HandleLogin";
import * as oidcAuth from "@/features/User/oidcAuth";
import { clientConfig } from "@/features/User/oidcAuth";
import { useInitializeApp } from "@/features/User/useInitializeApp";
import * as apiUtils from "@/utils/apiUtils";
import { renderHook, screen, waitFor } from "@testing-library/react";
import { Provider } from "react-redux";
import { push } from "redux-first-history";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as appHooks from '@/app/hooks'
import { AppDispatch, setupStore } from '@/app/store'
import HandleLogin from '@/features/User/HandleLogin'
import * as oidcAuth from '@/features/User/oidcAuth'
import { clientConfig } from '@/features/User/oidcAuth'
import { useInitializeApp } from '@/features/User/useInitializeApp'
import * as apiUtils from '@/utils/apiUtils'
import { renderHook, screen, waitFor } from '@testing-library/react'
import { Provider } from 'react-redux'
import { push } from 'redux-first-history'
import { renderWithProviders } from '../../utils/Renderwithproviders'
clientConfig.url = "https://example.com";
clientConfig.url = 'https://example.com'
describe("HandleLogin", () => {
describe('HandleLogin', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(apiUtils, "redirectTo").mockImplementation(() => {});
const dispatch = jest.fn() as AppDispatch;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
sessionStorage.clear();
Object.defineProperty(window, "matchMedia", {
jest.clearAllMocks()
jest.spyOn(apiUtils, 'redirectTo').mockImplementation(() => {})
const dispatch = jest.fn() as AppDispatch
jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch)
sessionStorage.clear()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
dispatchEvent: jest.fn()
}))
})
})
test("redirects and sets sessionStorage when no userData", async () => {
test('redirects and sets sessionStorage when no userData', async () => {
const loginUrlMock = {
code_verifier: "verifier123",
state: "state123",
redirectTo: new URL("http://login.url"),
};
code_verifier: 'verifier123',
state: 'state123',
redirectTo: new URL('http://login.url')
}
jest.spyOn(oidcAuth, "Auth").mockResolvedValue(loginUrlMock);
jest.spyOn(oidcAuth, 'Auth').mockResolvedValue(loginUrlMock)
const { result } = renderHook(() => useInitializeApp(), {
wrapper: ({ children }) => (
@@ -50,118 +50,118 @@ describe("HandleLogin", () => {
tokens: null,
loading: false,
error: null,
coreConfig: { language: "en" },
coreConfig: { language: 'en' }
},
calendars: { list: {}, pending: false, error: null },
calendars: { list: {}, pending: false, error: null }
})}
>
{children}
</Provider>
),
});
)
})
await waitFor(
() => {
expect(oidcAuth.Auth).toHaveBeenCalled();
expect(oidcAuth.Auth).toHaveBeenCalled()
},
{ timeout: 3000 }
);
)
await waitFor(
() => {
expect(sessionStorage.getItem("redirectState")).toEqual(
expect(sessionStorage.getItem('redirectState')).toEqual(
JSON.stringify({
code_verifier: "verifier123",
state: "state123",
code_verifier: 'verifier123',
state: 'state123'
})
);
)
},
{ timeout: 3000 }
);
)
await waitFor(
() => {
expect(apiUtils.redirectTo).toHaveBeenCalledWith(
loginUrlMock.redirectTo
);
)
},
{ timeout: 3000 }
);
});
)
})
test("does not render loading element when userData exists and calendars pending is true", () => {
test('does not render loading element when userData exists and calendars pending is true', () => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
sub: 'test',
email: 'test@test.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
},
tokens: { access_token: "test" },
loading: false,
tokens: { access_token: 'test' },
loading: false
},
calendars: { list: {}, pending: true },
loading: { isLoading: true },
};
loading: { isLoading: true }
}
renderWithProviders(<HandleLogin />, preloadedState);
renderWithProviders(<HandleLogin />, preloadedState)
// HandleLogin now returns null, loading is shown at App level via appLoading state
expect(screen.queryByTestId("loading")).not.toBeInTheDocument();
});
test("does not render loading element when userData exists and calendars pending is false", () => {
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
test('does not render loading element when userData exists and calendars pending is false', () => {
const preloadedState = {
user: {
userData: {
sub: "cmoussu",
email: "cmoussu@linagora.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
sub: 'cmoussu',
email: 'cmoussu@linagora.com',
sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: '667037022b752d0026472254'
},
tokens: { access_token: "test" },
loading: false,
tokens: { access_token: 'test' },
loading: false
},
calendars: { list: {}, pending: false },
loading: { isLoading: false },
};
renderWithProviders(<HandleLogin />, preloadedState);
loading: { isLoading: false }
}
renderWithProviders(<HandleLogin />, preloadedState)
// HandleLogin now returns null, loading is shown at App level via appLoading state
expect(screen.queryByTestId("loading")).not.toBeInTheDocument();
});
test("goes to error page when there is error in user data", async () => {
const mockDispatch = jest.fn();
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(mockDispatch);
jest.spyOn(oidcAuth, "Auth").mockResolvedValue({
code_verifier: "verifier123",
state: "state123",
redirectTo: new URL("http://login.url"),
});
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
test('goes to error page when there is error in user data', async () => {
const mockDispatch = jest.fn()
jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(mockDispatch)
jest.spyOn(oidcAuth, 'Auth').mockResolvedValue({
code_verifier: 'verifier123',
state: 'state123',
redirectTo: new URL('http://login.url')
})
renderWithProviders(<HandleLogin />, {
user: {
error: true,
loading: false,
userData: {
sub: "test",
email: "test@test.com",
sid: "testSid",
openpaasId: "testId",
sub: 'test',
email: 'test@test.com',
sid: 'testSid',
openpaasId: 'testId'
},
tokens: { access_token: "test" },
tokens: { access_token: 'test' }
},
calendars: {
list: {},
pending: false,
error: null,
},
});
error: null
}
})
await waitFor(
() => {
expect(mockDispatch).toHaveBeenCalledWith(push("/error"));
expect(mockDispatch).toHaveBeenCalledWith(push('/error'))
},
{ timeout: 3000 }
);
});
});
)
})
})
+99 -100
View File
@@ -1,183 +1,182 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync";
import { CallbackResume } from "@/features/User/LoginCallback";
import * as oidcAuth from "@/features/User/oidcAuth";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { getCalendarsListAsync } from '@/features/Calendars/services/getCalendarsListAsync'
import { CallbackResume } from '@/features/User/LoginCallback'
import * as oidcAuth from '@/features/User/oidcAuth'
import {
getOpenPaasUserDataAsync,
setTokens,
setUserData,
} from "@/features/User/userSlice";
import { render, waitFor } from "@testing-library/react";
import { replace } from "redux-first-history";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import { setAppLoading } from "@/app/loadingSlice";
setUserData
} from '@/features/User/userSlice'
import { render, waitFor } from '@testing-library/react'
import { replace } from 'redux-first-history'
import { renderWithProviders } from '../../utils/Renderwithproviders'
import { setAppLoading } from '@/app/loadingSlice'
// Mocks
jest.mock("@/app/hooks", () => ({
jest.mock('@/app/hooks', () => ({
useAppDispatch: jest.fn(),
useAppSelector: jest.fn(() => ({
user: {
userData: null,
tokens: null,
loading: false,
error: null,
error: null
},
calendars: {
list: {},
pending: false,
error: null,
},
})),
}));
error: null
}
}))
}))
jest.mock("@/features/User/oidcAuth", () => ({
Callback: jest.fn(),
}));
jest.mock('@/features/User/oidcAuth', () => ({
Callback: jest.fn()
}))
jest.mock("@/features/User/userSlice", () => {
jest.mock('@/features/User/userSlice', () => {
const mockGetUser = Object.assign(
jest.fn(() => ({ type: "GET_USER_ID" })),
jest.fn(() => ({ type: 'GET_USER_ID' })),
{
pending: { type: "GET_USER_ID/pending" },
fulfilled: { type: "GET_USER_ID/fulfilled" },
rejected: { type: "GET_USER_ID/rejected" },
pending: { type: 'GET_USER_ID/pending' },
fulfilled: { type: 'GET_USER_ID/fulfilled' },
rejected: { type: 'GET_USER_ID/rejected' }
}
);
)
return {
setUserData: jest.fn((data) => ({ type: "SET_USER", payload: data })),
setTokens: jest.fn((tokens) => ({ type: "SET_TOKENS", payload: tokens })),
getOpenPaasUserDataAsync: mockGetUser,
};
});
setUserData: jest.fn(data => ({ type: 'SET_USER', payload: data })),
setTokens: jest.fn(tokens => ({ type: 'SET_TOKENS', payload: tokens })),
getOpenPaasUserDataAsync: mockGetUser
}
})
jest.mock("@/features/Calendars/services/getCalendarsListAsync", () => {
jest.mock('@/features/Calendars/services/getCalendarsListAsync', () => {
const mockGetCalendars = Object.assign(
jest.fn(() => ({ type: "GET_CALENDARS" })),
jest.fn(() => ({ type: 'GET_CALENDARS' })),
{
pending: { type: "GET_CALENDARS/pending" },
fulfilled: { type: "GET_CALENDARS/fulfilled" },
rejected: { type: "GET_CALENDARS/rejected" },
pending: { type: 'GET_CALENDARS/pending' },
fulfilled: { type: 'GET_CALENDARS/fulfilled' },
rejected: { type: 'GET_CALENDARS/rejected' }
}
);
)
return {
getCalendarsListAsync: mockGetCalendars,
};
});
getCalendarsListAsync: mockGetCalendars
}
})
describe("CallbackResume", () => {
const dispatch = jest.fn();
let mockUserState: any;
let mockCalendarsState: any;
describe('CallbackResume', () => {
const dispatch = jest.fn()
let mockUserState: any
let mockCalendarsState: any
beforeEach(() => {
jest.clearAllMocks();
(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch);
jest.clearAllMocks()
;(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch)
// Initialize mock states
mockUserState = {
userData: null,
tokens: null,
loading: false,
error: null,
};
error: null
}
mockCalendarsState = {
list: {},
pending: false,
error: null,
};
(useAppSelector as jest.Mock).mockImplementation((selector) => {
error: null
}
;(useAppSelector as jest.Mock).mockImplementation(selector => {
const state = {
user: mockUserState,
calendars: mockCalendarsState,
};
return selector(state);
});
});
calendars: mockCalendarsState
}
return selector(state)
})
})
it("should call Callback and dispatch necessary actions", async () => {
const mockTokenSet = { access_token: "abc" };
const mockUserInfo = { name: "Test User" };
it('should call Callback and dispatch necessary actions', async () => {
const mockTokenSet = { access_token: 'abc' }
const mockUserInfo = { name: 'Test User' }
const mockData = {
tokenSet: mockTokenSet,
userinfo: mockUserInfo,
};
userinfo: mockUserInfo
}
(oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData);
;(oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData)
sessionStorage.setItem(
"redirectState",
JSON.stringify({ code_verifier: "verifier123", state: "state456" })
);
'redirectState',
JSON.stringify({ code_verifier: 'verifier123', state: 'state456' })
)
const { rerender } = render(<CallbackResume />);
const { rerender } = render(<CallbackResume />)
await waitFor(() => {
expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456");
});
expect(oidcAuth.Callback).toHaveBeenCalledWith('verifier123', 'state456')
})
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setAppLoading(true));
});
expect(dispatch).toHaveBeenCalledWith(setAppLoading(true))
})
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo));
});
expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo))
})
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet));
});
expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet))
})
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync());
});
expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync())
})
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync());
});
expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync())
})
// Simulate async actions completing by updating mock state
mockUserState = {
userData: mockUserInfo,
tokens: mockTokenSet,
loading: false,
error: null,
};
error: null
}
mockCalendarsState = {
list: { calendar1: {} },
pending: false,
error: null,
};
error: null
}
// Re-render to trigger navigation effect
rerender(<CallbackResume />);
rerender(<CallbackResume />)
await waitFor(
() => {
expect(dispatch).toHaveBeenCalledWith(setAppLoading(false));
expect(dispatch).toHaveBeenCalledWith(setAppLoading(false))
},
{ timeout: 3000 }
);
)
await waitFor(
() => {
expect(dispatch).toHaveBeenCalledWith(replace("/calendar"));
expect(dispatch).toHaveBeenCalledWith(replace('/calendar'))
},
{ timeout: 3000 }
);
)
await waitFor(() => {
expect(sessionStorage.getItem("redirectState")).toBe(null);
});
expect(sessionStorage.getItem('redirectState')).toBe(null)
})
await waitFor(() => {
expect(sessionStorage.getItem("tokenSet")).toEqual(
expect(sessionStorage.getItem('tokenSet')).toEqual(
JSON.stringify(mockTokenSet)
);
});
});
)
})
})
it("should handle missing redirectState gracefully", async () => {
sessionStorage.removeItem("redirectState");
renderWithProviders(<CallbackResume />);
it('should handle missing redirectState gracefully', async () => {
sessionStorage.removeItem('redirectState')
renderWithProviders(<CallbackResume />)
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(replace("/"));
});
});
});
expect(dispatch).toHaveBeenCalledWith(replace('/'))
})
})
})
+112 -113
View File
@@ -4,169 +4,168 @@ import {
Callback,
clientConfig,
getClientConfig,
Logout,
} from "@/features/User/oidcAuth";
import * as apiUtils from "@/utils/apiUtils";
import * as client from "openid-client";
Logout
} from '@/features/User/oidcAuth'
import * as apiUtils from '@/utils/apiUtils'
import * as client from 'openid-client'
clientConfig.url = "https://example.com";
const localAdress = "https://local.exemple.com";
clientConfig.url = 'https://example.com'
const localAdress = 'https://local.exemple.com'
describe("OpenID Client Auth Module", () => {
describe('OpenID Client Auth Module', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(apiUtils, "getLocation").mockImplementation(() => localAdress);
});
jest.clearAllMocks()
jest.spyOn(apiUtils, 'getLocation').mockImplementation(() => localAdress)
})
describe("getClientConfig", () => {
it("should call discovery with clientConfig.url", async () => {
const discoveryMock = client.discovery as jest.Mock;
discoveryMock.mockResolvedValue("discoveredClient");
describe('getClientConfig', () => {
it('should call discovery with clientConfig.url', async () => {
const discoveryMock = client.discovery as jest.Mock
discoveryMock.mockResolvedValue('discoveredClient')
const result = await getClientConfig();
const result = await getClientConfig()
expect(discoveryMock).toHaveBeenCalledWith(
new URL(clientConfig.url),
clientConfig.client_id
);
expect(result).toBe("discoveredClient");
});
});
)
expect(result).toBe('discoveredClient')
})
})
describe("Auth", () => {
it("should generate PKCE and build authorization URL with PKCE", async () => {
(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
"verifier123"
);
(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
"challenge123"
);
describe('Auth', () => {
it('should generate PKCE and build authorization URL with PKCE', async () => {
;(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
'verifier123'
)
;(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
'challenge123'
)
// Mock discovery returning an object with serverMetadata()
const discoveredClient = {
serverMetadata: jest.fn(() => ({
supportsPKCE: () => true,
})),
};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
supportsPKCE: () => true
}))
}
;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
;(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
'https://auth.url'
)
(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
"https://auth.url"
);
const result = await Auth()
const result = await Auth();
expect(client.randomPKCECodeVerifier).toHaveBeenCalled();
expect(client.randomPKCECodeVerifier).toHaveBeenCalled()
expect(client.calculatePKCECodeChallenge).toHaveBeenCalledWith(
"verifier123"
);
'verifier123'
)
expect(client.buildAuthorizationUrl).toHaveBeenCalledWith(
discoveredClient,
expect.objectContaining({
code_challenge: "challenge123",
code_challenge: 'challenge123',
code_challenge_method: clientConfig.code_challenge_method,
redirect_uri: clientConfig.redirect_uri,
scope: clientConfig.scope,
scope: clientConfig.scope
})
);
)
expect(result).toEqual({
redirectTo: "https://auth.url",
code_verifier: "verifier123",
state: undefined,
});
});
redirectTo: 'https://auth.url',
code_verifier: 'verifier123',
state: undefined
})
})
it("should generate state when PKCE not supported", async () => {
(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
"verifier123"
);
(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
"challenge123"
);
it('should generate state when PKCE not supported', async () => {
;(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
'verifier123'
)
;(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
'challenge123'
)
const discoveredClient = {
serverMetadata: jest.fn(() => ({
supportsPKCE: () => false,
})),
};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
(client.randomState as jest.Mock).mockReturnValue("state123");
(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
"https://auth.url"
);
supportsPKCE: () => false
}))
}
;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
;(client.randomState as jest.Mock).mockReturnValue('state123')
;(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
'https://auth.url'
)
const result = await Auth();
const result = await Auth()
expect(client.randomState).toHaveBeenCalled();
expect(result.state).toBe("state123");
expect(result.redirectTo).toBe("https://auth.url");
});
});
expect(client.randomState).toHaveBeenCalled()
expect(result.state).toBe('state123')
expect(result.redirectTo).toBe('https://auth.url')
})
})
describe("Logout", () => {
it("should build end session URL", async () => {
const discoveredClient = {};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
(client.buildEndSessionUrl as jest.Mock).mockReturnValue(
"https://logout.url"
);
describe('Logout', () => {
it('should build end session URL', async () => {
const discoveredClient = {}
;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
;(client.buildEndSessionUrl as jest.Mock).mockReturnValue(
'https://logout.url'
)
const result = await Logout();
const result = await Logout()
expect(client.buildEndSessionUrl).toHaveBeenCalledWith(discoveredClient, {
post_logout_redirect_uri: clientConfig.post_logout_redirect_uri,
});
expect(result).toBe("https://logout.url");
});
});
post_logout_redirect_uri: clientConfig.post_logout_redirect_uri
})
expect(result).toBe('https://logout.url')
})
})
describe("Callback", () => {
it("should perform authorization code grant and fetch user info", async () => {
const discoveredClient = {};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
describe('Callback', () => {
it('should perform authorization code grant and fetch user info', async () => {
const discoveredClient = {}
;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
const mockTokenSet = {
access_token: "access123",
claims: jest.fn(() => ({ sub: "user123" })),
};
(client.authorizationCodeGrant as jest.Mock).mockResolvedValue(
access_token: 'access123',
claims: jest.fn(() => ({ sub: 'user123' }))
}
;(client.authorizationCodeGrant as jest.Mock).mockResolvedValue(
mockTokenSet
);
(client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: "User" });
const result = await Callback("verifier123", "state123");
)
;(client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: 'User' })
const result = await Callback('verifier123', 'state123')
expect(client.authorizationCodeGrant).toHaveBeenCalledWith(
discoveredClient,
new URL(localAdress),
{ pkceCodeVerifier: "verifier123", expectedState: "state123" }
);
{ pkceCodeVerifier: 'verifier123', expectedState: 'state123' }
)
expect(client.fetchUserInfo).toHaveBeenCalledWith(
discoveredClient,
"access123",
"user123"
);
'access123',
'user123'
)
expect(result).toEqual({
tokenSet: mockTokenSet,
userinfo: { name: "User" },
});
});
userinfo: { name: 'User' }
})
})
it("should catch and log errors", async () => {
const error = new Error("fail");
(client.discovery as jest.Mock).mockResolvedValue({});
(client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error);
it('should catch and log errors', async () => {
const error = new Error('fail')
;(client.discovery as jest.Mock).mockResolvedValue({})
;(client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error)
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
.spyOn(console, 'error')
.mockImplementation(() => {})
const result = await Callback("verifier", "state");
const result = await Callback('verifier', 'state')
expect(consoleErrorSpy).toHaveBeenCalledWith("Token grant error:", error);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith('Token grant error:', error)
expect(result).toBeUndefined()
consoleErrorSpy.mockRestore();
});
});
});
consoleErrorSpy.mockRestore()
})
})
})
+79 -79
View File
@@ -1,114 +1,114 @@
import { clientConfig } from "@/features/User/oidcAuth";
import { clientConfig } from '@/features/User/oidcAuth'
import {
getOpenPaasUser,
updateUserConfigurations,
getResourceDetails,
getUserDetails,
} from "@/features/User/userAPI";
import { api } from "@/utils/apiUtils";
getUserDetails
} from '@/features/User/userAPI'
import { api } from '@/utils/apiUtils'
jest.mock("@/utils/apiUtils");
jest.mock('@/utils/apiUtils')
clientConfig.url = "https://example.com";
clientConfig.url = 'https://example.com'
describe("getOpenPaasUser", () => {
it("should fetch and return user data", async () => {
const mockUser = { id: "123", name: "OpenPaas User" };
describe('getOpenPaasUser', () => {
it('should fetch and return user data', async () => {
const mockUser = { id: '123', name: 'OpenPaas User' }
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser),
});
;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser)
})
const result = await getOpenPaasUser();
const result = await getOpenPaasUser()
expect(api.get).toHaveBeenCalledWith("api/user");
expect(result).toEqual(mockUser);
});
});
expect(api.get).toHaveBeenCalledWith('api/user')
expect(result).toEqual(mockUser)
})
})
describe("getUserDetails", () => {
it("should fetch and return user details", async () => {
describe('getUserDetails', () => {
it('should fetch and return user details', async () => {
const mockUser = {
firstname: "John",
lastname: "Doe",
emails: ["john@test.com"],
};
const userId = "123";
firstname: 'John',
lastname: 'Doe',
emails: ['john@test.com']
}
const userId = '123'
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser),
});
;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser)
})
const result = await getUserDetails(userId);
const result = await getUserDetails(userId)
expect(api.get).toHaveBeenCalledWith(`api/users/${userId}`);
expect(result).toEqual(mockUser);
});
});
expect(api.get).toHaveBeenCalledWith(`api/users/${userId}`)
expect(result).toEqual(mockUser)
})
})
describe("getResourceDetails", () => {
it("should fetch and return resource details", async () => {
const mockResource = { _id: "res-123", name: "Meeting Room A" };
const resourceId = "res-123";
describe('getResourceDetails', () => {
it('should fetch and return resource details', async () => {
const mockResource = { _id: 'res-123', name: 'Meeting Room A' }
const resourceId = 'res-123'
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResource),
});
;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResource)
})
const result = await getResourceDetails(resourceId);
const result = await getResourceDetails(resourceId)
expect(api.get).toHaveBeenCalledWith(`api/resources/${resourceId}`);
expect(result).toEqual(mockResource);
});
});
expect(api.get).toHaveBeenCalledWith(`api/resources/${resourceId}`)
expect(result).toEqual(mockResource)
})
})
describe("updateUserConfigurations", () => {
describe('updateUserConfigurations', () => {
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("should PATCH configurations with language update", async () => {
const mockResponse = { status: 204 };
(api.patch as jest.Mock).mockResolvedValue(mockResponse);
it('should PATCH configurations with language update', async () => {
const mockResponse = { status: 204 }
;(api.patch as jest.Mock).mockResolvedValue(mockResponse)
await updateUserConfigurations({ language: "vi" });
await updateUserConfigurations({ language: 'vi' })
expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", {
expect(api.patch).toHaveBeenCalledWith('api/configurations?scope=user', {
json: [
{
name: "core",
configurations: [{ name: "language", value: "vi" }],
},
],
});
});
name: 'core',
configurations: [{ name: 'language', value: 'vi' }]
}
]
})
})
it("should PATCH configurations with multiple updates", async () => {
const mockResponse = { status: 204 };
(api.patch as jest.Mock).mockResolvedValue(mockResponse);
it('should PATCH configurations with multiple updates', async () => {
const mockResponse = { status: 204 }
;(api.patch as jest.Mock).mockResolvedValue(mockResponse)
await updateUserConfigurations({
language: "fr",
timezone: "Europe/Paris",
});
language: 'fr',
timezone: 'Europe/Paris'
})
expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", {
expect(api.patch).toHaveBeenCalledWith('api/configurations?scope=user', {
json: [
{
name: "core",
name: 'core',
configurations: [
{ name: "language", value: "fr" },
{ name: "datetime", value: { timeZone: "Europe/Paris" } },
],
},
],
});
});
{ name: 'language', value: 'fr' },
{ name: 'datetime', value: { timeZone: 'Europe/Paris' } }
]
}
]
})
})
it("should handle empty updates without calling API", async () => {
const result = await updateUserConfigurations({});
it('should handle empty updates without calling API', async () => {
const result = await updateUserConfigurations({})
expect(api.patch).not.toHaveBeenCalled();
expect(result).toEqual({ status: 204 });
});
});
expect(api.patch).not.toHaveBeenCalled()
expect(result).toEqual({ status: 204 })
})
})
File diff suppressed because it is too large Load Diff
@@ -1,46 +1,46 @@
import { api } from "@/utils/apiUtils";
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
import { api } from '@/utils/apiUtils'
import { fetchWebSocketTicket } from '@/websocket/api/fetchWebSocketTicket'
jest.mock("@/utils/apiUtils");
jest.mock('@/utils/apiUtils')
describe("fetchWebSocketTicket", () => {
describe('fetchWebSocketTicket', () => {
const mockTicket = {
clientAddress: "127.0.0.1",
value: "test-ticket-123",
generatedOn: "2025-01-12T10:00:00Z",
validUntil: "2025-01-12T11:00:00Z",
username: "testuser",
};
clientAddress: '127.0.0.1',
value: 'test-ticket-123',
generatedOn: '2025-01-12T10:00:00Z',
validUntil: '2025-01-12T11:00:00Z',
username: 'testuser'
}
beforeEach(() => {
jest.clearAllMocks();
});
jest.clearAllMocks()
})
it("should fetch ticket successfully", async () => {
(api.post as jest.Mock).mockResolvedValue({
it('should fetch ticket successfully', async () => {
;(api.post as jest.Mock).mockResolvedValue({
ok: true,
json: async () => mockTicket,
});
json: async () => mockTicket
})
const ticket = await fetchWebSocketTicket();
const ticket = await fetchWebSocketTicket()
expect(api.post).toHaveBeenCalledWith("ws/ticket");
expect(ticket).toEqual(mockTicket);
});
expect(api.post).toHaveBeenCalledWith('ws/ticket')
expect(ticket).toEqual(mockTicket)
})
it("should throw error when response is not ok", async () => {
(api.post as jest.Mock).mockResolvedValue({
ok: false,
});
it('should throw error when response is not ok', async () => {
;(api.post as jest.Mock).mockResolvedValue({
ok: false
})
await expect(fetchWebSocketTicket()).rejects.toThrow(
"Failed to fetch WebSocket ticket"
);
});
'Failed to fetch WebSocket ticket'
)
})
it("should throw error when network fails", async () => {
(api.post as jest.Mock).mockRejectedValue(new Error("Network error"));
it('should throw error when network fails', async () => {
;(api.post as jest.Mock).mockRejectedValue(new Error('Network error'))
await expect(fetchWebSocketTicket()).rejects.toThrow("Network error");
});
});
await expect(fetchWebSocketTicket()).rejects.toThrow('Network error')
})
})
@@ -1,211 +1,210 @@
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
import { createWebSocketConnection } from "@/websocket/connection/createConnection";
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { waitFor } from "@testing-library/dom";
import { setupWebsocket } from "./utils/setupWebsocket";
import { fetchWebSocketTicket } from '@/websocket/api/fetchWebSocketTicket'
import { createWebSocketConnection } from '@/websocket/connection/createConnection'
import { WS_INBOUND_EVENTS } from '@/websocket/protocols'
import { waitFor } from '@testing-library/dom'
import { setupWebsocket } from './utils/setupWebsocket'
jest.mock("@/websocket/api/fetchWebSocketTicket");
jest.mock('@/websocket/api/fetchWebSocketTicket')
describe("createWebSocketConnection", () => {
let mockWebSocket: jest.Mock;
let cleanup: () => void;
let webSocketInstances: any[] = [];
describe('createWebSocketConnection', () => {
let mockWebSocket: jest.Mock
let cleanup: () => void
let webSocketInstances: any[] = []
const mockTicket = {
value: "test-ticket-123",
clientAddress: "127.0.0.1",
generatedOn: "2025-01-12T10:00:00Z",
validUntil: "2025-01-12T11:00:00Z",
username: "testuser",
};
value: 'test-ticket-123',
clientAddress: '127.0.0.1',
generatedOn: '2025-01-12T10:00:00Z',
validUntil: '2025-01-12T11:00:00Z',
username: 'testuser'
}
/** ---------- Helpers ---------- */
const getWs = () => webSocketInstances[0];
const getWs = () => webSocketInstances[0]
const triggerEvent = (ws: any, event: string, payload?: any) => {
ws._listeners[event]?.[0]?.(payload);
};
ws._listeners[event]?.[0]?.(payload)
}
const createAndOpenConnection = async () => {
const mockCallbacks = {
onMessage: jest.fn(),
onClose: jest.fn(),
onError: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
onError: jest.fn()
}
const promise = createWebSocketConnection(mockCallbacks)
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
});
expect(webSocketInstances.length).toBe(1)
})
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED);
const socket = await promise;
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED)
const socket = await promise
return { socket, ws: getWs(), promise, mockCallbacks };
};
return { socket, ws: getWs(), promise, mockCallbacks }
}
/** ---------- Setup ---------- */
beforeEach(() => {
({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket());
window.WEBSOCKET_URL = "wss://calendar.example.com";
(fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket);
});
;({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket())
window.WEBSOCKET_URL = 'wss://calendar.example.com'
;(fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket)
})
afterEach(() => {
jest.clearAllMocks();
delete window.WEBSOCKET_URL;
delete window.CALENDAR_BASE_URL;
cleanup();
});
jest.clearAllMocks()
delete window.WEBSOCKET_URL
delete window.CALENDAR_BASE_URL
cleanup()
})
/** ---------- Tests ---------- */
it("throws when WEBSOCKET_URL is not defined", async () => {
delete window.WEBSOCKET_URL;
it('throws when WEBSOCKET_URL is not defined', async () => {
delete window.WEBSOCKET_URL
const mockCallbacks = {
onMessage: jest.fn(),
};
onMessage: jest.fn()
}
await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow(
"WEBSOCKET_URL is not defined"
);
});
'WEBSOCKET_URL is not defined'
)
})
it("fetches WebSocket ticket", async () => {
await createAndOpenConnection();
expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1);
});
it('fetches WebSocket ticket', async () => {
await createAndOpenConnection()
expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1)
})
it("creates WebSocket with correct URL and ticket", async () => {
await createAndOpenConnection();
it('creates WebSocket with correct URL and ticket', async () => {
await createAndOpenConnection()
expect(mockWebSocket).toHaveBeenCalledWith(
"wss://calendar.example.com/ws?ticket=test-ticket-123"
);
});
'wss://calendar.example.com/ws?ticket=test-ticket-123'
)
})
it("creates WebSocket with correct URL and ticket without the WEBSOCKET_URL", async () => {
delete window.WEBSOCKET_URL;
window.CALENDAR_BASE_URL = "https://calendar.example.com";
await createAndOpenConnection();
it('creates WebSocket with correct URL and ticket without the WEBSOCKET_URL', async () => {
delete window.WEBSOCKET_URL
window.CALENDAR_BASE_URL = 'https://calendar.example.com'
await createAndOpenConnection()
expect(mockWebSocket).toHaveBeenCalledWith(
"wss://calendar.example.com/ws?ticket=test-ticket-123"
);
});
'wss://calendar.example.com/ws?ticket=test-ticket-123'
)
})
it("resolves with socket when connection opens", async () => {
const { socket, ws } = await createAndOpenConnection();
expect(socket).toBe(ws);
});
it('resolves with socket when connection opens', async () => {
const { socket, ws } = await createAndOpenConnection()
expect(socket).toBe(ws)
})
it("rejects when connection fails", async () => {
it('rejects when connection fails', async () => {
const mockCallbacks = {
onMessage: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
onMessage: jest.fn()
}
const promise = createWebSocketConnection(mockCallbacks)
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
});
expect(webSocketInstances.length).toBe(1)
})
triggerEvent(getWs(), WS_INBOUND_EVENTS.ERROR, new Event("error"));
triggerEvent(getWs(), WS_INBOUND_EVENTS.ERROR, new Event('error'))
await expect(promise).rejects.toThrow("WebSocket connection failed");
});
await expect(promise).rejects.toThrow('WebSocket connection failed')
})
it("attaches message event listener", async () => {
const { ws } = await createAndOpenConnection();
it('attaches message event listener', async () => {
const { ws } = await createAndOpenConnection()
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE]).toBeDefined();
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE].length).toBeGreaterThan(0);
});
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE]).toBeDefined()
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE].length).toBeGreaterThan(0)
})
it("attaches close event listener", async () => {
const { ws } = await createAndOpenConnection();
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined();
});
it('attaches close event listener', async () => {
const { ws } = await createAndOpenConnection()
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined()
})
it("handles invalid JSON messages", async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation();
it('handles invalid JSON messages', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation()
const { ws } = await createAndOpenConnection();
const { ws } = await createAndOpenConnection()
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" });
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: 'invalid json' })
expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
'Failed to parse WebSocket message:',
expect.any(Error)
);
)
errorSpy.mockRestore();
});
errorSpy.mockRestore()
})
it("rejects on timeout", async () => {
jest.useFakeTimers();
it('rejects on timeout', async () => {
jest.useFakeTimers()
const mockCallbacks = {
onMessage: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
onMessage: jest.fn()
}
const promise = createWebSocketConnection(mockCallbacks)
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
});
expect(webSocketInstances.length).toBe(1)
})
jest.advanceTimersByTime(10000);
jest.advanceTimersByTime(10000)
await expect(promise).rejects.toThrow("WebSocket connection timed out");
await expect(promise).rejects.toThrow('WebSocket connection timed out')
jest.useRealTimers();
});
jest.useRealTimers()
})
it("calls onMessage callback when message received", async () => {
const { ws, mockCallbacks } = await createAndOpenConnection();
it('calls onMessage callback when message received', async () => {
const { ws, mockCallbacks } = await createAndOpenConnection()
const testMessage = { type: "test", payload: "data" };
const testMessage = { type: 'test', payload: 'data' }
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, {
data: JSON.stringify(testMessage),
});
data: JSON.stringify(testMessage)
})
expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage);
});
expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage)
})
it("does not call onMessage when JSON parsing fails", async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation();
const { ws, mockCallbacks } = await createAndOpenConnection();
it('does not call onMessage when JSON parsing fails', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation()
const { ws, mockCallbacks } = await createAndOpenConnection()
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" });
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: 'invalid json' })
expect(mockCallbacks.onMessage).not.toHaveBeenCalled();
expect(mockCallbacks.onMessage).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
'Failed to parse WebSocket message:',
expect.any(Error)
);
)
errorSpy.mockRestore();
});
errorSpy.mockRestore()
})
it("calls onClose callback when connection closes", async () => {
const { ws, mockCallbacks } = await createAndOpenConnection();
it('calls onClose callback when connection closes', async () => {
const { ws, mockCallbacks } = await createAndOpenConnection()
const closeEvent = new CloseEvent("close", {
const closeEvent = new CloseEvent('close', {
code: 1000,
reason: "Normal closure",
});
triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent);
reason: 'Normal closure'
})
triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent)
expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent);
});
expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent)
})
it("calls onError callback when error occurs", async () => {
const { ws, mockCallbacks } = await createAndOpenConnection();
it('calls onError callback when error occurs', async () => {
const { ws, mockCallbacks } = await createAndOpenConnection()
const errorEvent = new Event("error");
triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent);
const errorEvent = new Event('error')
triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent)
expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent);
});
});
expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent)
})
})
@@ -1,41 +1,41 @@
import { getRetryDelay } from "@/utils/getRetryDelay";
import { getRetryDelay } from '@/utils/getRetryDelay'
import {
MAX_RECONNECT_ATTEMPTS,
RECONNECT_CONFIG,
useWebSocketReconnect,
} from "@/websocket/connection/lifecycle/useWebSocketReconnect";
import { act, renderHook } from "@testing-library/react";
import { MutableRefObject } from "react";
useWebSocketReconnect
} from '@/websocket/connection/lifecycle/useWebSocketReconnect'
import { act, renderHook } from '@testing-library/react'
import { MutableRefObject } from 'react'
// Mock the retry delay utility
jest.mock("@/utils/getRetryDelay");
jest.mock('@/utils/getRetryDelay')
const mockGetRetryDelay = getRetryDelay as jest.MockedFunction<
typeof getRetryDelay
>;
>
describe("useWebSocketReconnect", () => {
let reconnectTimeoutRef: MutableRefObject<NodeJS.Timeout | null>;
let isAuthenticatedRef: MutableRefObject<boolean>;
let reconnectAttemptsRef: MutableRefObject<number>;
let setShouldConnect: jest.Mock;
describe('useWebSocketReconnect', () => {
let reconnectTimeoutRef: MutableRefObject<NodeJS.Timeout | null>
let isAuthenticatedRef: MutableRefObject<boolean>
let reconnectAttemptsRef: MutableRefObject<number>
let setShouldConnect: jest.Mock
beforeEach(() => {
jest.useFakeTimers();
reconnectTimeoutRef = { current: null };
isAuthenticatedRef = { current: true };
reconnectAttemptsRef = { current: 0 };
setShouldConnect = jest.fn();
mockGetRetryDelay.mockReturnValue(1000); // Default 1 second delay
});
jest.useFakeTimers()
reconnectTimeoutRef = { current: null }
isAuthenticatedRef = { current: true }
reconnectAttemptsRef = { current: 0 }
setShouldConnect = jest.fn()
mockGetRetryDelay.mockReturnValue(1000) // Default 1 second delay
})
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.clearAllMocks();
});
jest.runOnlyPendingTimers()
jest.useRealTimers()
jest.clearAllMocks()
})
describe("scheduleReconnect", () => {
it("should schedule a reconnection with correct delay", () => {
describe('scheduleReconnect', () => {
it('should schedule a reconnection with correct delay', () => {
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -43,26 +43,26 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG);
expect(reconnectTimeoutRef.current).not.toBeNull();
expect(setShouldConnect).not.toHaveBeenCalled();
expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG)
expect(reconnectTimeoutRef.current).not.toBeNull()
expect(setShouldConnect).not.toHaveBeenCalled()
act(() => {
jest.advanceTimersByTime(1000);
});
jest.advanceTimersByTime(1000)
})
expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function));
expect(reconnectAttemptsRef.current).toBe(1);
});
expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function))
expect(reconnectAttemptsRef.current).toBe(1)
})
it("should not schedule reconnection if not authenticated", () => {
isAuthenticatedRef = { current: false };
it('should not schedule reconnection if not authenticated', () => {
isAuthenticatedRef = { current: false }
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -70,19 +70,19 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
expect(reconnectTimeoutRef.current).toBeNull();
expect(mockGetRetryDelay).not.toHaveBeenCalled();
});
expect(reconnectTimeoutRef.current).toBeNull()
expect(mockGetRetryDelay).not.toHaveBeenCalled()
})
it("should stop after MAX_RECONNECT_ATTEMPTS", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS;
it('should stop after MAX_RECONNECT_ATTEMPTS', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS
const { result } = renderHook(() =>
useWebSocketReconnect(
@@ -91,23 +91,23 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS})`
)
);
expect(reconnectTimeoutRef.current).toBeNull();
)
expect(reconnectTimeoutRef.current).toBeNull()
consoleErrorSpy.mockRestore();
});
consoleErrorSpy.mockRestore()
})
it("should increment attempt counter on each reconnection", () => {
it('should increment attempt counter on each reconnection', () => {
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -115,26 +115,26 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
expect(reconnectAttemptsRef.current).toBe(0);
expect(reconnectAttemptsRef.current).toBe(0)
act(() => {
result.current.scheduleReconnect();
jest.advanceTimersByTime(1000);
});
result.current.scheduleReconnect()
jest.advanceTimersByTime(1000)
})
expect(reconnectAttemptsRef.current).toBe(1);
expect(reconnectAttemptsRef.current).toBe(1)
act(() => {
result.current.scheduleReconnect();
jest.advanceTimersByTime(1000);
});
result.current.scheduleReconnect()
jest.advanceTimersByTime(1000)
})
expect(reconnectAttemptsRef.current).toBe(2);
});
expect(reconnectAttemptsRef.current).toBe(2)
})
it("should toggle setShouldConnect correctly", () => {
it('should toggle setShouldConnect correctly', () => {
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -142,24 +142,24 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
act(() => {
result.current.scheduleReconnect();
jest.advanceTimersByTime(1000);
});
result.current.scheduleReconnect()
jest.advanceTimersByTime(1000)
})
expect(setShouldConnect).toHaveBeenCalledTimes(1);
expect(setShouldConnect).toHaveBeenCalledTimes(1)
// Test the toggle function
const toggleFn = setShouldConnect.mock.calls[0][0];
expect(toggleFn(false)).toBe(true);
expect(toggleFn(true)).toBe(false);
});
});
const toggleFn = setShouldConnect.mock.calls[0][0]
expect(toggleFn(false)).toBe(true)
expect(toggleFn(true)).toBe(false)
})
})
describe("clearReconnectTimeout", () => {
it("should clear pending timeout", () => {
describe('clearReconnectTimeout', () => {
it('should clear pending timeout', () => {
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -167,29 +167,29 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
expect(reconnectTimeoutRef.current).not.toBeNull();
expect(reconnectTimeoutRef.current).not.toBeNull()
act(() => {
result.current.clearReconnectTimeout();
});
result.current.clearReconnectTimeout()
})
expect(reconnectTimeoutRef.current).toBeNull();
expect(reconnectTimeoutRef.current).toBeNull()
// Timeout should not fire
act(() => {
jest.advanceTimersByTime(1000);
});
jest.advanceTimersByTime(1000)
})
expect(setShouldConnect).not.toHaveBeenCalled();
});
expect(setShouldConnect).not.toHaveBeenCalled()
})
it("should handle multiple clears gracefully", () => {
it('should handle multiple clears gracefully', () => {
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -197,18 +197,18 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
act(() => {
result.current.clearReconnectTimeout();
result.current.clearReconnectTimeout();
result.current.clearReconnectTimeout();
});
result.current.clearReconnectTimeout()
result.current.clearReconnectTimeout()
result.current.clearReconnectTimeout()
})
expect(reconnectTimeoutRef.current).toBeNull();
});
expect(reconnectTimeoutRef.current).toBeNull()
})
it("should clear timeout before scheduling new one", () => {
it('should clear timeout before scheduling new one', () => {
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -216,35 +216,35 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
// Schedule first reconnection
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
const firstTimeout = reconnectTimeoutRef.current;
expect(firstTimeout).not.toBeNull();
const firstTimeout = reconnectTimeoutRef.current
expect(firstTimeout).not.toBeNull()
// Schedule second reconnection (should clear first)
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
const secondTimeout = reconnectTimeoutRef.current;
expect(secondTimeout).not.toBeNull();
expect(secondTimeout).not.toBe(firstTimeout);
const secondTimeout = reconnectTimeoutRef.current
expect(secondTimeout).not.toBeNull()
expect(secondTimeout).not.toBe(firstTimeout)
// Only second timeout should fire
act(() => {
jest.advanceTimersByTime(1000);
});
jest.advanceTimersByTime(1000)
})
expect(setShouldConnect).toHaveBeenCalledTimes(1);
});
});
it("should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
expect(setShouldConnect).toHaveBeenCalledTimes(1)
})
})
it('should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const { result } = renderHook(() =>
useWebSocketReconnect(
reconnectTimeoutRef,
@@ -252,32 +252,32 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef,
setShouldConnect
)
);
)
// Simulate 10 reconnection attempts
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
act(() => {
result.current.scheduleReconnect();
result.current.scheduleReconnect()
jest.advanceTimersByTime(
mockGetRetryDelay.mock.results[i]?.value || 1000
);
});
)
})
}
expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS);
expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS);
expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS)
expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS)
// Try to schedule one more reconnection - should fail
act(() => {
result.current.scheduleReconnect();
});
result.current.scheduleReconnect()
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`
);
expect(reconnectTimeoutRef.current).toBeNull();
expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); // Should not increment
)
expect(reconnectTimeoutRef.current).toBeNull()
expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS) // Should not increment
consoleErrorSpy.mockRestore();
});
});
consoleErrorSpy.mockRestore()
})
})
@@ -1,135 +1,135 @@
import type { AppDispatch, RootState } from "@/app/store";
import { store } from "@/app/store";
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services";
import { getDisplayedCalendarRange } from "@/utils";
import { updateCalendars } from "@/websocket/messaging/updateCalendars";
import type { AppDispatch, RootState } from '@/app/store'
import { store } from '@/app/store'
import { refreshCalendarWithSyncToken } from '@/features/Calendars/services'
import { getDisplayedCalendarRange } from '@/utils'
import { updateCalendars } from '@/websocket/messaging/updateCalendars'
jest.mock("@/features/Calendars/services", () => ({
refreshCalendarWithSyncToken: jest.fn(),
}));
jest.mock('@/features/Calendars/services', () => ({
refreshCalendarWithSyncToken: jest.fn()
}))
jest.mock("@/utils", () => ({
jest.mock('@/utils', () => ({
getDisplayedCalendarRange: jest.fn(),
findCalendarById: jest.requireActual("@/utils").findCalendarById,
}));
findCalendarById: jest.requireActual('@/utils').findCalendarById
}))
jest.mock("@/app/store", () => ({
jest.mock('@/app/store', () => ({
store: {
getState: jest.fn(),
},
}));
getState: jest.fn()
}
}))
jest.useFakeTimers();
const mockDispatch = jest.fn();
jest.useFakeTimers()
const mockDispatch = jest.fn()
const mockRange = {
start: new Date("2025-01-15T10:00:00Z"),
end: new Date("2025-01-16T10:00:00Z"),
};
start: new Date('2025-01-15T10:00:00Z'),
end: new Date('2025-01-16T10:00:00Z')
}
const mockState = {
calendars: {
list: {
"cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 },
"cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 },
"cal/A": { id: "cal/A", name: "Cal A", syncToken: 1 },
"cal/B": { id: "cal/B", name: "Cal B", syncToken: 1 },
"cal/C": { id: "cal/C", name: "Cal C", syncToken: 1 },
'cal1/entry1': { id: 'cal1/entry1', name: 'Calendar 1', syncToken: 1 },
'cal2/entry2': { id: 'cal2/entry2', name: 'Calendar 2', syncToken: 1 },
'cal/A': { id: 'cal/A', name: 'Cal A', syncToken: 1 },
'cal/B': { id: 'cal/B', name: 'Cal B', syncToken: 1 },
'cal/C': { id: 'cal/C', name: 'Cal C', syncToken: 1 }
},
templist: {},
},
} as unknown as RootState;
templist: {}
}
} as unknown as RootState
const mockAccumulators: {
calendarsToRefresh: Map<string, any>;
calendarsToHide: Set<string>;
debouncedUpdateFn?: (dispatch: AppDispatch) => void;
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>;
currentDebouncePeriod?: number;
calendarsToRefresh: Map<string, any>
calendarsToHide: Set<string>
debouncedUpdateFn?: (dispatch: AppDispatch) => void
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>
currentDebouncePeriod?: number
} = {
calendarsToRefresh: new Map<string, any>(),
calendarsToHide: new Set(),
shouldRefreshCalendarListRef: { current: false },
currentDebouncePeriod: 0,
debouncedUpdateFn: undefined,
};
debouncedUpdateFn: undefined
}
describe("websocket messages storm", () => {
describe('websocket messages storm', () => {
beforeEach(() => {
(refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear();
jest.clearAllMocks();
jest.clearAllTimers();
jest.resetModules();
(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange);
(store.getState as jest.Mock).mockReturnValue(mockState);
window.WS_DEBOUNCE_PERIOD_MS = 500;
mockAccumulators.calendarsToRefresh = new Map<string, any>();
mockAccumulators.calendarsToHide = new Set();
mockAccumulators.shouldRefreshCalendarListRef.current = false;
mockAccumulators.currentDebouncePeriod = 0;
;(refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear()
jest.clearAllMocks()
jest.clearAllTimers()
jest.resetModules()
;(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange)
;(store.getState as jest.Mock).mockReturnValue(mockState)
window.WS_DEBOUNCE_PERIOD_MS = 500
mockAccumulators.calendarsToRefresh = new Map<string, any>()
mockAccumulators.calendarsToHide = new Set()
mockAccumulators.shouldRefreshCalendarListRef.current = false
mockAccumulators.currentDebouncePeriod = 0
mockAccumulators.debouncedUpdateFn = undefined;
});
it("debounces calendar updates during message storm", () => {
mockAccumulators.debouncedUpdateFn = undefined
})
it('debounces calendar updates during message storm', () => {
const mockMessage = {
"/calendars/cal1/entry1": {
syncToken: "ldsk",
},
};
'/calendars/cal1/entry1': {
syncToken: 'ldsk'
}
}
for (let i = 0; i < 50; i++) {
updateCalendars(mockMessage, mockDispatch, mockAccumulators);
updateCalendars(mockMessage, mockDispatch, mockAccumulators)
}
// Dispatch called once because of leading edge
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1);
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1)
// Trailing edge
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500)
// only one call for the last message + leading edge message
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2);
});
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2)
})
it("debounces calendar updates during message storm with multiple updates", () => {
it('debounces calendar updates during message storm with multiple updates', () => {
// Send a storm with mixed messages
for (let i = 0; i < 50; i++) {
if (i % 3 === 0)
updateCalendars(
{ "/calendars/cal/A": { syncToken: "ldskfjsld" + i } },
{ '/calendars/cal/A': { syncToken: 'ldskfjsld' + i } },
mockDispatch,
mockAccumulators
);
)
else if (i % 3 === 1)
updateCalendars(
{ "/calendars/cal/B": { syncToken: "ldskfjsld" + i } },
{ '/calendars/cal/B': { syncToken: 'ldskfjsld' + i } },
mockDispatch,
mockAccumulators
);
)
else
updateCalendars(
{ "/calendars/cal/C": { syncToken: "ldskfjsld" + i } },
{ '/calendars/cal/C': { syncToken: 'ldskfjsld' + i } },
mockDispatch,
mockAccumulators
);
)
}
// Dispatch called once because of leading edge
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1);
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1)
// Trailing edge
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500)
// Trailing edge updates once per calendar + the original leading edge
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(4);
});
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(4)
})
it("executes immediately when debounce is disabled", () => {
window.WS_DEBOUNCE_PERIOD_MS = 0;
it('executes immediately when debounce is disabled', () => {
window.WS_DEBOUNCE_PERIOD_MS = 0
updateCalendars(
{ "/calendars/cal1/entry1": { syncToken: "abc" } },
{ '/calendars/cal1/entry1': { syncToken: 'abc' } },
mockDispatch,
mockAccumulators
);
)
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1);
});
});
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1)
})
})
@@ -1,6 +1,6 @@
export function setupWebsocket() {
const originalWebSocket = global.WebSocket;
const webSocketInstances: any[] = [];
const originalWebSocket = global.WebSocket
const webSocketInstances: any[] = []
const mockWebSocket = jest.fn().mockImplementation((url: string) => {
const ws = {
url,
@@ -9,27 +9,27 @@ export function setupWebsocket() {
removeEventListener: jest.fn(),
send: jest.fn(),
close: jest.fn(),
_listeners: {} as Record<string, Function[]>,
};
_listeners: {} as Record<string, Function[]>
}
ws.addEventListener.mockImplementation((event, handler) => {
ws._listeners[event] ??= [];
ws._listeners[event].push(handler);
});
ws._listeners[event] ??= []
ws._listeners[event].push(handler)
})
ws.removeEventListener.mockImplementation((event, handler) => {
ws._listeners[event] =
ws._listeners[event]?.filter((h) => h !== handler) ?? [];
});
ws._listeners[event]?.filter(h => h !== handler) ?? []
})
webSocketInstances.push(ws);
return ws;
});
webSocketInstances.push(ws)
return ws
})
global.WebSocket = mockWebSocket as any;
global.WebSocket = mockWebSocket as any
const cleanup = () => {
global.WebSocket = originalWebSocket;
webSocketInstances.length = 0;
};
return { webSocketInstances, mockWebSocket, cleanup };
global.WebSocket = originalWebSocket
webSocketInstances.length = 0
}
return { webSocketInstances, mockWebSocket, cleanup }
}
@@ -1,147 +1,145 @@
import { AppDispatch, RootState, store } from "@/app/store";
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar";
import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager";
import { updateCalendars } from "@/websocket/messaging/updateCalendars";
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { waitFor } from "@testing-library/dom";
import { AppDispatch, RootState, store } from '@/app/store'
import { refreshCalendarWithSyncToken } from '@/features/Calendars/services/refreshCalendar'
import { getDisplayedCalendarRange } from '@/utils/CalendarRangeManager'
import { updateCalendars } from '@/websocket/messaging/updateCalendars'
import { WS_INBOUND_EVENTS } from '@/websocket/protocols'
import { waitFor } from '@testing-library/dom'
jest.mock("@/features/Calendars/services/refreshCalendar");
jest.mock("@/utils/CalendarRangeManager");
jest.mock("@/app/store", () => ({
jest.mock('@/features/Calendars/services/refreshCalendar')
jest.mock('@/utils/CalendarRangeManager')
jest.mock('@/app/store', () => ({
store: {
getState: jest.fn(),
},
}));
getState: jest.fn()
}
}))
describe("updateCalendars", () => {
let mockDispatch: jest.Mock;
describe('updateCalendars', () => {
let mockDispatch: jest.Mock
const mockRange = {
start: new Date("2025-01-15T10:00:00Z"),
end: new Date("2025-01-16T10:00:00Z"),
};
start: new Date('2025-01-15T10:00:00Z'),
end: new Date('2025-01-16T10:00:00Z')
}
const mockState = {
calendars: {
list: {
"cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 },
"cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 },
'cal1/entry1': { id: 'cal1/entry1', name: 'Calendar 1', syncToken: 1 },
'cal2/entry2': { id: 'cal2/entry2', name: 'Calendar 2', syncToken: 1 }
},
templist: {},
},
} as unknown as RootState;
templist: {}
}
} as unknown as RootState
const mockAccumulators: {
calendarsToRefresh: Map<string, any>;
calendarsToHide: Set<string>;
debouncedUpdateFn?: (dispatch: AppDispatch) => void;
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>;
currentDebouncePeriod?: number;
calendarsToRefresh: Map<string, any>
calendarsToHide: Set<string>
debouncedUpdateFn?: (dispatch: AppDispatch) => void
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>
currentDebouncePeriod?: number
} = {
calendarsToRefresh: new Map<string, any>(),
calendarsToHide: new Set(),
shouldRefreshCalendarListRef: { current: false },
currentDebouncePeriod: 0,
debouncedUpdateFn: jest.fn(),
};
debouncedUpdateFn: jest.fn()
}
beforeEach(() => {
jest.clearAllMocks();
mockDispatch = jest.fn();
(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange);
(store.getState as jest.Mock).mockReturnValue(mockState);
mockAccumulators.calendarsToRefresh = new Map<string, any>();
mockAccumulators.calendarsToHide = new Set();
mockAccumulators.currentDebouncePeriod = 0;
mockAccumulators.shouldRefreshCalendarListRef.current = false;
mockAccumulators.debouncedUpdateFn = jest.fn();
});
jest.clearAllMocks()
mockDispatch = jest.fn()
;(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange)
;(store.getState as jest.Mock).mockReturnValue(mockState)
mockAccumulators.calendarsToRefresh = new Map<string, any>()
mockAccumulators.calendarsToHide = new Set()
mockAccumulators.currentDebouncePeriod = 0
mockAccumulators.shouldRefreshCalendarListRef.current = false
mockAccumulators.debouncedUpdateFn = jest.fn()
})
it("should not dispatch for non-object messages", () => {
updateCalendars(null, mockDispatch, mockAccumulators);
updateCalendars("string", mockDispatch, mockAccumulators);
updateCalendars(123, mockDispatch, mockAccumulators);
it('should not dispatch for non-object messages', () => {
updateCalendars(null, mockDispatch, mockAccumulators)
updateCalendars('string', mockDispatch, mockAccumulators)
updateCalendars(123, mockDispatch, mockAccumulators)
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled();
});
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled()
})
it("should dispatch for registered calendars", async () => {
it('should dispatch for registered calendars', async () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
"/calendars/cal1/entry1",
"/calendars/cal2/entry2",
],
};
'/calendars/cal1/entry1',
'/calendars/cal2/entry2'
]
}
updateCalendars(message, mockDispatch, mockAccumulators);
updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() =>
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2)
);
});
)
})
it("should dispatch for calendar path updates", async () => {
it('should dispatch for calendar path updates', async () => {
const message = {
"/calendars/cal1/entry1": { updated: true },
};
'/calendars/cal1/entry1': { updated: true }
}
updateCalendars(message, mockDispatch, mockAccumulators);
await waitFor(() =>
expect(refreshCalendarWithSyncToken).toHaveBeenCalled()
);
updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() => expect(refreshCalendarWithSyncToken).toHaveBeenCalled())
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
calendar: mockState.calendars.list["cal1/entry1"],
calendar: mockState.calendars.list['cal1/entry1'],
calType: undefined,
calendarRange: mockRange,
});
});
calendarRange: mockRange
})
})
it("should use displayed calendar range", async () => {
it('should use displayed calendar range', async () => {
const message = {
"/calendars/cal1/entry1": {},
};
'/calendars/cal1/entry1': {}
}
updateCalendars(message, mockDispatch, mockAccumulators);
await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled());
});
updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled())
})
it("should handle temp calendars", async () => {
it('should handle temp calendars', async () => {
const stateWithTemp = {
calendars: {
list: {},
templist: {
"temp1/entry1": {
id: "temp1/entry1",
name: "Temp Calendar",
syncToken: 1,
},
},
},
};
'temp1/entry1': {
id: 'temp1/entry1',
name: 'Temp Calendar',
syncToken: 1
}
}
}
}
(store.getState as jest.Mock).mockReturnValue(stateWithTemp);
;(store.getState as jest.Mock).mockReturnValue(stateWithTemp)
const message = {
"/calendars/temp1/entry1": {},
};
'/calendars/temp1/entry1': {}
}
updateCalendars(message, mockDispatch, mockAccumulators);
updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() =>
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
calendar: stateWithTemp.calendars.templist["temp1/entry1"],
calType: "temp",
calendarRange: mockRange,
calendar: stateWithTemp.calendars.templist['temp1/entry1'],
calType: 'temp',
calendarRange: mockRange
})
);
});
)
})
it("should handle invalid calendar paths gracefully", () => {
it('should handle invalid calendar paths gracefully', () => {
const message = {
"/invalid/path": {},
"not-a-path": {},
};
'/invalid/path': {},
'not-a-path': {}
}
updateCalendars(message, mockDispatch, mockAccumulators);
updateCalendars(message, mockDispatch, mockAccumulators)
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled();
});
});
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled()
})
})
@@ -1,76 +1,76 @@
import { parseMessage } from "@/websocket/messaging/parseMessage";
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { parseMessage } from '@/websocket/messaging/parseMessage'
import { WS_INBOUND_EVENTS } from '@/websocket/protocols'
describe("parseMessage", () => {
it("should return empty set for non-object messages", () => {
const result1 = parseMessage(null);
const result2 = parseMessage("string");
const result3 = parseMessage(123);
describe('parseMessage', () => {
it('should return empty set for non-object messages', () => {
const result1 = parseMessage(null)
const result2 = parseMessage('string')
const result3 = parseMessage(123)
expect(result1.calendarsToRefresh).toEqual(new Set<string>());
expect(result2.calendarsToRefresh).toEqual(new Set<string>());
expect(result3.calendarsToRefresh).toEqual(new Set<string>());
});
expect(result1.calendarsToRefresh).toEqual(new Set<string>())
expect(result2.calendarsToRefresh).toEqual(new Set<string>())
expect(result3.calendarsToRefresh).toEqual(new Set<string>())
})
it("should handle registered event", () => {
it('should handle registered event', () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
"/calendars/cal1/entry1",
"/calendars/cal2/entry2",
],
};
'/calendars/cal1/entry1',
'/calendars/cal2/entry2'
]
}
const result = parseMessage(message);
const result = parseMessage(message)
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2");
expect(result.calendarsToRefresh.size).toBe(2);
});
expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1')
expect(result.calendarsToRefresh).toContain('/calendars/cal2/entry2')
expect(result.calendarsToRefresh.size).toBe(2)
})
it("should handle unregistered event", () => {
it('should handle unregistered event', () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal1/entry1"],
};
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ['/calendars/cal1/entry1']
}
const result = parseMessage(message);
const result = parseMessage(message)
expect(result.calendarsToHide).toContain("/calendars/cal1/entry1");
});
expect(result.calendarsToHide).toContain('/calendars/cal1/entry1')
})
it("should handle calendar path updates", () => {
it('should handle calendar path updates', () => {
const message = {
"/calendars/cal1/entry1": { updated: true },
};
'/calendars/cal1/entry1': { updated: true }
}
const result = parseMessage(message);
const result = parseMessage(message)
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
expect(result.calendarsToRefresh.size).toBe(1);
});
expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1')
expect(result.calendarsToRefresh.size).toBe(1)
})
it("should parse multiple calendar paths", () => {
it('should parse multiple calendar paths', () => {
const message = {
"/calendars/cal1/entry1": {},
"/calendars/cal2/entry2": {},
};
'/calendars/cal1/entry1': {},
'/calendars/cal2/entry2': {}
}
const result = parseMessage(message);
const result = parseMessage(message)
expect(result.calendarsToRefresh.size).toBe(2);
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2");
});
expect(result.calendarsToRefresh.size).toBe(2)
expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1')
expect(result.calendarsToRefresh).toContain('/calendars/cal2/entry2')
})
it("should handle multiple event types in single message", () => {
it('should handle multiple event types in single message', () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ["/calendars/cal1/entry1"],
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal2/entry2"],
"/calendars/cal1/entry1": {},
};
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ['/calendars/cal1/entry1'],
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ['/calendars/cal2/entry2'],
'/calendars/cal1/entry1': {}
}
const result = parseMessage(message);
const result = parseMessage(message)
expect(result.calendarsToRefresh.size).toBe(1);
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
});
});
expect(result.calendarsToRefresh.size).toBe(1)
expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1')
})
})
@@ -1,57 +1,57 @@
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
import { registerToCalendars } from '@/websocket/operations/registerToCalendars'
describe("registerToCalendars", () => {
let mockSocket: any;
describe('registerToCalendars', () => {
let mockSocket: any
beforeEach(() => {
mockSocket = {
readyState: WebSocket.OPEN,
send: jest.fn(),
};
});
send: jest.fn()
}
})
it("should send registration message with calendar URIs", () => {
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
it('should send registration message with calendar URIs', () => {
const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
registerToCalendars(mockSocket, calendarURIs);
registerToCalendars(mockSocket, calendarURIs)
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
register: calendarURIs,
register: calendarURIs
})
);
});
)
})
it("should throw error if socket is not open", () => {
mockSocket.readyState = WebSocket.CONNECTING;
const calendarURIs = ["/calendars/cal1"];
it('should throw error if socket is not open', () => {
mockSocket.readyState = WebSocket.CONNECTING
const calendarURIs = ['/calendars/cal1']
expect(() => registerToCalendars(mockSocket, calendarURIs)).toThrow(
"Cannot register: WebSocket is not open"
);
});
'Cannot register: WebSocket is not open'
)
})
it("should handle empty calendar list", () => {
registerToCalendars(mockSocket, []);
it('should handle empty calendar list', () => {
registerToCalendars(mockSocket, [])
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
register: [],
register: []
})
);
});
)
})
it("should log registration", () => {
const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation();
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
it('should log registration', () => {
const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation()
const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
registerToCalendars(mockSocket, calendarURIs);
registerToCalendars(mockSocket, calendarURIs)
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Registered to calendars",
'Registered to calendars',
calendarURIs
);
)
consoleInfoSpy.mockRestore();
});
});
consoleInfoSpy.mockRestore()
})
})
@@ -1,58 +1,58 @@
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
import { unregisterToCalendars } from '@/websocket/operations/unregisterToCalendars'
describe("unregisterToCalendars", () => {
let mockSocket: any;
describe('unregisterToCalendars', () => {
let mockSocket: any
beforeEach(() => {
mockSocket = {
readyState: WebSocket.OPEN,
send: jest.fn(),
};
jest.clearAllMocks();
});
send: jest.fn()
}
jest.clearAllMocks()
})
it("should send unregistration message with calendar URIs", () => {
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
it('should send unregistration message with calendar URIs', () => {
const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
unregisterToCalendars(mockSocket, calendarURIs);
unregisterToCalendars(mockSocket, calendarURIs)
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
unregister: calendarURIs,
unregister: calendarURIs
})
);
});
)
})
it("should throw error if socket is not open", () => {
mockSocket.readyState = WebSocket.CONNECTING;
const calendarURIs = ["/calendars/cal1"];
it('should throw error if socket is not open', () => {
mockSocket.readyState = WebSocket.CONNECTING
const calendarURIs = ['/calendars/cal1']
expect(() => unregisterToCalendars(mockSocket, calendarURIs)).toThrow(
"Cannot unregister: WebSocket is not open"
);
});
'Cannot unregister: WebSocket is not open'
)
})
it("should handle empty calendar list", () => {
unregisterToCalendars(mockSocket, []);
it('should handle empty calendar list', () => {
unregisterToCalendars(mockSocket, [])
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
unregister: [],
unregister: []
})
);
});
)
})
it("should log unregistration", () => {
const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation();
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
it('should log unregistration', () => {
const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation()
const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
unregisterToCalendars(mockSocket, calendarURIs);
unregisterToCalendars(mockSocket, calendarURIs)
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Unregistered to calendars",
'Unregistered to calendars',
calendarURIs
);
)
consoleInfoSpy.mockRestore();
});
});
consoleInfoSpy.mockRestore()
})
})
+51 -51
View File
@@ -2,72 +2,72 @@ import {
calendarRangeManager,
getDisplayedCalendarRange,
getDisplayedDate,
setDisplayedDateAndRange,
} from "@/utils/CalendarRangeManager";
setDisplayedDateAndRange
} from '@/utils/CalendarRangeManager'
describe("CalendarRangeManager", () => {
describe('CalendarRangeManager', () => {
beforeEach(() => {
setDisplayedDateAndRange(new Date());
});
setDisplayedDateAndRange(new Date())
})
it("should return a singleton instance", () => {
const instance1 = calendarRangeManager;
const instance2 = calendarRangeManager;
it('should return a singleton instance', () => {
const instance1 = calendarRangeManager
const instance2 = calendarRangeManager
expect(instance1).toStrictEqual(instance2);
});
expect(instance1).toStrictEqual(instance2)
})
it("should compute and return calendar range when date is set", () => {
const testDate = new Date("2025-06-15T10:00:00Z");
setDisplayedDateAndRange(testDate);
it('should compute and return calendar range when date is set', () => {
const testDate = new Date('2025-06-15T10:00:00Z')
setDisplayedDateAndRange(testDate)
const range = getDisplayedCalendarRange();
expect(range.start).toBeInstanceOf(Date);
expect(range.end).toBeInstanceOf(Date);
expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime());
expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime());
});
const range = getDisplayedCalendarRange()
expect(range.start).toBeInstanceOf(Date)
expect(range.end).toBeInstanceOf(Date)
expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime())
expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime())
})
it("should get the default date", () => {
const date = getDisplayedDate();
expect(date).toBeInstanceOf(Date);
});
it('should get the default date', () => {
const date = getDisplayedDate()
expect(date).toBeInstanceOf(Date)
})
it("should set and get a date", () => {
const testDate = new Date("2025-01-15T10:00:00Z");
setDisplayedDateAndRange(testDate);
it('should set and get a date', () => {
const testDate = new Date('2025-01-15T10:00:00Z')
setDisplayedDateAndRange(testDate)
const retrievedDate = getDisplayedDate();
expect(retrievedDate).toStrictEqual(testDate);
});
const retrievedDate = getDisplayedDate()
expect(retrievedDate).toStrictEqual(testDate)
})
it("should persist date across multiple calls", () => {
const testDate = new Date("2025-06-20T15:30:00Z");
setDisplayedDateAndRange(testDate);
it('should persist date across multiple calls', () => {
const testDate = new Date('2025-06-20T15:30:00Z')
setDisplayedDateAndRange(testDate)
const date1 = getDisplayedDate();
const date2 = getDisplayedDate();
const date1 = getDisplayedDate()
const date2 = getDisplayedDate()
expect(date1).toStrictEqual(date2);
expect(date1).toStrictEqual(testDate);
});
expect(date1).toStrictEqual(date2)
expect(date1).toStrictEqual(testDate)
})
it("should update date when set multiple times", () => {
const date1 = new Date("2025-01-01T00:00:00Z");
const date2 = new Date("2025-12-31T23:59:59Z");
it('should update date when set multiple times', () => {
const date1 = new Date('2025-01-01T00:00:00Z')
const date2 = new Date('2025-12-31T23:59:59Z')
setDisplayedDateAndRange(date1);
expect(getDisplayedDate()).toStrictEqual(date1);
setDisplayedDateAndRange(date1)
expect(getDisplayedDate()).toStrictEqual(date1)
setDisplayedDateAndRange(date2);
expect(getDisplayedDate()).toStrictEqual(date2);
});
setDisplayedDateAndRange(date2)
expect(getDisplayedDate()).toStrictEqual(date2)
})
it("should maintain shared state (singleton behavior)", () => {
const testDate = new Date("2025-03-01");
setDisplayedDateAndRange(testDate);
it('should maintain shared state (singleton behavior)', () => {
const testDate = new Date('2025-03-01')
setDisplayedDateAndRange(testDate)
// Verify CalendarRangeManager reflects the same state
expect(calendarRangeManager.getDate()).toStrictEqual(testDate);
});
});
expect(calendarRangeManager.getDate()).toStrictEqual(testDate)
})
})
+24 -24
View File
@@ -1,15 +1,15 @@
import type { AppStore, RootState } from "@/app/store";
import { setupStore } from "@/app/store";
import { TwakeMuiThemeProvider } from "@linagora/twake-mui";
import type { RenderOptions } from "@testing-library/react";
import { render } from "@testing-library/react";
import React, { PropsWithChildren } from "react";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
import { I18nContext } from "twake-i18n";
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
preloadedState?: Partial<RootState>;
store?: AppStore;
import type { AppStore, RootState } from '@/app/store'
import { setupStore } from '@/app/store'
import { TwakeMuiThemeProvider } from '@linagora/twake-mui'
import type { RenderOptions } from '@testing-library/react'
import { render } from '@testing-library/react'
import React, { PropsWithChildren } from 'react'
import { Provider } from 'react-redux'
import { MemoryRouter } from 'react-router-dom'
import { I18nContext } from 'twake-i18n'
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>
store?: AppStore
}
export function renderWithProviders(
@@ -18,7 +18,7 @@ export function renderWithProviders(
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const { store = setupStore(preloadedState), ...renderOptions } =
extendedRenderOptions;
extendedRenderOptions
const Wrapper = ({ children }: PropsWithChildren) => {
return (
@@ -26,31 +26,31 @@ export function renderWithProviders(
<I18nContext.Provider
value={{
t: (key: string, vars?: Record<string, string>) => {
if (key === "locale") {
return "en";
if (key === 'locale') {
return 'en'
}
if (vars) {
const params = Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(",");
return `${key}(${params})`;
.join(',')
return `${key}(${params})`
}
return key;
return key
},
f: (date: Date, formatStr: string) => date.toString(),
lang: "en",
lang: 'en'
}}
>
<MemoryRouter initialEntries={["/"]}>
<MemoryRouter initialEntries={['/']}>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</I18nContext.Provider>
</TwakeMuiThemeProvider>
);
};
)
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}
+93 -93
View File
@@ -1,113 +1,113 @@
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
import { getInitials, stringToGradient } from '@/utils/avatarUtils'
jest.mock("@linagora/twake-mui", () => ({
jest.mock('@linagora/twake-mui', () => ({
nameToColor: jest.fn((name: string) => {
if (!name) return undefined;
if (!name) return undefined
const colors = [
"sunrise",
"downy",
"sugarCoral",
"pinkBonnet",
"blueMana",
"nightBlue",
"snowPea",
"pluviophile",
"cornflower",
"paleGreen",
"moonBlue",
];
'sunrise',
'downy',
'sugarCoral',
'pinkBonnet',
'blueMana',
'nightBlue',
'snowPea',
'pluviophile',
'cornflower',
'paleGreen',
'moonBlue'
]
const hash = Array.from(name.toUpperCase())
.map((letter) => letter.charCodeAt(0))
.reduce((sum, number) => sum + number, 0);
return colors[hash % colors.length];
}),
}));
.map(letter => letter.charCodeAt(0))
.reduce((sum, number) => sum + number, 0)
return colors[hash % colors.length]
})
}))
describe("avatarUtils", () => {
describe("getInitials", () => {
it("returns 2-letter initials for full name", () => {
expect(getInitials("John Doe")).toBe("JD");
expect(getInitials("Alice Smith")).toBe("AS");
expect(getInitials("Nguyễn Văn")).toBe("NV");
});
describe('avatarUtils', () => {
describe('getInitials', () => {
it('returns 2-letter initials for full name', () => {
expect(getInitials('John Doe')).toBe('JD')
expect(getInitials('Alice Smith')).toBe('AS')
expect(getInitials('Nguyễn Văn')).toBe('NV')
})
it("returns single letter for single word", () => {
expect(getInitials("Alice")).toBe("A");
expect(getInitials("John")).toBe("J");
});
it('returns single letter for single word', () => {
expect(getInitials('Alice')).toBe('A')
expect(getInitials('John')).toBe('J')
})
it("returns uppercase initials", () => {
expect(getInitials("john doe")).toBe("JD");
expect(getInitials("ALICE SMITH")).toBe("AS");
expect(getInitials("a")).toBe("A");
});
it('returns uppercase initials', () => {
expect(getInitials('john doe')).toBe('JD')
expect(getInitials('ALICE SMITH')).toBe('AS')
expect(getInitials('a')).toBe('A')
})
it("handles multiple spaces between words", () => {
expect(getInitials("John Doe")).toBe("JD");
expect(getInitials("Alice Smith Brown")).toBe("AS");
});
it('handles multiple spaces between words', () => {
expect(getInitials('John Doe')).toBe('JD')
expect(getInitials('Alice Smith Brown')).toBe('AS')
})
it("handles email addresses", () => {
expect(getInitials("john.doe@email.com")).toBe("J");
expect(getInitials("test@example.com")).toBe("T");
});
it('handles email addresses', () => {
expect(getInitials('john.doe@email.com')).toBe('J')
expect(getInitials('test@example.com')).toBe('T')
})
it("handles empty string", () => {
expect(getInitials("")).toBe("");
});
it('handles empty string', () => {
expect(getInitials('')).toBe('')
})
it("handles whitespace-only string", () => {
expect(getInitials(" ")).toBe("");
});
it('handles whitespace-only string', () => {
expect(getInitials(' ')).toBe('')
})
it("handles single character", () => {
expect(getInitials("A")).toBe("A");
expect(getInitials("a")).toBe("A");
});
it('handles single character', () => {
expect(getInitials('A')).toBe('A')
expect(getInitials('a')).toBe('A')
})
it("handles names with special characters", () => {
expect(getInitials("Jean-Pierre")).toBe("J");
expect(getInitials("Mary Jane Watson")).toBe("MJ");
});
it('handles names with special characters', () => {
expect(getInitials('Jean-Pierre')).toBe('J')
expect(getInitials('Mary Jane Watson')).toBe('MJ')
})
it("handles unicode characters", () => {
expect(getInitials("Nguyễn Văn")).toBe("NV");
expect(getInitials("José María")).toBe("JM");
});
it('handles unicode characters', () => {
expect(getInitials('Nguyễn Văn')).toBe('NV')
expect(getInitials('José María')).toBe('JM')
})
it("takes first two words for names with more than two words", () => {
expect(getInitials("John Michael Smith")).toBe("JM");
expect(getInitials("Alice Bob Charlie")).toBe("AB");
});
});
it('takes first two words for names with more than two words', () => {
expect(getInitials('John Michael Smith')).toBe('JM')
expect(getInitials('Alice Bob Charlie')).toBe('AB')
})
})
describe("stringToGradient", () => {
it("returns color for valid string", () => {
const result = stringToGradient("John Doe");
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
describe('stringToGradient', () => {
it('returns color for valid string', () => {
const result = stringToGradient('John Doe')
expect(result).toBeDefined()
expect(typeof result).toBe('string')
})
it("returns same color for same input", () => {
const result1 = stringToGradient("John Doe");
const result2 = stringToGradient("John Doe");
expect(result1).toBe(result2);
});
it('returns same color for same input', () => {
const result1 = stringToGradient('John Doe')
const result2 = stringToGradient('John Doe')
expect(result1).toBe(result2)
})
it("returns undefined for empty string", () => {
expect(stringToGradient("")).toBeUndefined();
});
it('returns undefined for empty string', () => {
expect(stringToGradient('')).toBeUndefined()
})
it("returns color for single character", () => {
const result = stringToGradient("A");
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
it('returns color for single character', () => {
const result = stringToGradient('A')
expect(result).toBeDefined()
expect(typeof result).toBe('string')
})
it("returns color for email address", () => {
const result = stringToGradient("test@example.com");
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
});
});
it('returns color for email address', () => {
const result = stringToGradient('test@example.com')
expect(result).toBeDefined()
expect(typeof result).toBe('string')
})
})
})
+36 -36
View File
@@ -1,51 +1,51 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { buildDelegatedEventURL } from "@/features/Events/utils";
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { buildDelegatedEventURL } from '@/features/Events/utils'
const makeCalendar = (link: string): Calendar =>
({
id: "user2/cal1",
id: 'user2/cal1',
delegated: true,
link,
owner: { emails: ["owner@example.com"] },
}) as Calendar;
owner: { emails: ['owner@example.com'] }
}) as Calendar
describe("buildDelegatedEventURL", () => {
it("rebases event filename onto calendar link base path", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const eventURL = "/calendars/someother/path/event-abc.ics";
describe('buildDelegatedEventURL', () => {
it('rebases event filename onto calendar link base path', () => {
const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = '/calendars/someother/path/event-abc.ics'
expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/calendars/user2/cal1/event-abc.ics"
);
});
'/calendars/user2/cal1/event-abc.ics'
)
})
it("strips .json suffix from calendar link", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const eventURL = "/calendars/user2/cal1/event-abc.ics";
const result = buildDelegatedEventURL(calendar, eventURL);
expect(result).not.toContain(".json");
});
it('strips .json suffix from calendar link', () => {
const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = '/calendars/user2/cal1/event-abc.ics'
const result = buildDelegatedEventURL(calendar, eventURL)
expect(result).not.toContain('.json')
})
it("preserves the exact filename from the event URL", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const eventURL = "/calendars/user2/cal1/some-uid-with-dashes.ics";
it('preserves the exact filename from the event URL', () => {
const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = '/calendars/user2/cal1/some-uid-with-dashes.ics'
expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/calendars/user2/cal1/some-uid-with-dashes.ics"
);
});
'/calendars/user2/cal1/some-uid-with-dashes.ics'
)
})
it("throws when event URL has no filename", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const eventURL = "";
it('throws when event URL has no filename', () => {
const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = ''
expect(() => buildDelegatedEventURL(calendar, eventURL)).toThrow(
/Cannot extract filename from event URL/
);
});
)
})
it("works with nested calendar link paths", () => {
const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json");
const eventURL = "/other/path/event-xyz.ics";
it('works with nested calendar link paths', () => {
const calendar = makeCalendar('/dav/calendars/users/user2/cal1.json')
const eventURL = '/other/path/event-xyz.ics'
expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/dav/calendars/users/user2/cal1/event-xyz.ics"
);
});
});
'/dav/calendars/users/user2/cal1/event-xyz.ics'
)
})
})
+140 -140
View File
@@ -1,231 +1,231 @@
import {
eventToFullCalendarFormat,
getCalendarVisibility,
} from "@/components/Calendar/utils/calendarUtils";
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes";
import { AclEntry } from "@/features/Calendars/types/CalendarData";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { userOrganiser } from "@/features/User/userDataTypes";
getCalendarVisibility
} from '@/components/Calendar/utils/calendarUtils'
import { Calendar, DelegationAccess } from '@/features/Calendars/CalendarTypes'
import { AclEntry } from '@/features/Calendars/types/CalendarData'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { userOrganiser } from '@/features/User/userDataTypes'
describe("getCalendarVisibility", () => {
describe('getCalendarVisibility', () => {
it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => {
const acl: AclEntry[] = [
{
privilege: "{DAV:}read",
principal: "{DAV:}authenticated",
protected: true,
},
];
expect(getCalendarVisibility(acl)).toBe("public");
});
privilege: '{DAV:}read',
principal: '{DAV:}authenticated',
protected: true
}
]
expect(getCalendarVisibility(acl)).toBe('public')
})
it("returns 'private' when {DAV:}authenticated only has read-free-busy", () => {
const acl: AclEntry[] = [
{
privilege: "{urn:ietf:params:xml:ns:caldav}read-free-busy",
principal: "{DAV:}authenticated",
protected: true,
},
];
expect(getCalendarVisibility(acl)).toBe("private");
});
privilege: '{urn:ietf:params:xml:ns:caldav}read-free-busy',
principal: '{DAV:}authenticated',
protected: true
}
]
expect(getCalendarVisibility(acl)).toBe('private')
})
it("returns 'private' when {DAV:}authenticated is not present", () => {
const acl: AclEntry[] = [
{
privilege: "{DAV:}read",
principal: "principals/users/123",
protected: true,
},
];
expect(getCalendarVisibility(acl)).toBe("private");
});
privilege: '{DAV:}read',
principal: 'principals/users/123',
protected: true
}
]
expect(getCalendarVisibility(acl)).toBe('private')
})
it("ignores non-authenticated principals and still returns 'private'", () => {
const acl: AclEntry[] = [
{
privilege: "{DAV:}read",
principal: "principals/users/other",
protected: true,
privilege: '{DAV:}read',
principal: 'principals/users/other',
protected: true
},
{
privilege: "{DAV:}write",
principal: "principals/users/other",
protected: true,
},
];
expect(getCalendarVisibility(acl)).toBe("private");
});
privilege: '{DAV:}write',
principal: 'principals/users/other',
protected: true
}
]
expect(getCalendarVisibility(acl)).toBe('private')
})
it("stops early when it finds a {DAV:}read for {DAV:}authenticated", () => {
it('stops early when it finds a {DAV:}read for {DAV:}authenticated', () => {
const acl: AclEntry[] = [
{
privilege: "{DAV:}read",
principal: "{DAV:}authenticated",
protected: true,
privilege: '{DAV:}read',
principal: '{DAV:}authenticated',
protected: true
},
{
privilege: "{DAV:}write",
principal: "{DAV:}authenticated",
protected: true,
},
];
expect(getCalendarVisibility(acl)).toBe("public");
});
});
privilege: '{DAV:}write',
principal: '{DAV:}authenticated',
protected: true
}
]
expect(getCalendarVisibility(acl)).toBe('public')
})
})
jest.mock("twake-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
jest.mock('twake-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
const baseEvent: CalendarEvent = {
uid: "event-1",
calId: "user1/cal1",
title: "Test Event",
start: "2024-01-15T10:00:00",
end: "2024-01-15T11:00:00",
uid: 'event-1',
calId: 'user1/cal1',
title: 'Test Event',
start: '2024-01-15T10:00:00',
end: '2024-01-15T11:00:00',
allday: false,
organizer: { cal_address: "owner@example.com" } as userOrganiser,
color: { light: "#FF0000" },
class: "PUBLIC",
} as unknown as CalendarEvent;
organizer: { cal_address: 'owner@example.com' } as userOrganiser,
color: { light: '#FF0000' },
class: 'PUBLIC'
} as unknown as CalendarEvent
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
({
id: "user1/cal1",
name: "Test Calendar",
id: 'user1/cal1',
name: 'Test Calendar',
delegated: false,
access: {} as DelegationAccess,
owner: { emails: ["alice@example.com"], firstname: "Alice" },
color: { light: "#FF0000" },
owner: { emails: ['alice@example.com'], firstname: 'Alice' },
color: { light: '#FF0000' },
events: {},
...overrides,
}) as Calendar;
...overrides
}) as Calendar
const callFormat = (
event: CalendarEvent,
calendars: Record<string, Calendar>,
userAddress = "alice@example.com",
userId = "user1"
userAddress = 'alice@example.com',
userId = 'user1'
) =>
eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars);
eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars)
describe("eventToFullCalendarFormat - editable flag", () => {
describe("personal calendar (non-delegated)", () => {
const calendar = makeCalendar({ delegated: false });
describe('eventToFullCalendarFormat - editable flag', () => {
describe('personal calendar (non-delegated)', () => {
const calendar = makeCalendar({ delegated: false })
it("is editable when user is organizer", () => {
it('is editable when user is organizer', () => {
const event = {
...baseEvent,
organizer: { cal_address: "alice@example.com" },
} as CalendarEvent;
const [result] = callFormat(event, { "user1/cal1": calendar });
expect(result.editable).toBe(true);
});
organizer: { cal_address: 'alice@example.com' }
} as CalendarEvent
const [result] = callFormat(event, { 'user1/cal1': calendar })
expect(result.editable).toBe(true)
})
it("is not editable when user is not organizer", () => {
it('is not editable when user is not organizer', () => {
const event = {
...baseEvent,
organizer: { cal_address: "other@example.com" },
} as CalendarEvent;
const [result] = callFormat(event, { "user1/cal1": calendar });
expect(result.editable).toBe(false);
});
organizer: { cal_address: 'other@example.com' }
} as CalendarEvent
const [result] = callFormat(event, { 'user1/cal1': calendar })
expect(result.editable).toBe(false)
})
it("is not editable when pending", () => {
it('is not editable when pending', () => {
const event = {
...baseEvent,
organizer: { cal_address: "alice@example.com" },
} as CalendarEvent;
organizer: { cal_address: 'alice@example.com' }
} as CalendarEvent
const [result] = eventToFullCalendarFormat(
[event],
[],
"user1",
"alice@example.com",
'user1',
'alice@example.com',
true,
{ "user1/cal1": calendar }
);
expect(result.editable).toBe(false);
});
});
{ 'user1/cal1': calendar }
)
expect(result.editable).toBe(false)
})
})
describe("delegated calendar", () => {
describe('delegated calendar', () => {
const writeDelegatedCalendar = makeCalendar({
id: "user2/cal1",
id: 'user2/cal1',
delegated: true,
access: { write: true } as DelegationAccess,
owner: { emails: ["owner@example.com"], firstname: "Owner" },
});
owner: { emails: ['owner@example.com'], firstname: 'Owner' }
})
const readDelegatedCalendar = makeCalendar({
id: "user2/cal1",
id: 'user2/cal1',
delegated: true,
access: { read: true } as DelegationAccess,
owner: { emails: ["owner@example.com"], firstname: "Owner" },
});
const event = { ...baseEvent, calId: "user2/cal1" };
owner: { emails: ['owner@example.com'], firstname: 'Owner' }
})
const event = { ...baseEvent, calId: 'user2/cal1' }
it("is editable when owner is organizer and delegated user has write rights", () => {
it('is editable when owner is organizer and delegated user has write rights', () => {
const [result] = callFormat(
{
...event,
organizer: { cal_address: "owner@example.com" },
organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent,
{ "user2/cal1": writeDelegatedCalendar }
);
expect(result.editable).toBe(true);
});
{ 'user2/cal1': writeDelegatedCalendar }
)
expect(result.editable).toBe(true)
})
it("is not editable when owner is organizer but delegated user only has read access", () => {
it('is not editable when owner is organizer but delegated user only has read access', () => {
const [result] = callFormat(
{
...event,
organizer: { cal_address: "owner@example.com" },
organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent,
{ "user2/cal1": readDelegatedCalendar }
);
expect(result.editable).toBe(false);
});
{ 'user2/cal1': readDelegatedCalendar }
)
expect(result.editable).toBe(false)
})
it("is not editable when owner is not organizer", () => {
it('is not editable when owner is not organizer', () => {
const [result] = callFormat(
{
...event,
organizer: { cal_address: "someone-else@example.com" },
organizer: { cal_address: 'someone-else@example.com' }
} as CalendarEvent,
{ "user2/cal1": writeDelegatedCalendar }
);
expect(result.editable).toBe(false);
});
{ 'user2/cal1': writeDelegatedCalendar }
)
expect(result.editable).toBe(false)
})
it("is not editable when pending even if owner is organizer", () => {
it('is not editable when pending even if owner is organizer', () => {
const [result] = eventToFullCalendarFormat(
[
{
...event,
organizer: { cal_address: "owner@example.com" },
} as CalendarEvent,
organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent
],
[],
"user1",
"alice@example.com",
'user1',
'alice@example.com',
true,
{ "user2/cal1": writeDelegatedCalendar }
);
expect(result.editable).toBe(false);
});
{ 'user2/cal1': writeDelegatedCalendar }
)
expect(result.editable).toBe(false)
})
it("uses owner email not logged-in user email for organizer check", () => {
it('uses owner email not logged-in user email for organizer check', () => {
// logged-in user is alice, owner is owner@example.com
// organizer matches owner → editable
const [result] = callFormat(
{
...event,
organizer: { cal_address: "owner@example.com" },
organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent,
{ "user2/cal1": writeDelegatedCalendar },
"alice@example.com" // logged-in user, should NOT be used for delegated
);
expect(result.editable).toBe(true);
});
});
});
{ 'user2/cal1': writeDelegatedCalendar },
'alice@example.com' // logged-in user, should NOT be used for delegated
)
expect(result.editable).toBe(true)
})
})
})
+88 -88
View File
@@ -1,120 +1,120 @@
import { createEventContext } from "@/features/Events/createEventContext";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { userData } from "@/features/User/userDataTypes";
import { userAttendee } from "@/features/User/models/attendee";
import { createEventContext } from '@/features/Events/createEventContext'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { userData } from '@/features/User/userDataTypes'
import { userAttendee } from '@/features/User/models/attendee'
const makeUser = (email: string): userData => ({
email,
given_name: "Alice",
family_name: "User",
name: "Alice User",
sid: "user1",
sub: "user1",
});
given_name: 'Alice',
family_name: 'User',
name: 'Alice User',
sid: 'user1',
sub: 'user1'
})
const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent =>
({
uid: "event-1",
calId: "user1/cal1",
title: "Test Event",
start: "2024-01-15T10:00:00",
end: "2024-01-15T11:00:00",
organizer: { cal_address: "alice@example.com" },
uid: 'event-1',
calId: 'user1/cal1',
title: 'Test Event',
start: '2024-01-15T10:00:00',
end: '2024-01-15T11:00:00',
organizer: { cal_address: 'alice@example.com' },
attendee: [
{ cal_address: "alice@example.com", partstat: "ACCEPTED" },
{ cal_address: "owner@example.com", partstat: "ACCEPTED" },
{ cal_address: "other@example.com", partstat: "NEEDS-ACTION" },
{ cal_address: 'alice@example.com', partstat: 'ACCEPTED' },
{ cal_address: 'owner@example.com', partstat: 'ACCEPTED' },
{ cal_address: 'other@example.com', partstat: 'NEEDS-ACTION' }
],
...overrides,
}) as CalendarEvent;
...overrides
}) as CalendarEvent
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
({
id: "user1/cal1",
name: "Test Calendar",
id: 'user1/cal1',
name: 'Test Calendar',
delegated: false,
owner: { emails: ["alice@example.com"], firstname: "Alice" },
owner: { emails: ['alice@example.com'], firstname: 'Alice' },
events: {},
...overrides,
}) as Calendar;
...overrides
}) as Calendar
describe("createEventContext", () => {
describe("non-delegated calendar", () => {
const calendar = makeCalendar({ delegated: false });
const user = makeUser("alice@example.com");
describe('createEventContext', () => {
describe('non-delegated calendar', () => {
const calendar = makeCalendar({ delegated: false })
const user = makeUser('alice@example.com')
it("finds currentUserAttendee by logged-in user email", () => {
const event = makeEvent();
const ctx = createEventContext(event, calendar, user);
expect(ctx.currentUserAttendee?.cal_address).toBe("alice@example.com");
});
it('finds currentUserAttendee by logged-in user email', () => {
const event = makeEvent()
const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee?.cal_address).toBe('alice@example.com')
})
it("returns null currentUserAttendee when user is not an attendee", () => {
const event = makeEvent({ attendee: [] });
const ctx = createEventContext(event, calendar, user);
expect(ctx.currentUserAttendee).toBeUndefined();
});
it('returns null currentUserAttendee when user is not an attendee', () => {
const event = makeEvent({ attendee: [] })
const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee).toBeUndefined()
})
it("isOwn is true when user email is in owner emails", () => {
const ctx = createEventContext(makeEvent(), calendar, user);
expect(ctx.isOwn).toBe(true);
});
it('isOwn is true when user email is in owner emails', () => {
const ctx = createEventContext(makeEvent(), calendar, user)
expect(ctx.isOwn).toBe(true)
})
it("isOwn is false when user email is not in owner emails", () => {
const other = makeUser("other@example.com");
const ctx = createEventContext(makeEvent(), calendar, other);
expect(ctx.isOwn).toBe(false);
});
});
it('isOwn is false when user email is not in owner emails', () => {
const other = makeUser('other@example.com')
const ctx = createEventContext(makeEvent(), calendar, other)
expect(ctx.isOwn).toBe(false)
})
})
describe("delegated calendar", () => {
describe('delegated calendar', () => {
const calendar = makeCalendar({
id: "user2/cal1",
id: 'user2/cal1',
delegated: true,
owner: { emails: ["owner@example.com"], firstname: "Owner" },
});
const user = makeUser("alice@example.com"); // logged-in user, not the owner
owner: { emails: ['owner@example.com'], firstname: 'Owner' }
})
const user = makeUser('alice@example.com') // logged-in user, not the owner
it("finds currentUserAttendee by owner email, not logged-in user email", () => {
const event = makeEvent({ calId: "user2/cal1" });
const ctx = createEventContext(event, calendar, user);
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com");
});
it('finds currentUserAttendee by owner email, not logged-in user email', () => {
const event = makeEvent({ calId: 'user2/cal1' })
const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee?.cal_address).toBe('owner@example.com')
})
it("does not find logged-in user as attendee for delegated calendar", () => {
it('does not find logged-in user as attendee for delegated calendar', () => {
const event = makeEvent({
calId: "user2/cal1",
calId: 'user2/cal1',
attendee: [
{
cal_address: "owner@example.com",
partstat: "ACCEPTED",
} as userAttendee,
],
});
const ctx = createEventContext(event, calendar, user);
cal_address: 'owner@example.com',
partstat: 'ACCEPTED'
} as userAttendee
]
})
const ctx = createEventContext(event, calendar, user)
// currentUserAttendee should be owner's, not alice's
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com");
});
expect(ctx.currentUserAttendee?.cal_address).toBe('owner@example.com')
})
it("returns undefined currentUserAttendee when owner is not an attendee", () => {
it('returns undefined currentUserAttendee when owner is not an attendee', () => {
const event = makeEvent({
calId: "user2/cal1",
calId: 'user2/cal1',
attendee: [
{
cal_address: "alice@example.com",
partstat: "ACCEPTED",
} as userAttendee,
],
});
const ctx = createEventContext(event, calendar, user);
expect(ctx.currentUserAttendee).toBeUndefined();
});
cal_address: 'alice@example.com',
partstat: 'ACCEPTED'
} as userAttendee
]
})
const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee).toBeUndefined()
})
it("passes event through unchanged", () => {
const event = makeEvent({ calId: "user2/cal1" });
const ctx = createEventContext(event, calendar, user);
expect(ctx.event).toBe(event);
});
});
});
it('passes event through unchanged', () => {
const event = makeEvent({ calId: 'user2/cal1' })
const ctx = createEventContext(event, calendar, user)
expect(ctx.event).toBe(event)
})
})
})
+30 -30
View File
@@ -1,38 +1,38 @@
import { getCalendarRange } from "@/utils/dateUtils";
import { getCalendarRange } from '@/utils/dateUtils'
describe("getCalendarRange", () => {
it("Nov 2025 (5 weeks): 2025-10-27 to 2025-11-30", () => {
describe('getCalendarRange', () => {
it('Nov 2025 (5 weeks): 2025-10-27 to 2025-11-30', () => {
const { start, end } = getCalendarRange(
new Date("2025-11-01T00:00:00.000Z")
);
expect(start.toISOString().slice(0, 10)).toBe("2025-10-27");
expect(end.toISOString().slice(0, 10)).toBe("2025-11-30");
});
new Date('2025-11-01T00:00:00.000Z')
)
expect(start.toISOString().slice(0, 10)).toBe('2025-10-27')
expect(end.toISOString().slice(0, 10)).toBe('2025-11-30')
})
it("Dec 2025 (6 weeks): end Sunday 2026-01-04", () => {
it('Dec 2025 (6 weeks): end Sunday 2026-01-04', () => {
const { start, end } = getCalendarRange(
new Date("2025-12-01T00:00:00.000Z")
);
expect(start.toISOString().slice(0, 10)).toBe("2025-12-01");
expect(end.toISOString().slice(0, 10)).toBe("2026-01-04");
});
new Date('2025-12-01T00:00:00.000Z')
)
expect(start.toISOString().slice(0, 10)).toBe('2025-12-01')
expect(end.toISOString().slice(0, 10)).toBe('2026-01-04')
})
it("Feb 2025 (short month): end Sunday 2025-03-02", () => {
it('Feb 2025 (short month): end Sunday 2025-03-02', () => {
const { start, end } = getCalendarRange(
new Date("2025-02-01T00:00:00.000Z")
);
expect(start.toISOString().slice(0, 10)).toBe("2025-01-27");
expect(end.toISOString().slice(0, 10)).toBe("2025-03-02");
});
new Date('2025-02-01T00:00:00.000Z')
)
expect(start.toISOString().slice(0, 10)).toBe('2025-01-27')
expect(end.toISOString().slice(0, 10)).toBe('2025-03-02')
})
it("sets boundary times correctly (start 00:00, end 23:59)", () => {
it('sets boundary times correctly (start 00:00, end 23:59)', () => {
const { start, end } = getCalendarRange(
new Date("2025-11-01T00:00:00.000Z")
);
expect(start.getHours()).toBe(0);
expect(start.getMinutes()).toBe(0);
expect(start.getSeconds()).toBe(0);
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
});
});
new Date('2025-11-01T00:00:00.000Z')
)
expect(start.getHours()).toBe(0)
expect(start.getMinutes()).toBe(0)
expect(start.getSeconds()).toBe(0)
expect(end.getHours()).toBe(23)
expect(end.getMinutes()).toBe(59)
})
})
+62 -62
View File
@@ -1,99 +1,99 @@
import { RootState } from "@/app/store";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { findCalendarById } from "@/utils";
import { RootState } from '@/app/store'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { findCalendarById } from '@/utils'
describe("findCalendarById", () => {
describe('findCalendarById', () => {
const mockCalendar1: Calendar = {
id: "cal1",
name: "Personal",
color: { light: "#FF0000" },
} as unknown as Calendar;
id: 'cal1',
name: 'Personal',
color: { light: '#FF0000' }
} as unknown as Calendar
const mockCalendar2: Calendar = {
id: "cal2",
name: "Work",
color: { light: "#0000FF" },
} as unknown as Calendar;
id: 'cal2',
name: 'Work',
color: { light: '#0000FF' }
} as unknown as Calendar
const mockTempCalendar: Calendar = {
id: "temp1",
name: "Temporary",
color: { light: "#00FF00" },
} as unknown as Calendar;
id: 'temp1',
name: 'Temporary',
color: { light: '#00FF00' }
} as unknown as Calendar
const mockState = {
calendars: {
list: {
cal1: mockCalendar1,
cal2: mockCalendar2,
cal2: mockCalendar2
},
templist: {
temp1: mockTempCalendar,
},
},
} as unknown as Partial<RootState>;
temp1: mockTempCalendar
}
}
} as unknown as Partial<RootState>
it("should find calendar in main list", () => {
const result = findCalendarById(mockState, "cal1");
it('should find calendar in main list', () => {
const result = findCalendarById(mockState, 'cal1')
expect(result?.calendar).toEqual(mockCalendar1);
expect(result?.type).toBeUndefined();
});
expect(result?.calendar).toEqual(mockCalendar1)
expect(result?.type).toBeUndefined()
})
it("should find calendar in temp list", () => {
const result = findCalendarById(mockState, "temp1");
it('should find calendar in temp list', () => {
const result = findCalendarById(mockState, 'temp1')
expect(result?.calendar).toEqual(mockTempCalendar);
expect(result?.type).toBe("temp");
});
expect(result?.calendar).toEqual(mockTempCalendar)
expect(result?.type).toBe('temp')
})
it("should not return calendar for non-existent id", () => {
const result = findCalendarById(mockState, "nonexistent");
it('should not return calendar for non-existent id', () => {
const result = findCalendarById(mockState, 'nonexistent')
expect(result).toBeUndefined();
});
expect(result).toBeUndefined()
})
it("should not return calendar for empty string", () => {
const result = findCalendarById(mockState, "");
it('should not return calendar for empty string', () => {
const result = findCalendarById(mockState, '')
expect(result).toBeUndefined();
});
expect(result).toBeUndefined()
})
it("should prioritize temp list over main list", () => {
it('should prioritize temp list over main list', () => {
const stateWithDuplicate = {
calendars: {
list: {
dup1: mockCalendar1,
dup1: mockCalendar1
},
templist: {
dup1: mockTempCalendar,
},
},
} as unknown as Partial<RootState>;
dup1: mockTempCalendar
}
}
} as unknown as Partial<RootState>
const result = findCalendarById(stateWithDuplicate, "dup1");
const result = findCalendarById(stateWithDuplicate, 'dup1')
expect(result?.calendar).toEqual(mockTempCalendar);
expect(result?.type).toBe("temp");
});
expect(result?.calendar).toEqual(mockTempCalendar)
expect(result?.type).toBe('temp')
})
it("should handle undefined list or templist", () => {
it('should handle undefined list or templist', () => {
const stateWithPartialCalendars = {
calendars: {
list: undefined,
templist: { temp1: mockTempCalendar },
},
} as unknown as Partial<RootState>;
templist: { temp1: mockTempCalendar }
}
} as unknown as Partial<RootState>
const result = findCalendarById(stateWithPartialCalendars, "temp1");
expect(result?.calendar).toEqual(mockTempCalendar);
});
const result = findCalendarById(stateWithPartialCalendars, 'temp1')
expect(result?.calendar).toEqual(mockTempCalendar)
})
it("should handle missing calendars state", () => {
const emptyState = {};
it('should handle missing calendars state', () => {
const emptyState = {}
const result = findCalendarById(emptyState, "cal1");
const result = findCalendarById(emptyState, 'cal1')
expect(result).toBeUndefined();
});
});
expect(result).toBeUndefined()
})
})
+72 -72
View File
@@ -1,92 +1,92 @@
import { userData } from "@/features/User/userDataTypes";
import { getUserDisplayName } from "@/utils/userUtils";
import { userData } from '@/features/User/userDataTypes'
import { getUserDisplayName } from '@/utils/userUtils'
describe("userUtils", () => {
describe("getUserDisplayName", () => {
it("returns full name when user has name and family_name", () => {
describe('userUtils', () => {
describe('getUserDisplayName', () => {
it('returns full name when user has name and family_name', () => {
const user: userData = {
sub: "test",
email: "test@test.com",
family_name: "Doe",
given_name: "John",
name: "John",
sid: "mockSid",
};
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
given_name: 'John',
name: 'John',
sid: 'mockSid'
}
expect(getUserDisplayName(user)).toBe("John Doe");
});
expect(getUserDisplayName(user)).toBe('John Doe')
})
it("returns email when user does not have name and family_name", () => {
it('returns email when user does not have name and family_name', () => {
const user: userData = {
sub: "test",
email: "test@test.com",
family_name: "",
given_name: "",
name: "",
sid: "mockSid",
};
sub: 'test',
email: 'test@test.com',
family_name: '',
given_name: '',
name: '',
sid: 'mockSid'
}
expect(getUserDisplayName(user)).toBe("test@test.com");
});
expect(getUserDisplayName(user)).toBe('test@test.com')
})
it("returns email when user has only email", () => {
it('returns email when user has only email', () => {
const user: userData = {
sub: "test",
email: "user@example.com",
family_name: "",
given_name: "",
name: "",
sid: "mockSid",
};
sub: 'test',
email: 'user@example.com',
family_name: '',
given_name: '',
name: '',
sid: 'mockSid'
}
expect(getUserDisplayName(user)).toBe("user@example.com");
});
expect(getUserDisplayName(user)).toBe('user@example.com')
})
it("returns empty string when user is null", () => {
expect(getUserDisplayName(null)).toBe("");
});
it('returns empty string when user is null', () => {
expect(getUserDisplayName(null)).toBe('')
})
it("returns empty string when user is undefined", () => {
expect(getUserDisplayName(undefined)).toBe("");
});
it('returns empty string when user is undefined', () => {
expect(getUserDisplayName(undefined)).toBe('')
})
it("returns empty string when user has no name, family_name, or email", () => {
it('returns empty string when user has no name, family_name, or email', () => {
const user: userData = {
sub: "test",
email: "",
family_name: "",
given_name: "",
name: "",
sid: "mockSid",
};
sub: 'test',
email: '',
family_name: '',
given_name: '',
name: '',
sid: 'mockSid'
}
expect(getUserDisplayName(user)).toBe("");
});
expect(getUserDisplayName(user)).toBe('')
})
it("handles user with only name (no family_name)", () => {
it('handles user with only name (no family_name)', () => {
const user: userData = {
sub: "test",
email: "test@test.com",
family_name: "",
given_name: "John",
name: "John",
sid: "mockSid",
};
sub: 'test',
email: 'test@test.com',
family_name: '',
given_name: 'John',
name: 'John',
sid: 'mockSid'
}
expect(getUserDisplayName(user)).toBe("test@test.com");
});
expect(getUserDisplayName(user)).toBe('test@test.com')
})
it("handles user with only family_name (no name)", () => {
it('handles user with only family_name (no name)', () => {
const user: userData = {
sub: "test",
email: "test@test.com",
family_name: "Doe",
given_name: "",
name: "",
sid: "mockSid",
};
sub: 'test',
email: 'test@test.com',
family_name: 'Doe',
given_name: '',
name: '',
sid: 'mockSid'
}
expect(getUserDisplayName(user)).toBe("test@test.com");
});
});
});
expect(getUserDisplayName(user)).toBe('test@test.com')
})
})
})
+4 -4
View File
@@ -1,6 +1,6 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
["@babel/preset-react", { runtime: "automatic", importSource: "preact" }],
],
};
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic', importSource: 'preact' }]
]
}
+65 -40
View File
@@ -1,74 +1,99 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import importPlugin from "eslint-plugin-import";
import jest from "eslint-plugin-jest";
import prettier from "eslint-config-prettier";
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import tanstackQuery from '@tanstack/eslint-plugin-query'
import cozyReact from 'eslint-config-cozy-app/react'
// Flatten cozyReact config (may be an object or array)
const cozyReactConfigs = Array.isArray(cozyReact) ? cozyReact : [cozyReact]
export default [
{
ignores: ["dist", "build", "node_modules"],
ignores: [
'dist/',
'build/',
'node_modules/',
'coverage/',
'public/',
'*.config.js',
'*.config.ts',
'fileTransformer.ts'
]
},
js.configs.recommended,
...tseslint.configs.recommended,
// TanStack Query recommended rules
...tanstackQuery.configs['flat/recommended'],
// Cozy React recommended rules
...cozyReactConfigs,
{
files: ["**/*.{js,jsx,ts,tsx}"],
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
project: true,
},
},
plugins: {
react,
"react-hooks": reactHooks,
import: importPlugin,
jest,
project: true
}
},
settings: {
react: {
version: "detect",
},
version: 'detect'
}
},
rules: {
/* React */
"react/react-in-jsx-scope": "off", // React 18+
"react/prop-types": "off",
'react/react-in-jsx-scope': 'off', // React 18+
'react/prop-types': 'off',
/* Hooks */
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
/* Imports */
"import/order": "off",
'import/order': 'off',
/* TS */
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' }
],
/* Jest */
"jest/no-disabled-tests": "warn",
"jest/no-focused-tests": "error",
// TO DO: Turn these back on warning
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/no-dynamic-delete': 'warn',
'@typescript-eslint/await-thenable': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/require-await': 'warn',
/* Prettier compatibility */
...prettier.rules,
/* Jest */
'jest/no-disabled-tests': 'warn',
'jest/no-focused-tests': 'error',
/* Keep cozyReact prettier options but skip singleQuote/semi to avoid unnecessary changes */
'prettier/prettier': 'error',
/* No noises */
"no-debugger": "error",
"no-console": ["warn", { allow: ["info", "warn", "error"] }],
},
},
];
'no-debugger': 'error',
'no-console': ['warn', { allow: ['info', 'warn', 'error'] }]
}
}
]
+5 -5
View File
@@ -1,9 +1,9 @@
const path = require("path");
const path = require('path')
module.exports = {
process(sourceText, sourcePath, options) {
return {
code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`,
};
},
};
code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`
}
}
}
+49 -49
View File
@@ -1,78 +1,78 @@
import type { Config } from "jest";
import type { Config } from 'jest'
// Set timezone to UTC for consistent test results across all environments
process.env.TZ = "UTC";
process.env.TZ = 'UTC'
const config: Config = {
collectCoverage: true,
coverageDirectory: "coverage",
coverageDirectory: 'coverage',
projects: [
{
displayName: "dom",
displayName: 'dom',
clearMocks: true,
moduleFileExtensions: [
"js",
"mjs",
"cjs",
"jsx",
"ts",
"tsx",
"json",
"node",
'js',
'mjs',
'cjs',
'jsx',
'ts',
'tsx',
'json',
'node'
],
testEnvironment: "jsdom",
testMatch: ["**/*.test.tsx"],
testEnvironment: 'jsdom',
testMatch: ['**/*.test.tsx'],
testTimeout: 15000,
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }],
"^.+\\.(js|jsx|mjs)$": "babel-jest",
"^.+\\.(css|scss|sass|less|styl|stylus)$":
"jest-preview/transforms/css",
"^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)":
"jest-preview/transforms/file",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/fileTransformer.ts",
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
'^.+\\.(js|jsx|mjs)$': 'babel-jest',
'^.+\\.(css|scss|sass|less|styl|stylus)$':
'jest-preview/transforms/css',
'^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)':
'jest-preview/transforms/file',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/fileTransformer.ts'
},
transformIgnorePatterns: [
"/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky|cozy-ui|p-map|@linagora/twake-mui|@lottiefiles)/)",
'/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky|cozy-ui|p-map|@linagora/twake-mui|@lottiefiles)/)'
],
moduleNameMapper: {
"^preact(/(.*)|$)": "preact$1",
"^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom",
"^@/(.*)$": "<rootDir>/src/$1",
'^preact(/(.*)|$)': 'preact$1',
'^react$': '<rootDir>/node_modules/react',
'^react-dom$': '<rootDir>/node_modules/react-dom',
'^@/(.*)$': '<rootDir>/src/$1'
},
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts']
},
{
displayName: "node",
displayName: 'node',
clearMocks: true,
moduleFileExtensions: [
"js",
"mjs",
"cjs",
"jsx",
"ts",
"tsx",
"json",
"node",
'js',
'mjs',
'cjs',
'jsx',
'ts',
'tsx',
'json',
'node'
],
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testTimeout: 15000,
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }],
"^.+\\.(js|jsx|mjs)$": "babel-jest",
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
'^.+\\.(js|jsx|mjs)$': 'babel-jest'
},
transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"],
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
transformIgnorePatterns: ['/node_modules/(?!(ky|@linagora/twake-mui)/)'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
},
],
};
'^@/(.*)$': '<rootDir>/src/$1'
}
}
]
}
export default config;
export default config
+236 -244
View File
@@ -24,6 +24,7 @@
"@mui/x-date-pickers": "^8.14.0",
"@reduxjs/toolkit": "^2.8.2",
"cozy-external-bridge": "^1.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"i18next": "^23.7.9",
"ical.js": "^2.2.0",
@@ -50,6 +51,7 @@
"@rsbuild/plugin-react": "^1.4.2",
"@rsbuild/plugin-stylus": "^1.2.0",
"@rsbuild/plugin-svgr": "^1.2.3",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -63,13 +65,9 @@
"@typescript-eslint/parser": "^8.48.1",
"babel-jest": "^30.2.0",
"babel-plugin-istanbul": "^7.0.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.13.0",
"eslint": "10.0.2",
"eslint-config-cozy-app": "7.0.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"history": "^5.3.0",
"i18next-resources-for-ts": "1.4.0",
"jest": "^30.2.0",
@@ -2332,198 +2330,111 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"node_modules/@eslint/compat": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz",
"integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"@eslint/core": "^1.1.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"peerDependencies": {
"eslint": "^8.40 || 9 || 10"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.3",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
"minimatch": "^10.2.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
"integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0"
"@eslint/core": "^1.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
"integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
"integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
"version": "9.39.3",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
"integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/object-schema": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@fullcalendar/core": {
@@ -4333,6 +4244,29 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.95.2",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.95.2.tgz",
"integrity": "sha512-EYUFRaqjBep4EHMPpZR12sXP7Kr5qv9iDIlq93NfbhHwhITaW6Txu3ROO6dLFz5r84T8p+oZXBG77pa2Wuok7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.48.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": "^5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4516,6 +4450,13 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -6751,19 +6692,13 @@
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
@@ -7365,33 +7300,30 @@
}
},
"node_modules/eslint": {
"version": "9.39.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.3",
"@eslint/plugin-kit": "^0.4.1",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.2",
"@eslint/config-helpers": "^0.5.2",
"@eslint/core": "^1.1.0",
"@eslint/plugin-kit": "^0.6.0",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"eslint-scope": "^9.1.1",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.1.1",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
@@ -7401,8 +7333,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"minimatch": "^10.2.1",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -7410,7 +7341,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
@@ -7424,6 +7355,36 @@
}
}
},
"node_modules/eslint-config-cozy-app": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/eslint-config-cozy-app/-/eslint-config-cozy-app-7.0.0.tgz",
"integrity": "sha512-NphYDGxEfzQPSQr2RNivCKhxqJviJ5HPtKcjkPWjVlV/OFuaaIwgGF3QPQ6qlDKRxayY/JK3zMbGuf4U2j9DSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/compat": "2.0.2",
"@eslint/js": "10.0.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"globals": "17.4.0",
"prettier": "3.8.1",
"typescript-eslint": "8.56.1"
},
"peerDependencies": {
"eslint": "^10.0.0",
"typescript": "^5.5.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
@@ -7595,6 +7556,37 @@
}
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.11.12"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-promise": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz",
@@ -7756,49 +7748,33 @@
"node": ">=10"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -7847,19 +7823,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
"integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint/node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
@@ -7877,31 +7840,31 @@
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -8046,6 +8009,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -8579,9 +8549,9 @@
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"version": "17.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
"integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11193,13 +11163,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -11363,13 +11326,13 @@
}
},
"node_modules/minimatch": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
"integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -12261,6 +12224,19 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-linter-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -14104,6 +14080,22 @@
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/twake-i18n/node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+8 -6
View File
@@ -19,6 +19,7 @@
"@mui/x-date-pickers": "^8.14.0",
"@reduxjs/toolkit": "^2.8.2",
"cozy-external-bridge": "^1.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"i18next": "^23.7.9",
"ical.js": "^2.2.0",
@@ -69,6 +70,7 @@
"@rsbuild/plugin-react": "^1.4.2",
"@rsbuild/plugin-stylus": "^1.2.0",
"@rsbuild/plugin-svgr": "^1.2.3",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -82,13 +84,9 @@
"@typescript-eslint/parser": "^8.48.1",
"babel-jest": "^30.2.0",
"babel-plugin-istanbul": "^7.0.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.13.0",
"eslint": "10.0.2",
"eslint-config-cozy-app": "7.0.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"history": "^5.3.0",
"i18next-resources-for-ts": "1.4.0",
"jest": "^30.2.0",
@@ -99,5 +97,9 @@
"ts-node": "^10.9.2",
"typescript": "^4.9.5",
"typescript-eslint": "^8.54.0"
},
"overrides": {
"eslint": "$eslint",
"typescript": "$typescript"
}
}
+18 -18
View File
@@ -1,18 +1,18 @@
var SSO_BASE_URL = "https://example.com";
var SSO_CLIENT_ID = "example";
var SSO_SCOPE = "openid profile email";
var SSO_REDIRECT_URI = "https://example.com/callback";
var SSO_RESPONSE_TYPE = "code";
var SSO_CODE_CHALLENGE_METHOD = "S256";
var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1";
var CALENDAR_BASE_URL = "https://calendar.example.com";
var DAV_BASE_URL = "https://dav.example.com";
var MAIL_SPA_URL = "https://mail.example.com";
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com";
var SUPPORT_URL = "https://twake.app/support/";
var DEBUG = false;
var LANG = "en";
var WEBSOCKET_URL = "wss://calendar.example.com";
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce
var WS_PING_PERIOD_MS = 30000;
var WS_PING_TIMEOUT_PERIOD_MS = 35000;
var SSO_BASE_URL = 'https://example.com'
var SSO_CLIENT_ID = 'example'
var SSO_SCOPE = 'openid profile email'
var SSO_REDIRECT_URI = 'https://example.com/callback'
var SSO_RESPONSE_TYPE = 'code'
var SSO_CODE_CHALLENGE_METHOD = 'S256'
var SSO_POST_LOGOUT_REDIRECT = 'http://example.com?logout=1'
var CALENDAR_BASE_URL = 'https://calendar.example.com'
var DAV_BASE_URL = 'https://dav.example.com'
var MAIL_SPA_URL = 'https://mail.example.com'
var VIDEO_CONFERENCE_BASE_URL = 'https://meet.linagora.com'
var SUPPORT_URL = 'https://twake.app/support/'
var DEBUG = false
var LANG = 'en'
var WEBSOCKET_URL = 'wss://calendar.example.com'
var WS_DEBOUNCE_PERIOD_MS = 100 // milliseconds, remove or set to 0 to disable debounce
var WS_PING_PERIOD_MS = 30000
var WS_PING_TIMEOUT_PERIOD_MS = 35000
+11 -11
View File
@@ -1,17 +1,17 @@
var appList = [
{
name: "Chat",
link: "/twake",
icon: "/assets/images/svg/app-chat.svg",
name: 'Chat',
link: '/twake',
icon: '/assets/images/svg/app-chat.svg'
},
{
name: "Drive",
link: "/drive",
icon: "/assets/images/svg/app-drive.svg",
name: 'Drive',
link: '/drive',
icon: '/assets/images/svg/app-drive.svg'
},
{
name: "Mail",
link: "/mail",
icon: "/assets/images/svg/app-mail.svg",
},
];
name: 'Mail',
link: '/mail',
icon: '/assets/images/svg/app-mail.svg'
}
]
+6 -6
View File
@@ -1,22 +1,22 @@
@font-face {
font-family: "Inter";
src: url("InterVariable.ttf") format("truetype");
font-family: 'Inter';
src: url('InterVariable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Roboto";
src: url("Roboto-VariableFont_wdth,wght.ttf") format("truetype");
font-family: 'Roboto';
src: url('Roboto-VariableFont_wdth,wght.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Cal Sans";
src: url("CalSans-SemiBold.woff2") format("woff2");
font-family: 'Cal Sans';
src: url('CalSans-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
+15 -15
View File
@@ -1,32 +1,32 @@
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginStylus } from "@rsbuild/plugin-stylus";
import { pluginSvgr } from "@rsbuild/plugin-svgr";
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { pluginStylus } from '@rsbuild/plugin-stylus'
import { pluginSvgr } from '@rsbuild/plugin-svgr'
export default defineConfig({
plugins: [pluginReact(), pluginStylus(), pluginSvgr()],
html: {
template: "./public/index.html",
template: './public/index.html'
},
server: {
port: 5000,
historyApiFallback: true,
historyApiFallback: true
},
source: {
entry: {
index: "./src/index.tsx",
},
index: './src/index.tsx'
}
},
output: {
distPath: {
root: "dist",
root: 'dist'
},
minify: false,
minify: false
},
resolve: {
alias: {
react: require.resolve("react"),
"react-dom": require.resolve("react-dom"),
},
},
});
react: require.resolve('react'),
'react-dom': require.resolve('react-dom')
}
}
})
+55 -55
View File
@@ -1,84 +1,84 @@
import { TwakeMuiThemeProvider } from "@linagora/twake-mui";
import { Suspense, useEffect, useState } from "react";
import { Route, Routes } from "react-router-dom";
import { push } from "redux-first-history";
import { HistoryRouter as Router } from "redux-first-history/rr6";
import "./App.styl";
import { useAppDispatch, useAppSelector } from "./app/hooks";
import { history } from "./app/store";
import CalendarLayout from "./components/Calendar/CalendarLayout";
import { Error as ErrorPage } from "./components/Error/Error";
import { ErrorSnackbar } from "./components/Error/ErrorSnackbar";
import { Loading } from "./components/Loading/Loading";
import { AVAILABLE_LANGUAGES } from "./features/Settings/constants";
import HandleLogin from "./features/User/HandleLogin";
import { CallbackResume } from "./features/User/LoginCallback";
import { useInitializeApp } from "./features/User/useInitializeApp";
import { ScreenTooSmall } from "./ScreenTooSmall";
import { WebSocketGate } from "./websocket/WebSocketGate";
import { TwakeMuiThemeProvider } from '@linagora/twake-mui'
import { Suspense, useEffect, useState } from 'react'
import { Route, Routes } from 'react-router-dom'
import { push } from 'redux-first-history'
import { HistoryRouter as Router } from 'redux-first-history/rr6'
import './App.styl'
import { useAppDispatch, useAppSelector } from './app/hooks'
import { history } from './app/store'
import CalendarLayout from './components/Calendar/CalendarLayout'
import { Error as ErrorPage } from './components/Error/Error'
import { ErrorSnackbar } from './components/Error/ErrorSnackbar'
import { Loading } from './components/Loading/Loading'
import { AVAILABLE_LANGUAGES } from './features/Settings/constants'
import HandleLogin from './features/User/HandleLogin'
import { CallbackResume } from './features/User/LoginCallback'
import { useInitializeApp } from './features/User/useInitializeApp'
import { ScreenTooSmall } from './ScreenTooSmall'
import { WebSocketGate } from './websocket/WebSocketGate'
import {
enGB,
fr as frLocale,
ru as ruLocale,
vi as viLocale,
} from "date-fns/locale";
vi as viLocale
} from 'date-fns/locale'
import I18n from "twake-i18n";
import en from "./locales/en.json";
import fr from "./locales/fr.json";
import ru from "./locales/ru.json";
import vi from "./locales/vi.json";
import I18n from 'twake-i18n'
import en from './locales/en.json'
import fr from './locales/fr.json'
import ru from './locales/ru.json'
import vi from './locales/vi.json'
const locale = { en, fr, ru, vi };
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
const locale = { en, fr, ru, vi }
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale }
const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map((lang) => lang.code);
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map(lang => lang.code)
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]
const isValidLanguage = (
lang: string | null | undefined
): lang is SupportedLanguage => {
return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage);
};
return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)
}
function App() {
const error = useAppSelector((state) => state.user.error);
const appLoading = useAppSelector((state) => state.loading.isLoading);
const userLanguage = useAppSelector(
(state) => state.user.coreConfig.language
);
const settingsLanguage = useAppSelector((state) => state.settings.language);
const savedLang = localStorage.getItem("lang");
const defaultLang = window.LANG;
const error = useAppSelector(state => state.user.error)
const appLoading = useAppSelector(state => state.loading.isLoading)
const userLanguage = useAppSelector(state => state.user.coreConfig.language)
const settingsLanguage = useAppSelector(state => state.settings.language)
const savedLang = localStorage.getItem('lang')
const defaultLang = window.LANG
const lang =
[userLanguage, settingsLanguage, savedLang, defaultLang].find(
(l) => !!l && isValidLanguage(l)
) || "en";
l => !!l && isValidLanguage(l)
) || 'en'
const dispatch = useAppDispatch();
const dispatch = useAppDispatch()
useEffect(() => {
if (error) {
dispatch(push("/error"));
dispatch(push('/error'))
}
}, [error, dispatch]);
}, [error, dispatch])
const SMALL_SCREEN_QUERY = "(max-width: 925px)";
const SMALL_SCREEN_QUERY = '(max-width: 925px)'
const [isTooSmall, setIsTooSmall] = useState(
() => window.matchMedia(SMALL_SCREEN_QUERY).matches
);
)
useInitializeApp();
useInitializeApp()
useEffect(() => {
const mediaQuery = window.matchMedia(SMALL_SCREEN_QUERY);
const mediaQuery = window.matchMedia(SMALL_SCREEN_QUERY)
const onChange = (event: MediaQueryListEvent) =>
setIsTooSmall(event.matches);
setIsTooSmall(mediaQuery.matches);
mediaQuery.addEventListener("change", onChange);
return () => mediaQuery.removeEventListener("change", onChange);
}, []);
setIsTooSmall(event.matches)
const onChangeInitial = () => setIsTooSmall(mediaQuery.matches)
onChangeInitial()
mediaQuery.addEventListener('change', onChange)
return () => mediaQuery.removeEventListener('change', onChange)
}, [])
return (
<TwakeMuiThemeProvider>
@@ -108,7 +108,7 @@ function App() {
)}
</I18n>
</TwakeMuiThemeProvider>
);
)
}
export default App;
export default App
+7 -7
View File
@@ -1,11 +1,11 @@
declare namespace Intl {
type Key =
| "calendar"
| "collation"
| "currency"
| "numberingSystem"
| "timeZone"
| "unit";
| 'calendar'
| 'collation'
| 'currency'
| 'numberingSystem'
| 'timeZone'
| 'unit'
function supportedValuesOf(input: Key): string[];
function supportedValuesOf(input: Key): string[]
}
+15 -15
View File
@@ -1,32 +1,32 @@
import calendarImage from "@/static/images/calendar.svg";
import { Box, Typography } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import calendarImage from '@/static/images/calendar.svg'
import { Box, Typography } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export function ScreenTooSmall() {
const { t } = useI18n();
const { t } = useI18n()
return (
<Box
sx={{
position: "fixed",
position: 'fixed',
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
gap: 2
}}
>
<img src={calendarImage} style={{ width: 80, height: 80 }} alt="logo" />
<Typography variant="h6">{t("mobile.comingSoon.title")}</Typography>
<Typography variant="h6">{t('mobile.comingSoon.title')}</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ maxWidth: "264px" }}
sx={{ maxWidth: '264px' }}
>
{t("mobile.comingSoon.subtitle")}
{t('mobile.comingSoon.subtitle')}
</Typography>
</Box>
);
)
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store";
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+14 -14
View File
@@ -1,30 +1,30 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface LoadingState {
isLoading: boolean;
message?: string;
isLoading: boolean
message?: string
}
const initialState: LoadingState = {
isLoading: false,
message: undefined,
};
message: undefined
}
const loadingSlice = createSlice({
name: "loading",
name: 'loading',
initialState,
reducers: {
setAppLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
state.isLoading = action.payload
if (!action.payload) {
state.message = undefined;
state.message = undefined
}
},
setLoadingMessage: (state, action: PayloadAction<string | undefined>) => {
state.message = action.payload;
},
},
});
state.message = action.payload
}
}
})
export const { setAppLoading, setLoadingMessage } = loadingSlice.actions;
export default loadingSlice.reducer;
export const { setAppLoading, setLoadingMessage } = loadingSlice.actions
export default loadingSlice.reducer
+4 -4
View File
@@ -1,7 +1,7 @@
import { RootState } from "@/app/store";
import { createSelector } from "@reduxjs/toolkit";
import { RootState } from '@/app/store'
import { createSelector } from '@reduxjs/toolkit'
export const selectCalendars = createSelector(
(state: RootState) => state.calendars.list,
(list) => Object.values(list)
);
list => Object.values(list)
)
+20 -20
View File
@@ -1,14 +1,14 @@
import eventsCalendar from "@/features/Calendars/CalendarSlice";
import searchResultReducer from "@/features/Search/SearchSlice";
import settingsReducer from "@/features/Settings/SettingsSlice";
import userReducer from "@/features/User/userSlice";
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import loadingReducer from "./loadingSlice";
import { createBrowserHistory } from "history";
import { createReduxHistoryContext } from "redux-first-history";
import eventsCalendar from '@/features/Calendars/CalendarSlice'
import searchResultReducer from '@/features/Search/SearchSlice'
import settingsReducer from '@/features/Settings/SettingsSlice'
import userReducer from '@/features/User/userSlice'
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import loadingReducer from './loadingSlice'
import { createBrowserHistory } from 'history'
import { createReduxHistoryContext } from 'redux-first-history'
const { createReduxHistory, routerMiddleware, routerReducer } =
createReduxHistoryContext({ history: createBrowserHistory() });
createReduxHistoryContext({ history: createBrowserHistory() })
const rootReducer = combineReducers({
router: routerReducer,
@@ -16,22 +16,22 @@ const rootReducer = combineReducers({
calendars: eventsCalendar,
settings: settingsReducer,
searchResult: searchResultReducer,
loading: loadingReducer,
});
loading: loadingReducer
})
export const setupStore = (preloadedState?: Partial<RootState>) => {
return configureStore({
reducer: rootReducer,
preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(routerMiddleware),
});
};
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(routerMiddleware)
})
}
export const store = setupStore();
export const store = setupStore()
export const history = createReduxHistory(store);
export const history = createReduxHistory(store)
export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore["dispatch"];
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
+75 -64
View File
@@ -1,22 +1,22 @@
import { userAttendee } from "@/features/User/models/attendee";
import { createAttendee } from "@/features/User/models/attendee.mapper";
import { useRef, useState } from "react";
import { FreeBusyIndicator } from "./FreeBusyIndicator";
import { userAttendee } from '@/features/User/models/attendee'
import { createAttendee } from '@/features/User/models/attendee.mapper'
import { useEffect, useRef, useState } from 'react'
import { FreeBusyIndicator } from './FreeBusyIndicator'
import {
ExtendedAutocompleteRenderInputParams,
PeopleSearch,
User,
} from "./PeopleSearch";
import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy";
User
} from './PeopleSearch'
import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy'
const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({
const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({
email: a.cal_address,
displayName: a.cn ?? "",
avatarUrl: "",
openpaasId,
});
displayName: a.cn ?? '',
avatarUrl: '',
openpaasId
})
const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId;
const hasCalendar = (u: User) => u.objectType === 'user' && !!u.openpaasId
export default function AttendeeSearch({
attendees,
@@ -27,98 +27,109 @@ export default function AttendeeSearch({
start,
end,
timezone,
eventUid,
eventUid
}: {
attendees: userAttendee[];
setAttendees: (attendees: userAttendee[]) => void;
disabled?: boolean;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
placeholder?: string;
start?: string;
end?: string;
timezone?: string;
eventUid?: string | null;
attendees: userAttendee[]
setAttendees: (attendees: userAttendee[]) => void
disabled?: boolean
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
placeholder?: string
start?: string
end?: string
timezone?: string
eventUid?: string | null
}) {
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({});
const [addedUsers, setAddedUsers] = useState<User[]>([]);
const initialEmailsRef = useRef<Set<string> | null>(null);
if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) {
initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address));
}
const initialEmails = eventUid
? (initialEmailsRef.current ?? new Set<string>())
: new Set<string>();
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({})
const [addedUsers, setAddedUsers] = useState<User[]>([])
const initialEmailsRef = useRef<Set<string> | null>(null)
const [initialEmailsSet, setInitialEmailsSet] = useState<Set<string>>(
new Set()
)
useEffect(() => {
const updateInitialEmailsSet = () => {
if (
initialEmailsRef.current === null &&
!!eventUid &&
attendees.length > 0
) {
initialEmailsRef.current = new Set(attendees.map(a => a.cal_address))
setInitialEmailsSet(initialEmailsRef.current)
}
}
updateInitialEmailsSet()
}, [eventUid, attendees])
const initialEmails = eventUid ? initialEmailsSet : new Set<string>()
const selectedUsers: User[] = [
...addedUsers,
...attendees
.map((a) => attendeeToUser(a, userIdMap[a.cal_address]))
.filter((a) => !addedUsers.find((u) => a.email === u.email)),
];
.map(a => attendeeToUser(a, userIdMap[a.cal_address]))
.filter(a => !addedUsers.find(u => a.email === u.email))
]
const toAttendee = (u: User) => ({
email: u.email,
userId: u.openpaasId || userIdMap[u.email] || null,
});
userId: u.openpaasId || userIdMap[u.email] || null
})
const existingAttendees = selectedUsers
.filter((u) => initialEmails.has(u.email))
.map(toAttendee);
.filter(u => initialEmails.has(u.email))
.map(toAttendee)
const newAttendees = selectedUsers
.filter((u) => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee);
.filter(u => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee)
// Contacts and freeSolo users get a static "contact" status — no API call needed
const contactMap: FreeBusyMap = Object.fromEntries(
selectedUsers
.filter((u) => !initialEmails.has(u.email) && !hasCalendar(u))
.map((u) => [u.email, "contact" as const])
);
.filter(u => !initialEmails.has(u.email) && !hasCalendar(u))
.map(u => [u.email, 'contact' as const])
)
const freeBusyMap = useAttendeesFreeBusy({
existingAttendees,
newAttendees,
start: start ?? "",
end: end ?? "",
timezone: timezone ?? "",
start: start ?? '',
end: end ?? '',
timezone: timezone ?? '',
eventUid,
enabled: !!(start && end && selectedUsers.length > 0),
});
enabled: !!(start && end && selectedUsers.length > 0)
})
const statusMap = { ...freeBusyMap, ...contactMap };
const statusMap = { ...freeBusyMap, ...contactMap }
return (
<PeopleSearch
selectedUsers={selectedUsers}
objectTypes={["user", "contact"]}
objectTypes={['user', 'contact']}
disabled={disabled}
inputSlot={inputSlot}
placeholder={placeholder}
getChipIcon={
start && end
? (user) => (
<FreeBusyIndicator status={statusMap[user.email] ?? "unknown"} />
? user => (
<FreeBusyIndicator status={statusMap[user.email] ?? 'unknown'} />
)
: undefined
}
onChange={(_event, value: User[]) => {
setUserIdMap((prev) => {
const next = { ...prev };
setUserIdMap(prev => {
const next = { ...prev }
for (const u of value) {
if (u.openpaasId && u.email) next[u.email] = u.openpaasId;
if (u.openpaasId && u.email) next[u.email] = u.openpaasId
}
return next;
});
setAddedUsers(value.filter((u) => !initialEmails.has(u.email)));
return next
})
setAddedUsers(value.filter(u => !initialEmails.has(u.email)))
setAttendees(
value.map((u) =>
value.map(u =>
createAttendee({ cal_address: u.email, cn: u.displayName })
)
);
)
}}
freeSolo
/>
);
)
}
+24 -21
View File
@@ -1,32 +1,35 @@
import { Tooltip } from "@linagora/twake-mui";
import AccessTimeFilledIcon from "@mui/icons-material/AccessTimeFilled";
import CloseIcon from "@mui/icons-material/Close";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { FreeBusyStatus } from "./useFreeBusy";
import { Tooltip } from '@linagora/twake-mui'
import AccessTimeFilledIcon from '@mui/icons-material/AccessTimeFilled'
import CloseIcon from '@mui/icons-material/Close'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { FreeBusyStatus } from './useFreeBusy'
interface FreeBusyIndicatorProps {
status: FreeBusyStatus;
size?: number;
status: FreeBusyStatus
size?: number
}
export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const { t } = useI18n()
const [open, setOpen] = useState(false)
useEffect(() => {
if (status === "busy") setOpen(true);
}, [status]);
const triggerOpen = () => {
if (status === 'busy') setOpen(true)
}
triggerOpen()
}, [status])
if (status !== "busy") return null;
if (status !== 'busy') return null
return (
<Tooltip
title={
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{t("event.freeBusy.busy")}
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('event.freeBusy.busy')}
<CloseIcon
fontSize="inherit"
style={{ cursor: "pointer" }}
style={{ cursor: 'pointer' }}
onClick={() => setOpen(false)}
/>
</span>
@@ -35,16 +38,16 @@ export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
disableHoverListener
placement="bottom-start"
onClose={() => setOpen(false)}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: 'grey.900' } } }}
>
<AccessTimeFilledIcon
aria-label={t("event.freeBusy.busy")}
aria-label={t('event.freeBusy.busy')}
color="warning"
style={{
margin: "0 -6px 0 5px",
flexShrink: 0,
margin: '0 -6px 0 5px',
flexShrink: 0
}}
/>
</Tooltip>
);
)
}
+128 -132
View File
@@ -1,8 +1,8 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { stringAvatar } from "@/components/Event/utils/eventUtils";
import { useUserSearch } from "./useUserSearch";
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
import CloseIcon from "@mui/icons-material/Close";
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { stringAvatar } from '@/components/Event/utils/eventUtils'
import { useUserSearch } from './useUserSearch'
import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import CloseIcon from '@mui/icons-material/Close'
import {
Autocomplete,
Avatar,
@@ -15,35 +15,35 @@ import {
PopperProps,
TextField,
useTheme,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
type AutocompleteRenderInputParams
} from '@linagora/twake-mui'
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import {
HTMLAttributes,
useCallback,
type ReactNode,
type SyntheticEvent,
} from "react";
import { useI18n } from "twake-i18n";
import { ResourceIcon } from "./ResourceIcon";
import { isValidEmail } from "../../utils/isValidEmail";
import { usePasteHandler } from "./usePasteHandler";
type SyntheticEvent
} from 'react'
import { useI18n } from 'twake-i18n'
import { ResourceIcon } from './ResourceIcon'
import { isValidEmail } from '../../utils/isValidEmail'
import { usePasteHandler } from './usePasteHandler'
export interface User {
email: string;
displayName: string;
avatarUrl?: string;
openpaasId?: string;
color?: Record<string, string>;
objectType?: string;
email: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
objectType?: string
}
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean;
helperText?: string | null;
placeholder?: string;
label?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
error?: boolean
helperText?: string | null
placeholder?: string
label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function PeopleSearch({
@@ -57,33 +57,31 @@ export function PeopleSearch({
inputSlot,
customRenderInput,
customSlotProps,
getChipIcon,
getChipIcon
}: {
selectedUsers: User[];
onChange: (event: SyntheticEvent, users: User[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void
objectTypes: string[]
disabled?: boolean
freeSolo?: boolean
onToggleEventPreview?: () => void
placeholder?: string
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
customRenderInput?: (
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => ReactNode;
) => ReactNode
customSlotProps?: {
popper?: Partial<PopperProps>;
paper?: Partial<PaperProps>;
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
};
getChipIcon?: (user: User) => ReactNode;
popper?: Partial<PopperProps>
paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}
getChipIcon?: (user: User) => ReactNode
}) {
const { t } = useI18n();
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
const errorMessage = t("peopleSearch.searchError");
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder')
const errorMessage = t('peopleSearch.searchError')
const {
query,
@@ -98,32 +96,32 @@ export function PeopleSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
} = useUserSearch<User>({ objectTypes, errorMessage });
setSnackbarMessage
} = useUserSearch<User>({ objectTypes, errorMessage })
const theme = useTheme();
const theme = useTheme()
const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => {
const trimmed = query.trim();
if (!trimmed) return;
const trimmed = query.trim()
if (!trimmed) return
if (!isValidEmail(trimmed)) {
setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", trimmed)
);
return;
t('peopleSearch.invalidEmail').replace('%{email}', trimmed)
)
return
}
if (selectedUsers.find((u) => u.email === trimmed)) {
setQuery("");
return;
if (selectedUsers.find(u => u.email === trimmed)) {
setQuery('')
return
}
setInputError(null);
const newUser: User = { email: trimmed, displayName: trimmed };
onChange(event, [...selectedUsers, newUser]);
setQuery("");
setInputError(null)
const newUser: User = { email: trimmed, displayName: trimmed }
onChange(event, [...selectedUsers, newUser])
setQuery('')
},
[query, selectedUsers, onChange, t, setInputError, setQuery]
);
)
const handlePaste = usePasteHandler({
freeSolo,
@@ -131,8 +129,8 @@ export function PeopleSearch({
onChange,
setQuery,
setInputError,
t,
});
t
})
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
@@ -143,7 +141,7 @@ export function PeopleSearch({
) : (
<PeopleOutlineOutlinedIcon
fontSize="small"
sx={{ mr: 1, color: "action.active" }}
sx={{ mr: 1, color: 'action.active' }}
/>
),
endAdornment: (
@@ -151,61 +149,61 @@ export function PeopleSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
};
)
}
const enhancedParams = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
onPaste: handlePaste,
},
};
autoComplete: 'off',
onPaste: handlePaste
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
}
};
}
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
label: '',
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
...inputProps
}
}
}
if (inputSlot) {
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
{t('peopleSearch.label')}
</label>
{inputSlot({
...enhancedParams,
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
label: '',
onKeyDown: handleEnterKey
})}
</>
);
)
}
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
{t('peopleSearch.label')}
</label>
<TextField
{...enhancedParams}
@@ -214,7 +212,7 @@ export function PeopleSearch({
size="medium"
/>
</>
);
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -223,9 +221,9 @@ export function PeopleSearch({
onToggleEventPreview,
loading,
searchPlaceholder,
handlePaste,
handlePaste
]
);
)
return (
<>
@@ -246,68 +244,68 @@ export function PeopleSearch({
onClose={() => setIsOpen(false)}
disabled={disabled}
loading={loading}
filterOptions={(x) => x}
filterOptions={x => x}
fullWidth
noOptionsText={t("peopleSearch.noResults")}
loadingText={t("peopleSearch.loading")}
getOptionLabel={(option) => {
if (typeof option === "object") {
return option.displayName || option.email;
noOptionsText={t('peopleSearch.noResults')}
loadingText={t('peopleSearch.loading')}
getOptionLabel={option => {
if (typeof option === 'object') {
return option.displayName || option.email
} else {
return option;
return option
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
'& .MuiAutocomplete-inputRoot': {
py: 0
}
}}
filterSelectedOptions
value={selectedUsers}
inputValue={query}
onInputChange={(_event, value) => setQuery(value)}
onChange={(event, value) => {
const last = value[value.length - 1];
if (typeof last === "string" && !isValidEmail(last.trim())) {
const invalidEmailMessage = t("peopleSearch.invalidEmail").replace(
"%{email}",
const last = value[value.length - 1]
if (typeof last === 'string' && !isValidEmail(last.trim())) {
const invalidEmailMessage = t('peopleSearch.invalidEmail').replace(
'%{email}',
last
);
setInputError(invalidEmailMessage);
return;
)
setInputError(invalidEmailMessage)
return
}
setInputError(null);
setInputError(null)
const mapped = value
.map((v: string | User) =>
typeof v === "string"
typeof v === 'string'
? { email: v.trim(), displayName: v.trim() }
: v
)
.filter(
(user, index, self) =>
self.findIndex((u) => u.email === user.email) === index
);
onChange(event, mapped);
self.findIndex(u => u.email === user.email) === index
)
onChange(event, mapped)
}}
slotProps={{
...customSlotProps,
popper: {
placement: "bottom-start",
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
...customSlotProps?.popper,
},
placement: 'bottom-start',
sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper
}
}}
forcePopupIcon={false}
disableClearable
renderInput={(params) =>
renderInput={params =>
customRenderInput
? customRenderInput(params, query, setQuery)
: defaultRenderInput(params)
}
renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null;
const { key, ...otherProps } = props;
const isResource = option.objectType === "resource";
if (selectedUsers.find(u => u.email === option.email)) return null
const { key, ...otherProps } = props
const isResource = option.objectType === 'resource'
return (
<ListItem key={key + option?.email} {...otherProps} disableGutters>
<ListItemAvatar>
@@ -321,25 +319,23 @@ export function PeopleSearch({
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={isResource ? "" : option.email}
secondary={isResource ? '' : option.email}
slotProps={{
primary: { variant: "body2" },
secondary: { variant: "caption" },
primary: { variant: 'body2' },
secondary: { variant: 'caption' }
}}
/>
</ListItem>
);
)
}}
renderValue={(value, getTagProps) =>
value.map((option, index) => {
const isString = typeof option === "string";
const label = isString
? option
: option.displayName || option.email;
const isString = typeof option === 'string'
const label = isString ? option : option.displayName || option.email
const chipColor = isString
? theme.palette.grey[200]
: (option.color?.light ?? theme.palette.grey[200]);
const textColor = getAccessiblePair(chipColor, theme);
: (option.color?.light ?? theme.palette.grey[200])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
@@ -351,25 +347,25 @@ export function PeopleSearch({
deleteIcon={<CloseIcon />}
style={{
backgroundColor: chipColor,
color: textColor,
color: textColor
}}
label={label}
/>
);
)
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
setSnackbarOpen(open)
if (!open) {
setSnackbarMessage("");
setSnackbarMessage('')
}
}}
message={snackbarMessage}
severity="error"
/>
</>
);
)
}
+13 -13
View File
@@ -1,41 +1,41 @@
import { Avatar, Box } from "@linagora/twake-mui";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import { Avatar, Box } from '@linagora/twake-mui'
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'
interface ResourceIconProps {
avatarUrl?: string;
colorIcon?: boolean;
color?: string;
avatarUrl?: string
colorIcon?: boolean
color?: string
}
export function ResourceIcon({
avatarUrl,
colorIcon,
color,
color
}: ResourceIconProps) {
if (colorIcon && avatarUrl) {
return (
<Box
sx={{
width: "24px",
height: "24px",
width: '24px',
height: '24px',
backgroundColor: color,
maskImage: `url(${avatarUrl})`,
maskSize: "cover",
maskSize: 'cover'
}}
/>
);
)
}
return avatarUrl ? (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
src={avatarUrl}
/>
) : (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
>
<LayersOutlinedIcon />
</Avatar>
);
)
}
+106 -110
View File
@@ -1,7 +1,7 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { useUserSearch } from "./useUserSearch";
import { ResourceIcon } from "./ResourceIcon";
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { useUserSearch } from './useUserSearch'
import { ResourceIcon } from './ResourceIcon'
import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import {
Autocomplete,
Chip,
@@ -14,31 +14,31 @@ import {
TextField,
useTheme,
Typography,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import SearchIcon from "@mui/icons-material/Search";
type AutocompleteRenderInputParams
} from '@linagora/twake-mui'
import SearchIcon from '@mui/icons-material/Search'
import {
HTMLAttributes,
useCallback,
type ReactNode,
type SyntheticEvent,
} from "react";
import { useI18n } from "twake-i18n";
type SyntheticEvent
} from 'react'
import { useI18n } from 'twake-i18n'
export interface Resource {
email?: string;
displayName: string;
avatarUrl?: string;
openpaasId?: string;
color?: Record<string, string>;
email?: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
}
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean;
helperText?: string | null;
placeholder?: string;
label?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
error?: boolean
helperText?: string | null
placeholder?: string
label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function ResourceSearch({
@@ -52,33 +52,31 @@ export function ResourceSearch({
inputSlot,
customRenderInput,
customSlotProps,
hideLabel,
hideLabel
}: {
selectedResources: Resource[];
onChange: (event: SyntheticEvent, users: Resource[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
selectedResources: Resource[]
onChange: (event: SyntheticEvent, users: Resource[]) => void
objectTypes: string[]
disabled?: boolean
freeSolo?: boolean
onToggleEventPreview?: () => void
placeholder?: string
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
customRenderInput?: (
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => ReactNode;
) => ReactNode
customSlotProps?: {
popper?: Partial<PopperProps>;
paper?: Partial<PaperProps>;
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
};
hideLabel?: boolean;
popper?: Partial<PopperProps>
paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}
hideLabel?: boolean
}) {
const { t } = useI18n();
const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder");
const errorMessage = t("resourceSearch.searchError");
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t('resourceSearch.placeholder')
const errorMessage = t('resourceSearch.searchError')
const {
query,
@@ -93,26 +91,26 @@ export function ResourceSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
} = useUserSearch<Resource>({ objectTypes, errorMessage });
setSnackbarMessage
} = useUserSearch<Resource>({ objectTypes, errorMessage })
const theme = useTheme();
const theme = useTheme()
const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => {
const trimmed = query.trim();
if (!trimmed) return;
if (selectedResources.find((u) => u.displayName === trimmed)) {
setQuery("");
return;
const trimmed = query.trim()
if (!trimmed) return
if (selectedResources.find(u => u.displayName === trimmed)) {
setQuery('')
return
}
setInputError(null);
const newResource: Resource = { displayName: trimmed };
onChange(event, [...selectedResources, newResource]);
setQuery("");
setInputError(null)
const newResource: Resource = { displayName: trimmed }
onChange(event, [...selectedResources, newResource])
setQuery('')
},
[query, selectedResources, onChange, setInputError, setQuery]
);
)
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
@@ -123,7 +121,7 @@ export function ResourceSearch({
{!selectedResources?.length ? (
<SearchIcon
fontSize="small"
sx={{ mr: 1, color: "action.active" }}
sx={{ mr: 1, color: 'action.active' }}
/>
) : null}
{params.InputProps.startAdornment}
@@ -134,44 +132,44 @@ export function ResourceSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{!selectedResources?.length ? params.InputProps.endAdornment : null}
</>
),
};
)
}
const enhancedParams = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
},
};
autoComplete: 'off'
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
}
};
}
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
label: '',
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
...inputProps
}
}
}
if (inputSlot) {
return (
<>
{!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
{t("resourceSearch.label")}
<Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t('resourceSearch.label')}
</Typography>
)}
{inputSlot({
@@ -179,18 +177,18 @@ export function ResourceSearch({
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
label: '',
onKeyDown: handleEnterKey
})}
</>
);
)
}
return (
<>
{!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
{t("resourceSearch.label")}
<Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t('resourceSearch.label')}
</Typography>
)}
<TextField
@@ -200,7 +198,7 @@ export function ResourceSearch({
size="medium"
/>
</>
);
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -209,9 +207,9 @@ export function ResourceSearch({
onToggleEventPreview,
loading,
searchPlaceholder,
selectedResources?.length,
selectedResources?.length
]
);
)
return (
<>
@@ -234,55 +232,53 @@ export function ResourceSearch({
loading={loading}
filterOptions={(options: Resource[]) => options}
fullWidth
noOptionsText={t("resourceSearch.noResults")}
loadingText={t("resourceSearch.loading")}
noOptionsText={t('resourceSearch.noResults')}
loadingText={t('resourceSearch.loading')}
getOptionLabel={(option: Resource | string) => {
if (typeof option === "object") {
return option.displayName;
if (typeof option === 'object') {
return option.displayName
} else {
return option;
return option
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
'& .MuiAutocomplete-inputRoot': {
py: 0
}
}}
filterSelectedOptions
value={selectedResources}
inputValue={query}
onInputChange={(_event, value: string) => setQuery(value)}
onChange={(event, value: string[] | Resource[]) => {
setInputError(null);
setInputError(null)
const mapped = value
.map((v: string | Resource) =>
typeof v === "string" ? { displayName: v.trim() } : v
typeof v === 'string' ? { displayName: v.trim() } : v
)
.filter((v) => v.displayName.trim().length > 0);
onChange(event, mapped);
.filter(v => v.displayName.trim().length > 0)
onChange(event, mapped)
}}
slotProps={{
...customSlotProps,
popper: {
placement: "bottom-start",
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
...customSlotProps?.popper,
},
placement: 'bottom-start',
sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper
}
}}
// When render input is custom, the adornments should be handled by the custom component
forcePopupIcon={!customRenderInput}
disableClearable={!!customRenderInput}
renderInput={(params) =>
renderInput={params =>
customRenderInput
? customRenderInput(params, query, setQuery)
: defaultRenderInput(params)
}
renderOption={(props, option: Resource) => {
if (
selectedResources.find((u) => u.displayName === option.displayName)
)
return null;
const { key, ...otherProps } = props;
if (selectedResources.find(u => u.displayName === option.displayName))
return null
const { key, ...otherProps } = props
return (
<ListItem
key={key + option?.displayName}
@@ -294,16 +290,16 @@ export function ResourceSearch({
</ListItemAvatar>
<ListItemText primary={option.displayName} />
</ListItem>
);
)
}}
renderValue={(value: string[] | Resource[], getTagProps) =>
value.map((option: string | Resource, index) => {
const isString = typeof option === "string";
const label = isString ? option : option.displayName;
const isString = typeof option === 'string'
const label = isString ? option : option.displayName
const chipColor = isString
? theme.palette.grey[300]
: (option.color?.light ?? theme.palette.grey[300]);
const textColor = getAccessiblePair(chipColor, theme);
: (option.color?.light ?? theme.palette.grey[300])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
@@ -311,25 +307,25 @@ export function ResourceSearch({
key={label}
style={{
backgroundColor: chipColor,
color: textColor,
color: textColor
}}
label={label}
/>
);
)
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
setSnackbarOpen(open)
if (!open) {
setSnackbarMessage("");
setSnackbarMessage('')
}
}}
message={snackbarMessage}
severity="error"
/>
</>
);
)
}
+119 -122
View File
@@ -1,99 +1,92 @@
import { getFreeBusyForAddedAttendeesREPORT } from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT";
import { getFreeBusyForEventAttendeesPOST } from "@/features/Events/api/getFreeBusyForEventAttendeesPOST";
import { getUserDataFromEmail } from "@/features/Events/api/getUserDataFromEmail";
import moment from "moment-timezone";
import { useEffect, useRef, useState } from "react";
import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail'
import moment from 'moment-timezone'
import { useEffect, useRef, useState } from 'react'
export type FreeBusyStatus =
| "free"
| "busy"
| "loading"
| "contact"
| "unknown";
export type FreeBusyMap = Record<string, FreeBusyStatus>;
export type FreeBusyStatus = 'free' | 'busy' | 'loading' | 'contact' | 'unknown'
export type FreeBusyMap = Record<string, FreeBusyStatus>
interface Attendee {
email: string;
userId?: string | null;
email: string
userId?: string | null
}
interface ResolvedAttendee {
email: string;
userId: string;
email: string
userId: string
}
// Helpers
async function resolveUserId(attendee: Attendee): Promise<string | null> {
if (attendee.userId) return attendee.userId;
if (attendee.userId) return attendee.userId
return getUserDataFromEmail(attendee.email)
.then((u) => u[0]?._id ?? null)
.catch(() => null);
.then(u => u[0]?._id ?? null)
.catch(() => null)
}
async function resolveAll(attendees: Attendee[]): Promise<ResolvedAttendee[]> {
const results = await Promise.all(
attendees.map(async (a) => {
const userId = await resolveUserId(a);
return userId ? { email: a.email, userId } : null;
attendees.map(async a => {
const userId = await resolveUserId(a)
return userId ? { email: a.email, userId } : null
})
);
return results.filter((r): r is ResolvedAttendee => r !== null);
)
return results.filter((r): r is ResolvedAttendee => r !== null)
}
export function hasFreeBusyConflict(data: unknown): boolean {
try {
const jcal = (data as { data: unknown[] }).data;
if (!Array.isArray(jcal) || jcal[0] !== "vcalendar") return false;
const components = jcal[2] as unknown[][];
return (
Array.isArray(components) && components.some(isVFreeBusyWithConflict)
);
const jcal = (data as { data: unknown[] }).data
if (!Array.isArray(jcal) || jcal[0] !== 'vcalendar') return false
const components = jcal[2] as unknown[][]
return Array.isArray(components) && components.some(isVFreeBusyWithConflict)
} catch {
return false;
return false
}
}
function isVFreeBusyWithConflict(component: unknown): boolean {
if (!Array.isArray(component) || component[0] !== "vfreebusy") return false;
const props = component[1] as unknown[][];
if (!Array.isArray(component) || component[0] !== 'vfreebusy') return false
const props = component[1] as unknown[][]
return (
Array.isArray(props) &&
props.some((p) => Array.isArray(p) && p[0] === "freebusy")
);
props.some(p => Array.isArray(p) && p[0] === 'freebusy')
)
}
function toUtcIcal(datetime: string, timezone: string): string {
return moment.tz(datetime, timezone).utc().format("YYYYMMDDTHHmmss");
return moment.tz(datetime, timezone).utc().format('YYYYMMDDTHHmmss')
}
async function fetchFreeBusyMap(
attendees: Attendee[],
fetcher: (resolved: ResolvedAttendee[]) => Promise<FreeBusyMap>
): Promise<FreeBusyMap> {
const resolved = await resolveAll(attendees);
const resolved = await resolveAll(attendees)
const unresolved: FreeBusyMap = Object.fromEntries(
attendees
.filter((a) => !resolved.find((r) => r.email === a.email))
.map((a) => [a.email, "unknown" as FreeBusyStatus])
);
.filter(a => !resolved.find(r => r.email === a.email))
.map(a => [a.email, 'unknown' as FreeBusyStatus])
)
if (resolved.length === 0) return unresolved;
if (resolved.length === 0) return unresolved
const fetched = await fetcher(resolved);
return { ...unresolved, ...fetched };
const fetched = await fetcher(resolved)
return { ...unresolved, ...fetched }
}
function toLoadingMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries(
attendees.map((a) => [a.email, "loading" as FreeBusyStatus])
);
attendees.map(a => [a.email, 'loading' as FreeBusyStatus])
)
}
function toUnknownMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries(
attendees.map((a) => [a.email, "unknown" as FreeBusyStatus])
);
attendees.map(a => [a.email, 'unknown' as FreeBusyStatus])
)
}
function toFreeBusyMap(
@@ -101,26 +94,26 @@ function toFreeBusyMap(
): (busyByUserId: Record<string, boolean>) => FreeBusyMap {
const userIdToEmail = Object.fromEntries(
resolved.map(({ email, userId }) => [userId, email])
);
return (busyByUserId) =>
)
return busyByUserId =>
Object.fromEntries(
Object.entries(busyByUserId).flatMap(([uid, busy]) => {
const email = userIdToEmail[uid];
const email = userIdToEmail[uid]
return email
? [[email, (busy ? "busy" : "free") as FreeBusyStatus]]
: [];
? [[email, (busy ? 'busy' : 'free') as FreeBusyStatus]]
: []
})
);
)
}
interface UseAttendeesFreeBusyOptions {
existingAttendees: Attendee[];
newAttendees: Attendee[];
start: string;
end: string;
timezone: string;
eventUid?: string | null;
enabled?: boolean;
existingAttendees: Attendee[]
newAttendees: Attendee[]
start: string
end: string
timezone: string
eventUid?: string | null
enabled?: boolean
}
export function useAttendeesFreeBusy({
@@ -130,18 +123,18 @@ export function useAttendeesFreeBusy({
end,
timezone,
eventUid,
enabled = true,
enabled = true
}: UseAttendeesFreeBusyOptions): FreeBusyMap {
const [statusMap, setStatusMap] = useState<FreeBusyMap>({});
const fetchedNewEmailsRef = useRef<Set<string>>(new Set());
const [statusMap, setStatusMap] = useState<FreeBusyMap>({})
const fetchedNewEmailsRef = useRef<Set<string>>(new Set())
const existingKey = existingAttendees.map((a) => a.email).join(",");
const newKey = newAttendees.map((a) => a.email).join(",");
const existingKey = existingAttendees.map(a => a.email).join(',')
const newKey = newAttendees.map(a => a.email).join(',')
useEffect(() => {
fetchedNewEmailsRef.current = new Set();
setStatusMap({});
}, [start, end, timezone]);
fetchedNewEmailsRef.current = new Set()
setStatusMap({})
}, [start, end, timezone])
useEffect(() => {
if (
@@ -151,101 +144,105 @@ export function useAttendeesFreeBusy({
!eventUid ||
existingAttendees.length === 0
)
return;
return
let cancelled = false;
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) }));
let cancelled = false
setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) }))
fetchFreeBusyMap(existingAttendees, (resolved) =>
fetchFreeBusyMap(existingAttendees, resolved =>
getFreeBusyForEventAttendeesPOST(
resolved.map((r) => r.userId),
resolved.map(r => r.userId),
toUtcIcal(start, timezone),
toUtcIcal(end, timezone),
eventUid!
eventUid
).then(toFreeBusyMap(resolved))
)
.then((updates) => {
if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates }));
.then(updates => {
if (!cancelled) {
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
})
.catch(() => {
if (!cancelled)
setStatusMap((prev) => ({
setStatusMap(prev => ({
...prev,
...toUnknownMap(existingAttendees),
}));
});
...toUnknownMap(existingAttendees)
}))
})
return () => {
cancelled = true;
};
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingKey, start, end, eventUid, enabled, timezone]);
}, [existingKey, start, end, eventUid, enabled, timezone])
useEffect(() => {
if (!enabled || !start || !end) return;
if (!enabled || !start || !end) return
const currentEmails = new Set(newAttendees.map((a) => a.email));
const currentEmails = new Set(newAttendees.map(a => a.email))
const removedEmails = [...fetchedNewEmailsRef.current].filter(
(e) => !currentEmails.has(e)
);
e => !currentEmails.has(e)
)
if (removedEmails.length > 0) {
removedEmails.forEach((e) => {
fetchedNewEmailsRef.current.delete(e);
});
setStatusMap((prev) => {
const next = { ...prev };
removedEmails.forEach((e) => {
delete next[e];
});
return next;
});
removedEmails.forEach(e => {
fetchedNewEmailsRef.current.delete(e)
})
setStatusMap(prev => {
const next = { ...prev }
removedEmails.forEach(e => {
delete next[e]
})
return next
})
}
const toFetch = newAttendees.filter(
(a) => !fetchedNewEmailsRef.current.has(a.email)
);
if (toFetch.length === 0) return;
a => !fetchedNewEmailsRef.current.has(a.email)
)
if (toFetch.length === 0) return
let cancelled = false;
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(toFetch) }));
fetchFreeBusyMap(toFetch, (resolved) =>
let cancelled = false
setStatusMap(prev => ({ ...prev, ...toLoadingMap(toFetch) }))
fetchFreeBusyMap(toFetch, resolved =>
Promise.all(
resolved.map(async ({ email, userId }) => {
try {
const busy = await getFreeBusyForAddedAttendeesREPORT(
userId,
moment.tz(start, timezone).utc().format("YYYYMMDDTHHmmss"),
moment.tz(end, timezone).utc().format("YYYYMMDDTHHmmss")
);
return [email, (busy ? "busy" : "free") as FreeBusyStatus] as const;
moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'),
moment.tz(end, timezone).utc().format('YYYYMMDDTHHmmss')
)
return [email, (busy ? 'busy' : 'free') as FreeBusyStatus] as const
} catch {
return [email, "unknown" as FreeBusyStatus] as const;
return [email, 'unknown' as FreeBusyStatus] as const
}
})
).then(Object.fromEntries)
)
.then((updates) => {
.then(updates => {
if (!cancelled) {
Object.keys(updates).forEach((e) => {
fetchedNewEmailsRef.current.add(e);
});
setStatusMap((prev) => ({ ...prev, ...updates }));
Object.keys(updates).forEach(e => {
fetchedNewEmailsRef.current.add(e)
})
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
})
.catch(() => {
if (!cancelled) {
toFetch.forEach((a) => {
fetchedNewEmailsRef.current.add(a.email);
});
setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) }));
toFetch.forEach(a => {
fetchedNewEmailsRef.current.add(a.email)
})
setStatusMap(prev => ({ ...prev, ...toUnknownMap(toFetch) }))
}
});
})
return () => {
cancelled = true;
};
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newKey, start, end, enabled, timezone]);
}, [newKey, start, end, enabled, timezone])
return statusMap;
return statusMap
}
+31 -31
View File
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import type { SyntheticEvent } from "react";
import { isValidEmail } from "../../utils/isValidEmail";
import type { User } from "./PeopleSearch";
import { useCallback } from 'react'
import type { SyntheticEvent } from 'react'
import { isValidEmail } from '../../utils/isValidEmail'
import type { User } from './PeopleSearch'
export function usePasteHandler({
freeSolo,
@@ -9,62 +9,62 @@ export function usePasteHandler({
onChange,
setQuery,
setInputError,
t,
t
}: {
freeSolo?: boolean;
selectedUsers: User[];
onChange: (event: SyntheticEvent, users: User[]) => void;
setQuery: (value: string) => void;
setInputError: (error: string | null) => void;
t: (key: string) => string;
freeSolo?: boolean
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void
setQuery: (value: string) => void
setInputError: (error: string | null) => void
t: (key: string) => string
}) {
return useCallback(
(event: React.ClipboardEvent<HTMLInputElement>) => {
if (!freeSolo) return;
if (!freeSolo) return
const pasted = event.clipboardData.getData("text/plain");
if (!pasted) return;
const pasted = event.clipboardData.getData('text/plain')
if (!pasted) return
// Split by comma, semicolon, newline, or whitespace
const chunks = pasted
.split(/[,;\n\r\s]+/)
.map((s) => s.trim())
.filter(Boolean);
.map(s => s.trim())
.filter(Boolean)
// If there is only one chunk, let the default Autocomplete behaviour handle it
if (chunks.length <= 1) return;
if (chunks.length <= 1) return
event.preventDefault();
event.preventDefault()
const existingEmails = new Set(selectedUsers.map((u) => u.email));
const validUsers: User[] = [];
const invalid: string[] = [];
const existingEmails = new Set(selectedUsers.map(u => u.email))
const validUsers: User[] = []
const invalid: string[] = []
for (const chunk of chunks) {
if (!isValidEmail(chunk)) {
invalid.push(chunk);
invalid.push(chunk)
} else if (!existingEmails.has(chunk)) {
existingEmails.add(chunk);
validUsers.push({ email: chunk, displayName: chunk });
existingEmails.add(chunk)
validUsers.push({ email: chunk, displayName: chunk })
}
// silently skip duplicates
}
if (validUsers.length > 0) {
onChange(event, [...selectedUsers, ...validUsers]);
onChange(event, [...selectedUsers, ...validUsers])
}
if (invalid.length > 0) {
// Leave the invalid text in the input for manual correction
setQuery(invalid.join(", "));
setQuery(invalid.join(', '))
setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", "))
);
t('peopleSearch.invalidEmail').replace('%{email}', invalid.join(', '))
)
} else {
setQuery("");
setInputError(null);
setQuery('')
setInputError(null)
}
},
[freeSolo, selectedUsers, onChange, setQuery, setInputError, t]
);
)
}
+34 -34
View File
@@ -1,67 +1,67 @@
import { useState, useEffect } from "react";
import { searchUsers } from "@/features/User/userAPI";
import { useState, useEffect } from 'react'
import { searchUsers } from '@/features/User/userAPI'
export interface UseUserSearchProps {
objectTypes: string[];
errorMessage: string;
objectTypes: string[]
errorMessage: string
}
export function useUserSearch<T>({
objectTypes,
errorMessage,
errorMessage
}: UseUserSearchProps) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<T[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<T[]>([])
const [hasSearched, setHasSearched] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [inputError, setInputError] = useState<string | null>(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [inputError, setInputError] = useState<string | null>(null)
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
useEffect(() => {
let cancelled = false;
let cancelled = false
const delayDebounceFn = setTimeout(async () => {
if (!query.trim()) {
if (!cancelled) {
setOptions([]);
setLoading(false);
setHasSearched(false);
setOptions([])
setLoading(false)
setHasSearched(false)
}
return;
return
}
if (!cancelled) {
setLoading(true);
setHasSearched(false);
setLoading(true)
setHasSearched(false)
}
try {
const res = await searchUsers(query, objectTypes);
const res = await searchUsers(query, objectTypes)
if (!cancelled) {
setOptions(res as unknown as T[]);
setHasSearched(true);
setOptions(res as unknown as T[])
setHasSearched(true)
}
} catch {
if (!cancelled) {
setHasSearched(false);
setSnackbarMessage(errorMessage);
setSnackbarOpen(true);
setHasSearched(false)
setSnackbarMessage(errorMessage)
setSnackbarOpen(true)
}
} finally {
if (!cancelled) {
setLoading(false);
setLoading(false)
}
}
}, 300);
}, 300)
return () => {
cancelled = true;
clearTimeout(delayDebounceFn);
};
}, [objectTypes, query, errorMessage]);
cancelled = true
clearTimeout(delayDebounceFn)
}
}, [objectTypes, query, errorMessage])
return {
query,
@@ -76,6 +76,6 @@ export function useUserSearch<T>({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
};
setSnackbarMessage
}
}
+69 -72
View File
@@ -1,8 +1,5 @@
import {
exportCalendar,
getSecretLink,
} from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { exportCalendar, getSecretLink } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
Box,
Button,
@@ -10,90 +7,90 @@ import {
IconButton,
InputAdornment,
TextField,
Typography,
} from "@linagora/twake-mui";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights";
Typography
} from '@linagora/twake-mui'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { SnackbarAlert } from '../Loading/SnackBarAlert'
import { CalendarAccessRights, UserWithAccess } from './CalendarAccessRights'
interface AccessTabProps {
calendar: Calendar;
usersWithAccess: UserWithAccess[];
onUsersWithAccessChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
calendar: Calendar
usersWithAccess: UserWithAccess[]
onUsersWithAccessChange: (users: UserWithAccess[]) => void
onInvitesLoaded: (users: UserWithAccess[]) => void
}
export function AccessTab({
calendar,
usersWithAccess,
onUsersWithAccessChange,
onInvitesLoaded,
onInvitesLoaded
}: AccessTabProps) {
const { t } = useI18n();
const { t } = useI18n()
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`;
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace('.json', '')}`
const isResource = useMemo(
() => calendar?.owner?.resource,
[calendar?.owner?.resource]
);
)
const [secretLink, setSecretLink] = useState("");
const [open, setOpen] = useState(false);
const [secretLink, setSecretLink] = useState('')
const [open, setOpen] = useState(false)
useEffect(() => {
async function fetchSecret() {
const existing = await getSecretLink(
calendar.link.replace(".json", ""),
calendar.link.replace('.json', ''),
false
);
setSecretLink(existing.secretLink);
)
setSecretLink(existing.secretLink)
}
fetchSecret();
}, [calendar.link]);
fetchSecret()
}, [calendar.link])
const handleCopy = (content: string) => {
navigator.clipboard.writeText(content);
setOpen(true);
};
navigator.clipboard.writeText(content)
setOpen(true)
}
const handleResetSecretLink = async () => {
const newSecret = await getSecretLink(
calendar.link.replace(".json", ""),
calendar.link.replace('.json', ''),
true
);
setSecretLink(newSecret.secretLink);
};
)
setSecretLink(newSecret.secretLink)
}
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState("");
const [exportLoading, setExportLoading] = useState(false)
const [exportError, setExportError] = useState('')
const handleExport = async () => {
try {
setExportLoading(true);
setExportLoading(true)
const exportedData = await exportCalendar(
calendar.link.replace(".json", "")
);
const blob = new Blob([exportedData], { type: "text/calendar" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${calendar.id.split("/")[1]}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
calendar.link.replace('.json', '')
)
const blob = new Blob([exportedData], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${calendar.id.split('/')[1]}.ics`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (e) {
setExportError((e as Error).message);
setExportError((e as Error).message)
} finally {
setExportLoading(false);
setExportLoading(false)
}
};
}
return (
<>
@@ -105,13 +102,13 @@ export function AccessTab({
/>
{!!window.DAV_BASE_URL && !isResource && (
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
<FieldWithLabel label={t('calendar.caldav_access')} isExpanded={false}>
<Box mt={2}>
<TextField
disabled
fullWidth
label=""
inputProps={{ "aria-label": t("calendar.caldav_access") }}
inputProps={{ 'aria-label': t('calendar.caldav_access') }}
value={calDAVLink}
size="small"
InputProps={{
@@ -124,20 +121,20 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
)
}}
/>
</Box>
</FieldWithLabel>
)}
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
<FieldWithLabel label={t('calendar.secretUrl')} isExpanded={false}>
<Box mt={3} display="flex" alignItems="center" gap={1}>
<TextField
disabled
fullWidth
label=""
inputProps={{ "aria-label": t("calendar.secretUrl") }}
inputProps={{ 'aria-label': t('calendar.secretUrl') }}
value={secretLink}
size="small"
InputProps={{
@@ -147,32 +144,32 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
)
}}
/>
<Button
variant="contained"
color="secondary"
onClick={handleResetSecretLink}
sx={{ borderRadius: "4px" }}
sx={{ borderRadius: '4px' }}
>
{t("actions.reset")}
{t('actions.reset')}
</Button>
</Box>
<Typography
variant="body2"
sx={{ color: "text.secondary", mt: 1, lineHeight: 1.5 }}
sx={{ color: 'text.secondary', mt: 1, lineHeight: 1.5 }}
>
{t("calendar.secretUrlDesc")}
{t('calendar.secretUrlDesc')}
</Typography>
</FieldWithLabel>
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}>
<FieldWithLabel label={t('calendar.exportCalendar')} isExpanded={false}>
<Typography
variant="body2"
sx={{ color: "text.secondary", my: 1, lineHeight: 1.5 }}
sx={{ color: 'text.secondary', my: 1, lineHeight: 1.5 }}
>
{t("calendar.exportDesc")}
{t('calendar.exportDesc')}
</Typography>
<Button
variant="contained"
@@ -180,15 +177,15 @@ export function AccessTab({
onClick={handleExport}
startIcon={!exportLoading && <FileDownloadOutlinedIcon />}
disabled={exportLoading}
sx={{ borderRadius: "4px" }}
sx={{ borderRadius: '4px' }}
>
{exportLoading ? (
<Box display="flex" alignItems="center" gap={1}>
<CircularProgress size={18} />
{t("actions.exporting")}
{t('actions.exporting')}
</Box>
) : (
t("actions.export")
t('actions.export')
)}
</Button>
</FieldWithLabel>
@@ -196,9 +193,9 @@ export function AccessTab({
<SnackbarAlert
setOpen={setOpen}
open={open}
message={t("common.link_copied")}
message={t('common.link_copied')}
/>
<ErrorSnackbar error={exportError} type="calendar" />
</>
);
)
}

Some files were not shown because too many files have changed in this diff Show More