#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, "semi": false,
"singleQuote": false, "singleQuote": true,
"trailingComma": "es5", "trailingComma": "none",
"printWidth": 80, "printWidth": 80,
"tabWidth": 2, "tabWidth": 2,
"arrowParens": "always", "arrowParens": "avoid",
"endOfLine": "lf" "endOfLine": "auto"
} }
+11 -11
View File
@@ -103,21 +103,21 @@ Example:
```js ```js
var appList = [ var appList = [
{ {
name: "Chat", name: 'Chat',
link: "/twake", link: '/twake',
icon: "/assets/images/svg/app-chat.svg", icon: '/assets/images/svg/app-chat.svg'
}, },
{ {
name: "Drive", name: 'Drive',
link: "/drive", link: '/drive',
icon: "/assets/images/svg/app-drive.svg", icon: '/assets/images/svg/app-drive.svg'
}, },
{ {
name: "Mail", name: 'Mail',
link: "/mail", link: '/mail',
icon: "/assets/images/svg/app-mail.svg", 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. **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 CalendarLayout from '@/components/Calendar/CalendarLayout'
import { act, waitFor } from "@testing-library/react"; import { act, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../utils/Renderwithproviders"; 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) => ({ const makeState = (timezone: string) => ({
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}, },
settings: { view: "calendar", timeZone: timezone }, settings: { view: 'calendar', timeZone: timezone },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
name: "Calendar personal", name: 'Calendar personal',
id: "user1/cal1", id: 'user1/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["test@test.com"] }, owner: { emails: ['test@test.com'] },
events: {}, events: {}
}
}, },
}, pending: false
pending: false, }
}, })
});
describe("Calendar dayHeaderContent respects selected timezone", () => { describe('Calendar dayHeaderContent respects selected timezone', () => {
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers()
}); })
async function renderAtPivotTime(timezone: string): Promise<string[]> { async function renderAtPivotTime(timezone: string): Promise<string[]> {
// Set fake timers the same way the working test does — single chained call // 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 () => { await act(async () => {
renderWithProviders(<CalendarLayout />, makeState(timezone)); renderWithProviders(<CalendarLayout />, makeState(timezone))
}); })
// Advance past the debounce (300ms) like the working navigation test does // Advance past the debounce (300ms) like the working navigation test does
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
const calendarRef = window.__calendarRef; const calendarRef = window.__calendarRef
await waitFor(() => expect(calendarRef?.current).not.toBeNull()); await waitFor(() => expect(calendarRef?.current).not.toBeNull())
await act(async () => { await act(async () => {
calendarRef.current?.changeView("timeGridWeek"); calendarRef.current?.changeView('timeGridWeek')
}); })
await waitFor(() => { 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( return Array.from(document.querySelectorAll('.fc-col-header-cell')).map(
(cell) => cell.textContent ?? "" cell => cell.textContent ?? ''
); )
} }
it("UTC: Monday March 16 column exists", async () => { it('UTC: Monday March 16 column exists', async () => {
const cells = await renderAtPivotTime("UTC"); const cells = await renderAtPivotTime('UTC')
const monday = cells.find((t) => t.includes("16")); const monday = cells.find(t => t.includes('16'))
expect(monday).toBeDefined(); expect(monday).toBeDefined()
expect(monday).toMatch(/MON/i); expect(monday).toMatch(/MON/i)
}); })
it("Europe/Paris: Monday March 16 column exists (UTC+1, no day shift)", async () => { it('Europe/Paris: Monday March 16 column exists (UTC+1, no day shift)', async () => {
const cells = await renderAtPivotTime("Europe/Paris"); const cells = await renderAtPivotTime('Europe/Paris')
const monday = cells.find((t) => t.includes("16")); const monday = cells.find(t => t.includes('16'))
expect(monday).toBeDefined(); expect(monday).toBeDefined()
expect(monday).toMatch(/MON/i); expect(monday).toMatch(/MON/i)
}); })
it("Asia/Jakarta: Tuesday March 17 column exists (UTC+7, day shifts forward)", async () => { it('Asia/Jakarta: Tuesday March 17 column exists (UTC+7, day shifts forward)', async () => {
const cells = await renderAtPivotTime("Asia/Jakarta"); const cells = await renderAtPivotTime('Asia/Jakarta')
const tuesday = cells.find((t) => t.includes("17")); const tuesday = cells.find(t => t.includes('17'))
expect(tuesday).toBeDefined(); expect(tuesday).toBeDefined()
expect(tuesday).toMatch(/TUE/i); expect(tuesday).toMatch(/TUE/i)
}); })
it("Asia/Jakarta: no column incorrectly labeled MON 17 regression", async () => { it('Asia/Jakarta: no column incorrectly labeled MON 17 regression', async () => {
const cells = await renderAtPivotTime("Asia/Jakarta"); const cells = await renderAtPivotTime('Asia/Jakarta')
const wrongCell = cells.find((t) => t.includes("17") && /MON/i.test(t)); const wrongCell = cells.find(t => t.includes('17') && /MON/i.test(t))
expect(wrongCell).toBeUndefined(); expect(wrongCell).toBeUndefined()
}); })
it("Asia/Tokyo: Tuesday March 17 column exists (UTC+9)", async () => { it('Asia/Tokyo: Tuesday March 17 column exists (UTC+9)', async () => {
const cells = await renderAtPivotTime("Asia/Tokyo"); const cells = await renderAtPivotTime('Asia/Tokyo')
const tuesday = cells.find((t) => t.includes("17")); const tuesday = cells.find(t => t.includes('17'))
expect(tuesday).toBeDefined(); expect(tuesday).toBeDefined()
expect(tuesday).toMatch(/TUE/i); expect(tuesday).toMatch(/TUE/i)
}); })
it("America/New_York: Monday March 16 column exists (UTC-4, no day shift)", async () => { it('America/New_York: Monday March 16 column exists (UTC-4, no day shift)', async () => {
const cells = await renderAtPivotTime("America/New_York"); const cells = await renderAtPivotTime('America/New_York')
const monday = cells.find((t) => t.includes("16")); const monday = cells.find(t => t.includes('16'))
expect(monday).toBeDefined(); expect(monday).toBeDefined()
expect(monday).toMatch(/MON/i); expect(monday).toMatch(/MON/i)
}); })
}); })
+233 -235
View File
@@ -1,413 +1,411 @@
import CalendarSearch from "@/components/Calendar/CalendarSearch"; import CalendarSearch from '@/components/Calendar/CalendarSearch'
import * as CalendarApi from "@/features/Calendars/CalendarApi"; import * as CalendarApi from '@/features/Calendars/CalendarApi'
import * as CalendarSlice from "@/features/Calendars/services"; import * as CalendarSlice from '@/features/Calendars/services'
import { searchUsers } from "@/features/User/userAPI"; import { searchUsers } from '@/features/User/userAPI'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from "@testing-library/user-event"; import userEvent from '@testing-library/user-event'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/User/userAPI"); jest.mock('@/features/User/userAPI')
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
const mockedSearchUsers = searchUsers as jest.MockedFunction< const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
typeof searchUsers
>;
const mockedGetCalendars = CalendarApi.getCalendars as jest.MockedFunction< const mockedGetCalendars = CalendarApi.getCalendars as jest.MockedFunction<
typeof CalendarApi.getCalendars typeof CalendarApi.getCalendars
>; >
describe("CalendarSearch", () => { describe('CalendarSearch', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const mockUser = { const mockUser = {
email: "user@example.com", email: 'user@example.com',
displayName: "Test User", displayName: 'Test User',
avatarUrl: "https://example.com/avatar.jpg", avatarUrl: 'https://example.com/avatar.jpg',
openpaasId: "user123", openpaasId: 'user123'
}; }
const mockCalendar = { const mockCalendar = {
"dav:name": "Test Calendar", 'dav:name': 'Test Calendar',
"apple:color": "#FF0000", 'apple:color': '#FF0000',
_links: { _links: {
self: { self: {
href: "/calendars/user123/cal1.json", href: '/calendars/user123/cal1.json'
}, }
}, }
}; }
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}, },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
name: "My Calendar", name: 'My Calendar',
id: "user1/cal1", id: 'user1/cal1',
color: "#0000FF", color: '#0000FF',
owner: { emails: ["test@test.com"] }, owner: { emails: ['test@test.com'] },
events: {}, events: {}
}
}, },
}, pending: false
pending: false, }
}, }
};
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("searches for users and displays their calendars", async () => { it('searches for users and displays their calendars', async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({ mockedGetCalendars.mockResolvedValueOnce({
_embedded: { _embedded: {
"dav:calendar": [mockCalendar], 'dav:calendar': [mockCalendar]
}, }
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { await waitFor(() => {
expect(mockedGetCalendars).toHaveBeenCalledWith( expect(mockedGetCalendars).toHaveBeenCalledWith(
"user123", 'user123',
"sharedPublic=true&" 'sharedPublic=true&'
); )
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
expect(screen.getByText("user@example.com")).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 const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync") .spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any
}); })
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({ mockedGetCalendars.mockResolvedValueOnce({
_embedded: { _embedded: {
"dav:calendar": [mockCalendar], 'dav:calendar': [mockCalendar]
}, }
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { 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 () => { await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton)
}); })
expect(addSharedCalendarSpy).toHaveBeenCalled(); expect(addSharedCalendarSpy).toHaveBeenCalled()
expect(mockOnClose).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 const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync") .spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any
}); })
const existingCalendar = { const existingCalendar = {
...mockCalendar, ...mockCalendar,
_links: { _links: {
self: { self: {
href: "/calendars/user1/cal1.json", href: '/calendars/user1/cal1.json'
}, }
}, }
}; }
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({ mockedGetCalendars.mockResolvedValueOnce({
_embedded: { _embedded: {
"dav:calendar": [existingCalendar], 'dav:calendar': [existingCalendar]
}, }
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText("calendar.noMoreCalendarsFor(name=Test User)") screen.getByText('calendar.noMoreCalendarsFor(name=Test User)')
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
const addButton = screen.getByRole("button", { name: /add/i }); const addButton = screen.getByRole('button', { name: /add/i })
await act(async () => { 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 () => { it('displays message when user has no publicly available calendars', async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({}); mockedGetCalendars.mockResolvedValueOnce({})
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText("calendar.noPublicCalendarsFor(name=Test User)") screen.getByText('calendar.noPublicCalendarsFor(name=Test User)')
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
}); })
it("changes calendar color", async () => { it('changes calendar color', async () => {
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({ mockedGetCalendars.mockResolvedValueOnce({
_embedded: { _embedded: {
"dav:calendar": [mockCalendar], 'dav:calendar': [mockCalendar]
}, }
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { 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 // ColorPicker would need to be interacted with based on its implementation
// This is a placeholder for color change interaction // 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) { if (colorPicker) {
await act(async () => { 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 = { const secondCalendar = {
"dav:name": "Second Calendar", 'dav:name': 'Second Calendar',
"apple:color": "#00FF00", 'apple:color': '#00FF00',
_links: { _links: {
self: { self: {
href: "/calendars/user123/cal2.json", href: '/calendars/user123/cal2.json'
}, }
}, }
}; }
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({ mockedGetCalendars.mockResolvedValueOnce({
_embedded: { _embedded: {
"dav:calendar": [mockCalendar, secondCalendar], 'dav:calendar': [mockCalendar, secondCalendar]
}, }
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
expect(screen.getByText(/Second 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 const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync") .spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const addButton = screen.getByRole("button", { name: /add/i }); const addButton = screen.getByRole('button', { name: /add/i })
await act(async () => { await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton)
}); })
expect(addSharedCalendarSpy).not.toHaveBeenCalled(); expect(addSharedCalendarSpy).not.toHaveBeenCalled()
}); })
it("BUGFIX : handles calendar with no apple:color", async () => { it('BUGFIX : handles calendar with no apple:color', async () => {
const addSharedCalendarSpy = jest const addSharedCalendarSpy = jest
.spyOn(CalendarSlice, "addSharedCalendarAsync") .spyOn(CalendarSlice, 'addSharedCalendarAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any
}); })
const mockCalendarNoColor = { const mockCalendarNoColor = {
"dav:name": "Test Calendar", 'dav:name': 'Test Calendar',
_links: { _links: {
self: { self: {
href: "/calendars/user123/cal2.json", href: '/calendars/user123/cal2.json'
}, }
}, }
}; }
mockedSearchUsers.mockResolvedValueOnce([mockUser]); mockedSearchUsers.mockResolvedValueOnce([mockUser])
mockedGetCalendars.mockResolvedValueOnce({ mockedGetCalendars.mockResolvedValueOnce({
_embedded: { _embedded: {
"dav:calendar": [mockCalendarNoColor], 'dav:calendar': [mockCalendarNoColor]
}, }
}); })
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarSearch open={true} onClose={mockOnClose} />, <CalendarSearch open={true} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { await act(async () => {
userEvent.type(input, "Test"); userEvent.type(input, 'Test')
}); })
await waitFor(() => { 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 () => { await act(async () => {
fireEvent.click(option); fireEvent.click(option)
}); })
await waitFor(() => { await waitFor(() => {
expect(mockedGetCalendars).toHaveBeenCalledWith( expect(mockedGetCalendars).toHaveBeenCalledWith(
"user123", 'user123',
"sharedPublic=true&" 'sharedPublic=true&'
); )
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument()
expect(screen.getByText("user@example.com")).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 () => { await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton)
}); })
await waitFor(() => await waitFor(() =>
expect(addSharedCalendarSpy).toHaveBeenCalledWith({ expect(addSharedCalendarSpy).toHaveBeenCalledWith({
cal: { cal: {
cal: { cal: {
_links: { self: { href: "/calendars/user123/cal2.json" } }, _links: { self: { href: '/calendars/user123/cal2.json' } },
"dav:name": "Test Calendar", 'dav:name': 'Test Calendar'
}, },
color: { dark: "#329655", light: "#D0ECDA" }, color: { dark: '#329655', light: '#D0ECDA' },
owner: { owner: {
avatarUrl: "https://example.com/avatar.jpg", avatarUrl: 'https://example.com/avatar.jpg',
displayName: "Test User", displayName: 'Test User',
email: "user@example.com", email: 'user@example.com',
openpaasId: "user123", openpaasId: 'user123'
}, }
}, },
calId: expect.any(String), calId: expect.any(String),
userId: "user1", userId: 'user1'
}) })
); )
expect(mockOnClose).toHaveBeenCalledWith( 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 CalendarResources from '@/components/Calendar/CalendarResources'
import { getCalendars } from "@/features/Calendars/CalendarApi"; import { getCalendars } from '@/features/Calendars/CalendarApi'
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
jest.mock("@/features/Calendars/api/addCalendarResourceAsync"); jest.mock('@/features/Calendars/api/addCalendarResourceAsync')
jest.mock("@/components/Attendees/ResourceSearch", () => ({ jest.mock('@/components/Attendees/ResourceSearch', () => ({
ResourceSearch: ({ ResourceSearch: ({
onChange, onChange
}: { }: {
onChange: ( onChange: (
event: null, event: null,
value: { displayName: string; openpaasId: string }[] value: { displayName: string; openpaasId: string }[]
) => void; ) => void
}) => ( }) => (
<div data-testid="resource-search"> <div data-testid="resource-search">
<button <button
data-testid="mock-resource-search-select" data-testid="mock-resource-search-select"
onClick={() => onClick={() =>
onChange(null, [ onChange(null, [
{ displayName: "Room A", openpaasId: "room-a-id" }, { displayName: 'Room A', openpaasId: 'room-a-id' },
{ displayName: "Room B", openpaasId: "room-b-id" }, { displayName: 'Room B', openpaasId: 'room-b-id' }
]) ])
} }
> >
Select Resources Select Resources
</button> </button>
</div> </div>
), )
})); }))
const mockedGetCalendars = getCalendars as jest.Mock; const mockedGetCalendars = getCalendars as jest.Mock
const mockedAddCalendarResourceAsync = const mockedAddCalendarResourceAsync =
addCalendarResourceAsync as unknown as jest.Mock; addCalendarResourceAsync as unknown as jest.Mock
describe("CalendarResources", () => { describe('CalendarResources', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
const baseUser = { const baseUser = {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}; }
const setup = (isOpen = true) => { const setup = (isOpen = true) => {
const onClose = jest.fn(); const onClose = jest.fn()
renderWithProviders(<CalendarResources onClose={onClose} open={isOpen} />, { renderWithProviders(<CalendarResources onClose={onClose} open={isOpen} />, {
user: baseUser, user: baseUser
}); })
return { onClose }; return { onClose }
}; }
it("renders correctly and closes on cancel", () => { it('renders correctly and closes on cancel', () => {
const { onClose } = setup(); const { onClose } = setup()
expect(screen.getByText("calendar.browseResources")).toBeInTheDocument(); expect(screen.getByText('calendar.browseResources')).toBeInTheDocument()
const cancelButton = screen.getByRole("button", { const cancelButton = screen.getByRole('button', {
name: "common.cancel", name: 'common.cancel'
}); })
fireEvent.click(cancelButton); fireEvent.click(cancelButton)
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled()
}); })
it("does not render when isOpen is false", () => { it('does not render when isOpen is false', () => {
setup(false); setup(false)
expect( expect(
screen.queryByText("calendar.browseResources") screen.queryByText('calendar.browseResources')
).not.toBeInTheDocument(); ).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 = { const mockCalendarDataA = {
_embedded: { _embedded: {
"dav:calendar": [ 'dav:calendar': [
{ {
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } }, _links: { self: { href: '/calendars/room-a-id/cal-a.json' } },
"dav:name": "Room A Calendar", 'dav:name': 'Room A Calendar',
"caldav:description": "Main room A calendar", 'caldav:description': 'Main room A calendar',
color: "red", color: 'red'
}, }
], ]
}, }
}; }
mockedGetCalendars.mockImplementation((userId) => { mockedGetCalendars.mockImplementation(userId => {
if (userId === "room-a-id") return Promise.resolve(mockCalendarDataA); if (userId === 'room-a-id') return Promise.resolve(mockCalendarDataA)
// Simulate failure for room-b // Simulate failure for room-b
if (userId === "room-b-id") if (userId === 'room-b-id')
return Promise.reject(new Error("Failed fetching Room B")); return Promise.reject(new Error('Failed fetching Room B'))
return Promise.resolve([]); return Promise.resolve([])
}); })
const mockDispatchResult = { const mockDispatchResult = {
unwrap: () => Promise.resolve({ type: "success" }), unwrap: () => Promise.resolve({ type: 'success' })
}; }
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult); mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult)
const { onClose } = setup(); const { onClose } = setup()
// Trigger resource selection // Trigger resource selection
const mockSelectBtn = screen.getByTestId("mock-resource-search-select"); const mockSelectBtn = screen.getByTestId('mock-resource-search-select')
await act(async () => { await act(async () => {
fireEvent.click(mockSelectBtn); fireEvent.click(mockSelectBtn)
}); })
// After selection, the button should be enabled and say Add // After selection, the button should be enabled and say Add
const addButton = await screen.findByRole("button", { const addButton = await screen.findByRole('button', {
name: "actions.add", name: 'actions.add'
}); })
expect(addButton).not.toBeDisabled(); expect(addButton).not.toBeDisabled()
// Click add to trigger addCalendarResourceAsync // Click add to trigger addCalendarResourceAsync
await act(async () => { await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton)
}); })
await waitFor(() => { await waitFor(() => {
// It should only have been called for Room A because Room B failed (Promise.allSettled) // 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(payload).toEqual(
expect.objectContaining({ expect.objectContaining({
userId: "user1", userId: 'user1',
calId: expect.any(String), calId: expect.any(String),
cal: expect.objectContaining({ 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 = { const mockCalendarDataA = {
_embedded: { _embedded: {
"dav:calendar": [ 'dav:calendar': [
{ {
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } }, _links: { self: { href: '/calendars/room-a-id/cal-a.json' } },
"dav:name": "Room A Calendar", 'dav:name': 'Room A Calendar',
"caldav:description": "Main room A calendar", 'caldav:description': 'Main room A calendar',
color: "red", color: 'red'
}, }
], ]
}, }
}; }
mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA); mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA)
mockedGetCalendars.mockResolvedValueOnce([]); // Mock array of size 2 (for both A and B but second resolves empty) mockedGetCalendars.mockResolvedValueOnce([]) // Mock array of size 2 (for both A and B but second resolves empty)
// Simulate successful API response // Simulate successful API response
const mockDispatchResult = { const mockDispatchResult = {
unwrap: () => Promise.resolve(), unwrap: () => Promise.resolve()
}; }
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult); mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult)
const { onClose } = setup(); const { onClose } = setup()
// Trigger resource selection // Trigger resource selection
const mockSelectBtn = screen.getByTestId("mock-resource-search-select"); const mockSelectBtn = screen.getByTestId('mock-resource-search-select')
await act(async () => { await act(async () => {
fireEvent.click(mockSelectBtn); fireEvent.click(mockSelectBtn)
}); })
const addButton = await screen.findByRole("button", { const addButton = await screen.findByRole('button', {
name: "actions.add", name: 'actions.add'
}); })
await act(async () => { await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton)
}); })
await waitFor(() => { await waitFor(() => {
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled()
}); })
}); })
}); })
+149 -149
View File
@@ -1,80 +1,80 @@
import CalendarSelection from "@/components/Calendar/CalendarSelection"; import CalendarSelection from '@/components/Calendar/CalendarSelection'
import * as calendarThunks from "@/features/Calendars/services"; import * as calendarThunks from '@/features/Calendars/services'
import "@testing-library/jest-dom"; import '@testing-library/jest-dom'
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from "@testing-library/user-event"; import userEvent from '@testing-library/user-event'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
describe("CalendarSelection", () => { describe('CalendarSelection', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear()
}); })
const baseUser = { const baseUser = {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}; }
const calendarsMock = { const calendarsMock = {
"user1/cal1": { 'user1/cal1': {
name: "Calendar personal", name: 'Calendar personal',
id: "user1/cal1", id: 'user1/cal1',
color: "#FF0000", color: '#FF0000',
owner: { emails: ["alice@example.com"], lastname: "alice" }, owner: { emails: ['alice@example.com'], lastname: 'alice' }
}, },
"user2/cal1": { 'user2/cal1': {
name: "Calendar delegated", name: 'Calendar delegated',
delegated: true, delegated: true,
id: "user2/cal1", id: 'user2/cal1',
color: "#00FF00", color: '#00FF00',
owner: { owner: {
firstname: "Bob", firstname: 'Bob',
lastname: "Builder", lastname: 'Builder',
emails: ["bob@example.com"], emails: ['bob@example.com']
}
}, },
}, 'user3/cal1': {
"user3/cal1": { name: 'Calendar shared',
name: "Calendar shared", id: 'user3/cal1',
id: "user3/cal1", color: '#0000FF',
color: "#0000FF",
owner: { owner: {
firstname: "Charlie", firstname: 'Charlie',
lastname: "Chaplin", lastname: 'Chaplin',
emails: ["charlie@example.com"], emails: ['charlie@example.com']
}, }
}, }
}; }
beforeAll(() => { beforeAll(() => {
jest.clearAllMocks(); jest.clearAllMocks()
cleanup(); cleanup()
}); })
it("renders personal, delegated and calendar.other", () => { it('renders personal, delegated and calendar.other', () => {
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={["user1/cal1"]} selectedCalendars={['user1/cal1']}
setSelectedCalendars={jest.fn()} setSelectedCalendars={jest.fn()}
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
expect(screen.getByText("calendar.personal")).toBeInTheDocument(); expect(screen.getByText('calendar.personal')).toBeInTheDocument()
expect(screen.getByText("calendar.delegated")).toBeInTheDocument(); expect(screen.getByText('calendar.delegated')).toBeInTheDocument()
expect(screen.getByText("calendar.other")).toBeInTheDocument(); expect(screen.getByText('calendar.other')).toBeInTheDocument()
expect(screen.getByLabelText("Calendar personal")).toBeChecked(); expect(screen.getByLabelText('Calendar personal')).toBeChecked()
expect(screen.getByLabelText("Calendar delegated")).not.toBeChecked(); expect(screen.getByLabelText('Calendar delegated')).not.toBeChecked()
expect(screen.getByLabelText("Calendar shared")).not.toBeChecked(); expect(screen.getByLabelText('Calendar shared')).not.toBeChecked()
}); })
it("toggles a calendar selection on click", () => { it('toggles a calendar selection on click', () => {
const setSelectedCalendars = jest.fn(); const setSelectedCalendars = jest.fn()
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
@@ -83,41 +83,41 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const checkbox = screen.getByLabelText("Calendar personal"); const checkbox = screen.getByLabelText('Calendar personal')
fireEvent.click(checkbox); fireEvent.click(checkbox)
expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function)); expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function))
const updater = setSelectedCalendars.mock.calls[0][0]; const updater = setSelectedCalendars.mock.calls[0][0]
expect(updater([])).toEqual(["user1/cal1"]); expect(updater([])).toEqual(['user1/cal1'])
}); })
it("removes calendar from selection if already selected", () => { it('removes calendar from selection if already selected', () => {
const setSelectedCalendars = jest.fn(); const setSelectedCalendars = jest.fn()
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={["user1/cal1"]} selectedCalendars={['user1/cal1']}
setSelectedCalendars={setSelectedCalendars} setSelectedCalendars={setSelectedCalendars}
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const checkbox = screen.getByLabelText("Calendar personal"); const checkbox = screen.getByLabelText('Calendar personal')
fireEvent.click(checkbox); fireEvent.click(checkbox)
const updater = setSelectedCalendars.mock.calls[0][0]; const updater = setSelectedCalendars.mock.calls[0][0]
expect(updater(["user1/cal1"])).toEqual([]); 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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -125,26 +125,26 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const addButtons = screen.getAllByRole("button"); const addButtons = screen.getAllByRole('button')
fireEvent.click(addButtons[1]); fireEvent.click(addButtons[1])
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByText("calendarPopover.tabs.addNew") screen.getByText('calendarPopover.tabs.addNew')
).toBeInTheDocument() ).toBeInTheDocument()
); )
}); })
it("Navigates to deletion dialog and deletes personal cal", async () => { it('Navigates to deletion dialog and deletes personal cal', async () => {
const spy = jest const spy = jest
.spyOn(calendarThunks, "removeCalendarAsync") .spyOn(calendarThunks, 'removeCalendarAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any
}); })
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -152,31 +152,31 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const addButtons = screen.getAllByTestId("MoreHorizIcon"); const addButtons = screen.getAllByTestId('MoreHorizIcon')
fireEvent.click(addButtons[0]); fireEvent.click(addButtons[0])
userEvent.click(screen.getByText(/delete/i)); userEvent.click(screen.getByText(/delete/i))
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByText("calendar.delete.title(name=Calendar personal)") screen.getByText('calendar.delete.title(name=Calendar personal)')
).toBeInTheDocument() ).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 const spy = jest
.spyOn(calendarThunks, "removeCalendarAsync") .spyOn(calendarThunks, 'removeCalendarAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any
}); })
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -184,26 +184,26 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const addButtons = screen.getAllByTestId("MoreHorizIcon"); const addButtons = screen.getAllByTestId('MoreHorizIcon')
fireEvent.click(addButtons[1]); fireEvent.click(addButtons[1])
userEvent.click(screen.getByText(/remove/i)); userEvent.click(screen.getByText(/remove/i))
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByText("calendar.delete.title(name=Calendar delegated)") screen.getByText('calendar.delete.title(name=Calendar delegated)')
).toBeInTheDocument() ).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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -211,19 +211,19 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const addButtons = screen.getAllByTestId("AddIcon"); const addButtons = screen.getAllByTestId('AddIcon')
fireEvent.click(addButtons[1]); // seccond Add button (other) fireEvent.click(addButtons[1]) // seccond Add button (other)
expect( expect(
screen.getByText("calendar.browseOtherCalendars") screen.getByText('calendar.browseOtherCalendars')
).toBeInTheDocument(); ).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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -233,19 +233,19 @@ describe("CalendarSelection", () => {
user: baseUser, user: baseUser,
calendars: { calendars: {
list: { list: {
"user1/cal1": calendarsMock["user1/cal1"], 'user1/cal1': calendarsMock['user1/cal1']
},
pending: false,
}, },
pending: false
} }
); }
)
expect(screen.getByText("calendar.personal")).toBeInTheDocument(); expect(screen.getByText('calendar.personal')).toBeInTheDocument()
expect(screen.queryByText("calendar.delegated")).not.toBeInTheDocument(); expect(screen.queryByText('calendar.delegated')).not.toBeInTheDocument()
expect(screen.queryByText("calendar.other")).toBeInTheDocument(); expect(screen.queryByText('calendar.other')).toBeInTheDocument()
}); })
it("renders nothing when no calendars are present", () => { it('renders nothing when no calendars are present', () => {
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -253,14 +253,14 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, 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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -268,31 +268,31 @@ describe("CalendarSelection", () => {
/>, />,
{ {
user: baseUser, user: baseUser,
calendars: { list: calendarsMock, pending: false }, calendars: { list: calendarsMock, pending: false }
} }
); )
const delegatedAccordionSummary = screen const delegatedAccordionSummary = screen
.getByText("calendar.delegated") .getByText('calendar.delegated')
.closest(".MuiAccordionSummary-root"); .closest('.MuiAccordionSummary-root')
fireEvent.click(delegatedAccordionSummary!); fireEvent.click(delegatedAccordionSummary!)
expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "false"); expect(delegatedAccordionSummary).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(delegatedAccordionSummary!); fireEvent.click(delegatedAccordionSummary!)
expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true"); 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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
setSelectedCalendars={jest.fn()} setSelectedCalendars={jest.fn()}
/>, />,
{ user: baseUser, calendars: { list: calendarsMock, pending: false } } { user: baseUser, calendars: { list: calendarsMock, pending: false } }
); )
expect(screen.getByText("Bob Builder")).toBeInTheDocument(); expect(screen.getByText('Bob Builder')).toBeInTheDocument()
expect(screen.getByText("Charlie Chaplin")).toBeInTheDocument(); expect(screen.getByText('Charlie Chaplin')).toBeInTheDocument()
// personal calendar should NOT show a caption // 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 { import {
DateTimeFields, DateTimeFields,
DateTimeFieldsProps, DateTimeFieldsProps
} from "@/components/Event/components/DateTimeFields"; } from '@/components/Event/components/DateTimeFields'
import { import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
act, import userEvent from '@testing-library/user-event'
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
jest.mock("twake-i18n", () => ({ jest.mock('twake-i18n', () => ({
useI18n: () => ({ useI18n: () => ({
t: (key: string) => key, t: (key: string) => key,
lang: "en", lang: 'en'
}), })
})); }))
describe("DateTimeFields", () => { describe('DateTimeFields', () => {
const mockHandlers = { const mockHandlers = {
onStartDateChange: jest.fn(), onStartDateChange: jest.fn(),
onStartTimeChange: jest.fn(), onStartTimeChange: jest.fn(),
onEndDateChange: jest.fn(), onEndDateChange: jest.fn(),
onEndTimeChange: jest.fn(), onEndTimeChange: jest.fn(),
onToggleEndDate: jest.fn(), onToggleEndDate: jest.fn()
}; }
const defaultProps: DateTimeFieldsProps = { const defaultProps: DateTimeFieldsProps = {
startDate: "2025-07-18", startDate: '2025-07-18',
startTime: "09:00", startTime: '09:00',
endDate: "2025-07-18", endDate: '2025-07-18',
endTime: "10:00", endTime: '10:00',
allday: false, allday: false,
showMore: true, showMore: true,
hasEndDateChanged: false, hasEndDateChanged: false,
showEndDate: false, showEndDate: false,
validation: { validation: {
errors: { errors: {
dateTime: "", dateTime: ''
}
}, },
}, ...mockHandlers
...mockHandlers, }
};
const renderField = async (props: Partial<DateTimeFieldsProps> = {}) => { const renderField = async (props: Partial<DateTimeFieldsProps> = {}) => {
await act(async () => await act(async () =>
render(<DateTimeFields {...defaultProps} {...props} />) render(<DateTimeFields {...defaultProps} {...props} />)
); )
}; }
beforeEach(() => { 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({ await renderField({
startDate: "2025-01-01", startDate: '2025-01-01',
startTime: "10:00", startTime: '10:00',
endDate: "2025-01-01", endDate: '2025-01-01',
endTime: "11:00", endTime: '11:00',
showMore: true, showMore: true
}); })
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId('start-time-input')
fireEvent.change(startTimeInput, { target: { value: "12:00" } }); fireEvent.change(startTimeInput, { target: { value: '12:00' } })
fireEvent.blur(startTimeInput); fireEvent.blur(startTimeInput)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00") expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('12:00')
); )
await waitFor(() => 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({ await renderField({
startDate: "2025-01-01", startDate: '2025-01-01',
startTime: "09:00", startTime: '09:00',
endDate: "2025-01-01", endDate: '2025-01-01',
endTime: "10:00", endTime: '10:00',
showMore: true, showMore: true
}); })
await userEvent.click(screen.getByTestId("start-date-input")); await userEvent.click(screen.getByTestId('start-date-input'))
const dayButton = screen.getByRole("gridcell", { name: "3" }); const dayButton = screen.getByRole('gridcell', { name: '3' })
await userEvent.click(dayButton); await userEvent.click(dayButton)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03") expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-01-03')
); )
await waitFor(() => 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({ await renderField({
startDate: "2025-01-01", startDate: '2025-01-01',
startTime: "10:00", startTime: '10:00',
endDate: "2025-01-01", endDate: '2025-01-01',
endTime: "11:00", endTime: '11:00',
showMore: true, showMore: true
}); })
const endTimeInput = screen.getByTestId("end-time-input"); const endTimeInput = screen.getByTestId('end-time-input')
fireEvent.change(endTimeInput, { target: { value: "08:00" } }); fireEvent.change(endTimeInput, { target: { value: '08:00' } })
fireEvent.blur(endTimeInput); fireEvent.blur(endTimeInput)
await waitFor(() => 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 // Start time should NOT be automatically adjusted when end time changes
expect(mockHandlers.onStartTimeChange).not.toHaveBeenCalled(); expect(mockHandlers.onStartTimeChange).not.toHaveBeenCalled()
expect(mockHandlers.onStartDateChange).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({ await renderField({
startDate: "2025-01-05", startDate: '2025-01-05',
startTime: "09:00", startTime: '09:00',
endDate: "2025-01-05", endDate: '2025-01-05',
endTime: "10:00", endTime: '10:00',
showMore: true, showMore: true
}); })
await userEvent.click(screen.getByTestId("end-date-input")); await userEvent.click(screen.getByTestId('end-date-input'))
const dayButton = screen.getByRole("gridcell", { name: "3" }); const dayButton = screen.getByRole('gridcell', { name: '3' })
await userEvent.click(dayButton); await userEvent.click(dayButton)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03") expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-01-03')
); )
await waitFor(() => 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({ await renderField({
allday: true, allday: true,
startDate: "2025-02-01", startDate: '2025-02-01',
endDate: "2025-02-03", endDate: '2025-02-03',
showEndDate: true, showEndDate: true
}); })
await userEvent.click(screen.getByTestId("start-date-input")); await userEvent.click(screen.getByTestId('start-date-input'))
const dayButton = screen.getByRole("gridcell", { name: "10" }); const dayButton = screen.getByRole('gridcell', { name: '10' })
await userEvent.click(dayButton); await userEvent.click(dayButton)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-02-10") expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-02-10')
); )
await waitFor(() => 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({ await renderField({
allday: true, allday: true,
startDate: "2025-05-10", startDate: '2025-05-10',
endDate: "2025-05-15", endDate: '2025-05-15',
showEndDate: true, showEndDate: true
}); })
await userEvent.click(screen.getByTestId("end-date-input")); await userEvent.click(screen.getByTestId('end-date-input'))
const dayButton = screen.getByRole("gridcell", { name: "1" }); const dayButton = screen.getByRole('gridcell', { name: '1' })
await userEvent.click(dayButton); await userEvent.click(dayButton)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-05-01") expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-05-01')
); )
await waitFor(() => 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({ 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.onStartDateChange).not.toHaveBeenCalled()
expect(mockHandlers.onEndDateChange).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({ await renderField({
startDate: "2025-01-01", startDate: '2025-01-01',
startTime: "09:00", startTime: '09:00',
endDate: "2025-01-01", endDate: '2025-01-01',
endTime: "10:00", endTime: '10:00',
showMore: true, showMore: true
}); })
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId('start-time-input')
fireEvent.change(startTimeInput, { target: { value: "09:30" } }); fireEvent.change(startTimeInput, { target: { value: '09:30' } })
fireEvent.blur(startTimeInput); fireEvent.blur(startTimeInput)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30") expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('09:30')
); )
await waitFor(() => 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({ await renderField({
startDate: "2025-01-15", startDate: '2025-01-15',
startTime: "22:30", startTime: '22:30',
endDate: "2025-01-15", endDate: '2025-01-15',
endTime: "23:30", endTime: '23:30',
showMore: true, 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 // Change start time from 22:30 to 23:45
fireEvent.change(startTimeInput, { target: { value: "23:45" } }); fireEvent.change(startTimeInput, { target: { value: '23:45' } })
fireEvent.blur(startTimeInput); fireEvent.blur(startTimeInput)
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45") expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('23:45')
); )
// End date should move to the next day // End date should move to the next day
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith( expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith(
"2025-01-16", '2025-01-16',
"00:45" '00:45'
) )
); )
}); })
it("should have aria-label for accessibility and testing", async () => { it('should have aria-label for accessibility and testing', async () => {
await renderField({ await renderField({
startDate: "2025-01-01", startDate: '2025-01-01',
startTime: "10:00", startTime: '10:00',
endDate: "2025-01-01", endDate: '2025-01-01',
endTime: "11:00", endTime: '11:00',
showMore: true, showMore: true
}); })
const startDateInput = screen.getByTestId("start-date-input"); const startDateInput = screen.getByTestId('start-date-input')
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId('start-time-input')
const endDateInput = screen.getByTestId("end-date-input"); const endDateInput = screen.getByTestId('end-date-input')
const endTimeInput = screen.getByTestId("end-time-input"); const endTimeInput = screen.getByTestId('end-time-input')
expect(startDateInput).toHaveAttribute( expect(startDateInput).toHaveAttribute(
"aria-label", 'aria-label',
"dateTimeFields.startDate" 'dateTimeFields.startDate'
); )
expect(startTimeInput).toHaveAttribute( expect(startTimeInput).toHaveAttribute(
"aria-label", 'aria-label',
"dateTimeFields.startTime" 'dateTimeFields.startTime'
); )
expect(endDateInput).toHaveAttribute( expect(endDateInput).toHaveAttribute('aria-label', 'dateTimeFields.endDate')
"aria-label", expect(endTimeInput).toHaveAttribute('aria-label', 'dateTimeFields.endTime')
"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) // 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({ await renderField({
allday: true, allday: true,
hasEndDateChanged: false, hasEndDateChanged: false,
startDate: "2025-07-18", startDate: '2025-07-18',
endDate: "2025-07-20", endDate: '2025-07-20',
showEndDate: true, showEndDate: true,
showMore: false, showMore: false
}); })
expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.queryByTestId("start-time-input")).not.toBeInTheDocument(); expect(screen.queryByTestId('start-time-input')).not.toBeInTheDocument()
expect(screen.queryByTestId("end-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) // 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({ await renderField({
allday: false, allday: false,
hasEndDateChanged: true, hasEndDateChanged: true,
startDate: "2025-07-18", startDate: '2025-07-18',
endDate: "2025-07-20", endDate: '2025-07-20',
startTime: "09:00", startTime: '09:00',
endTime: "10:00", endTime: '10:00',
showEndDate: true, showEndDate: true,
showMore: false, showMore: false
}); })
expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
}); })
// Test 1.3: Display single date + time fields // 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({ await renderField({
allday: false, allday: false,
hasEndDateChanged: false, hasEndDateChanged: false,
startDate: "2025-07-18", startDate: '2025-07-18',
endDate: "2025-07-18", endDate: '2025-07-18',
startTime: "09:00", startTime: '09:00',
endTime: "10:00", endTime: '10:00',
showEndDate: false, showEndDate: false,
showMore: false, showMore: false
}); })
const startDateInput = screen.getByTestId("start-date-input"); const startDateInput = screen.getByTestId('start-date-input')
expect(startDateInput).toHaveAttribute( expect(startDateInput).toHaveAttribute(
"aria-label", 'aria-label',
"dateTimeFields.date" 'dateTimeFields.date'
); )
expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
expect(screen.queryByTestId("end-date-input")).not.toBeInTheDocument(); expect(screen.queryByTestId('end-date-input')).not.toBeInTheDocument()
}); })
}); })
}); })
+84 -84
View File
@@ -1,93 +1,93 @@
import { RootState } from "@/app/store"; import { RootState } from '@/app/store'
import EventDuplication from "@/components/Event/EventDuplicate"; import EventDuplication from '@/components/Event/EventDuplicate'
import EventPreviewModal from "@/features/Events/EventPreview"; import EventPreviewModal from '@/features/Events/EventPreview'
import EventPopover from "@/features/Events/EventModal"; import EventPopover from '@/features/Events/EventModal'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
const day = new Date(); const day = new Date()
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "mailto:test@test.com", cal_address: 'mailto:test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "Calendar", name: 'Calendar',
color: "#FF0000", color: '#FF0000',
owner: { emails: ["test@test.com"] }, owner: { emails: ['test@test.com'] },
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
URL: "calendars/667037022b752d0026472254/cal1/event1.ics", URL: 'calendars/667037022b752d0026472254/cal1/event1.ics',
title: "Test Event", title: 'Test Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: day.toISOString(), start: day.toISOString(),
end: day.toISOString(), end: day.toISOString(),
timezone: "UTC", timezone: 'UTC',
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [ attendee: [
{ {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL'
}, },
{ {
cn: "John", cn: 'John',
cal_address: "john@test.com", cal_address: 'john@test.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL'
}
]
}
}
}
}, },
], pending: false
}, }
}, } as unknown as RootState
},
},
pending: false,
},
} as unknown as RootState;
describe("EventDuplication", () => { describe('EventDuplication', () => {
it("calls onOpenDuplicate when button clicked", () => { it('calls onOpenDuplicate when button clicked', () => {
const handleClose = jest.fn(); const handleClose = jest.fn()
const onOpenDuplicate = jest.fn(); const onOpenDuplicate = jest.fn()
renderWithProviders( renderWithProviders(
<EventDuplication <EventDuplication
event={ event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1 .event1
} }
onClose={handleClose} onClose={handleClose}
onOpenDuplicate={onOpenDuplicate} onOpenDuplicate={onOpenDuplicate}
/>, />,
preloadedState preloadedState
); )
fireEvent.click( fireEvent.click(
screen.getByRole("menuitem", { name: "eventDuplication.duplicateEvent" }) screen.getByRole('menuitem', { name: 'eventDuplication.duplicateEvent' })
); )
expect(onOpenDuplicate).toHaveBeenCalled(); expect(onOpenDuplicate).toHaveBeenCalled()
}); })
}); })
describe("EventPopover", () => { describe('EventPopover', () => {
it("renders with event data", () => { it('renders with event data', () => {
renderWithProviders( renderWithProviders(
<EventPopover <EventPopover
anchorEl={null} anchorEl={null}
@@ -97,18 +97,18 @@ describe("EventPopover", () => {
setSelectedRange={jest.fn()} setSelectedRange={jest.fn()}
calendarRef={{ current: null }} calendarRef={{ current: null }}
event={ event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1 .event1
} }
/>, />,
preloadedState preloadedState
); )
expect(screen.getByDisplayValue(/Test Event/i)).toBeInTheDocument(); expect(screen.getByDisplayValue(/Test Event/i)).toBeInTheDocument()
}); })
it("saves duplicated event when Save is clicked", () => { it('saves duplicated event when Save is clicked', () => {
const onClose = jest.fn(); const onClose = jest.fn()
renderWithProviders( renderWithProviders(
<EventPopover <EventPopover
@@ -119,24 +119,24 @@ describe("EventPopover", () => {
setSelectedRange={jest.fn()} setSelectedRange={jest.fn()}
calendarRef={{ current: null }} calendarRef={{ current: null }}
event={ event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1 .event1
} }
/>, />,
preloadedState preloadedState
); )
fireEvent.change(screen.getByLabelText(/Title/i), { fireEvent.change(screen.getByLabelText(/Title/i), {
target: { value: "Duplicated Event" }, target: { value: 'Duplicated Event' }
}); })
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
waitFor(() => expect(onClose).toHaveBeenCalled()); waitFor(() => expect(onClose).toHaveBeenCalled())
}); })
}); })
describe("EventDisplayModal", () => { describe('EventDisplayModal', () => {
it("shows duplication button and opens duplication form", () => { it('shows duplication button and opens duplication form', () => {
renderWithProviders( renderWithProviders(
<EventPreviewModal <EventPreviewModal
eventId="event1" eventId="event1"
@@ -145,20 +145,20 @@ describe("EventDisplayModal", () => {
onClose={jest.fn()} onClose={jest.fn()}
/>, />,
preloadedState preloadedState
); )
fireEvent.click(screen.getByTestId("MoreVertIcon")); fireEvent.click(screen.getByTestId('MoreVertIcon'))
fireEvent.click( fireEvent.click(
screen.getByRole("menuitem", { name: "eventDuplication.duplicateEvent" }) screen.getByRole('menuitem', { name: 'eventDuplication.duplicateEvent' })
); )
expect( expect(
screen.getAllByText("eventDuplication.duplicateEvent")[1] screen.getAllByText('eventDuplication.duplicateEvent')[1]
).toBeInTheDocument(); ).toBeInTheDocument()
expect( expect(
screen.getByDisplayValue( screen.getByDisplayValue(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events preloadedState.calendars.list['667037022b752d0026472254/cal1'].events
.event1.title as string .event1.title as string
) )
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
}); })
@@ -3,176 +3,174 @@ import {
convertFormDateTimeToISO, convertFormDateTimeToISO,
DATETIME_WITH_SECONDS_LENGTH, DATETIME_WITH_SECONDS_LENGTH,
DATETIME_FORMAT_WITH_SECONDS, DATETIME_FORMAT_WITH_SECONDS,
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("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"
);
});
it("should return format without seconds for length < 19", () => {
expect(detectDateTimeFormat("2024-01-15T10:30")).toBe(
DATETIME_FORMAT_WITHOUT_SECONDS DATETIME_FORMAT_WITHOUT_SECONDS
); } from '@/components/Event/utils/dateTimeHelpers'
expect(detectDateTimeFormat("2024-01-15T10:30")).toBe("YYYY-MM-DDTHH:mm");
});
it("should return format with seconds for length exactly 19", () => { describe('dateTimeHelpers', () => {
const datetime = "2024-01-15T10:30:45"; describe('Constants', () => {
expect(datetime.length).toBe(19); it('should have correct constant values', () => {
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS); 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')
})
})
it("should return format without seconds for length 16", () => { describe('detectDateTimeFormat', () => {
const datetime = "2024-01-15T10:30"; it('should return format with seconds for length >= 19', () => {
expect(datetime.length).toBe(16); 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'
)
})
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')
})
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)
expect(detectDateTimeFormat(datetime)).toBe( expect(detectDateTimeFormat(datetime)).toBe(
DATETIME_FORMAT_WITHOUT_SECONDS DATETIME_FORMAT_WITHOUT_SECONDS
); )
}); })
it("should return format with seconds for length > 19", () => { it('should return format with seconds for length > 19', () => {
const datetime = "2024-01-15T10:30:45.123"; const datetime = '2024-01-15T10:30:45.123'
expect(datetime.length).toBeGreaterThan(19); expect(datetime.length).toBeGreaterThan(19)
expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS); expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS)
}); })
it("should handle empty string", () => { it('should handle empty string', () => {
expect(detectDateTimeFormat("")).toBe(DATETIME_FORMAT_WITHOUT_SECONDS); expect(detectDateTimeFormat('')).toBe(DATETIME_FORMAT_WITHOUT_SECONDS)
}); })
it("should handle very short strings", () => { it('should handle very short strings', () => {
expect(detectDateTimeFormat("2024")).toBe( expect(detectDateTimeFormat('2024')).toBe(DATETIME_FORMAT_WITHOUT_SECONDS)
DATETIME_FORMAT_WITHOUT_SECONDS })
); })
});
});
describe("convertFormDateTimeToISO", () => { describe('convertFormDateTimeToISO', () => {
const originalConsoleWarn = console.warn; const originalConsoleWarn = console.warn
let consoleWarnSpy: jest.SpyInstance; let consoleWarnSpy: jest.SpyInstance
beforeEach(() => { beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
}); })
afterEach(() => { 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( const result = convertFormDateTimeToISO(
"2024-01-15T10:30", '2024-01-15T10:30',
"America/New_York" 'America/New_York'
); )
expect(result).toBeTruthy(); expect(result).toBeTruthy()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(consoleWarnSpy).not.toHaveBeenCalled(); 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( const result = convertFormDateTimeToISO(
"2024-01-15T10:30:45", '2024-01-15T10:30:45',
"America/New_York" 'America/New_York'
); )
expect(result).toBeTruthy(); expect(result).toBeTruthy()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).not.toHaveBeenCalled()
}); })
it("should use Etc/UTC as default timezone when timezone is empty", () => { it('should use Etc/UTC as default timezone when timezone is empty', () => {
const result = convertFormDateTimeToISO("2024-01-15T10:30", ""); const result = convertFormDateTimeToISO('2024-01-15T10:30', '')
expect(result).toBeTruthy(); expect(result).toBeTruthy()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); 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", () => { it('should return empty string for empty datetime input', () => {
const result = convertFormDateTimeToISO("", "America/New_York"); const result = convertFormDateTimeToISO('', 'America/New_York')
expect(result).toBe(""); expect(result).toBe('')
expect(consoleWarnSpy).not.toHaveBeenCalled(); 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( const result = convertFormDateTimeToISO(
"invalid-date", 'invalid-date',
"America/New_York" 'America/New_York'
); )
expect(result).toBe(""); expect(result).toBe('')
expect(consoleWarnSpy).toHaveBeenCalledWith( expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("[convertFormDateTimeToISO] Invalid datetime:") expect.stringContaining('[convertFormDateTimeToISO] Invalid datetime:')
); )
expect(consoleWarnSpy).toHaveBeenCalledWith( expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('"invalid-date"') 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( const result = convertFormDateTimeToISO(
"2024-13-45T25:99:99", '2024-13-45T25:99:99',
"America/New_York" 'America/New_York'
); )
expect(result).toBe(""); expect(result).toBe('')
expect(consoleWarnSpy).toHaveBeenCalled(); expect(consoleWarnSpy).toHaveBeenCalled()
}); })
it("should handle different timezones correctly", () => { it('should handle different timezones correctly', () => {
const result1 = convertFormDateTimeToISO( const result1 = convertFormDateTimeToISO(
"2024-01-15T10:30", '2024-01-15T10:30',
"America/New_York" 'America/New_York'
); )
const result2 = convertFormDateTimeToISO( const result2 = convertFormDateTimeToISO(
"2024-01-15T10:30", '2024-01-15T10:30',
"Europe/London" 'Europe/London'
); )
expect(result1).toBeTruthy(); expect(result1).toBeTruthy()
expect(result2).toBeTruthy(); expect(result2).toBeTruthy()
expect(result1).not.toBe(result2); 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( const result = convertFormDateTimeToISO(
"2024-01-15T10:30", '2024-01-15T10:30',
// @ts-ignore - testing edge case // @ts-ignore - testing edge case
null null
); )
expect(result).toBeTruthy(); expect(result).toBeTruthy()
}); })
it("should convert correctly for UTC timezone", () => { it('should convert correctly for UTC timezone', () => {
const result = convertFormDateTimeToISO("2024-01-15T10:30", "Etc/UTC"); const result = convertFormDateTimeToISO('2024-01-15T10:30', 'Etc/UTC')
expect(result).toBeTruthy(); expect(result).toBeTruthy()
expect(result).toContain("T10:30:00"); expect(result).toContain('T10:30:00')
}); })
it("should handle datetime at boundary (exactly 19 characters)", () => { it('should handle datetime at boundary (exactly 19 characters)', () => {
const datetime = "2024-01-15T10:30:45"; const datetime = '2024-01-15T10:30:45'
expect(datetime.length).toBe(19); expect(datetime.length).toBe(19)
const result = convertFormDateTimeToISO(datetime, "Etc/UTC"); const result = convertFormDateTimeToISO(datetime, 'Etc/UTC')
expect(result).toBeTruthy(); expect(result).toBeTruthy()
expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).not.toHaveBeenCalled()
}); })
it("should handle datetime at boundary (exactly 16 characters)", () => { it('should handle datetime at boundary (exactly 16 characters)', () => {
const datetime = "2024-01-15T10:30"; const datetime = '2024-01-15T10:30'
expect(datetime.length).toBe(16); expect(datetime.length).toBe(16)
const result = convertFormDateTimeToISO(datetime, "Etc/UTC"); const result = convertFormDateTimeToISO(datetime, 'Etc/UTC')
expect(result).toBeTruthy(); expect(result).toBeTruthy()
expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).not.toHaveBeenCalled()
}); })
}); })
}); })
+78 -80
View File
@@ -1,102 +1,100 @@
import CalendarLayout from "@/components/Calendar/CalendarLayout"; import CalendarLayout from '@/components/Calendar/CalendarLayout'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
describe("Event Error Handling", () => { describe('Event Error Handling', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear()
jest.clearAllMocks(); jest.clearAllMocks()
}); })
const today = new Date(); const today = new Date()
const start = new Date(today); const start = new Date(today)
start.setHours(10, 0, 0, 0); start.setHours(10, 0, 0, 0)
const end = new Date(today); const end = new Date(today)
end.setHours(11, 0, 0, 0); end.setHours(11, 0, 0, 0)
const erroredState = { const erroredState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}, },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
name: "Calendar personal", name: 'Calendar personal',
id: "user1/cal1", id: 'user1/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user1/cal1", calId: 'user1/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: start.toISOString(), end: start.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", 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();
await act(async () =>
renderWithProviders(<CalendarLayout />, erroredState)
);
await waitFor(
() => {
expect(screen.getByText("Test Event"));
expect(screen.getByRole("alert")).toBeInTheDocument();
},
{ timeout: 10000 }
);
const closeButton = screen.queryByRole("button", { name: "common.ok" });
if (closeButton) {
const initialWarnCount = consoleWarnSpy.mock.calls.length;
await act(async () => {
fireEvent.click(closeButton);
});
await waitFor(
() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
},
{ timeout: 5000 }
);
const afterCloseWarnCount = consoleWarnSpy.mock.calls.length;
expect(afterCloseWarnCount).toBe(initialWarnCount);
} }
consoleWarnSpy.mockRestore(); it('BUGFIX: does not re-report errors after clearing error snackbar', async () => {
}, 15000); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
});
await act(async () => renderWithProviders(<CalendarLayout />, erroredState))
await waitFor(
() => {
expect(screen.getByText('Test Event'))
expect(screen.getByRole('alert')).toBeInTheDocument()
},
{ timeout: 10000 }
)
const closeButton = screen.queryByRole('button', { name: 'common.ok' })
if (closeButton) {
const initialWarnCount = consoleWarnSpy.mock.calls.length
await act(async () => {
fireEvent.click(closeButton)
})
await waitFor(
() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
},
{ timeout: 5000 }
)
const afterCloseWarnCount = consoleWarnSpy.mock.calls.length
expect(afterCloseWarnCount).toBe(initialWarnCount)
}
consoleWarnSpy.mockRestore()
}, 15000)
})
+350 -360
View File
@@ -1,350 +1,340 @@
import * as appHooks from "@/app/hooks"; import * as appHooks from '@/app/hooks'
import { AppDispatch } from "@/app/store"; import { AppDispatch } from '@/app/store'
import CalendarApp from "@/components/Calendar/Calendar"; import CalendarApp from '@/components/Calendar/Calendar'
import { import {
createEventHandlers, createEventHandlers,
EventHandlersProps, EventHandlersProps
} from "@/components/Calendar/handlers/eventHandlers"; } from '@/components/Calendar/handlers/eventHandlers'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import EventUpdateModal from "@/features/Events/EventUpdateModal"; import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { CalendarApi } from "@fullcalendar/core"; import { CalendarApi } from '@fullcalendar/core'
import { jest } from "@jest/globals"; import { jest } from '@jest/globals'
import "@testing-library/jest-dom"; import '@testing-library/jest-dom'
import { import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
act, import { renderWithProviders } from '../utils/Renderwithproviders'
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
describe("CalendarApp integration", () => { describe('CalendarApp integration', () => {
const today = new Date(); const today = new Date()
const start = new Date(today); const start = new Date(today)
start.setHours(10, 0, 0, 0); start.setHours(10, 0, 0, 0)
const end = new Date(today); const end = new Date(today)
end.setHours(11, 0, 0, 0); end.setHours(11, 0, 0, 0)
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
const renderCalendar = () => { const renderCalendar = () => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
tokens: { accessToken: "token" }, // required to avoid redirect tokens: { accessToken: 'token' } // required to avoid redirect
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
name: "Calendar 1", name: 'Calendar 1',
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
color: { light: "#FFFFFF", dark: "#000000" }, color: { light: '#FFFFFF', dark: '#000000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}
]
}
}
}
}, },
], pending: false
}, }
}, }
},
},
pending: false,
},
};
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null }
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState 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( const dispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), { Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}), unwrap: () => Promise.resolve({})
}) })
) as unknown as AppDispatch; ) as unknown as AppDispatch
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch)
renderCalendar(); renderCalendar()
const calendarRef: React.RefObject<CalendarApi | null> = (window as any) 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 // Wait for the FullCalendar DOM to populate
const eventEl = await screen.findByText( const eventEl = await screen.findByText('Test Event', {}, { timeout: 3000 })
"Test Event", expect(eventEl).toBeInTheDocument()
{},
{ timeout: 3000 }
);
expect(eventEl).toBeInTheDocument();
act(() => { act(() => {
if (calendarApi) { if (calendarApi) {
const fcEvent = calendarApi.getEventById("event1"); const fcEvent = calendarApi.getEventById('event1')
expect(fcEvent?.title).toBe("Test Event"); expect(fcEvent?.title).toBe('Test Event')
const oldEnd = new Date(today.getTime() + 3600000); // +1 hour const oldEnd = new Date(today.getTime() + 3600000) // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min 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 = {}) => ({ const createPreloadedState = (eventProps = {}) => ({
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
tokens: { tokens: {
accessToken: "token", accessToken: 'token'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
name: "Calendar 1", name: 'Calendar 1',
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
color: { light: "#FFFFFF", dark: "#000000" }, color: { light: '#FFFFFF', dark: '#000000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
uid: "event1", uid: 'event1',
start: new Date().toISOString(), start: new Date().toISOString(),
end: new Date(Date.now() + 3600000).toISOString(), end: new Date(Date.now() + 3600000).toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, }
], ],
...eventProps, ...eventProps
}
}
}
}, },
}, pending: false
}, }
}, })
pending: false,
},
});
it("renders lock icon for private events on the calendar", async () => { it('renders lock icon for private events on the calendar', async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true, configurable: true,
value: 120, value: 120
}); })
const preloadedState = createPreloadedState({ const preloadedState = createPreloadedState({
class: "PRIVATE", class: 'PRIVATE',
title: "Private Event", title: 'Private Event'
}); })
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null }
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState preloadedState
); )
const card = screen.getByTestId("event-card-event1"); const card = screen.getByTestId('event-card-event1')
const lockIcon = within(card).getByTestId("LockOutlineIcon"); const lockIcon = within(card).getByTestId('LockOutlineIcon')
expect(lockIcon).toBeInTheDocument(); expect(lockIcon).toBeInTheDocument()
}); })
it("renders lock icon for confidential events on the calendar", async () => { it('renders lock icon for confidential events on the calendar', async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true, configurable: true,
value: 120, value: 120
}); })
const preloadedState = createPreloadedState({ const preloadedState = createPreloadedState({
class: "CONFIDENTIAL", class: 'CONFIDENTIAL',
title: "Confidential Event", title: 'Confidential Event'
}); })
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null }
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState preloadedState
); )
const card = screen.getByTestId("event-card-event1"); const card = screen.getByTestId('event-card-event1')
const lockIcon = within(card).getByTestId("LockOutlineIcon"); const lockIcon = within(card).getByTestId('LockOutlineIcon')
expect(lockIcon).toBeInTheDocument(); expect(lockIcon).toBeInTheDocument()
}); })
it("does NOT render a lock icon for public events on the calendar", async () => { it('does NOT render a lock icon for public events on the calendar', async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true, configurable: true,
value: 120, value: 120
}); })
const preloadedState = createPreloadedState({ const preloadedState = createPreloadedState({
class: "PUBLIC", class: 'PUBLIC',
title: "Public Event", title: 'Public Event'
}); })
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null }
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState preloadedState
); )
const card = screen.getByTestId("event-card-event1"); const card = screen.getByTestId('event-card-event1')
const lockIcon = within(card).queryByTestId("LockOutlineIcon"); const lockIcon = within(card).queryByTestId('LockOutlineIcon')
expect(lockIcon).not.toBeInTheDocument(); expect(lockIcon).not.toBeInTheDocument()
}); })
it("does render a title for events without any attendees or user as organizer", async () => { it('does render a title for events without any attendees or user as organizer', async () => {
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null }
renderWithProviders(<CalendarApp calendarRef={mockCalendarRef} />, { renderWithProviders(<CalendarApp calendarRef={mockCalendarRef} />, {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
tokens: { tokens: {
accessToken: "token", accessToken: 'token'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
name: "Calendar 1", name: 'Calendar 1',
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
uid: "event1", uid: 'event1',
start: new Date().toISOString(), start: new Date().toISOString(),
end: new Date(Date.now() + 3600000).toISOString(), end: new Date(Date.now() + 3600000).toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
class: "PUBLIC", class: 'PUBLIC',
title: "Public Event", title: 'Public Event'
}
}
}
}, },
}, pending: false
}, }
}, })
pending: false,
},
});
expect(screen.getByText("Public Event")).toBeInTheDocument(); expect(screen.getByText('Public Event')).toBeInTheDocument()
}); })
describe("BUGFIX", () => { describe('BUGFIX', () => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
tokens: { tokens: {
accessToken: "token", accessToken: 'token'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
name: "Calendar 1", name: 'Calendar 1',
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
color: { light: "#FFFFFF", dark: "#000000" }, color: { light: '#FFFFFF', dark: '#000000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
uid: "event1", uid: 'event1',
title: "Original Event", title: 'Original Event',
start: new Date("2025-11-14T10:31:00.000Z").toISOString(), start: new Date('2025-11-14T10:31:00.000Z').toISOString(),
end: new Date("2025-11-14T11:31:00.000Z").toISOString(), end: new Date('2025-11-14T11:31:00.000Z').toISOString(),
class: "PUBLIC", class: 'PUBLIC',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
sequence: 2, sequence: 2,
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "CHAIR", role: 'CHAIR',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
{ {
cn: "Bob", cn: 'Bob',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "bob@example.com", 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 const updateSpy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const onClose = jest.fn(); const onClose = jest.fn()
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
eventId="event1" eventId="event1"
@@ -354,56 +344,56 @@ describe("CalendarApp integration", () => {
typeOfAction="solo" typeOfAction="solo"
/>, />,
preloadedState preloadedState
); )
const titleInput = await screen.findByDisplayValue("Original Event"); const titleInput = await screen.findByDisplayValue('Original Event')
await act(async () => { 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 () => { await act(async () => {
saveButton.click(); saveButton.click()
}); })
await waitFor(() => expect(updateSpy).toHaveBeenCalled()); await waitFor(() => expect(updateSpy).toHaveBeenCalled())
const dispatchedCalls = updateSpy.mock.calls; const dispatchedCalls = updateSpy.mock.calls
expect(dispatchedCalls.length).toBeGreaterThan(0); expect(dispatchedCalls.length).toBeGreaterThan(0)
const updatedEvent = dispatchedCalls[0][0].newEvent; const updatedEvent = dispatchedCalls[0][0].newEvent
// Ensure organizer attendee info is preserved // Ensure organizer attendee info is preserved
const organizerAttendee = updatedEvent?.attendee?.find( 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).toBeTruthy()
expect(organizerAttendee?.partstat).toBe("ACCEPTED"); expect(organizerAttendee?.partstat).toBe('ACCEPTED')
expect(organizerAttendee?.role).toBe("CHAIR"); expect(organizerAttendee?.role).toBe('CHAIR')
// Ensure normal attendee info is preserved too // Ensure normal attendee info is preserved too
const normalAttendee = updatedEvent?.attendee?.find( 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).toBeTruthy()
expect(normalAttendee?.partstat).toBe("ACCEPTED"); expect(normalAttendee?.partstat).toBe('ACCEPTED')
expect(normalAttendee?.role).toBe("REQ-PARTICIPANT"); expect(normalAttendee?.role).toBe('REQ-PARTICIPANT')
// Verify SEQUENCE is incremented // 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 const updateSpy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const onClose = jest.fn(); const onClose = jest.fn()
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
eventId="event1" eventId="event1"
@@ -413,78 +403,78 @@ describe("CalendarApp integration", () => {
typeOfAction="solo" typeOfAction="solo"
/>, />,
preloadedState preloadedState
); )
// Expand to show date/time inputs (normal mode shows DateTimeSummary) // Expand to show date/time inputs (normal mode shows DateTimeSummary)
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" }) screen.getByRole('button', { name: 'common.moreOptions' })
); )
const startDateInput = await screen.findByTestId("start-time-input"); const startDateInput = await screen.findByTestId('start-time-input')
await act(async () => { await act(async () => {
fireEvent.change(startDateInput, { fireEvent.change(startDateInput, {
target: { value: "08:00" }, target: { value: '08:00' }
}); })
fireEvent.blur(startDateInput); fireEvent.blur(startDateInput)
}); })
// Wait for blur handler to complete // Wait for blur handler to complete
await act(async () => { 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 () => { await act(async () => {
saveButton.click(); saveButton.click()
}); })
await waitFor(() => expect(updateSpy).toHaveBeenCalled()); await waitFor(() => expect(updateSpy).toHaveBeenCalled())
const dispatchedCalls = updateSpy.mock.calls; const dispatchedCalls = updateSpy.mock.calls
expect(dispatchedCalls.length).toBeGreaterThan(0); expect(dispatchedCalls.length).toBeGreaterThan(0)
const updatedEvent = dispatchedCalls[0][0].newEvent; const updatedEvent = dispatchedCalls[0][0].newEvent
// Ensure organizer attendee info is preserved // Ensure organizer attendee info is preserved
const organizerAttendee = updatedEvent?.attendee?.find( 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).toBeTruthy()
expect(organizerAttendee?.partstat).toBe("ACCEPTED"); expect(organizerAttendee?.partstat).toBe('ACCEPTED')
expect(organizerAttendee?.role).toBe("CHAIR"); expect(organizerAttendee?.role).toBe('CHAIR')
// Ensure normal attendee partstat is updated // Ensure normal attendee partstat is updated
const normalAttendee = updatedEvent?.attendee?.find( 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).toBeTruthy()
expect(normalAttendee?.partstat).toBe("NEEDS-ACTION"); expect(normalAttendee?.partstat).toBe('NEEDS-ACTION')
expect(normalAttendee?.role).toBe("REQ-PARTICIPANT"); expect(normalAttendee?.role).toBe('REQ-PARTICIPANT')
// Verify SEQUENCE is incremented // 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 // Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch. // and does not go through the Redux store or useAppDispatch.
const mockDispatch = jest.fn().mockReturnValue( const mockDispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), { Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}), unwrap: () => Promise.resolve({})
}) })
) as unknown as AppDispatch; ) as unknown as AppDispatch
jest jest
.spyOn(appHooks, "useAppDispatch") .spyOn(appHooks, 'useAppDispatch')
.mockReturnValue(mockDispatch as unknown as AppDispatch); .mockReturnValue(mockDispatch as unknown as AppDispatch)
const updateSpy = jest const updateSpy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const eventHandlers = createEventHandlers({ const eventHandlers = createEventHandlers({
setSelectedRange: jest.fn(), setSelectedRange: jest.fn(),
@@ -499,70 +489,70 @@ describe("CalendarApp integration", () => {
calendars: preloadedState.calendars.list, calendars: preloadedState.calendars.list,
setSelectedEvent: jest.fn(), setSelectedEvent: jest.fn(),
setAfterChoiceFunc: jest.fn(), setAfterChoiceFunc: jest.fn(),
setOpenEditModePopup: jest.fn(), setOpenEditModePopup: jest.fn()
} as unknown as EventHandlersProps); } as unknown as EventHandlersProps)
const mockArg = { const mockArg = {
event: { event: {
_def: { _def: {
extendedProps: { extendedProps: {
uid: "event1", uid: 'event1',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1'
}, }
}, }
}, },
// drag event → move by 1 day // 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(); renderCalendar()
eventHandlers.handleEventDrop(mockArg); eventHandlers.handleEventDrop(mockArg)
expect(updateSpy).toHaveBeenCalled(); expect(updateSpy).toHaveBeenCalled()
// Extract the dispatched update event // 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 updatePayload = dispatched[0]
const updatedEvent = updatePayload.newEvent; const updatedEvent = updatePayload.newEvent
// Organizer should remain unchanged // Organizer should remain unchanged
const organizer = updatedEvent.attendee.find( const organizer = updatedEvent.attendee.find(
(a: any) => a.cal_address === "alice@example.com" (a: any) => a.cal_address === 'alice@example.com'
); )
expect(organizer?.partstat).toBe("ACCEPTED"); expect(organizer?.partstat).toBe('ACCEPTED')
// Normal attendee must become NEEDS-ACTION // Normal attendee must become NEEDS-ACTION
const normal = updatedEvent.attendee.find( const normal = updatedEvent.attendee.find(
(a: any) => a.cal_address === "bob@example.com" (a: any) => a.cal_address === 'bob@example.com'
); )
expect(normal?.partstat).toBe("NEEDS-ACTION"); expect(normal?.partstat).toBe('NEEDS-ACTION')
// Verify SEQUENCE is incremented // 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 // Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch. // and does not go through the Redux store or useAppDispatch.
const mockDispatch = jest.fn().mockReturnValue( const mockDispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), { Object.assign(Promise.resolve({}), {
unwrap: () => Promise.resolve({}), unwrap: () => Promise.resolve({})
}) })
) as unknown as AppDispatch; ) as unknown as AppDispatch
jest jest
.spyOn(appHooks, "useAppDispatch") .spyOn(appHooks, 'useAppDispatch')
.mockReturnValue(mockDispatch as unknown as AppDispatch); .mockReturnValue(mockDispatch as unknown as AppDispatch)
const updateSpy = jest const updateSpy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const eventHandlers = createEventHandlers({ const eventHandlers = createEventHandlers({
setSelectedRange: jest.fn(), setSelectedRange: jest.fn(),
@@ -577,49 +567,49 @@ describe("CalendarApp integration", () => {
calendars: preloadedState.calendars.list, calendars: preloadedState.calendars.list,
setSelectedEvent: jest.fn(), setSelectedEvent: jest.fn(),
setAfterChoiceFunc: jest.fn(), setAfterChoiceFunc: jest.fn(),
setOpenEditModePopup: jest.fn(), setOpenEditModePopup: jest.fn()
} as unknown as EventHandlersProps); } as unknown as EventHandlersProps)
const mockArg = { const mockArg = {
event: { event: {
_def: { _def: {
extendedProps: { extendedProps: {
uid: "event1", uid: 'event1',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1'
}, }
}, }
}, },
startDelta: { years: 0, months: 0, days: 0, milliseconds: 0 }, 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(); renderCalendar()
eventHandlers.handleEventResize(mockArg); eventHandlers.handleEventResize(mockArg)
expect(updateSpy).toHaveBeenCalled(); expect(updateSpy).toHaveBeenCalled()
// Extract the dispatched update event // 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 updatePayload = dispatched[0]
const updatedEvent = updatePayload.newEvent; const updatedEvent = updatePayload.newEvent
// Organizer should remain unchanged // Organizer should remain unchanged
const organizer = updatedEvent.attendee.find( const organizer = updatedEvent.attendee.find(
(a: any) => a.cal_address === "alice@example.com" (a: any) => a.cal_address === 'alice@example.com'
); )
expect(organizer?.partstat).toBe("ACCEPTED"); expect(organizer?.partstat).toBe('ACCEPTED')
// Normal attendee must become NEEDS-ACTION // Normal attendee must become NEEDS-ACTION
const normal = updatedEvent.attendee.find( const normal = updatedEvent.attendee.find(
(a: any) => a.cal_address === "bob@example.com" (a: any) => a.cal_address === 'bob@example.com'
); )
expect(normal?.partstat).toBe("NEEDS-ACTION"); expect(normal?.partstat).toBe('NEEDS-ACTION')
// Verify SEQUENCE is incremented // 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 * as appHooks from '@/app/hooks'
import { AppDispatch } from "@/app/store"; import { AppDispatch } from '@/app/store'
import CalendarApp from "@/components/Calendar/Calendar"; import CalendarApp from '@/components/Calendar/Calendar'
import { jest } from "@jest/globals"; import { jest } from '@jest/globals'
import { screen } from "@testing-library/react"; import { screen } from '@testing-library/react'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
describe("MiniCalendar", () => { describe('MiniCalendar', () => {
const day = new Date(); const day = new Date()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
const dispatch = jest.fn().mockReturnValue( const dispatch = jest.fn().mockReturnValue(
Object.assign(Promise.resolve({}), { 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 renderCalendar = () => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
name: "Calendar 1", name: 'Calendar 1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["test@test.com"] }, owner: { emails: ['test@test.com'] },
events: { events: {
event1: { event1: {
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
id: "event1", id: 'event1',
title: "Test Event", title: 'Test Event',
start: day.toISOString(), start: day.toISOString()
}
}
}
}, },
}, pending: false
}, }
}, }
pending: false, const mockCalendarRef = { current: null }
},
};
const mockCalendarRef = { current: null };
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState preloadedState
); )
}; }
it("renders mini calendar with today in orange", async () => { it('renders mini calendar with today in orange', async () => {
renderCalendar(); renderCalendar()
const today = new Date(); const today = new Date()
const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
const todayTile = screen.getByTestId(dateTestId); const todayTiles = screen.getAllByTestId(dateTestId)
expect(todayTile).toHaveClass("today"); expect(todayTiles[0]).toHaveClass('today')
}); })
}); })
+241 -243
View File
@@ -1,412 +1,410 @@
import { PeopleSearch, User } from "@/components/Attendees/PeopleSearch"; import { PeopleSearch, User } from '@/components/Attendees/PeopleSearch'
import { searchUsers } from "@/features/User/userAPI"; import { searchUsers } from '@/features/User/userAPI'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from "@testing-library/user-event"; import userEvent from '@testing-library/user-event'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
jest.mock("@/features/User/userAPI"); jest.mock('@/features/User/userAPI')
const mockedSearchUsers = searchUsers as jest.MockedFunction< const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
typeof searchUsers
>;
describe("PeopleSearch", () => { describe('PeopleSearch', () => {
const baseUser: User = { const baseUser: User = {
email: "test@example.com", email: 'test@example.com',
displayName: "Test User", displayName: 'Test User',
avatarUrl: "https://example.com/avatar.png", avatarUrl: 'https://example.com/avatar.png',
openpaasId: "1234567890", openpaasId: '1234567890'
}; }
function setup( function setup(
selectedUsers: User[] = [], selectedUsers: User[] = [],
props?: Partial<React.ComponentProps<typeof PeopleSearch>> props?: Partial<React.ComponentProps<typeof PeopleSearch>>
) { ) {
const onChange = jest.fn(); const onChange = jest.fn()
renderWithProviders( renderWithProviders(
<PeopleSearch <PeopleSearch
objectTypes={["user"]} objectTypes={['user']}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
onChange={onChange} onChange={onChange}
{...props} {...props}
/> />
); )
return { onChange }; return { onChange }
} }
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers()
mockedSearchUsers.mockReset(); mockedSearchUsers.mockReset()
}); })
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers()
}); })
it("calls searchUsers after debounce when typing", async () => { it('calls searchUsers after debounce when typing', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]); mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
await waitFor(() => { await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]); expect(mockedSearchUsers).toHaveBeenCalledWith('Test', ['user'])
}); })
}); })
it("renders search results and allows selection", async () => { it('renders search results and allows selection', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]); mockedSearchUsers.mockResolvedValueOnce([baseUser])
const { onChange } = setup(); const { onChange } = setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
const option = await screen.findByText("Test User"); const option = await screen.findByText('Test User')
await userEvent.click(option); await userEvent.click(option)
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalled(); expect(onChange).toHaveBeenCalled()
}); })
}); })
it("does not show already selected users in options", async () => { it('does not show already selected users in options', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]); mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup([baseUser]); setup([baseUser])
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
await waitFor(() => { 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", () => { it('triggers onToggleEventPreview on Enter key press', () => {
const onToggleEventPreview = jest.fn(); const onToggleEventPreview = jest.fn()
setup([], { onToggleEventPreview }); setup([], { onToggleEventPreview })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: 'Enter' })
expect(onToggleEventPreview).toHaveBeenCalled(); expect(onToggleEventPreview).toHaveBeenCalled()
}); })
it("respects disabled state", () => { it('respects disabled state', () => {
setup([], { disabled: true }); setup([], { disabled: true })
expect(screen.getByRole("combobox")).toBeDisabled(); expect(screen.getByRole('combobox')).toBeDisabled()
}); })
it("no options doesn't show dropdown when input is empty", async () => { it("no options doesn't show dropdown when input is empty", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]); mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
userEvent.type(input, "Test"); userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument(); expect(screen.getByRole('listbox')).toBeInTheDocument()
}); })
userEvent.clear(input); userEvent.clear(input)
await waitFor(() => { 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 () => { it("shows 'No results' when search succeeds but returns empty array", async () => {
mockedSearchUsers.mockResolvedValueOnce([]); mockedSearchUsers.mockResolvedValueOnce([])
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
await Promise.resolve(); await Promise.resolve()
}); })
const noResults = await screen.findByText( const noResults = await screen.findByText(
"peopleSearch.noResults", 'peopleSearch.noResults',
{}, {},
{ timeout: 5000 } { timeout: 5000 }
); )
expect(noResults).toBeInTheDocument(); expect(noResults).toBeInTheDocument()
}); })
it("does not clear options when search fails and shows error snackbar", async () => { it('does not clear options when search fails and shows error snackbar', async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]); mockedSearchUsers.mockResolvedValueOnce([baseUser])
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Test User")).toBeInTheDocument(); expect(screen.getByText('Test User')).toBeInTheDocument()
}); })
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error")); mockedSearchUsers.mockRejectedValueOnce(new Error('Network error'))
await userEvent.clear(input); await userEvent.clear(input)
await userEvent.type(input, "Error"); await userEvent.type(input, 'Error')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
}); })
const errorMessage = await screen.findByText("peopleSearch.searchError"); const errorMessage = await screen.findByText('peopleSearch.searchError')
expect(errorMessage).toBeInTheDocument(); expect(errorMessage).toBeInTheDocument()
expect(screen.queryByText("Test User")).not.toBeInTheDocument(); expect(screen.queryByText('Test User')).not.toBeInTheDocument()
mockedSearchUsers.mockResolvedValueOnce([baseUser]); mockedSearchUsers.mockResolvedValueOnce([baseUser])
await userEvent.clear(input); await userEvent.clear(input)
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Test User")).toBeInTheDocument(); expect(screen.getByText('Test User')).toBeInTheDocument()
}); })
}); })
it("shows loading text when searching", async () => { it('shows loading text when searching', async () => {
let resolveSearch: (value: User[]) => void; let resolveSearch: (value: User[]) => void
const searchPromise = new Promise<User[]>((resolve) => { const searchPromise = new Promise<User[]>(resolve => {
resolveSearch = resolve; resolveSearch = resolve
}); })
mockedSearchUsers.mockReturnValueOnce(searchPromise); mockedSearchUsers.mockReturnValueOnce(searchPromise)
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Test"); await userEvent.type(input, 'Test')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
const loadingText = await screen.findByText( const loadingText = await screen.findByText(
"peopleSearch.loading", 'peopleSearch.loading',
{}, {},
{ timeout: 5000 } { timeout: 5000 }
); )
expect(loadingText).toBeInTheDocument(); expect(loadingText).toBeInTheDocument()
await act(async () => { await act(async () => {
resolveSearch!([baseUser]); resolveSearch!([baseUser])
await searchPromise; await searchPromise
}); })
}); })
describe("paste multiple attendees", () => { describe('paste multiple attendees', () => {
function setupFreeSolo(selectedUsers: User[] = []) { function setupFreeSolo(selectedUsers: User[] = []) {
const onChange = jest.fn(); const onChange = jest.fn()
renderWithProviders( renderWithProviders(
<PeopleSearch <PeopleSearch
objectTypes={["user"]} objectTypes={['user']}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
onChange={onChange} onChange={onChange}
freeSolo freeSolo
/> />
); )
return { onChange }; return { onChange }
} }
it("splits pasted comma-separated emails into individual attendees", async () => { it('splits pasted comma-separated emails into individual attendees', async () => {
const { onChange } = setupFreeSolo(); const { onChange } = setupFreeSolo()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com, bob@example.com", getData: () => 'alice@example.com, bob@example.com'
}, }
}); })
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }), expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: "bob@example.com" }), expect.objectContaining({ email: 'bob@example.com' })
]) ])
); )
}); })
}); })
it("splits pasted semicolon-separated emails", async () => { it('splits pasted semicolon-separated emails', async () => {
const { onChange } = setupFreeSolo(); const { onChange } = setupFreeSolo()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com;bob@example.com", getData: () => 'alice@example.com;bob@example.com'
}, }
}); })
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }), expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: "bob@example.com" }), expect.objectContaining({ email: 'bob@example.com' })
]) ])
); )
}); })
}); })
it("splits pasted newline-separated emails", async () => { it('splits pasted newline-separated emails', async () => {
const { onChange } = setupFreeSolo(); const { onChange } = setupFreeSolo()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com\nbob@example.com", getData: () => 'alice@example.com\nbob@example.com'
}, }
}); })
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }), expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: "bob@example.com" }), expect.objectContaining({ email: 'bob@example.com' })
]) ])
); )
}); })
}); })
it("splits pasted space-separated emails", async () => { it('splits pasted space-separated emails', async () => {
const { onChange } = setupFreeSolo(); const { onChange } = setupFreeSolo()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com bob@example.com", getData: () => 'alice@example.com bob@example.com'
}, }
}); })
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }), expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: "bob@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 = { const existing: User = {
email: "alice@example.com", email: 'alice@example.com',
displayName: "Alice", displayName: 'Alice'
}; }
const { onChange } = setupFreeSolo([existing]); const { onChange } = setupFreeSolo([existing])
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com, bob@example.com", getData: () => 'alice@example.com, bob@example.com'
}, }
}); })
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.arrayContaining([ expect.arrayContaining([
existing, existing,
expect.objectContaining({ email: "bob@example.com" }), expect.objectContaining({ email: 'bob@example.com' })
]) ])
); )
// Should NOT have alice duplicated // Should NOT have alice duplicated
const call = onChange.mock.calls[0][1]; const call = onChange.mock.calls[0][1]
const aliceCount = call.filter( const aliceCount = call.filter(
(u: User) => u.email === "alice@example.com" (u: User) => u.email === 'alice@example.com'
).length; ).length
expect(aliceCount).toBe(1); expect(aliceCount).toBe(1)
}); })
}); })
it("leaves invalid text in input when some emails are invalid", async () => { it('leaves invalid text in input when some emails are invalid', async () => {
const { onChange } = setupFreeSolo(); const { onChange } = setupFreeSolo()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com, not-an-email, bob@example.com", getData: () => 'alice@example.com, not-an-email, bob@example.com'
}, }
}); })
await waitFor(() => { await waitFor(() => {
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }), expect.objectContaining({ email: 'alice@example.com' }),
expect.objectContaining({ email: "bob@example.com" }), expect.objectContaining({ email: 'bob@example.com' })
]) ])
); )
}); })
}); })
it("does not intercept paste of a single email", async () => { it('does not intercept paste of a single email', async () => {
const { onChange } = setupFreeSolo(); const { onChange } = setupFreeSolo()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
fireEvent.paste(input, { fireEvent.paste(input, {
clipboardData: { clipboardData: {
getData: () => "alice@example.com", getData: () => 'alice@example.com'
}, }
}); })
// Single email should NOT trigger multi-paste handler // 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 () => { it('retains input value when field loses focus (blur)', async () => {
let resolveSearch: (value: User[]) => void; let resolveSearch: (value: User[]) => void
const searchPromise = new Promise<User[]>((resolve) => { const searchPromise = new Promise<User[]>(resolve => {
resolveSearch = resolve; resolveSearch = resolve
}); })
mockedSearchUsers.mockReturnValueOnce(searchPromise); mockedSearchUsers.mockReturnValueOnce(searchPromise)
await act(async () => { await act(async () => {
setup(); setup()
}); })
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await act(async () => { await act(async () => {
userEvent.type(input, "Test"); userEvent.type(input, 'Test')
}); })
await waitFor(() => { await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]); expect(mockedSearchUsers).toHaveBeenCalledWith('Test', ['user'])
}); })
expect(input).toHaveValue("Test"); expect(input).toHaveValue('Test')
await act(async () => { await act(async () => {
input.blur(); input.blur()
}); })
await waitFor(() => { await waitFor(() => {
expect(input).toHaveValue("Test"); expect(input).toHaveValue('Test')
}); })
}); })
}); })
+254 -258
View File
@@ -1,69 +1,69 @@
import { RootState } from "@/app/store"; import { RootState } from '@/app/store'
import RepeatEvent from "@/components/Event/EventRepeat"; import RepeatEvent from '@/components/Event/EventRepeat'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import EventPopover from "@/features/Events/EventModal"; import EventPopover from '@/features/Events/EventModal'
import { RepetitionObject } from "@/features/Events/EventsTypes"; import { RepetitionObject } from '@/features/Events/EventsTypes'
import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import { CalendarApi, DateSelectArg } from '@fullcalendar/core'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from '../utils/Renderwithproviders'
const baseRepetition: RepetitionObject = { const baseRepetition: RepetitionObject = {
freq: "", freq: '',
interval: 1, interval: 1,
occurrences: 0, occurrences: 0,
endDate: "", endDate: ''
}; }
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const mockSetSelectedRange = jest.fn(); const mockSetSelectedRange = jest.fn()
const mockCalendarRef = { const mockCalendarRef = {
current: { select: jest.fn() } as unknown as CalendarApi, current: { select: jest.fn() } as unknown as CalendarApi
}; }
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "Calendar 1", name: 'Calendar 1',
color: "#FF0000", color: '#FF0000'
}, },
"667037022b752d0026472254/cal2": { '667037022b752d0026472254/cal2': {
id: "667037022b752d0026472254/cal2", id: '667037022b752d0026472254/cal2',
name: "Calendar 2", name: 'Calendar 2',
color: "#00FF00", color: '#00FF00'
}
}, },
}, pending: false
pending: false, }
}, }
};
const defaultSelectedRange = { const defaultSelectedRange = {
startStr: "2025-07-18T09:00", startStr: '2025-07-18T09:00',
endStr: "2025-07-18T10:00", endStr: '2025-07-18T10:00',
start: new Date("2025-07-18T09:00"), start: new Date('2025-07-18T09:00'),
end: new Date("2025-07-18T10:00"), end: new Date('2025-07-18T10:00'),
allDay: false, allDay: false,
resource: undefined, resource: undefined
} as unknown as DateSelectArg; } as unknown as DateSelectArg
function setupRepeatEvent( function setupRepeatEvent(
props?: Partial<RepetitionObject>, props?: Partial<RepetitionObject>,
state?: RootState state?: RootState
) { ) {
const setRepetition = jest.fn(); const setRepetition = jest.fn()
renderWithProviders( renderWithProviders(
<RepeatEvent <RepeatEvent
repetition={{ ...baseRepetition, ...props }} repetition={{ ...baseRepetition, ...props }}
@@ -72,20 +72,20 @@ function setupRepeatEvent(
isOwn={true} isOwn={true}
/>, />,
state state
); )
return { setRepetition }; return { setRepetition }
} }
async function setupEventPopover() { async function setupEventPopover() {
jest jest
.spyOn(crypto, "randomUUID") .spyOn(crypto, 'randomUUID')
.mockReturnValue("fixed-uuid-with-correct-format"); .mockReturnValue('fixed-uuid-with-correct-format')
const originalDateResolvedOptions = const originalDateResolvedOptions =
new Intl.DateTimeFormat().resolvedOptions(); new Intl.DateTimeFormat().resolvedOptions()
jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
...originalDateResolvedOptions, ...originalDateResolvedOptions,
timeZone: "UTC", timeZone: 'UTC'
}); })
renderWithProviders( renderWithProviders(
<EventPopover <EventPopover
@@ -97,324 +97,320 @@ async function setupEventPopover() {
calendarRef={mockCalendarRef} calendarRef={mockCalendarRef}
/>, />,
preloadedState preloadedState
); )
// Fill in title // Fill in title
const titleInput = screen.getByLabelText("event.form.title"); const titleInput = screen.getByLabelText('event.form.title')
fireEvent.change(titleInput, { target: { value: "Meeting" } }); fireEvent.change(titleInput, { target: { value: 'Meeting' } })
// Click More options to expand the dialog // Click More options to expand the dialog
const showMoreButton = screen.getByRole("button", { const showMoreButton = screen.getByRole('button', {
name: "common.moreOptions", name: 'common.moreOptions'
}); })
fireEvent.click(showMoreButton); fireEvent.click(showMoreButton)
// Check Repeat checkbox to show repeat options // Check Repeat checkbox to show repeat options
const repeatCheckbox = screen.getByLabelText("event.form.repeat"); const repeatCheckbox = screen.getByLabelText('event.form.repeat')
fireEvent.click(repeatCheckbox); fireEvent.click(repeatCheckbox)
// Wait for RepeatEvent component to be rendered // Wait for RepeatEvent component to be rendered
await waitFor(() => { await waitFor(() => {
expect( expect(screen.getByText('event.repeat.frequency.weeks')).toBeInTheDocument()
screen.getByText("event.repeat.frequency.weeks") })
).toBeInTheDocument();
});
} }
async function expectRRule(expected: Partial<RepetitionObject>) { async function expectRRule(expected: Partial<RepetitionObject>) {
const spy = jest const spy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const saveButton = screen.getByRole("button", { name: /save/i }); const saveButton = screen.getByRole('button', { name: /save/i })
act(() => fireEvent.click(saveButton)); act(() => fireEvent.click(saveButton))
await waitFor(() => expect(spy).toHaveBeenCalled()); await waitFor(() => expect(spy).toHaveBeenCalled())
const received = spy.mock.calls[0][0]; const received = spy.mock.calls[0][0]
expect(received.newEvent.repetition).toMatchObject(expected); expect(received.newEvent.repetition).toMatchObject(expected)
} }
describe("RepeatEvent Component", () => { describe('RepeatEvent Component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest.restoreAllMocks(); jest.restoreAllMocks()
}); })
it("renders with no repetition by default", () => { it('renders with no repetition by default', () => {
setupRepeatEvent(); setupRepeatEvent()
// Check that interval input shows default value // Check that interval input shows default value
const intervalInput = screen.getByTestId("repeat-interval"); const intervalInput = screen.getByTestId('repeat-interval')
expect(intervalInput).toBeInTheDocument(); expect(intervalInput).toBeInTheDocument()
// Check that frequency dropdown shows default value // Check that frequency dropdown shows default value
const frequencySelect = screen.getByRole("combobox"); const frequencySelect = screen.getByRole('combobox')
expect(frequencySelect).toBeInTheDocument(); expect(frequencySelect).toBeInTheDocument()
}); })
it("allows selecting repetition frequency", () => { it('allows selecting repetition frequency', () => {
const { setRepetition } = setupRepeatEvent(); const { setRepetition } = setupRepeatEvent()
// Click on frequency dropdown // Click on frequency dropdown
const frequencySelect = screen.getByRole("combobox"); const frequencySelect = screen.getByRole('combobox')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
// Select Week(s) // Select Week(s)
const weeklyOption = screen.getByText("event.repeat.frequency.weeks"); const weeklyOption = screen.getByText('event.repeat.frequency.weeks')
fireEvent.click(weeklyOption); fireEvent.click(weeklyOption)
expect(setRepetition).toHaveBeenCalledWith( expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ freq: "weekly" }) expect.objectContaining({ freq: 'weekly' })
); )
}); })
it("renders interval input when frequency is selected", () => { it('renders interval input when frequency is selected', () => {
setupRepeatEvent({ freq: "daily" }); setupRepeatEvent({ freq: 'daily' })
const intervalInput = screen.getByTestId("repeat-interval"); const intervalInput = screen.getByTestId('repeat-interval')
expect(intervalInput).toBeInTheDocument(); expect(intervalInput).toBeInTheDocument()
}); })
it("updates interval value", () => { it('updates interval value', () => {
const { setRepetition } = setupRepeatEvent(); const { setRepetition } = setupRepeatEvent()
const intervalInput = screen.getByTestId("repeat-interval"); const intervalInput = screen.getByTestId('repeat-interval')
fireEvent.change(intervalInput, { target: { value: "3" } }); fireEvent.change(intervalInput, { target: { value: '3' } })
expect(setRepetition).toHaveBeenCalledWith( expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ interval: 3 }) expect.objectContaining({ interval: 3 })
); )
}); })
it("toggles day selection for weekly frequency", () => { it('toggles day selection for weekly frequency', () => {
const { setRepetition } = setupRepeatEvent({ freq: "weekly" }); const { setRepetition } = setupRepeatEvent({ freq: 'weekly' })
const mondayChip = screen.getByLabelText("event.repeat.days.monday"); const mondayChip = screen.getByLabelText('event.repeat.days.monday')
fireEvent.click(mondayChip); fireEvent.click(mondayChip)
expect(setRepetition).toHaveBeenCalledWith( 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 // Increase timeout for all tests in this describe block
jest.setTimeout(30000); jest.setTimeout(30000)
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest.restoreAllMocks(); jest.restoreAllMocks()
}); })
it("sends correct CalendarEvent payload for daily repeat", async () => { it('sends correct CalendarEvent payload for daily repeat', async () => {
await setupEventPopover(); await setupEventPopover()
// When Repeat checkbox is checked, repetition is set to empty object // When Repeat checkbox is checked, repetition is set to empty object
// We need to set the frequency manually // We need to set the frequency manually
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole("option", { const dailyOption = screen.getByRole('option', {
name: "event.repeat.frequency.days", name: 'event.repeat.frequency.days'
}); })
fireEvent.click(dailyOption); fireEvent.click(dailyOption)
await expectRRule({ freq: "daily", interval: 1 }); await expectRRule({ freq: 'daily', interval: 1 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat daily with 2 day interval", async () => { it('sends correct API payload for repeat daily with 2 day interval', async () => {
await setupEventPopover(); await setupEventPopover()
// Ensure frequency is daily // Ensure frequency is daily
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole("option", { const dailyOption = screen.getByRole('option', {
name: "event.repeat.frequency.days", name: 'event.repeat.frequency.days'
}); })
fireEvent.click(dailyOption); fireEvent.click(dailyOption)
// Set interval to 2 // Set interval to 2
const intervalInput = screen.getByTestId("repeat-interval"); const intervalInput = screen.getByTestId('repeat-interval')
fireEvent.change(intervalInput, { target: { value: "2" } }); fireEvent.change(intervalInput, { target: { value: '2' } })
await expectRRule({ freq: "daily", interval: 2 }); await expectRRule({ freq: 'daily', interval: 2 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat daily for 5 repetitions", async () => { it('sends correct API payload for repeat daily for 5 repetitions', async () => {
await setupEventPopover(); await setupEventPopover()
// Ensure frequency is daily // Ensure frequency is daily
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole("option", { const dailyOption = screen.getByRole('option', {
name: "event.repeat.frequency.days", name: 'event.repeat.frequency.days'
}); })
fireEvent.click(dailyOption); fireEvent.click(dailyOption)
// Select "After" end option // Select "After" end option
const afterRadio = screen.getByLabelText(/after/i); const afterRadio = screen.getByLabelText(/after/i)
fireEvent.click(afterRadio); fireEvent.click(afterRadio)
// Set occurrences to 5 // Set occurrences to 5
const occurrencesInput = screen.getByTestId("occurrences-input"); const occurrencesInput = screen.getByTestId('occurrences-input')
fireEvent.change(occurrencesInput, { target: { value: "5" } }); fireEvent.change(occurrencesInput, { target: { value: '5' } })
await expectRRule({ freq: "daily", interval: 1, occurrences: 5 }); await expectRRule({ freq: 'daily', interval: 1, occurrences: 5 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat daily until specific date", async () => { it('sends correct API payload for repeat daily until specific date', async () => {
await setupEventPopover(); await setupEventPopover()
// Ensure frequency is daily // Ensure frequency is daily
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const dailyOption = screen.getByRole("option", { const dailyOption = screen.getByRole('option', {
name: "event.repeat.frequency.days", name: 'event.repeat.frequency.days'
}); })
fireEvent.click(dailyOption); fireEvent.click(dailyOption)
// Select "On" end option // Select "On" end option
const onRadio = screen const onRadio = screen
.getAllByLabelText(/on/i) .getAllByLabelText(/on/i)
.find((el) => el.type === "radio"); .find(el => el.type === 'radio')
fireEvent.click(onRadio!); fireEvent.click(onRadio!)
// End date is set by UI to some valid date string (YYYY-MM-DD) // End date is set by UI to some valid date string (YYYY-MM-DD)
await expectRRule({ await expectRRule({
freq: "daily", freq: 'daily',
interval: 1, interval: 1,
endDate: expect.any(String), endDate: expect.any(String)
}); })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat weekly on specific days", async () => { it('sends correct API payload for repeat weekly on specific days', async () => {
await setupEventPopover(); await setupEventPopover()
// Select Week(s) frequency // Select Week(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const weeklyOption = screen.getByRole("option", { const weeklyOption = screen.getByRole('option', {
name: "event.repeat.frequency.weeks", name: 'event.repeat.frequency.weeks'
}); })
fireEvent.click(weeklyOption); fireEvent.click(weeklyOption)
// Select Thursday // Select Thursday
const thursdayCheckbox = screen.getByLabelText( const thursdayCheckbox = screen.getByLabelText('event.repeat.days.thursday')
"event.repeat.days.thursday" fireEvent.click(thursdayCheckbox)
);
fireEvent.click(thursdayCheckbox);
await expectRRule({ await expectRRule({
freq: "weekly", freq: 'weekly',
interval: 1, interval: 1,
byday: ["FR", "TH"], byday: ['FR', 'TH']
}); })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat weekly with 3 week interval", async () => { it('sends correct API payload for repeat weekly with 3 week interval', async () => {
await setupEventPopover(); await setupEventPopover()
// Select Week(s) frequency // Select Week(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const weeklyOption = screen.getByRole("option", { const weeklyOption = screen.getByRole('option', {
name: "event.repeat.frequency.weeks", name: 'event.repeat.frequency.weeks'
}); })
fireEvent.click(weeklyOption); fireEvent.click(weeklyOption)
// Set interval to 3 // Set interval to 3
const intervalInput = screen.getByTestId("repeat-interval"); const intervalInput = screen.getByTestId('repeat-interval')
fireEvent.change(intervalInput, { target: { value: "3" } }); fireEvent.change(intervalInput, { target: { value: '3' } })
await expectRRule({ freq: "weekly", interval: 3 }); await expectRRule({ freq: 'weekly', interval: 3 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat monthly", async () => { it('sends correct API payload for repeat monthly', async () => {
await setupEventPopover(); await setupEventPopover()
// Select Month(s) frequency // Select Month(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const monthlyOption = screen.getByRole("option", { const monthlyOption = screen.getByRole('option', {
name: "event.repeat.frequency.months", name: 'event.repeat.frequency.months'
}); })
fireEvent.click(monthlyOption); fireEvent.click(monthlyOption)
await expectRRule({ freq: "monthly", interval: 1 }); await expectRRule({ freq: 'monthly', interval: 1 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat monthly and end after 5 occurrences", async () => { it('sends correct API payload for repeat monthly and end after 5 occurrences', async () => {
await setupEventPopover(); await setupEventPopover()
// Select Month(s) frequency // Select Month(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const monthlyOption = screen.getByRole("option", { const monthlyOption = screen.getByRole('option', {
name: "event.repeat.frequency.months", name: 'event.repeat.frequency.months'
}); })
fireEvent.click(monthlyOption); fireEvent.click(monthlyOption)
// Select "After" end option // Select "After" end option
const afterRadio = screen.getByLabelText(/after/i); const afterRadio = screen.getByLabelText(/after/i)
fireEvent.click(afterRadio); fireEvent.click(afterRadio)
// Set occurrences to 5 // Set occurrences to 5
const occurrencesInput = screen.getByTestId("occurrences-input"); const occurrencesInput = screen.getByTestId('occurrences-input')
fireEvent.change(occurrencesInput, { target: { value: "5" } }); fireEvent.change(occurrencesInput, { target: { value: '5' } })
await expectRRule({ freq: "monthly", interval: 1, occurrences: 5 }); await expectRRule({ freq: 'monthly', interval: 1, occurrences: 5 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat yearly", async () => { it('sends correct API payload for repeat yearly', async () => {
await setupEventPopover(); await setupEventPopover()
// Select Year(s) frequency // Select Year(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const yearlyOption = screen.getByRole("option", { const yearlyOption = screen.getByRole('option', {
name: "event.repeat.frequency.years", name: 'event.repeat.frequency.years'
}); })
fireEvent.click(yearlyOption); fireEvent.click(yearlyOption)
await expectRRule({ freq: "yearly", interval: 1 }); await expectRRule({ freq: 'yearly', interval: 1 })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("sends correct API payload for repeat yearly with end option changes", async () => { it('sends correct API payload for repeat yearly with end option changes', async () => {
await setupEventPopover(); await setupEventPopover()
// Select Year(s) frequency // Select Year(s) frequency
const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); const frequencySelect = screen.getByText('event.repeat.frequency.weeks')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const yearlyOption = screen.getByRole("option", { const yearlyOption = screen.getByRole('option', {
name: "event.repeat.frequency.years", name: 'event.repeat.frequency.years'
}); })
fireEvent.click(yearlyOption); fireEvent.click(yearlyOption)
// First choose "After" with 5 occurrences // First choose "After" with 5 occurrences
const afterRadio = screen.getByLabelText(/after/i); const afterRadio = screen.getByLabelText(/after/i)
fireEvent.click(afterRadio); fireEvent.click(afterRadio)
const occurrencesInput = screen.getByTestId("occurrences-input"); const occurrencesInput = screen.getByTestId('occurrences-input')
fireEvent.change(occurrencesInput, { target: { value: "5" } }); fireEvent.change(occurrencesInput, { target: { value: '5' } })
// Then change mind and choose "Never" // Then change mind and choose "Never"
const neverRadio = screen.getByLabelText(/never/i); const neverRadio = screen.getByLabelText(/never/i)
fireEvent.click(neverRadio); fireEvent.click(neverRadio)
await expectRRule({ await expectRRule({
freq: "yearly", freq: 'yearly',
interval: 1, interval: 1
}); })
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
}); })
+134 -139
View File
@@ -1,224 +1,219 @@
import { import { ResourceSearch, Resource } from '@/components/Attendees/ResourceSearch'
ResourceSearch, import { searchUsers } from '@/features/User/userAPI'
Resource, import { act, screen, waitFor } from '@testing-library/react'
} from "@/components/Attendees/ResourceSearch"; import userEvent from '@testing-library/user-event'
import { searchUsers } from "@/features/User/userAPI"; import { renderWithProviders } from '../utils/Renderwithproviders'
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"); jest.mock('@/features/User/userAPI')
const mockedSearchUsers = searchUsers as jest.MockedFunction< const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
typeof searchUsers
>;
describe("ResourceSearch", () => { describe('ResourceSearch', () => {
const baseResource: Resource = { const baseResource: Resource = {
displayName: "Projector Room", displayName: 'Projector Room'
}; }
function setup( function setup(
selectedResources: Resource[] = [], selectedResources: Resource[] = [],
props?: Partial<React.ComponentProps<typeof ResourceSearch>> props?: Partial<React.ComponentProps<typeof ResourceSearch>>
) { ) {
const onChange = jest.fn(); const onChange = jest.fn()
renderWithProviders( renderWithProviders(
<ResourceSearch <ResourceSearch
objectTypes={["resource"]} objectTypes={['resource']}
selectedResources={selectedResources} selectedResources={selectedResources}
onChange={onChange} onChange={onChange}
{...props} {...props}
/> />
); )
return { onChange }; return { onChange }
} }
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers()
mockedSearchUsers.mockReset(); mockedSearchUsers.mockReset()
}); })
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers()
}); })
it("calls searchUsers after debounce when typing", async () => { it('calls searchUsers after debounce when typing', async () => {
mockedSearchUsers.mockResolvedValueOnce([ mockedSearchUsers.mockResolvedValueOnce([
baseResource, baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>); ] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
await waitFor(() => { 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([ mockedSearchUsers.mockResolvedValueOnce([
baseResource, baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>); ] as unknown as Awaited<ReturnType<typeof searchUsers>>)
const { onChange } = setup(); const { onChange } = setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
const option = await screen.findByText("Projector Room"); const option = await screen.findByText('Projector Room')
await userEvent.click(option); await userEvent.click(option)
await waitFor(() => { 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([ mockedSearchUsers.mockResolvedValueOnce([
baseResource, baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>); ] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup([baseResource]); setup([baseResource])
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Projector"); await userEvent.type(input, 'Projector')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
await waitFor(() => { await waitFor(() => {
// It shouldn't be in the dropdown options anymore // It shouldn't be in the dropdown options anymore
const options = screen.queryAllByRole("option"); const options = screen.queryAllByRole('option')
expect( expect(
options.find((opt) => opt.textContent === "Projector Room") options.find(opt => opt.textContent === 'Projector Room')
).toBeUndefined(); ).toBeUndefined()
}); })
}); })
it("respects disabled state", () => { it('respects disabled state', () => {
setup([], { disabled: true }); setup([], { disabled: true })
expect(screen.getByRole("combobox")).toBeDisabled(); expect(screen.getByRole('combobox')).toBeDisabled()
}); })
it("no options doesn't show dropdown when input is empty", async () => { it("no options doesn't show dropdown when input is empty", async () => {
mockedSearchUsers.mockResolvedValueOnce([ mockedSearchUsers.mockResolvedValueOnce([
baseResource, baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>); ] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument(); expect(screen.getByRole('listbox')).toBeInTheDocument()
}); })
await userEvent.clear(input); await userEvent.clear(input)
await waitFor(() => { 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 () => { it("shows 'No results' when search succeeds but returns empty array", async () => {
mockedSearchUsers.mockResolvedValueOnce([]); mockedSearchUsers.mockResolvedValueOnce([])
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
await Promise.resolve(); await Promise.resolve()
}); })
const noResults = await screen.findByText( const noResults = await screen.findByText(
"resourceSearch.noResults", 'resourceSearch.noResults',
{}, {},
{ timeout: 5000 } { 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([ mockedSearchUsers.mockResolvedValueOnce([
baseResource, baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>); ] as unknown as Awaited<ReturnType<typeof searchUsers>>)
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Projector Room")).toBeInTheDocument(); expect(screen.getByText('Projector Room')).toBeInTheDocument()
}); })
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error")); mockedSearchUsers.mockRejectedValueOnce(new Error('Network error'))
await userEvent.clear(input); await userEvent.clear(input)
await userEvent.type(input, "Error"); await userEvent.type(input, 'Error')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
}); })
const errorMessage = await screen.findByText("resourceSearch.searchError"); const errorMessage = await screen.findByText('resourceSearch.searchError')
expect(errorMessage).toBeInTheDocument(); expect(errorMessage).toBeInTheDocument()
expect(screen.queryByText("Projector Room")).not.toBeInTheDocument(); expect(screen.queryByText('Projector Room')).not.toBeInTheDocument()
mockedSearchUsers.mockResolvedValueOnce([ mockedSearchUsers.mockResolvedValueOnce([
baseResource, baseResource
] as unknown as Awaited<ReturnType<typeof searchUsers>>); ] as unknown as Awaited<ReturnType<typeof searchUsers>>)
await userEvent.clear(input); await userEvent.clear(input)
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); await Promise.resolve()
}); })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Projector Room")).toBeInTheDocument(); expect(screen.getByText('Projector Room')).toBeInTheDocument()
}); })
}); })
it("shows loading text when searching", async () => { it('shows loading text when searching', async () => {
let resolveSearch: (value: Resource[]) => void; let resolveSearch: (value: Resource[]) => void
const searchPromise = new Promise<Resource[]>((resolve) => { const searchPromise = new Promise<Resource[]>(resolve => {
resolveSearch = resolve; resolveSearch = resolve
}); })
mockedSearchUsers.mockReturnValueOnce( mockedSearchUsers.mockReturnValueOnce(
searchPromise as unknown as ReturnType<typeof searchUsers> searchPromise as unknown as ReturnType<typeof searchUsers>
); )
setup(); setup()
const input = screen.getByRole("combobox"); const input = screen.getByRole('combobox')
await userEvent.type(input, "Room"); await userEvent.type(input, 'Room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
const loadingText = await screen.findByText( const loadingText = await screen.findByText(
"resourceSearch.loading", 'resourceSearch.loading',
{}, {},
{ timeout: 5000 } { timeout: 5000 }
); )
expect(loadingText).toBeInTheDocument(); expect(loadingText).toBeInTheDocument()
await act(async () => { await act(async () => {
resolveSearch!([baseResource]); resolveSearch!([baseResource])
await searchPromise; await searchPromise
}); })
}); })
}); })
+125 -125
View File
@@ -1,33 +1,33 @@
import { ResponsiveDialog } from "@/components/Dialog"; import { ResponsiveDialog } from '@/components/Dialog'
import { Button, TextField, TwakeMuiThemeProvider } from "@linagora/twake-mui"; import { Button, TextField, TwakeMuiThemeProvider } from '@linagora/twake-mui'
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from '@testing-library/react'
import React from "react"; import React from 'react'
describe("ResponsiveDialog", () => { describe('ResponsiveDialog', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const mockOnExpandToggle = jest.fn(); const mockOnExpandToggle = jest.fn()
const renderWithTheme = (ui: React.ReactElement) => { const renderWithTheme = (ui: React.ReactElement) => {
return render(<TwakeMuiThemeProvider>{ui}</TwakeMuiThemeProvider>); return render(<TwakeMuiThemeProvider>{ui}</TwakeMuiThemeProvider>)
}; }
beforeEach(() => { beforeEach(() => {
mockOnClose.mockClear(); mockOnClose.mockClear()
mockOnExpandToggle.mockClear(); mockOnExpandToggle.mockClear()
}); })
it("renders in normal mode by default", () => { it('renders in normal mode by default', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test Dialog"> <ResponsiveDialog open={true} onClose={mockOnClose} title="Test Dialog">
<TextField label="Name" /> <TextField label="Name" />
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.getByText("Test Dialog")).toBeInTheDocument(); expect(screen.getByText('Test Dialog')).toBeInTheDocument()
expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
}); })
it("renders title in normal mode", () => { it('renders title in normal mode', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -37,13 +37,13 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.getByText("My Title")).toBeInTheDocument(); expect(screen.getByText('My Title')).toBeInTheDocument()
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument(); expect(screen.queryByLabelText('show less')).not.toBeInTheDocument()
}); })
it("renders back arrow in extended mode", () => { it('renders back arrow in extended mode', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -54,13 +54,13 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.queryByText("My Title")).not.toBeInTheDocument(); expect(screen.queryByText('My Title')).not.toBeInTheDocument()
expect(screen.getByLabelText("show less")).toBeInTheDocument(); expect(screen.getByLabelText('show less')).toBeInTheDocument()
}); })
it("calls onExpandToggle when back arrow is clicked", () => { it('calls onExpandToggle when back arrow is clicked', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -71,15 +71,15 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
const backButton = screen.getByLabelText("show less"); const backButton = screen.getByLabelText('show less')
fireEvent.click(backButton); fireEvent.click(backButton)
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1); expect(mockOnExpandToggle).toHaveBeenCalledTimes(1)
}); })
it("renders actions when provided", () => { it('renders actions when provided', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -89,38 +89,38 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </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( const { container } = renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test"> <ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
const dialogActions = container.querySelector(".MuiDialogActions-root"); const dialogActions = container.querySelector('.MuiDialogActions-root')
expect(dialogActions).not.toBeInTheDocument(); expect(dialogActions).not.toBeInTheDocument()
}); })
it("calls onClose when backdrop is clicked", () => { it('calls onClose when backdrop is clicked', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test"> <ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
const backdrop = document.querySelector(".MuiBackdrop-root"); const backdrop = document.querySelector('.MuiBackdrop-root')
if (backdrop) { if (backdrop) {
fireEvent.click(backdrop); fireEvent.click(backdrop)
} }
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled()
}); })
it("applies custom normalMaxWidth", () => { it('applies custom normalMaxWidth', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -130,24 +130,24 @@ describe("ResponsiveDialog", () => {
> >
<div>Normal Width Content</div> <div>Normal Width Content</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test"> <ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
<TextField label="Field 1" /> <TextField label="Field 1" />
<TextField label="Field 2" /> <TextField label="Field 2" />
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.getByLabelText("Field 1")).toBeInTheDocument(); expect(screen.getByLabelText('Field 1')).toBeInTheDocument()
expect(screen.getByLabelText("Field 2")).toBeInTheDocument(); expect(screen.getByLabelText('Field 2')).toBeInTheDocument()
}); })
it("uses correct spacing in normal mode", () => { it('uses correct spacing in normal mode', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -158,12 +158,12 @@ describe("ResponsiveDialog", () => {
> >
<div>Normal Spacing Content</div> <div>Normal Spacing Content</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -174,12 +174,12 @@ describe("ResponsiveDialog", () => {
> >
<div>Extended Spacing Content</div> <div>Extended Spacing Content</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -189,28 +189,28 @@ describe("ResponsiveDialog", () => {
> >
<div>Custom Styled Content</div> <div>Custom Styled Content</div>
</ResponsiveDialog> </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( const { container } = renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
title="Test" title="Test"
titleSx={{ color: "red" }} titleSx={{ color: 'red' }}
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
const title = screen.getByText("Test"); const title = screen.getByText('Test')
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument()
}); })
it("shows dividers when dividers prop is true", () => { it('shows dividers when dividers prop is true', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -220,12 +220,12 @@ describe("ResponsiveDialog", () => {
> >
<div>Content with Dividers</div> <div>Content with Dividers</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -235,13 +235,13 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument(); expect(screen.queryByLabelText('show less')).not.toBeInTheDocument()
expect(screen.getByText("Test Title")).toBeInTheDocument(); expect(screen.getByText('Test Title')).toBeInTheDocument()
}); })
it("accepts custom headerHeight", () => { it('accepts custom headerHeight', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -252,12 +252,12 @@ describe("ResponsiveDialog", () => {
> >
<div>Custom Header Content</div> <div>Custom Header Content</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -268,22 +268,22 @@ describe("ResponsiveDialog", () => {
> >
<div>Wide Content</div> <div>Wide Content</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog open={false} onClose={mockOnClose} title="Test"> <ResponsiveDialog open={false} onClose={mockOnClose} title="Test">
<div>Test Content</div> <div>Test Content</div>
</ResponsiveDialog> </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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -294,13 +294,13 @@ describe("ResponsiveDialog", () => {
> >
<div>Extended Content</div> <div>Extended Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.getByText("Extended Content")).toBeInTheDocument(); expect(screen.getByText('Extended Content')).toBeInTheDocument()
expect(screen.getByLabelText("show less")).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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -312,13 +312,13 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.getByLabelText("expand")).toBeInTheDocument(); expect(screen.getByLabelText('expand')).toBeInTheDocument()
expect(screen.getByLabelText("close")).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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -330,14 +330,14 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
expect(screen.queryByLabelText("expand")).not.toBeInTheDocument(); expect(screen.queryByLabelText('expand')).not.toBeInTheDocument()
expect(screen.queryByLabelText("close")).not.toBeInTheDocument(); expect(screen.queryByLabelText('close')).not.toBeInTheDocument()
expect(screen.getByText("Test Title")).toBeInTheDocument(); expect(screen.getByText('Test Title')).toBeInTheDocument()
}); })
it("calls onClose when close icon is clicked", () => { it('calls onClose when close icon is clicked', () => {
renderWithTheme( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -348,15 +348,15 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
const closeButton = screen.getByLabelText("close"); const closeButton = screen.getByLabelText('close')
fireEvent.click(closeButton); 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( renderWithTheme(
<ResponsiveDialog <ResponsiveDialog
open={true} open={true}
@@ -368,11 +368,11 @@ describe("ResponsiveDialog", () => {
> >
<div>Content</div> <div>Content</div>
</ResponsiveDialog> </ResponsiveDialog>
); )
const expandButton = screen.getByLabelText("expand"); const expandButton = screen.getByLabelText('expand')
fireEvent.click(expandButton); 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 { renderHook, act } from '@testing-library/react'
import { useUserSearch } from "@/components/Attendees/useUserSearch"; import { useUserSearch } from '@/components/Attendees/useUserSearch'
import { searchUsers } from "@/features/User/userAPI"; import { searchUsers } from '@/features/User/userAPI'
jest.mock("@/features/User/userAPI", () => ({ jest.mock('@/features/User/userAPI', () => ({
searchUsers: jest.fn(), searchUsers: jest.fn()
})); }))
describe("useUserSearch", () => { describe('useUserSearch', () => {
const mockSearchUsers = searchUsers as jest.Mock; const mockSearchUsers = searchUsers as jest.Mock
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers()
jest.clearAllMocks(); jest.clearAllMocks()
}); })
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers()
}); })
it("should initialize with default values", () => { it('should initialize with default values', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' })
); )
expect(result.current.query).toBe(""); expect(result.current.query).toBe('')
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false)
expect(result.current.options).toEqual([]); expect(result.current.options).toEqual([])
expect(result.current.hasSearched).toBe(false); expect(result.current.hasSearched).toBe(false)
expect(result.current.isOpen).toBe(false); expect(result.current.isOpen).toBe(false)
expect(result.current.inputError).toBeNull(); expect(result.current.inputError).toBeNull()
expect(result.current.snackbarOpen).toBe(false); expect(result.current.snackbarOpen).toBe(false)
expect(result.current.snackbarMessage).toBe(""); expect(result.current.snackbarMessage).toBe('')
}); })
it("should debounce and fetch users when query changes", async () => { it('should debounce and fetch users when query changes', async () => {
const mockUsers = [{ displayName: "John Doe", email: "john@example.com" }]; const mockUsers = [{ displayName: 'John Doe', email: 'john@example.com' }]
mockSearchUsers.mockResolvedValueOnce(mockUsers); mockSearchUsers.mockResolvedValueOnce(mockUsers)
const { result } = renderHook(() => const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' })
); )
act(() => { 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 // Wait for the mock promise to resolve within act
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); // allow microtasks to flush await Promise.resolve() // allow microtasks to flush
}); })
expect(mockSearchUsers).toHaveBeenCalledWith("John", ["user"]); expect(mockSearchUsers).toHaveBeenCalledWith('John', ['user'])
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false)
expect(result.current.options).toEqual(mockUsers); expect(result.current.options).toEqual(mockUsers)
expect(result.current.hasSearched).toBe(true); 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(() => const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' })
); )
act(() => { act(() => {
result.current.setQuery(" "); // empty query with spaces result.current.setQuery(' ') // empty query with spaces
}); })
act(() => { act(() => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
}); })
expect(mockSearchUsers).not.toHaveBeenCalled(); expect(mockSearchUsers).not.toHaveBeenCalled()
expect(result.current.options).toEqual([]); expect(result.current.options).toEqual([])
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false)
expect(result.current.hasSearched).toBe(false); expect(result.current.hasSearched).toBe(false)
}); })
it("should handle search errors and show the custom error message", async () => { it('should handle search errors and show the custom error message', async () => {
mockSearchUsers.mockRejectedValueOnce(new Error("API Error")); mockSearchUsers.mockRejectedValueOnce(new Error('API Error'))
const { result } = renderHook(() => const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Custom Error" }) useUserSearch({ objectTypes: ['user'], errorMessage: 'Custom Error' })
); )
act(() => { act(() => {
result.current.setQuery("FailedSearch"); result.current.setQuery('FailedSearch')
}); })
await act(async () => { await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300)
await Promise.resolve(); // allow microtasks to flush await Promise.resolve() // allow microtasks to flush
}); })
expect(result.current.hasSearched).toBe(false); expect(result.current.hasSearched).toBe(false)
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false)
expect(result.current.snackbarOpen).toBe(true); expect(result.current.snackbarOpen).toBe(true)
expect(result.current.snackbarMessage).toBe("Custom Error"); expect(result.current.snackbarMessage).toBe('Custom Error')
}); })
}); })
+124 -124
View File
@@ -6,197 +6,197 @@ import {
getSecretLink, getSecretLink,
postCalendar, postCalendar,
proppatchCalendar, proppatchCalendar,
removeCalendar, removeCalendar
} from "@/features/Calendars/CalendarApi"; } from '@/features/Calendars/CalendarApi'
import { clientConfig } from "@/features/User/oidcAuth"; import { clientConfig } from '@/features/User/oidcAuth'
import { api } from "@/utils/apiUtils"; import { api } from '@/utils/apiUtils'
clientConfig.url = "https://example.com"; clientConfig.url = 'https://example.com'
jest.mock("@/utils/apiUtils"); jest.mock('@/utils/apiUtils')
describe("Calendar API", () => { describe('Calendar API', () => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("fetches calendar list for a user", async () => { it('fetches calendar list for a user', async () => {
const mockUserId = "user123"; const mockUserId = 'user123'
const mockResponse = [{ id: "calendar1" }, { id: "calendar2" }]; const mockResponse = [{ id: 'calendar1' }, { id: 'calendar2' }]
(api.get as jest.Mock).mockReturnValue({ ;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResponse), json: jest.fn().mockResolvedValue(mockResponse)
}); })
const calendars = await getCalendars(mockUserId); const calendars = await getCalendars(mockUserId)
expect(api.get).toHaveBeenCalledWith( expect(api.get).toHaveBeenCalledWith(
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`, `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 () => { it('fetches calendar events for a given ID and match window', async () => {
const calendarId = "calendar1"; const calendarId = 'calendar1'
const match = { start: "2025-07-01", end: "2025-07-31" }; const match = { start: '2025-07-01', end: '2025-07-31' }
const mockCalendarData = { events: ["event1", "event2"] }; const mockCalendarData = { events: ['event1', 'event2'] }
(api as unknown as jest.Mock).mockReturnValue({ ;(api as unknown as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockCalendarData), json: jest.fn().mockResolvedValue(mockCalendarData)
}); })
const result = await getCalendar(calendarId, match); const result = await getCalendar(calendarId, match)
expect(api).toHaveBeenCalledWith(`dav/calendars/${calendarId}.json`, { expect(api).toHaveBeenCalledWith(`dav/calendars/${calendarId}.json`, {
method: "REPORT", method: 'REPORT',
headers: { headers: {
Accept: "application/json, text/plain, */*", Accept: 'application/json, text/plain, */*'
}, },
body: JSON.stringify({ match }), body: JSON.stringify({ match })
}); })
expect(result).toEqual(mockCalendarData); expect(result).toEqual(mockCalendarData)
}); })
it("postCalendar", async () => { it('postCalendar', async () => {
const calId = "calId"; const calId = 'calId'
const userId = "userId"; const userId = 'userId'
const color = { light: "calId" }; const color = { light: 'calId' }
const name = "new cal"; const name = 'new cal'
const desc = "desc"; 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`, { expect(api.post).toHaveBeenCalledWith(`dav/calendars/${userId}.json`, {
headers: { headers: {
Accept: "application/json, text/plain, */*", Accept: 'application/json, text/plain, */*'
}, },
body: JSON.stringify({ body: JSON.stringify({
id: "calId", id: 'calId',
"dav:name": "new cal", 'dav:name': 'new cal',
"apple:color": "calId", 'apple:color': 'calId',
"caldav:description": "desc", 'caldav:description': 'desc'
}), })
}); })
}); })
it("patch Calendar", async () => { it('patch Calendar', async () => {
const calId = "calId"; const calId = 'calId'
const calLink = "/calendars/calId.json"; const calLink = '/calendars/calId.json'
const color = { light: "calIdLight", dark: "calIdDark" }; const color = { light: 'calIdLight', dark: 'calIdDark' }
const name = "new cal"; const name = 'new cal'
const desc = "desc"; const desc = 'desc'
const result = await proppatchCalendar(calLink, { color, name, desc }); const result = await proppatchCalendar(calLink, { color, name, desc })
expect(api).toHaveBeenCalledWith(`dav${calLink}`, { expect(api).toHaveBeenCalledWith(`dav${calLink}`, {
method: "PROPPATCH", method: 'PROPPATCH',
headers: { headers: {
Accept: "application/json, text/plain, */*", Accept: 'application/json, text/plain, */*'
}, },
body: JSON.stringify({ body: JSON.stringify({
"dav:name": "new cal", 'dav:name': 'new cal',
"caldav:description": "desc", 'caldav:description': 'desc',
"apple:color": "calIdLight", 'apple:color': 'calIdLight'
}), })
}); })
}); })
it("remove Calendar", async () => { it('remove Calendar', async () => {
const calLink = "/calendars/calId.json"; const calLink = '/calendars/calId.json'
const result = await removeCalendar(calLink); const result = await removeCalendar(calLink)
expect(api).toHaveBeenCalledWith(`dav${calLink}`, { expect(api).toHaveBeenCalledWith(`dav${calLink}`, {
method: "DELETE", method: 'DELETE',
headers: { headers: {
Accept: "application/json, text/plain, */*", Accept: 'application/json, text/plain, */*'
}, }
}); })
}); })
it("get secret link without reset", async () => { it('get secret link without reset', async () => {
const calLink = "/calendars/calId.json"; const calLink = '/calendars/calId.json'
(api.get as jest.Mock).mockReturnValue({ ;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue("link"), json: jest.fn().mockResolvedValue('link')
}); })
const noreset = await getSecretLink(calLink, false); const noreset = await getSecretLink(calLink, false)
expect(api.get).toHaveBeenCalledWith( expect(api.get).toHaveBeenCalledWith(
`calendar/api${calLink}/secret-link?shouldResetLink=false`, `calendar/api${calLink}/secret-link?shouldResetLink=false`,
{ {
headers: { headers: {
Accept: "application/json, text/plain, */*", Accept: 'application/json, text/plain, */*'
},
} }
); }
}); )
it("get secret link with reset", async () => { })
const calLink = "/calendars/calId.json"; it('get secret link with reset', async () => {
(api.get as jest.Mock).mockReturnValue({ const calLink = '/calendars/calId.json'
json: jest.fn().mockResolvedValue("link"), ;(api.get as jest.Mock).mockReturnValue({
}); json: jest.fn().mockResolvedValue('link')
const reset = await getSecretLink(calLink, true); })
const reset = await getSecretLink(calLink, true)
expect(api.get).toHaveBeenCalledWith( expect(api.get).toHaveBeenCalledWith(
`calendar/api${calLink}/secret-link?shouldResetLink=true`, `calendar/api${calLink}/secret-link?shouldResetLink=true`,
{ {
headers: { headers: {
Accept: "application/json, text/plain, */*", Accept: 'application/json, text/plain, */*'
},
} }
); }
}); )
})
it("get export data ", async () => { it('get export data ', async () => {
const calLink = "/calendars/calId.json"; const calLink = '/calendars/calId.json'
(api.get as jest.Mock).mockReturnValue({ ;(api.get as jest.Mock).mockReturnValue({
text: jest.fn().mockResolvedValue("data"), text: jest.fn().mockResolvedValue('data')
}); })
const data = await exportCalendar(calLink); const data = await exportCalendar(calLink)
expect(api.get).toHaveBeenCalledWith(`dav${calLink}?export`, { expect(api.get).toHaveBeenCalledWith(`dav${calLink}?export`, {
headers: { headers: {
Accept: "application/calendar", Accept: 'application/calendar'
}, }
}); })
}); })
it("When adding a sharedCal with #default #default is preserved", async () => { it('When adding a sharedCal with #default #default is preserved', async () => {
const mockApiPost = jest.spyOn(api, "post"); const mockApiPost = jest.spyOn(api, 'post')
const calData = { const calData = {
cal: { cal: {
id: "cal123", id: 'cal123',
"dav:name": "#default", 'dav:name': '#default',
"apple:color": "#FF5733", 'apple:color': '#FF5733',
"caldav:description": "Default calendar", 'caldav:description': 'Default calendar',
acl: [], acl: [],
invite: [], invite: [],
_links: { _links: {
self: { self: {
href: "/calendars/owner123/cal123.json", href: '/calendars/owner123/cal123.json'
}, }
}, }
}, },
owner: { owner: {
displayName: "John Doe", displayName: 'John Doe',
email: "john.doe@example.com", email: 'john.doe@example.com',
openpaasId: "owner123", openpaasId: 'owner123'
}, },
color: "#FF5733", color: '#FF5733'
}; }
await addSharedCalendar("currentUserId", "newCalId123", calData); await addSharedCalendar('currentUserId', 'newCalId123', calData)
expect(mockApiPost).toHaveBeenCalledWith( expect(mockApiPost).toHaveBeenCalledWith(
"dav/calendars/currentUserId.json", 'dav/calendars/currentUserId.json',
expect.objectContaining({ 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)); const callBody = JSON.parse(String(mockApiPost.mock.calls[0][1]?.body))
expect(callBody["dav:name"]).toBe("#default"); expect(callBody['dav:name']).toBe('#default')
}); })
}); })
@@ -1,103 +1,103 @@
import { AccessTab } from "@/components/Calendar/AccessTab"; import { AccessTab } from '@/components/Calendar/AccessTab'
import { import {
CalendarAccessRights, CalendarAccessRights,
UserWithAccess, UserWithAccess
} from "@/components/Calendar/CalendarAccessRights"; } from '@/components/Calendar/CalendarAccessRights'
import CalendarPopover from "@/components/Calendar/CalendarModal"; import CalendarPopover from '@/components/Calendar/CalendarModal'
import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar"; import { updateDelegationCalendar } from '@/features/Calendars/api/updateDelegationCalendar'
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes"; import { AccessRight, Calendar } from '@/features/Calendars/CalendarTypes'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync"; import * as delegationThunks from '@/features/Calendars/services/updateDelegationCalendarAsync'
import { getUserDetails } from "@/features/User/userAPI"; import { getUserDetails } from '@/features/User/userAPI'
import { accessRightToDavProp } from "@/utils/accessRightToDavProp"; import { accessRightToDavProp } from '@/utils/accessRightToDavProp'
import { api } from "@/utils/apiUtils"; import { api } from '@/utils/apiUtils'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils", () => ({ jest.mock('@/utils/apiUtils', () => ({
api: { post: jest.fn() }, api: { post: jest.fn() }
})); }))
jest.mock("@/features/User/userAPI", () => ({ jest.mock('@/features/User/userAPI', () => ({
getUserDetails: jest.fn(), getUserDetails: jest.fn()
})); }))
jest.mock("@/features/Calendars/CalendarApi", () => ({ jest.mock('@/features/Calendars/CalendarApi', () => ({
getSecretLink: jest.fn().mockReturnValue(""), getSecretLink: jest.fn().mockReturnValue(''),
exportCalendar: jest.fn(), exportCalendar: jest.fn()
})); }))
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) => const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
jest.fn().mockImplementation(() => { jest.fn().mockImplementation(() => {
const result = Object.assign(Promise.resolve(resolvedValue), { const result = Object.assign(Promise.resolve(resolvedValue), {
unwrap: () => Promise.resolve(resolvedValue), unwrap: () => Promise.resolve(resolvedValue)
}); })
return jest.fn().mockReturnValue(result); return jest.fn().mockReturnValue(result)
}); })
describe("accessRightToDavProp", () => { describe('accessRightToDavProp', () => {
it("maps 5 (ADMIN) → dav:administration", () => { it('maps 5 (ADMIN) → dav:administration', () => {
expect(accessRightToDavProp(5)).toBe("dav:administration"); expect(accessRightToDavProp(5)).toBe('dav:administration')
}); })
it("maps 3 (EDITOR) → dav:read-write", () => { it('maps 3 (EDITOR) → dav:read-write', () => {
expect(accessRightToDavProp(3)).toBe("dav:read-write"); expect(accessRightToDavProp(3)).toBe('dav:read-write')
}); })
it("maps 2 (VIEW) → dav:read", () => { it('maps 2 (VIEW) → dav:read', () => {
expect(accessRightToDavProp(2)).toBe("dav:read"); expect(accessRightToDavProp(2)).toBe('dav:read')
}); })
it("defaults to dav:read for unknown values (covered by default case)", () => { it('defaults to dav:read for unknown values (covered by default case)', () => {
expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read"); expect(accessRightToDavProp(999 as AccessRight)).toBe('dav:read')
}); })
}); })
describe("updateDelegationCalendar", () => { describe('updateDelegationCalendar', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks())
it("posts to the correct DAV endpoint with the share body", async () => { it('posts to the correct DAV endpoint with the share body', async () => {
(api.post as jest.Mock).mockResolvedValue({ ok: true }); ;(api.post as jest.Mock).mockResolvedValue({ ok: true })
const share = { const share = {
set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }], set: [{ 'dav:href': 'mailto:alice@example.com', 'dav:read': true }],
remove: [], remove: []
}; }
await updateDelegationCalendar("/calendars/user/cal1.json", share); await updateDelegationCalendar('/calendars/user/cal1.json', share)
await waitFor(() => await waitFor(() =>
expect(api.post).toHaveBeenCalledWith( expect(api.post).toHaveBeenCalledWith(
"dav/calendars/user/cal1.json", 'dav/calendars/user/cal1.json',
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ share }), body: JSON.stringify({ share })
}) })
) )
); )
}); })
}); })
const baseCalendar: Calendar = { const baseCalendar: Calendar = {
id: "user1/cal1", id: 'user1/cal1',
name: "My Calendar", name: 'My Calendar',
description: "", description: '',
color: { color: "#0062FF", dark: "#FFF" }, color: { color: '#0062FF', dark: '#FFF' },
link: "/calendars/user1/cal1.json", link: '/calendars/user1/cal1.json',
visibility: "public", visibility: 'public',
events: {}, events: {},
invite: [], invite: [],
owner: { emails: ["user1@example.com"] }, owner: { emails: ['user1@example.com'] },
access: { write: true } as any, access: { write: true } as any,
delegated: true, delegated: true
}; }
describe("CalendarAccessRights", () => { describe('CalendarAccessRights', () => {
const mockOnChange = jest.fn(); const mockOnChange = jest.fn()
const mockOnInvitesLoaded = 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( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
calendar={baseCalendar} calendar={baseCalendar}
@@ -106,22 +106,22 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...userState }
); )
expect( expect(
screen.getByText("calendarPopover.access.grantAccessRights") screen.getByText('calendarPopover.access.grantAccessRights')
).toBeInTheDocument(); ).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[] = [ const users: UserWithAccess[] = [
{ {
openpaasId: "user1", openpaasId: 'user1',
displayName: "Alice", displayName: 'Alice',
email: "alice@example.com", email: 'alice@example.com',
accessRight: 2, accessRight: 2
}, }
]; ]
renderWithProviders( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
@@ -130,21 +130,21 @@ describe("CalendarAccessRights", () => {
onChange={mockOnChange} onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/> />
); )
expect(screen.getByText("Alice")).toBeInTheDocument(); expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText("alice@example.com")).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[] = [ const users: UserWithAccess[] = [
{ {
openpaasId: "user1", openpaasId: 'user1',
displayName: "Alice", displayName: 'Alice',
email: "alice@example.com", email: 'alice@example.com',
accessRight: 5, accessRight: 5
}, }
]; ]
renderWithProviders( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
@@ -154,31 +154,31 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...userState }
); )
fireEvent.click(screen.getByLabelText(/remove/i)); fireEvent.click(screen.getByLabelText(/remove/i))
expect(mockOnChange).toHaveBeenCalledWith([]); 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 = { const calendarWithInvite: Calendar = {
...baseCalendar, ...baseCalendar,
invite: [ invite: [
{ {
href: "mailto:bob@example.com", href: 'mailto:bob@example.com',
principal: "/principals/users/bob123", principal: '/principals/users/bob123',
access: 3, access: 3,
inviteStatus: 1, inviteStatus: 1
}, }
], ]
}; }
(getUserDetails as jest.Mock).mockResolvedValue({ ;(getUserDetails as jest.Mock).mockResolvedValue({
preferredEmail: "bob@example.com", preferredEmail: 'bob@example.com',
firstname: "Bob", firstname: 'Bob',
lastname: "Smith", lastname: 'Smith',
emails: ["bob@example.com"], emails: ['bob@example.com']
}); })
renderWithProviders( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
@@ -188,35 +188,35 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...userState }
); )
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123")); await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith('bob123'))
await waitFor(() => await waitFor(() =>
expect(mockOnInvitesLoaded).toHaveBeenCalledWith( expect(mockOnInvitesLoaded).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
email: "bob@example.com", email: 'bob@example.com',
accessRight: 3, accessRight: 3
}), })
]) ])
) )
); )
}); })
it("skips invite entries where getUserDetails throws", async () => { it('skips invite entries where getUserDetails throws', async () => {
const calendarWithInvite: Calendar = { const calendarWithInvite: Calendar = {
...baseCalendar, ...baseCalendar,
invite: [ invite: [
{ {
href: "mailto:ghost@example.com", href: 'mailto:ghost@example.com',
principal: "/principals/users/ghost", principal: '/principals/users/ghost',
access: 2, 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( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
@@ -226,28 +226,28 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...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 = { const calendarWithInvite: Calendar = {
...baseCalendar, ...baseCalendar,
invite: [ invite: [
{ {
href: "mailto:carol@example.com", href: 'mailto:carol@example.com',
principal: "/principals/users/carol", principal: '/principals/users/carol',
access: 2, access: 2,
inviteStatus: 1, inviteStatus: 1
}, }
], ]
}; }
// Never resolves during the assertion window // Never resolves during the assertion window
(getUserDetails as jest.Mock).mockImplementation( ;(getUserDetails as jest.Mock).mockImplementation(
() => new Promise(() => {}) () => new Promise(() => {})
); )
renderWithProviders( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
@@ -257,12 +257,12 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...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( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
calendar={baseCalendar} calendar={baseCalendar}
@@ -271,46 +271,44 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...userState }
); )
expect(screen.getAllByText("user1@example.com").length).toBeGreaterThan(0); expect(screen.getAllByText('user1@example.com').length).toBeGreaterThan(0)
expect( expect(screen.getByText('calendarPopover.access.owner')).toBeInTheDocument()
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 = { const resourceCalendar: any = {
...baseCalendar, ...baseCalendar,
owner: { owner: {
_id: "resource1", _id: 'resource1',
resource: true, resource: true,
emails: ["resource1@example.com"], emails: ['resource1@example.com'],
administrators: [ administrators: [
{ id: "admin1" }, { id: 'admin1' },
{ id: "admin2" }, { id: 'admin2' },
{ id: "resource1" }, // Owner shouldn't be loaded as an admin { id: 'resource1' } // Owner shouldn't be loaded as an admin
], ]
}, }
}; }
(getUserDetails as jest.Mock).mockImplementation((id: string) => { ;(getUserDetails as jest.Mock).mockImplementation((id: string) => {
if (id === "admin1") { if (id === 'admin1') {
return Promise.resolve({ return Promise.resolve({
preferredEmail: "admin1@example.com", preferredEmail: 'admin1@example.com',
firstname: "Admin", firstname: 'Admin',
lastname: "One", lastname: 'One'
}); })
} }
if (id === "admin2") { if (id === 'admin2') {
return Promise.resolve({ return Promise.resolve({
preferredEmail: "admin2@example.com", preferredEmail: 'admin2@example.com',
firstname: "Admin", firstname: 'Admin',
lastname: "Two", lastname: 'Two'
}); })
} }
return Promise.resolve(null); return Promise.resolve(null)
}); })
renderWithProviders( renderWithProviders(
<CalendarAccessRights <CalendarAccessRights
@@ -320,37 +318,37 @@ describe("CalendarAccessRights", () => {
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/>, />,
{ ...userState } { ...userState }
); )
await waitFor(() => { await waitFor(() => {
expect(getUserDetails).toHaveBeenCalledWith("admin1"); expect(getUserDetails).toHaveBeenCalledWith('admin1')
expect(getUserDetails).toHaveBeenCalledWith("admin2"); expect(getUserDetails).toHaveBeenCalledWith('admin2')
}); })
expect(getUserDetails).not.toHaveBeenCalledWith("resource1"); expect(getUserDetails).not.toHaveBeenCalledWith('resource1')
expect(await screen.findByText("Admin One")).toBeInTheDocument(); expect(await screen.findByText('Admin One')).toBeInTheDocument()
expect(screen.getByText("admin1@example.com")).toBeInTheDocument(); expect(screen.getByText('admin1@example.com')).toBeInTheDocument()
expect(screen.getByText("Admin Two")).toBeInTheDocument(); expect(screen.getByText('Admin Two')).toBeInTheDocument()
expect(screen.getByText("admin2@example.com")).toBeInTheDocument(); expect(screen.getByText('admin2@example.com')).toBeInTheDocument()
expect( expect(
screen.getAllByText("calendarPopover.access.administrator") screen.getAllByText('calendarPopover.access.administrator')
).toHaveLength(2); ).toHaveLength(2)
}); })
}); })
const userState = { const userState = {
user: { user: {
userData: { openpaasId: "user1", email: "user1@example.com" }, userData: { openpaasId: 'user1', email: 'user1@example.com' }
}, }
}; }
describe("AccessTab conditional rendering of CalendarAccessRights", () => { describe('AccessTab conditional rendering of CalendarAccessRights', () => {
const noop = jest.fn(); 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( renderWithProviders(
<AccessTab <AccessTab
calendar={baseCalendar} calendar={baseCalendar}
@@ -360,21 +358,21 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
/>, />,
{ {
...userState, ...userState,
calendars: { list: { "user1/cal1": baseCalendar } }, calendars: { list: { 'user1/cal1': baseCalendar } }
} }
); )
expect( expect(
screen.getByText("calendarPopover.access.grantAccessRights") screen.getByText('calendarPopover.access.grantAccessRights')
).toBeInTheDocument(); ).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 = { const foreignCalendar: Calendar = {
...baseCalendar, ...baseCalendar,
id: "otherUser/cal1", id: 'otherUser/cal1',
invite: [], invite: []
}; }
renderWithProviders( renderWithProviders(
<AccessTab <AccessTab
@@ -385,29 +383,29 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
/>, />,
{ {
...userState, ...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( expect(
screen.getByText("calendarPopover.access.accessRights") screen.getByText('calendarPopover.access.accessRights')
).toBeInTheDocument(); ).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 = { const delegatedCalendar: Calendar = {
...baseCalendar, ...baseCalendar,
id: "otherUser/cal1", id: 'otherUser/cal1',
invite: [ invite: [
{ {
href: "mailto:user1@example.com", href: 'mailto:user1@example.com',
principal: "/principals/users/user1", principal: '/principals/users/user1',
access: 5, access: 5,
inviteStatus: 1, inviteStatus: 1
}, }
], ]
}; }
renderWithProviders( renderWithProviders(
<AccessTab <AccessTab
@@ -418,78 +416,78 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
/>, />,
{ {
...userState, ...userState,
calendars: { list: { "otherUser/cal1": delegatedCalendar } }, calendars: { list: { 'otherUser/cal1': delegatedCalendar } }
} }
); )
expect( expect(
screen.getByText("calendarPopover.access.grantAccessRights") screen.getByText('calendarPopover.access.grantAccessRights')
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
}); })
const existingCalendar: Calendar = { const existingCalendar: Calendar = {
id: "user1/cal1", id: 'user1/cal1',
name: "Existing Cal", name: 'Existing Cal',
description: "Desc", description: 'Desc',
color: { color: "#0062FF", dark: "#FFF" }, color: { color: '#0062FF', dark: '#FFF' },
link: "/calendars/user/cal1", link: '/calendars/user/cal1',
visibility: "public", visibility: 'public',
events: {}, events: {},
invite: [], invite: [],
owner: { emails: ["user1@example.com"] }, owner: { emails: ['user1@example.com'] }
}; }
describe("CalendarModal updateDelegationCalendarAsync integration", () => { describe('CalendarModal updateDelegationCalendarAsync integration', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest jest
.spyOn(eventThunks, "patchCalendarAsync") .spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
jest jest
.spyOn(eventThunks, "patchACLCalendarAsync") .spyOn(eventThunks, 'patchACLCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
jest jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync") .spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .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( renderWithProviders(
<CalendarPopover <CalendarPopover
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calendar={existingCalendar} 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( expect(
delegationThunks.updateDelegationCalendarAsync delegationThunks.updateDelegationCalendarAsync
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled()
}); })
}); })
describe("CalendarModal cancel button", () => { describe('CalendarModal cancel button', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync") .spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
jest jest
.spyOn(eventThunks, "patchCalendarAsync") .spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
}); })
it("calls onClose without saving when Cancel is clicked", async () => { it('calls onClose without saving when Cancel is clicked', async () => {
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
open={true} open={true}
@@ -497,14 +495,14 @@ describe("CalendarModal cancel button", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ ...userState } { ...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( expect(
delegationThunks.updateDelegationCalendarAsync delegationThunks.updateDelegationCalendarAsync
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled()
expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled(); expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled()
}); })
}); })
+258 -259
View File
@@ -1,141 +1,141 @@
import CalendarPopover from "@/components/Calendar/CalendarModal"; import CalendarPopover from '@/components/Calendar/CalendarModal'
import { getSecretLink } from "@/features/Calendars/CalendarApi"; import { getSecretLink } from '@/features/Calendars/CalendarApi'
import { Calendar } from "@/features/Calendars/CalendarTypes"; import { Calendar } from '@/features/Calendars/CalendarTypes'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync"; import * as delegationThunks from '@/features/Calendars/services/updateDelegationCalendarAsync'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/features/Calendars/CalendarApi", () => ({ jest.mock('@/features/Calendars/CalendarApi', () => ({
getSecretLink: jest.fn(), getSecretLink: jest.fn()
})); }))
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) => const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
jest.fn().mockImplementation(() => { jest.fn().mockImplementation(() => {
const dispatchResult = Object.assign(Promise.resolve(resolvedValue), { const dispatchResult = Object.assign(Promise.resolve(resolvedValue), {
unwrap: () => Promise.resolve(resolvedValue), unwrap: () => Promise.resolve(resolvedValue)
}); })
return jest.fn().mockReturnValue(dispatchResult); return jest.fn().mockReturnValue(dispatchResult)
}); })
describe("CalendarPopover", () => { describe('CalendarPopover', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync") .spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
}); })
const renderPopover = (open = true) => { const renderPopover = (open = true) => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}
}, },
}, calendars: { list: {}, pending: true }
calendars: { list: {}, pending: true }, }
};
renderWithProviders( renderWithProviders(
<CalendarPopover open={open} onClose={mockOnClose} />, <CalendarPopover open={open} onClose={mockOnClose} />,
preloadedState preloadedState
); )
}; }
it("renders popover and inputs", () => { it('renders popover and inputs', () => {
renderPopover(); renderPopover()
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/Name/i)).toBeInTheDocument()
expect(screen.getByText("event.form.addDescription")).toBeInTheDocument(); expect(screen.getByText('event.form.addDescription')).toBeInTheDocument()
}); })
it("updates name and description fields", () => { it('updates name and description fields', () => {
renderPopover(); renderPopover()
const nameInput = screen.getByLabelText(/Name/i); const nameInput = screen.getByLabelText(/Name/i)
fireEvent.change(nameInput, { target: { value: "My Calendar" } }); fireEvent.change(nameInput, { target: { value: 'My Calendar' } })
expect(nameInput).toHaveValue("My Calendar"); expect(nameInput).toHaveValue('My Calendar')
fireEvent.click(screen.getByText("event.form.addDescription")); fireEvent.click(screen.getByText('event.form.addDescription'))
const descInput = screen.getByLabelText(/Description/i); const descInput = screen.getByLabelText(/Description/i)
fireEvent.change(descInput, { target: { value: "Test description" } }); fireEvent.change(descInput, { target: { value: 'Test description' } })
expect(descInput).toHaveValue("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 jest
.spyOn(eventThunks, "createCalendarAsync") .spyOn(eventThunks, 'createCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
renderPopover(); renderPopover()
fireEvent.change(screen.getByLabelText(/Name/i), { fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Test Calendar" }, target: { value: 'Test Calendar' }
}); })
fireEvent.click(screen.getByText("event.form.addDescription")); fireEvent.click(screen.getByText('event.form.addDescription'))
fireEvent.change(screen.getByLabelText(/Description/i), { fireEvent.change(screen.getByLabelText(/Description/i), {
target: { value: "Test Description" }, target: { value: 'Test Description' }
}); })
const colorButtons = screen.getAllByRole("button", { const colorButtons = screen.getAllByRole('button', {
name: /select color/i, name: /select color/i
}); })
fireEvent.click(colorButtons[0]); fireEvent.click(colorButtons[0])
fireEvent.click(screen.getByRole("button", { name: /Create/i })); fireEvent.click(screen.getByRole('button', { name: /Create/i }))
await waitFor(() => await waitFor(() =>
expect(eventThunks.createCalendarAsync).toHaveBeenCalled() expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
); )
await waitFor(() => await waitFor(() =>
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick") expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick')
); )
}); })
it("calls onClose when Cancel clicked", () => { it('calls onClose when Cancel clicked', () => {
renderPopover(); 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)", () => { describe('CalendarPopover (editing mode)', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const baseUser = { const baseUser = {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, }
}; }
const existingCalendar: Calendar = { const existingCalendar: Calendar = {
id: "user1/cal1", id: 'user1/cal1',
link: "/calendars/user/cal1", link: '/calendars/user/cal1',
name: "Work Calendar", name: 'Work Calendar',
description: "Team meetings", description: 'Team meetings',
color: { light: "#33B679" }, color: { light: '#33B679' },
owner: { firstname: "alice", emails: ["alice@example.com"] }, owner: { firstname: 'alice', emails: ['alice@example.com'] },
visibility: "public", visibility: 'public',
events: {}, events: {}
}; }
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync") .spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
}); })
it("prefills fields when calendar prop is given", () => { it('prefills fields when calendar prop is given', () => {
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
open={true} open={true}
@@ -143,34 +143,34 @@ describe("CalendarPopover (editing mode)", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser } { user: baseUser }
); )
expect(screen.getByLabelText(/Name/i)).toHaveValue("Work Calendar"); expect(screen.getByLabelText(/Name/i)).toHaveValue('Work Calendar')
expect(screen.getByLabelText(/Description/i)).toHaveValue("Team meetings"); 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()} />, { renderWithProviders(<CalendarPopover open={true} onClose={jest.fn()} />, {
user: baseUser, user: baseUser
}); })
const saveButton = screen.getByRole("button", { name: /create/i }); const saveButton = screen.getByRole('button', { name: /create/i })
expect(saveButton).toBeDisabled(); expect(saveButton).toBeDisabled()
// only spaces // only spaces
const nameInput = screen.getByLabelText(/name/i); const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: " " } }); fireEvent.change(nameInput, { target: { value: ' ' } })
expect(saveButton).toBeDisabled(); expect(saveButton).toBeDisabled()
// valid name // valid name
fireEvent.change(nameInput, { target: { value: "Work Calendar" } }); fireEvent.change(nameInput, { target: { value: 'Work Calendar' } })
expect(saveButton).toBeEnabled(); expect(saveButton).toBeEnabled()
}); })
it("allows modifying and saving existing calendar", async () => { it('allows modifying and saving existing calendar', async () => {
jest jest
.spyOn(eventThunks, "patchCalendarAsync") .spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
@@ -179,87 +179,87 @@ describe("CalendarPopover (editing mode)", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser } { user: baseUser }
); )
// Change name // Change name
fireEvent.change(screen.getByLabelText(/Name/i), { fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Updated Calendar" }, target: { value: 'Updated Calendar' }
}); })
// Save // Save
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => await waitFor(() =>
expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith( expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
calId: "user1/cal1", calId: 'user1/cal1',
calLink: "/calendars/user/cal1", calLink: '/calendars/user/cal1',
patch: { patch: {
color: { light: "#33B679" }, color: { light: '#33B679' },
desc: "Team meetings", desc: 'Team meetings',
name: "Updated Calendar", name: 'Updated Calendar'
}, }
}) })
) )
); )
await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); await waitFor(() => expect(mockOnClose).toHaveBeenCalled())
}); })
}); })
describe("CalendarPopover - Tabs Scenarios", () => { describe('CalendarPopover - Tabs Scenarios', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const baseUser = { const baseUser = {
userData: { userData: {
openpaasId: "user1", openpaasId: 'user1'
}, }
}; }
const writeText = jest.fn(); const writeText = jest.fn()
Object.assign(navigator, { Object.assign(navigator, {
clipboard: { clipboard: {
writeText, writeText
}, }
}); })
const existingCalendar: Calendar = { const existingCalendar: Calendar = {
id: "user1/cal1", id: 'user1/cal1',
link: "/calendars/user1/cal1.json", link: '/calendars/user1/cal1.json',
name: "Work Calendar", name: 'Work Calendar',
description: "Team meetings", description: 'Team meetings',
color: { light: "#33B679" }, color: { light: '#33B679' },
owner: { firstname: "alice", emails: ["alice@example.com"] }, owner: { firstname: 'alice', emails: ['alice@example.com'] },
visibility: "public", visibility: 'public',
events: {}, events: {}
}; }
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync") .spyOn(delegationThunks, 'updateDelegationCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
}); })
it("resets state after closing and reopening", () => { it('resets state after closing and reopening', () => {
const { rerender } = renderWithProviders( const { rerender } = renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />, <CalendarPopover open={true} onClose={mockOnClose} />,
{ user: baseUser } { user: baseUser }
); )
// Enter some data // Enter some data
fireEvent.change(screen.getByLabelText(/Name/i), { fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Temp Calendar" }, target: { value: 'Temp Calendar' }
}); })
fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); fireEvent.click(screen.getByRole('button', { name: /Cancel/i }))
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled()
// Reopen: state should be reset // Reopen: state should be reset
rerender(<CalendarPopover open={true} onClose={mockOnClose} />); rerender(<CalendarPopover open={true} onClose={mockOnClose} />)
expect(screen.getByLabelText(/Name/i)).toHaveValue(""); 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( renderWithProviders(
<CalendarPopover <CalendarPopover
open={true} open={true}
@@ -267,29 +267,29 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser } { 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} />, { renderWithProviders(<CalendarPopover open={true} onClose={mockOnClose} />, {
user: baseUser, user: baseUser
}); })
expect( expect(
screen.queryByRole("tab", { name: /Access/i }) screen.queryByRole('tab', { name: /Access/i })
).not.toBeInTheDocument(); ).not.toBeInTheDocument()
}); })
it("patches ACL when visibility changes", async () => { it('patches ACL when visibility changes', async () => {
jest jest
.spyOn(eventThunks, "patchCalendarAsync") .spyOn(eventThunks, 'patchCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
jest jest
.spyOn(eventThunks, "patchACLCalendarAsync") .spyOn(eventThunks, 'patchACLCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
@@ -298,42 +298,42 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser } { user: baseUser }
); )
// By default: "All" (public) is selected // By default: "All" (public) is selected
const publicButton = screen.getByRole("button", { name: /All/i }); const publicButton = screen.getByRole('button', { name: /All/i })
const privateButton = screen.getByRole("button", { name: /You/i }); const privateButton = screen.getByRole('button', { name: /You/i })
expect(publicButton).toHaveAttribute("aria-pressed", "true"); expect(publicButton).toHaveAttribute('aria-pressed', 'true')
expect(privateButton).toHaveAttribute("aria-pressed", "false"); expect(privateButton).toHaveAttribute('aria-pressed', 'false')
// Change to private // Change to private
fireEvent.click(privateButton); fireEvent.click(privateButton)
expect(privateButton).toHaveAttribute("aria-pressed", "true"); expect(privateButton).toHaveAttribute('aria-pressed', 'true')
expect(publicButton).toHaveAttribute("aria-pressed", "false"); expect(publicButton).toHaveAttribute('aria-pressed', 'false')
// Save // Save
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => await waitFor(() =>
expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith( expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
calId: "user1/cal1", calId: 'user1/cal1',
request: "", request: ''
}) })
) )
); )
}); })
it("copies CalDAV link from Access tab", async () => { it('copies CalDAV link from Access tab', async () => {
window.DAV_BASE_URL = "https://cal.example.org"; window.DAV_BASE_URL = 'https://cal.example.org'
Object.assign(navigator, { Object.assign(navigator, {
clipboard: { writeText: jest.fn() }, clipboard: { writeText: jest.fn() }
}); })
(getSecretLink as jest.Mock).mockResolvedValue({ ;(getSecretLink as jest.Mock).mockResolvedValue({
secretLink: "https://example.org/secret/initial", secretLink: 'https://example.org/secret/initial'
}); })
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
@@ -342,79 +342,79 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser } { user: baseUser }
); )
// Switch to Access tab // 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 // Expect text field with caldav link
const input = screen.getByLabelText("calendar.caldav_access"); const input = screen.getByLabelText('calendar.caldav_access')
expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1"); expect(input).toHaveValue('https://cal.example.org/calendars/user1/cal1')
// Click copy button (find button containing ContentCopyIcon) // Click copy button (find button containing ContentCopyIcon)
const copyIcon = screen.getAllByTestId("ContentCopyIcon")[0]; const copyIcon = screen.getAllByTestId('ContentCopyIcon')[0]
const copyButton = copyIcon.closest("button"); const copyButton = copyIcon.closest('button')
if (copyButton) { if (copyButton) {
fireEvent.click(copyButton); fireEvent.click(copyButton)
} }
expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"https://cal.example.org/calendars/user1/cal1" 'https://cal.example.org/calendars/user1/cal1'
); )
// Snackbar should appear // Snackbar should appear
await waitFor(() => await waitFor(() =>
expect(screen.getByText("common.link_copied")).toBeInTheDocument() expect(screen.getByText('common.link_copied')).toBeInTheDocument()
); )
}); })
describe("Import flow", () => { describe('Import flow', () => {
const file = new File(["test"], "events.ics", { type: "text/calendar" }); const file = new File(['test'], 'events.ics', { type: 'text/calendar' })
it("creates a new calendar and imports events when Import with 'new' target", async () => { it("creates a new calendar and imports events when Import with 'new' target", async () => {
jest jest
.spyOn(eventThunks, "createCalendarAsync") .spyOn(eventThunks, 'createCalendarAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
jest jest
.spyOn(eventThunks, "importEventFromFileAsync") .spyOn(eventThunks, 'importEventFromFileAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
renderWithProviders( renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />, <CalendarPopover open={true} onClose={mockOnClose} />,
{ {
user: baseUser, user: baseUser
} }
); )
// Switch to Import tab // Switch to Import tab
fireEvent.click(screen.getByRole("tab", { name: /Import/i })); fireEvent.click(screen.getByRole('tab', { name: /Import/i }))
// Provide new calendar params // Provide new calendar params
fireEvent.change(screen.getByLabelText(/Name/i), { fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Imported Calendar" }, target: { value: 'Imported Calendar' }
}); })
const fileInput = screen.getByLabelText("common.select_file"); const fileInput = screen.getByLabelText('common.select_file')
fireEvent.change(fileInput, { target: { files: [file] } }); fireEvent.change(fileInput, { target: { files: [file] } })
// Click Import // Click Import
fireEvent.click(screen.getByRole("button", { name: "actions.import" })); fireEvent.click(screen.getByRole('button', { name: 'actions.import' }))
await waitFor(() => await waitFor(() =>
expect(eventThunks.createCalendarAsync).toHaveBeenCalled() expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
); )
await waitFor(() => await waitFor(() =>
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled() 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 jest
.spyOn(eventThunks, "importEventFromFileAsync") .spyOn(eventThunks, 'importEventFromFileAsync')
.mockImplementation(mockThunkWithUnwrap()); .mockImplementation(mockThunkWithUnwrap())
const calendars = { const calendars = {
"user1/cal1": existingCalendar, 'user1/cal1': existingCalendar
}; }
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
@@ -423,51 +423,50 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser, calendars: { list: calendars } } { user: baseUser, calendars: { list: calendars } }
); )
fireEvent.click(screen.getByRole("tab", { name: /Import/i })); fireEvent.click(screen.getByRole('tab', { name: /Import/i }))
const fileInput = screen.getByLabelText("common.select_file"); const fileInput = screen.getByLabelText('common.select_file')
fireEvent.change(fileInput, { target: { files: [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(() => await waitFor(() =>
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith( expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
calLink: "/calendars/user1/cal1.json", calLink: '/calendars/user1/cal1.json',
file, file
}) })
) )
); )
}); })
it("disables Import button until a file is uploaded", () => { it('disables Import button until a file is uploaded', () => {
renderWithProviders( renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />, <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", { const importButton = screen.getByRole('button', {
name: "actions.import", name: 'actions.import'
}); })
expect(importButton).toBeDisabled(); expect(importButton).toBeDisabled()
}); })
}); })
it("fetches and resets the secret link", async () => { it('fetches and resets the secret link', async () => {
window.DAV_BASE_URL = "https://cal.example.org"; window.DAV_BASE_URL = 'https://cal.example.org'
;(getSecretLink as jest.Mock)
(getSecretLink as jest.Mock)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
secretLink: "https://example.org/secret/initial", secretLink: 'https://example.org/secret/initial'
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
secretLink: "https://example.org/secret/new", secretLink: 'https://example.org/secret/new'
}); })
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
@@ -476,27 +475,27 @@ describe("CalendarPopover - Tabs Scenarios", () => {
calendar={existingCalendar} calendar={existingCalendar}
/>, />,
{ user: baseUser } { user: baseUser }
); )
fireEvent.click(screen.getByRole("tab", { name: /Access/i })); fireEvent.click(screen.getByRole('tab', { name: /Access/i }))
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByDisplayValue("https://example.org/secret/initial") screen.getByDisplayValue('https://example.org/secret/initial')
).toBeInTheDocument() ).toBeInTheDocument()
); )
fireEvent.click(screen.getByRole("button", { name: /reset/i })); fireEvent.click(screen.getByRole('button', { name: /reset/i }))
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByDisplayValue("https://example.org/secret/new") screen.getByDisplayValue('https://example.org/secret/new')
).toBeInTheDocument() ).toBeInTheDocument()
); )
expect(getSecretLink).toHaveBeenCalledWith( expect(getSecretLink).toHaveBeenCalledWith(
existingCalendar.link.replace(".json", ""), existingCalendar.link.replace('.json', ''),
true 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, { import reducer, {
addEvent, addEvent,
createCalendar, createCalendar,
removeEvent, removeEvent,
removeTempCal, removeTempCal,
updateEventLocal, updateEventLocal
} from "@/features/Calendars/CalendarSlice"; } from '@/features/Calendars/CalendarSlice'
import { Calendar } from "@/features/Calendars/CalendarTypes"; import { Calendar } from '@/features/Calendars/CalendarTypes'
import { import {
addSharedCalendarAsync, addSharedCalendarAsync,
createCalendarAsync, createCalendarAsync,
@@ -14,523 +14,523 @@ import {
getCalendarsListAsync, getCalendarsListAsync,
getEventAsync, getEventAsync,
getTempCalendarsListAsync, getTempCalendarsListAsync,
patchACLCalendarAsync, patchACLCalendarAsync
} from "@/features/Calendars/services"; } from '@/features/Calendars/services'
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from '@/features/Events/EventsTypes'
import * as userAPI from "@/features/User/userAPI"; import * as userAPI from '@/features/User/userAPI'
import userReducer, { setUserData } from "@/features/User/userSlice"; import userReducer, { setUserData } from '@/features/User/userSlice'
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from '@reduxjs/toolkit'
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
jest.mock("@/features/User/userAPI"); jest.mock('@/features/User/userAPI')
jest.mock("@/features/Events/EventApi"); jest.mock('@/features/Events/EventApi')
jest.mock("@/features/Events/utils"); jest.mock('@/features/Events/utils')
jest.mock("@/utils/apiUtils"); jest.mock('@/utils/apiUtils')
describe("CalendarSlice", () => { describe('CalendarSlice', () => {
const initialState = { const initialState = {
list: {}, list: {},
templist: {}, templist: {},
pending: false, pending: false,
error: null, error: null
}; }
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks()
}); })
describe("reducers", () => { describe('reducers', () => {
it("createCalendar adds new calendar", () => { it('createCalendar adds new calendar', () => {
const action = createCalendar({ const action = createCalendar({
name: "Test Cal", name: 'Test Cal',
color: "#ff0000", color: '#ff0000',
description: "desc", description: 'desc'
}); })
const state = reducer(initialState, action); const state = reducer(initialState, action)
const values = Object.values(state.list); const values = Object.values(state.list)
expect(values[0].name).toBe("Test Cal"); expect(values[0].name).toBe('Test Cal')
expect(values[0].color).toBe("#ff0000"); expect(values[0].color).toBe('#ff0000')
}); })
it("addEvent adds event to calendar", () => { it('addEvent adds event to calendar', () => {
const calId = "user/cal"; const calId = 'user/cal'
const event = { uid: "event1", title: "My Event" } as any; const event = { uid: 'event1', title: 'My Event' } as any
const stateWithCal = { const stateWithCal = {
...initialState, ...initialState,
list: { [calId]: { id: calId, events: {} } as any }, list: { [calId]: { id: calId, events: {} } as any }
}; }
const state = reducer( const state = reducer(
stateWithCal, stateWithCal,
addEvent({ calendarUid: calId, event }) addEvent({ calendarUid: calId, event })
); )
expect(state.list[calId].events["event1"]).toEqual( expect(state.list[calId].events['event1']).toEqual(
expect.objectContaining({ uid: "event1" }) expect.objectContaining({ uid: 'event1' })
); )
}); })
it("removeEvent deletes event", () => { it('removeEvent deletes event', () => {
const calId = "user/cal"; const calId = 'user/cal'
const stateWithEvent = { const stateWithEvent = {
...initialState, ...initialState,
list: { list: {
[calId]: { [calId]: {
id: calId, id: calId,
events: { e1: { uid: "e1" } }, events: { e1: { uid: 'e1' } }
} as unknown as Calendar, } as unknown as Calendar
}, }
}; }
const state = reducer( const state = reducer(
stateWithEvent, stateWithEvent,
removeEvent({ calendarUid: calId, eventUid: "e1" }) removeEvent({ calendarUid: calId, eventUid: 'e1' })
); )
expect(state.list[calId].events).toEqual({}); expect(state.list[calId].events).toEqual({})
}); })
it("updateEventLocal updates an event", () => { it('updateEventLocal updates an event', () => {
const calId = "user/cal"; const calId = 'user/cal'
const stateWithEvent = { const stateWithEvent = {
...initialState, ...initialState,
list: { list: {
[calId]: { [calId]: {
id: calId, id: calId,
events: { e1: { uid: "e1", title: "Old" } }, events: { e1: { uid: 'e1', title: 'Old' } }
} as unknown as Calendar, } as unknown as Calendar
}, }
}; }
const state = reducer( const state = reducer(
stateWithEvent, stateWithEvent,
updateEventLocal({ calId, event: { uid: "e1", title: "New" } as any }) updateEventLocal({ calId, event: { uid: 'e1', title: 'New' } as any })
); )
expect(state.list[calId].events.e1.title).toBe("New"); expect(state.list[calId].events.e1.title).toBe('New')
}); })
it("removeTempCal deletes temp calendar", () => { it('removeTempCal deletes temp calendar', () => {
const stateWithTemp = { const stateWithTemp = {
...initialState, ...initialState,
templist: { temp1: { id: "temp1" } as any }, templist: { temp1: { id: 'temp1' } as any }
}; }
const state = reducer(stateWithTemp, removeTempCal("temp1")); const state = reducer(stateWithTemp, removeTempCal('temp1'))
expect(state.templist).toEqual({}); expect(state.templist).toEqual({})
}); })
}); })
describe("extraReducers (thunks)", () => { describe('extraReducers (thunks)', () => {
const storeFactory = () => const storeFactory = () =>
configureStore({ configureStore({
reducer: { calendars: reducer, user: userReducer }, reducer: { calendars: reducer, user: userReducer }
}); })
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks()
}); })
it("getCalendarsListAsync.fulfilled replaces list", async () => { it('getCalendarsListAsync.fulfilled replaces list', async () => {
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' })
(calAPI.getCalendars as jest.Mock).mockResolvedValue({ ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({
_embedded: { "dav:calendar": [] }, _embedded: { 'dav:calendar': [] }
}); })
(userAPI.getUserDetails as jest.Mock).mockResolvedValue({ ;(userAPI.getUserDetails as jest.Mock).mockResolvedValue({
firstname: "Alice", firstname: 'Alice',
lastname: "Smith", lastname: 'Smith',
emails: ["a@b.com"], emails: ['a@b.com']
}); })
const store = storeFactory(); const store = storeFactory()
await store.dispatch(getCalendarsListAsync() as any); await store.dispatch(getCalendarsListAsync() as any)
const state = store.getState().calendars; const state = store.getState().calendars
expect(state.list).toEqual({}); 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 = [ const mockCalendars = [
{ {
_links: { self: { href: "/calendars/u1/cal1.json" } }, _links: { self: { href: '/calendars/u1/cal1.json' } },
"dav:name": "Calendar 1", 'dav:name': 'Calendar 1',
"apple:color": "#FF0000", 'apple:color': '#FF0000',
acl: [], acl: []
}, },
{ {
_links: { self: { href: "/calendars/u2/cal2.json" } }, _links: { self: { href: '/calendars/u2/cal2.json' } },
"dav:name": "Calendar 2", 'dav:name': 'Calendar 2',
"apple:color": "#00FF00", 'apple:color': '#00FF00',
acl: [], acl: []
}, },
{ {
_links: { self: { href: "/calendars/u3/cal3.json" } }, _links: { self: { href: '/calendars/u3/cal3.json' } },
"dav:name": "Calendar 3", 'dav:name': 'Calendar 3',
"apple:color": "#0000FF", 'apple:color': '#0000FF',
acl: [], acl: []
}, }
]; ]
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' })
(calAPI.getCalendars as jest.Mock).mockResolvedValue({ ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({
_embedded: { "dav:calendar": mockCalendars }, _embedded: { 'dav:calendar': mockCalendars }
}); })
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock getUserDetailsMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
firstname: "Alice", firstname: 'Alice',
lastname: "Smith", lastname: 'Smith',
emails: ["alice@example.com"], emails: ['alice@example.com']
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
firstname: "Bob", firstname: 'Bob',
lastname: "Jones", lastname: 'Jones',
emails: ["bob@example.com"], emails: ['bob@example.com']
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
firstname: "Charlie", firstname: 'Charlie',
lastname: "Brown", lastname: 'Brown',
emails: ["charlie@example.com"], emails: ['charlie@example.com']
}); })
const store = storeFactory(); const store = storeFactory()
await store.dispatch(getCalendarsListAsync() as any); await store.dispatch(getCalendarsListAsync() as any)
expect(getUserDetailsMock).toHaveBeenCalledTimes(3); expect(getUserDetailsMock).toHaveBeenCalledTimes(3)
expect(getUserDetailsMock).toHaveBeenCalledWith("u1"); expect(getUserDetailsMock).toHaveBeenCalledWith('u1')
expect(getUserDetailsMock).toHaveBeenCalledWith("u2"); expect(getUserDetailsMock).toHaveBeenCalledWith('u2')
expect(getUserDetailsMock).toHaveBeenCalledWith("u3"); expect(getUserDetailsMock).toHaveBeenCalledWith('u3')
const state = store.getState().calendars; const state = store.getState().calendars
expect(state.list["u1/cal1"].owner.firstname).toContain("Alice"); expect(state.list['u1/cal1'].owner.firstname).toContain('Alice')
expect(state.list["u2/cal2"].owner.firstname).toContain("Bob"); expect(state.list['u2/cal2'].owner.firstname).toContain('Bob')
expect(state.list["u3/cal3"].owner.firstname).toContain("Charlie"); 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 = [ const mockCalendars = [
{ {
_links: { self: { href: "/calendars/u1/cal1.json" } }, _links: { self: { href: '/calendars/u1/cal1.json' } },
"dav:name": "Calendar 1", 'dav:name': 'Calendar 1',
"apple:color": "#FF0000", 'apple:color': '#FF0000',
acl: [], acl: []
}, },
{ {
_links: { self: { href: "/calendars/u1/cal2.json" } }, _links: { self: { href: '/calendars/u1/cal2.json' } },
"dav:name": "Calendar 2", 'dav:name': 'Calendar 2',
"apple:color": "#00FF00", 'apple:color': '#00FF00',
acl: [], acl: []
}, },
{ {
_links: { self: { href: "/calendars/u1/cal3.json" } }, _links: { self: { href: '/calendars/u1/cal3.json' } },
"dav:name": "Calendar 3", 'dav:name': 'Calendar 3',
"apple:color": "#0000FF", 'apple:color': '#0000FF',
acl: [], acl: []
}, }
]; ]
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' })
(calAPI.getCalendars as jest.Mock).mockResolvedValue({ ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({
_embedded: { "dav:calendar": mockCalendars }, _embedded: { 'dav:calendar': mockCalendars }
}); })
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock.mockResolvedValue({ getUserDetailsMock.mockResolvedValue({
firstname: "Alice", firstname: 'Alice',
lastname: "Smith", lastname: 'Smith',
emails: ["alice@example.com"], emails: ['alice@example.com']
}); })
const store = storeFactory(); const store = storeFactory()
await store.dispatch(getCalendarsListAsync() as any); await store.dispatch(getCalendarsListAsync() as any)
expect(getUserDetailsMock).toHaveBeenCalledTimes(1); expect(getUserDetailsMock).toHaveBeenCalledTimes(1)
expect(getUserDetailsMock).toHaveBeenCalledWith("u1"); 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) => ({ const mockCalendars = Array.from({ length: 45 }, (_, i) => ({
_links: { self: { href: `/calendars/u${i + 1}/cal${i + 1}.json` } }, _links: { self: { href: `/calendars/u${i + 1}/cal${i + 1}.json` } },
"dav:name": `Calendar ${i + 1}`, 'dav:name': `Calendar ${i + 1}`,
"apple:color": "#FF0000", 'apple:color': '#FF0000',
acl: [], acl: []
})); }))
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' })
(calAPI.getCalendars as jest.Mock).mockResolvedValue({ ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({
_embedded: { "dav:calendar": mockCalendars }, _embedded: { 'dav:calendar': mockCalendars }
}); })
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock.mockImplementation((ownerId: string) => getUserDetailsMock.mockImplementation((ownerId: string) =>
Promise.resolve({ Promise.resolve({
firstname: "User", firstname: 'User',
lastname: ownerId, lastname: ownerId,
emails: [`${ownerId}@example.com`], emails: [`${ownerId}@example.com`]
}) })
); )
const store = storeFactory(); const store = storeFactory()
await store.dispatch(getCalendarsListAsync() as any); await store.dispatch(getCalendarsListAsync() as any)
expect(getUserDetailsMock).toHaveBeenCalledTimes(45); expect(getUserDetailsMock).toHaveBeenCalledTimes(45)
const state = store.getState().calendars; const state = store.getState().calendars
expect(Object.keys(state.list)).toHaveLength(45); 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 = { const existingCalendars = {
"u1/cal1": { 'u1/cal1': {
id: "u1/cal1", id: 'u1/cal1',
name: "Existing Calendar", name: 'Existing Calendar',
events: {}, events: {}
} as Calendar, } as Calendar
}; }
const store = storeFactory(); const store = storeFactory()
store.dispatch({ store.dispatch({
type: "calendars/getCalendars/fulfilled", type: 'calendars/getCalendars/fulfilled',
payload: { importedCalendars: existingCalendars, errors: "" }, payload: { importedCalendars: existingCalendars, errors: '' }
}); })
store.dispatch(setUserData({ openpaasId: "bla" })); store.dispatch(setUserData({ openpaasId: 'bla' }))
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; 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; const state = store.getState().calendars
expect(state.list).toEqual(existingCalendars); expect(state.list).toEqual(existingCalendars)
}); })
it("getCalendarsListAsync handles errors in getUserDetails gracefully", async () => { it('getCalendarsListAsync handles errors in getUserDetails gracefully', async () => {
const mockCalendars = [ const mockCalendars = [
{ {
_links: { self: { href: "/calendars/u1/cal1.json" } }, _links: { self: { href: '/calendars/u1/cal1.json' } },
"dav:name": "Calendar 1", 'dav:name': 'Calendar 1',
"apple:color": "#FF0000", 'apple:color': '#FF0000',
acl: [], acl: []
}, },
{ {
_links: { self: { href: "/calendars/u2/cal2.json" } }, _links: { self: { href: '/calendars/u2/cal2.json' } },
"dav:name": "Calendar 2", 'dav:name': 'Calendar 2',
"apple:color": "#00FF00", 'apple:color': '#00FF00',
acl: [], acl: []
}, }
]; ]
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' })
(calAPI.getCalendars as jest.Mock).mockResolvedValue({ ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({
_embedded: { "dav:calendar": mockCalendars }, _embedded: { 'dav:calendar': mockCalendars }
}); })
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; const getUserDetailsMock = userAPI.getUserDetails as jest.Mock
getUserDetailsMock getUserDetailsMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
firstname: "Alice", firstname: 'Alice',
lastname: "Smith", lastname: 'Smith',
emails: ["alice@example.com"], emails: ['alice@example.com']
}) })
.mockRejectedValueOnce(new Error("Failed to fetch user")); .mockRejectedValueOnce(new Error('Failed to fetch user'))
const store = storeFactory(); const store = storeFactory()
const result = await store.dispatch(getCalendarsListAsync() as any); const result = await store.dispatch(getCalendarsListAsync() as any)
expect(getUserDetailsMock).toHaveBeenCalledTimes(2); expect(getUserDetailsMock).toHaveBeenCalledTimes(2)
const state = store.getState().calendars; const state = store.getState().calendars
expect(state.list["u1/cal1"].owner.firstname).toContain("Alice"); expect(state.list['u1/cal1'].owner.firstname).toContain('Alice')
expect(state.list["u2/cal2"].owner.lastname).toContain("Unknown User"); expect(state.list['u2/cal2'].owner.lastname).toContain('Unknown User')
expect(result.payload.errors).toBeTruthy(); expect(result.payload.errors).toBeTruthy()
}); })
it("patchACLCalendarAsync.fulfilled sets visibility", () => { it('patchACLCalendarAsync.fulfilled sets visibility', () => {
const prev = { const prev = {
...initialState, ...initialState,
list: { c1: { id: "c1", visibility: "public" } as any }, list: { c1: { id: 'c1', visibility: 'public' } as any }
}; }
const state = reducer( const state = reducer(
prev, prev,
patchACLCalendarAsync.fulfilled( patchACLCalendarAsync.fulfilled(
{ calId: "c1", calLink: "l", request: "" }, { calId: 'c1', calLink: 'l', request: '' },
"req3", 'req3',
{ calId: "c1", calLink: "l", request: "" } { 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 = { const payload = {
userData: { userData: {
openpaasId: "u1", openpaasId: 'u1',
family_name: "Owner", family_name: 'Owner',
email: "o@example.com", email: 'o@example.com',
sid: "", sid: '',
sub: "", sub: '',
given_name: "Test", given_name: 'Test',
name: "Test Owner", name: 'Test Owner'
}, },
calId: "cal1", calId: 'cal1',
color: { "apple:color": "#f00" }, color: { 'apple:color': '#f00' },
name: "Test", name: 'Test',
desc: "Desc", desc: 'Desc'
}; }
const payloadResponse = { const payloadResponse = {
userId: "u1", userId: 'u1',
calId: "cal1", calId: 'cal1',
color: { "apple:color": "#f00" }, color: { 'apple:color': '#f00' },
name: "Test", name: 'Test',
desc: "Desc", desc: 'Desc',
owner: { firstname: "Owner", emails: ["o@example.com"] }, owner: { firstname: 'Owner', emails: ['o@example.com'] }
}; }
const state = reducer( const state = reducer(
initialState, initialState,
createCalendarAsync.fulfilled(payloadResponse, "req4", payload) createCalendarAsync.fulfilled(payloadResponse, 'req4', payload)
); )
expect(state.list["u1/cal1"].name).toBe("Test"); expect(state.list['u1/cal1'].name).toBe('Test')
expect(state.list["u1/cal1"].color?.["apple:color"]).toBe("#f00"); expect(state.list['u1/cal1'].color?.['apple:color']).toBe('#f00')
}); })
it("addSharedCalendarAsync.fulfilled adds shared calendar", () => { it('addSharedCalendarAsync.fulfilled adds shared calendar', () => {
const payload = { const payload = {
calId: "c1", calId: 'c1',
color: { "apple:color": "#0f0" }, color: { 'apple:color': '#0f0' },
link: "/calendars/u1/c1.json", link: '/calendars/u1/c1.json',
name: "Shared", name: 'Shared',
desc: "Shared Desc", desc: 'Shared Desc',
owner: { firstname: "O", emails: ["o@example.com"] }, owner: { firstname: 'O', emails: ['o@example.com'] }
}; }
const mockCal = { const mockCal = {
cal: { cal: {
_links: { self: { href: "/calendars/u1/c1.json" } }, _links: { self: { href: '/calendars/u1/c1.json' } },
"apple:color": "#0f0", 'apple:color': '#0f0',
"caldav:description": "Shared Desc", 'caldav:description': 'Shared Desc',
"dav:name": "Shared", 'dav:name': 'Shared'
}, }
}; }
const state = reducer( const state = reducer(
initialState, initialState,
addSharedCalendarAsync.fulfilled(payload, "req5", { addSharedCalendarAsync.fulfilled(payload, 'req5', {
userId: "u1", userId: 'u1',
calId: "c1", calId: 'c1',
cal: mockCal, 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 = { const payload = {
t1: { t1: {
id: "t1", id: 't1',
name: "Temp", name: 'Temp',
color: { "apple:color": "#aaa" }, color: { 'apple:color': '#aaa' },
events: {}, events: {},
visibility: "public", visibility: 'public',
owner: { firstname: "O", emails: ["o@o.com"] }, owner: { firstname: 'O', emails: ['o@o.com'] },
link: "/calendars/t1.json", link: '/calendars/t1.json',
description: "desc", description: 'desc'
} as Calendar, } as Calendar
}; }
const state = reducer( const state = reducer(
initialState, initialState,
getTempCalendarsListAsync.fulfilled(payload, "req7", { getTempCalendarsListAsync.fulfilled(payload, 'req7', {
openpaasId: "u1", openpaasId: 'u1',
color: { "apple:color": "#aaa" }, color: { 'apple:color': '#aaa' },
displayName: "test", displayName: 'test',
avatarUrl: "", avatarUrl: '',
email: "test@test.com", email: 'test@test.com'
})
)
expect(state.templist.t1.name).toBe('Temp')
}) })
);
expect(state.templist.t1.name).toBe("Temp");
});
it("getEventAsync.fulfilled adds single event", () => { it('getEventAsync.fulfilled adds single event', () => {
const payload = { calId: "c1", event: { uid: "e1" } as any }; const payload = { calId: 'c1', event: { uid: 'e1' } as any }
const state = reducer( const state = reducer(
initialState, initialState,
getEventAsync.fulfilled(payload, "req9", { uid: "e1" } as any) getEventAsync.fulfilled(payload, 'req9', { uid: 'e1' } as any)
); )
expect(state.list.c1.events.e1.uid).toBe("e1"); expect(state.list.c1.events.e1.uid).toBe('e1')
}); })
it("getCalendarDetailAsync.fulfilled adds calendar events", () => { it('getCalendarDetailAsync.fulfilled adds calendar events', () => {
const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] }; const payload = { calId: 'c1', events: [{ uid: 'e1' }] as any[] }
const state = reducer( const state = reducer(
{ {
...initialState, ...initialState,
list: { list: {
["c1"]: { ['c1']: {
id: "c1", id: 'c1',
events: {}, events: {}
} as unknown as Calendar, } as unknown as Calendar
}
}, },
}, getCalendarDetailAsync.fulfilled(payload, 'req11', {
getCalendarDetailAsync.fulfilled(payload, "req11", { calId: 'c1',
calId: "c1", match: { start: '', end: '' }
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", () => { it('getEventAsync.fulfilled doesnt create new events when there are already event with base UID', () => {
const baseUid = "recurring-event-base"; const baseUid = 'recurring-event-base'
const existingEvent = { const existingEvent = {
uid: `${baseUid}/20240115`, uid: `${baseUid}/20240115`,
title: "Existing Recurring Event", title: 'Existing Recurring Event',
recurrenceId: null, recurrenceId: null
} as unknown as CalendarEvent; } as unknown as CalendarEvent
const newEventInstance = { const newEventInstance = {
uid: baseUid, uid: baseUid,
title: "Fetched Master Event", title: 'Fetched Master Event',
recurrenceId: "20240115", recurrenceId: '20240115'
} as CalendarEvent; } as CalendarEvent
const stateWithEvent = { const stateWithEvent = {
...initialState, ...initialState,
list: { list: {
c1: { c1: {
id: "c1", id: 'c1',
events: { [`${baseUid}/20240115`]: existingEvent }, events: { [`${baseUid}/20240115`]: existingEvent }
} as unknown as Calendar, } as unknown as Calendar
}, }
}; }
const payload = { calId: "c1", event: newEventInstance }; const payload = { calId: 'c1', event: newEventInstance }
const state = reducer( const state = reducer(
stateWithEvent, stateWithEvent,
getEventAsync.fulfilled(payload, "req", newEventInstance) getEventAsync.fulfilled(payload, 'req', newEventInstance)
); )
// Should still only have the base event, not the instance // Should still only have the base event, not the instance
expect(Object.keys(state.list.c1.events)).toHaveLength(1); expect(Object.keys(state.list.c1.events)).toHaveLength(1)
expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined(); expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined()
expect(state.list.c1.events[baseUid]).toBeUndefined(); expect(state.list.c1.events[baseUid]).toBeUndefined()
}); })
it("getEventAsync.fulfilled create new event when there isn't any event with base UID", () => { 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 = { const newEvent = {
uid: eventUid, uid: eventUid,
title: "New Event", title: 'New Event',
recurrenceId: null, recurrenceId: null
} as unknown as CalendarEvent; } as unknown as CalendarEvent
const stateWithoutEvent = { const stateWithoutEvent = {
...initialState, ...initialState,
list: { list: {
c1: { c1: {
id: "c1", id: 'c1',
events: {}, events: {}
} as unknown as Calendar, } as unknown as Calendar
}, }
}; }
const payload = { calId: "c1", event: newEvent }; const payload = { calId: 'c1', event: newEvent }
const state = reducer( const state = reducer(
stateWithoutEvent, stateWithoutEvent,
getEventAsync.fulfilled(payload, "req", newEvent) getEventAsync.fulfilled(payload, 'req', newEvent)
); )
// Should create the new event // Should create the new event
expect(Object.keys(state.list.c1.events)).toHaveLength(1); expect(Object.keys(state.list.c1.events)).toHaveLength(1)
expect(state.list.c1.events[eventUid]).toBeDefined(); expect(state.list.c1.events[eventUid]).toBeDefined()
expect(state.list.c1.events[eventUid].uid).toBe(eventUid); expect(state.list.c1.events[eventUid].uid).toBe(eventUid)
expect(state.list.c1.events[eventUid].title).toBe("New Event"); expect(state.list.c1.events[eventUid].title).toBe('New Event')
}); })
}); })
}); })
@@ -1,196 +1,196 @@
import CalendarApp from "@/components/Calendar/Calendar"; import CalendarApp from '@/components/Calendar/Calendar'
import * as calendarUtils from "@/components/Calendar/utils/calendarUtils"; import * as calendarUtils from '@/components/Calendar/utils/calendarUtils'
import { updateSlotLabelVisibility } from "@/components/Calendar/utils/calendarUtils"; import { updateSlotLabelVisibility } from '@/components/Calendar/utils/calendarUtils'
import EventPreviewModal from "@/features/Events/EventPreview"; import EventPreviewModal from '@/features/Events/EventPreview'
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from '@/features/Events/EventsTypes'
import * as SettingsSlice from "@/features/Settings/SettingsSlice"; import * as SettingsSlice from '@/features/Settings/SettingsSlice'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("Calendar - Timezone Integration", () => { describe('Calendar - Timezone Integration', () => {
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null }
const baseState = { const baseState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "testSid", sid: 'testSid',
openpaasId: "user1", openpaasId: 'user1'
}, }
}, },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
id: "user1/cal1", id: 'user1/cal1',
link: "/calendars/user1/cal1.json", link: '/calendars/user1/cal1.json',
name: "Test Calendar", name: 'Test Calendar',
description: "", description: '',
color: "#33B679", color: '#33B679',
owner: { firstname: "user1", emails: ["test@test.com"] }, owner: { firstname: 'user1', emails: ['test@test.com'] },
visibility: "public", visibility: 'public',
events: {}, events: {}
}
}, },
}, timeZone: 'America/New_York',
timeZone: "America/New_York", pending: false
pending: false, }
}, }
};
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("renders TimezoneSelector in week view", async () => { it('renders TimezoneSelector in week view', async () => {
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
baseState baseState
); )
// Look for timezone selector button (should show offset) // Look for timezone selector button (should show offset)
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/UTC/)).toBeInTheDocument(); expect(screen.getByText(/UTC/)).toBeInTheDocument()
}); })
}); })
it("dispatches setTimeZone action when timezone is changed", async () => { it('dispatches setTimeZone action when timezone is changed', async () => {
const setTimeZoneSpy = jest.spyOn(SettingsSlice, "setTimeZone"); const setTimeZoneSpy = jest.spyOn(SettingsSlice, 'setTimeZone')
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
baseState baseState
); )
// Find and click timezone selector // Find and click timezone selector
await waitFor(() => { await waitFor(() => {
const timezoneButton = screen.getByText(/UTC/i); const timezoneButton = screen.getByText(/UTC/i)
fireEvent.click(timezoneButton); fireEvent.click(timezoneButton)
// Select a different timezone // Select a different timezone
const autocomplete = screen.getByRole("combobox"); const autocomplete = screen.getByRole('combobox')
fireEvent.change(autocomplete, { target: { value: "Tokyo" } }); fireEvent.change(autocomplete, { target: { value: 'Tokyo' } })
}); })
const option = await screen.findByText(/Tokyo/i); const option = await screen.findByText(/Tokyo/i)
fireEvent.click(option); fireEvent.click(option)
expect(setTimeZoneSpy).toHaveBeenCalledWith("Asia/Tokyo"); expect(setTimeZoneSpy).toHaveBeenCalledWith('Asia/Tokyo')
}); })
}); })
describe("Calendar - Timezone Slot Label Visibility", () => { describe('Calendar - Timezone Slot Label Visibility', () => {
it("hides slot labels within 15 minutes of current time", () => { it('hides slot labels within 15 minutes of current time', () => {
const currentTime = new Date("2025-01-15T14:30:00Z"); const currentTime = new Date('2025-01-15T14:30:00Z')
const timezone = "UTC"; const timezone = 'UTC'
jest jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay") .spyOn(calendarUtils, 'checkIfCurrentWeekOrDay')
.mockImplementation(() => true); .mockImplementation(() => true)
// 14:25 - within 15 minutes // 14:25 - within 15 minutes
const slot1425 = { text: "14:25" }; const slot1425 = { text: '14:25' }
expect(updateSlotLabelVisibility(currentTime, slot1425, timezone)).toBe( expect(updateSlotLabelVisibility(currentTime, slot1425, timezone)).toBe(
"timegrid-slot-label-hidden" 'timegrid-slot-label-hidden'
); )
// 14:30 - exact match // 14:30 - exact match
const slot1430 = { text: "14:30" }; const slot1430 = { text: '14:30' }
expect(updateSlotLabelVisibility(currentTime, slot1430, timezone)).toBe( expect(updateSlotLabelVisibility(currentTime, slot1430, timezone)).toBe(
"timegrid-slot-label-hidden" 'timegrid-slot-label-hidden'
); )
// 14:35 - within 15 minutes // 14:35 - within 15 minutes
const slot1435 = { text: "14:35" }; const slot1435 = { text: '14:35' }
expect(updateSlotLabelVisibility(currentTime, slot1435, timezone)).toBe( 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 jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay") .spyOn(calendarUtils, 'checkIfCurrentWeekOrDay')
.mockImplementation(() => true); .mockImplementation(() => true)
const currentTime = new Date("2025-01-15T14:30:00"); const currentTime = new Date('2025-01-15T14:30:00')
const timezone = "UTC"; const timezone = 'UTC'
// 14:00 - 30 minutes before // 14:00 - 30 minutes before
const slot1400 = { text: "14:00" }; const slot1400 = { text: '14:00' }
expect(updateSlotLabelVisibility(currentTime, slot1400, timezone)).toBe( expect(updateSlotLabelVisibility(currentTime, slot1400, timezone)).toBe(
"fc-timegrid-slot-label" 'fc-timegrid-slot-label'
); )
// 15:00 - 30 minutes after // 15:00 - 30 minutes after
const slot1500 = { text: "15:00" }; const slot1500 = { text: '15:00' }
expect(updateSlotLabelVisibility(currentTime, slot1500, timezone)).toBe( 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 jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay") .spyOn(calendarUtils, 'checkIfCurrentWeekOrDay')
.mockImplementation(() => false); .mockImplementation(() => false)
const currentTime = new Date("2025-01-15T14:30:00"); const currentTime = new Date('2025-01-15T14:30:00')
const timezone = "UTC"; const timezone = 'UTC'
const slot = { text: "14:30" }; const slot = { text: '14:30' }
// Should return visible class when not in current view // Should return visible class when not in current view
const result = updateSlotLabelVisibility(currentTime, slot, timezone); const result = updateSlotLabelVisibility(currentTime, slot, timezone)
expect(result).toBe("fc-timegrid-slot-label"); expect(result).toBe('fc-timegrid-slot-label')
}); })
}); })
describe("EventDisplayPreview - Timezone Display", () => { describe('EventDisplayPreview - Timezone Display', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const baseEvent: CalendarEvent = { const baseEvent: CalendarEvent = {
uid: "event1", uid: 'event1',
title: "Team Meeting", title: 'Team Meeting',
start: new Date("2025-01-15T14:00:00Z"), start: new Date('2025-01-15T14:00:00Z'),
end: new Date("2025-01-15T15:00:00Z"), end: new Date('2025-01-15T15:00:00Z'),
calendarId: "user1/cal1", calendarId: 'user1/cal1',
allday: false, allday: false
}; }
const allDayEvent = { const allDayEvent = {
...baseEvent, ...baseEvent,
allday: true, allday: true,
start: new Date("2025-01-15"), start: new Date('2025-01-15'),
end: new Date("2025-01-16"), end: new Date('2025-01-16')
}; }
const baseState = { const baseState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "testSid", sid: 'testSid',
openpaasId: "user1", openpaasId: 'user1'
}, }
}, },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
id: "user1/cal1", id: 'user1/cal1',
name: "Test Calendar", name: 'Test Calendar',
color: "#33B679", color: '#33B679',
owner: { firstname: "user1", emails: ["test@test.com"] }, owner: { firstname: 'user1', emails: ['test@test.com'] },
visibility: "public", visibility: 'public',
events: { events: {
event1: baseEvent, event1: baseEvent,
allDayEvent, allDayEvent
}
}
}, },
}, timeZone: 'America/New_York',
}, pending: false
timeZone: "America/New_York", }
pending: false, }
},
};
beforeEach(() => { 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( renderWithProviders(
<EventPreviewModal <EventPreviewModal
open={true} open={true}
@@ -199,33 +199,33 @@ describe("EventDisplayPreview - Timezone Display", () => {
calId="user1/cal1" calId="user1/cal1"
/>, />,
baseState baseState
); )
await waitFor(() => { await waitFor(() => {
const title = screen.getByText(/Team Meeting/i); const title = screen.getByText(/Team Meeting/i)
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument()
}); })
// Should not show UTC offset // 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 = [ const timezones = [
{ tz: "America/New_York", expectedOffset: /UTC[-][45]/ }, { tz: 'America/New_York', expectedOffset: /UTC[-][45]/ },
{ tz: "Europe/Paris", expectedOffset: /UTC\+[12]/ }, { tz: 'Europe/Paris', expectedOffset: /UTC\+[12]/ },
{ tz: "Asia/Tokyo", expectedOffset: /UTC\+9/ }, { tz: 'Asia/Tokyo', expectedOffset: /UTC\+9/ },
{ tz: "Australia/Sydney", expectedOffset: /UTC\+1[01]/ }, { tz: 'Australia/Sydney', expectedOffset: /UTC\+1[01]/ },
{ tz: "Asia/Kolkata", expectedOffset: /UTC\+5:30/ }, { tz: 'Asia/Kolkata', expectedOffset: /UTC\+5:30/ }
]; ]
for (const { tz, expectedOffset } of timezones) { for (const { tz, expectedOffset } of timezones) {
const state = { const state = {
...baseState, ...baseState,
settings: { settings: {
timeZone: tz, timeZone: tz
}, }
}; }
renderWithProviders( renderWithProviders(
<EventPreviewModal <EventPreviewModal
@@ -235,12 +235,12 @@ describe("EventDisplayPreview - Timezone Display", () => {
onClose={mockOnClose} onClose={mockOnClose}
/>, />,
state state
); )
await waitFor(() => { await waitFor(() => {
const content = document.body.textContent || ""; const content = document.body.textContent || ''
expect(content).toMatch(expectedOffset); expect(content).toMatch(expectedOffset)
}); })
} }
}); })
}); })
@@ -1,134 +1,134 @@
import { TimezoneSelector } from "@/components/Calendar/TimezoneSelector"; import { TimezoneSelector } from '@/components/Calendar/TimezoneSelector'
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("TimezoneSelector", () => { describe('TimezoneSelector', () => {
const mockOnChange = jest.fn(); const mockOnChange = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("renders with initial timezone value", () => { it('renders with initial timezone value', () => {
renderWithProviders( renderWithProviders(
<TimezoneSelector <TimezoneSelector
referenceDate={new Date()} referenceDate={new Date()}
value="America/New_York" value="America/New_York"
onChange={mockOnChange} onChange={mockOnChange}
/> />
); )
const button = screen.getByRole("button"); const button = screen.getByRole('button')
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument()
expect(button).toHaveTextContent(/UTC[-][45]/i); // New York offset 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( renderWithProviders(
<TimezoneSelector <TimezoneSelector
referenceDate={new Date()} referenceDate={new Date()}
value="Europe/Paris" value="Europe/Paris"
onChange={mockOnChange} onChange={mockOnChange}
/> />
); )
const button = screen.getByRole("button"); const button = screen.getByRole('button')
fireEvent.click(button); fireEvent.click(button)
await waitFor(() => { 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( renderWithProviders(
<TimezoneSelector <TimezoneSelector
referenceDate={new Date()} referenceDate={new Date()}
value="Europe/Paris" value="Europe/Paris"
onChange={mockOnChange} onChange={mockOnChange}
/> />
); )
const button = screen.getByRole("button"); const button = screen.getByRole('button')
fireEvent.click(button); fireEvent.click(button)
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument()
}); })
const autocomplete = screen.getByRole("combobox"); const autocomplete = screen.getByRole('combobox')
fireEvent.change(autocomplete, { target: { value: "Los Angeles" } }); fireEvent.change(autocomplete, { target: { value: 'Los Angeles' } })
// Find and click the Los Angeles option // Find and click the Los Angeles option
const option = await screen.findByText(/Los Angeles/i); const option = await screen.findByText(/Los Angeles/i)
fireEvent.click(option); 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( renderWithProviders(
<TimezoneSelector <TimezoneSelector
referenceDate={new Date()} referenceDate={new Date()}
value="Europe/Paris" value="Europe/Paris"
onChange={mockOnChange} onChange={mockOnChange}
/> />
); )
const button = screen.getByRole("button"); const button = screen.getByRole('button')
fireEvent.click(button); fireEvent.click(button)
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument()
}); })
const autocomplete = screen.getByRole("combobox"); const autocomplete = screen.getByRole('combobox')
fireEvent.change(autocomplete, { target: { value: "Tokyo" } }); fireEvent.change(autocomplete, { target: { value: 'Tokyo' } })
const option = await screen.findByText(/Tokyo/i); const option = await screen.findByText(/Tokyo/i)
fireEvent.click(option); fireEvent.click(option)
await waitFor(() => { 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( renderWithProviders(
<TimezoneSelector <TimezoneSelector
referenceDate={new Date()} referenceDate={new Date()}
value="Asia/Kolkata" value="Asia/Kolkata"
onChange={mockOnChange} onChange={mockOnChange}
/> />
); )
const button = screen.getByRole("button"); const button = screen.getByRole('button')
expect(button).toHaveTextContent("UTC+5:30"); // India offset 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) // Summer date (DST on)
const summerDate = new Date("2025-07-15T12:00:00Z"); const summerDate = new Date('2025-07-15T12:00:00Z')
renderWithProviders( renderWithProviders(
<TimezoneSelector <TimezoneSelector
value="Europe/Paris" value="Europe/Paris"
onChange={mockOnChange} onChange={mockOnChange}
referenceDate={summerDate} referenceDate={summerDate}
/> />
); )
let button = screen.getByRole("button"); let button = screen.getByRole('button')
expect(button).toHaveTextContent(/UTC\+2\b/); expect(button).toHaveTextContent(/UTC\+2\b/)
cleanup(); cleanup()
// Rerender with a winter date (DST off) // 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( renderWithProviders(
<TimezoneSelector <TimezoneSelector
value="Europe/Paris" value="Europe/Paris"
onChange={mockOnChange} onChange={mockOnChange}
referenceDate={winterDate} referenceDate={winterDate}
/> />
); )
button = screen.getByRole("button"); button = screen.getByRole('button')
expect(button).toHaveTextContent(/UTC\+1\b/); expect(button).toHaveTextContent(/UTC\+1\b/)
}); })
}); })
@@ -1,152 +1,150 @@
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync'
import { addSharedCalendar } from "@/features/Calendars/CalendarApi"; import { addSharedCalendar } from '@/features/Calendars/CalendarApi'
import { fetchOwnerOfResource } from "@/features/Calendars/services/helpers"; import { fetchOwnerOfResource } from '@/features/Calendars/services/helpers'
import { toRejectedError } from "@/utils/errorUtils"; import { toRejectedError } from '@/utils/errorUtils'
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from '@reduxjs/toolkit'
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
jest.mock("@/features/Calendars/services/helpers"); jest.mock('@/features/Calendars/services/helpers')
jest.mock("@/utils/errorUtils"); jest.mock('@/utils/errorUtils')
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock; const mockedAddSharedCalendar = addSharedCalendar as jest.Mock
const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock; const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock
const mockedToRejectedError = toRejectedError as jest.Mock; const mockedToRejectedError = toRejectedError as jest.Mock
describe("addCalendarResourceAsync thunk", () => { describe('addCalendarResourceAsync thunk', () => {
let store: ReturnType<typeof configureStore>; let store: ReturnType<typeof configureStore>
const dispatch = jest.fn(); const dispatch = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
store = configureStore({ store = configureStore({
reducer: () => ({}), reducer: () => ({})
}); })
}); })
const mockPayload = { const mockPayload = {
userId: "user-123", userId: 'user-123',
calId: "cal-123", calId: 'cal-123',
cal: { cal: {
color: { color: {
background: "#000000", background: '#000000',
foreground: "#FFFFFF", foreground: '#FFFFFF'
}, },
cal: { cal: {
"dav:name": "Resource Room A", 'dav:name': 'Resource Room A',
"caldav:description": "A meeting room", 'caldav:description': 'A meeting room',
_links: { _links: {
self: { self: {
href: "/calendars/res-456/cal-123.json", href: '/calendars/res-456/cal-123.json'
}, }
}, }
}, }
}, }
}; }
const mockResolvedResourceData = { const mockResolvedResourceData = {
_id: "res-456", _id: 'res-456',
id: "res-456", id: 'res-456',
name: "Resource Room A", name: 'Resource Room A',
description: "A meeting room", description: 'A meeting room',
creator: "user-789", creator: 'user-789',
deleted: false, 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({ mockedFetchOwnerOfResource.mockResolvedValueOnce({
firstname: "Creator", firstname: 'Creator',
lastname: "User", lastname: 'User',
emails: ["creator@example.com"], emails: ['creator@example.com']
}); })
mockedAddSharedCalendar.mockResolvedValueOnce({}); mockedAddSharedCalendar.mockResolvedValueOnce({})
const result = await addCalendarResourceAsync( const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0] mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined); )(dispatch, store.getState, undefined)
expect(mockedAddSharedCalendar).toHaveBeenCalledWith( expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId, mockPayload.userId,
mockPayload.calId, mockPayload.calId,
mockPayload.cal 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({ expect(result.payload).toEqual({
calId: "res-456/cal-123", calId: 'res-456/cal-123',
color: { background: "#000000", foreground: "#FFFFFF" }, color: { background: '#000000', foreground: '#FFFFFF' },
desc: "A meeting room", desc: 'A meeting room',
link: "/calendars/user-123/cal-123.json", link: '/calendars/user-123/cal-123.json',
name: "Resource Room A", name: 'Resource Room A',
owner: { owner: {
firstname: "Creator", firstname: 'Creator',
lastname: "User", lastname: 'User',
emails: ["creator@example.com"], emails: ['creator@example.com'],
resource: true, resource: true
}, }
}); })
}); })
it("should fallback to name if resource details fetch fails", async () => { it('should fallback to name if resource details fetch fails', async () => {
mockedAddSharedCalendar.mockResolvedValueOnce({}); mockedAddSharedCalendar.mockResolvedValueOnce({})
const errorDetails = new Error("Fetch failed"); const errorDetails = new Error('Fetch failed')
mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails); mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails)
// Silence expected console error in tests // Silence expected console error in tests
const consoleSpy = jest const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await addCalendarResourceAsync( const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0] mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined); )(dispatch, store.getState, undefined)
expect(mockedAddSharedCalendar).toHaveBeenCalledWith( expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId, mockPayload.userId,
mockPayload.calId, mockPayload.calId,
mockPayload.cal 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({ expect(result.payload).toEqual({
calId: "res-456/cal-123", calId: 'res-456/cal-123',
color: { background: "#000000", foreground: "#FFFFFF" }, color: { background: '#000000', foreground: '#FFFFFF' },
desc: "A meeting room", desc: 'A meeting room',
link: "/calendars/user-123/cal-123.json", link: '/calendars/user-123/cal-123.json',
name: "Resource Room A", name: 'Resource Room A',
owner: { owner: {
firstname: "", firstname: '',
lastname: "Resource Room A", lastname: 'Resource Room A',
emails: [], emails: [],
resource: true, resource: true
}, }
}); })
}); })
it("should handle error if addSharedCalendar fails", async () => { it('should handle error if addSharedCalendar fails', async () => {
const errorAdd = new Error("Add failed"); const errorAdd = new Error('Add failed')
mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd); mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd)
const mockRejectedErrorResult = { message: "Add failed" }; const mockRejectedErrorResult = { message: 'Add failed' }
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult); mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult)
const result = await addCalendarResourceAsync( const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0] mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined); )(dispatch, store.getState, undefined)
expect(mockedAddSharedCalendar).toHaveBeenCalledWith( expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId, mockPayload.userId,
mockPayload.calId, mockPayload.calId,
mockPayload.cal 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.type).toBe('calendars/addCalendarResource/rejected')
expect(result.payload).toEqual(mockRejectedErrorResult); expect(result.payload).toEqual(mockRejectedErrorResult)
}); })
}); })
@@ -1,234 +1,234 @@
import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync"; import { getCalendarsListAsync } from '@/features/Calendars/services/getCalendarsListAsync'
import { getOpenPaasUser } from "@/features/User/userAPI"; import { getOpenPaasUser } from '@/features/User/userAPI'
import { fetchOwnerData } from "@/features/Calendars/services/helpers"; import { fetchOwnerData } from '@/features/Calendars/services/helpers'
import { getCalendars } from "@/features/Calendars/CalendarApi"; import { getCalendars } from '@/features/Calendars/CalendarApi'
import { formatReduxError } from "@/utils/errorUtils"; import { formatReduxError } from '@/utils/errorUtils'
import { normalizeCalendar } from "@/features/Calendars/utils/normalizeCalendar"; import { normalizeCalendar } from '@/features/Calendars/utils/normalizeCalendar'
jest.mock("@/features/User/userAPI"); jest.mock('@/features/User/userAPI')
jest.mock("@/features/Calendars/services/helpers"); jest.mock('@/features/Calendars/services/helpers')
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
jest.mock("@/utils/errorUtils"); jest.mock('@/utils/errorUtils')
jest.mock("@/features/Calendars/utils/normalizeCalendar"); jest.mock('@/features/Calendars/utils/normalizeCalendar')
jest.mock("@/utils/getAccessiblePair", () => ({ jest.mock('@/utils/getAccessiblePair', () => ({
getAccessiblePair: jest.fn().mockReturnValue("#FFF"), getAccessiblePair: jest.fn().mockReturnValue('#FFF')
})); }))
jest.mock("@mui/material/styles", () => ({ jest.mock('@mui/material/styles', () => ({
createTheme: jest.fn().mockReturnValue({}), createTheme: jest.fn().mockReturnValue({})
})); }))
const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock; const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock
const mockedFetchOwnerData = fetchOwnerData as jest.Mock; const mockedFetchOwnerData = fetchOwnerData as jest.Mock
const mockedGetCalendars = getCalendars as jest.Mock; const mockedGetCalendars = getCalendars as jest.Mock
const mockedFormatReduxError = formatReduxError as jest.Mock; const mockedFormatReduxError = formatReduxError as jest.Mock
const mockedNormalizeCalendar = normalizeCalendar as jest.Mock; const mockedNormalizeCalendar = normalizeCalendar as jest.Mock
describe("getCalendarsListAsync", () => { describe('getCalendarsListAsync', () => {
let dispatch: jest.Mock; let dispatch: jest.Mock
let getState: jest.Mock; let getState: jest.Mock
beforeEach(() => { beforeEach(() => {
dispatch = jest.fn(); dispatch = jest.fn()
getState = jest.fn(); getState = jest.fn()
jest.clearAllMocks(); jest.clearAllMocks()
mockedFormatReduxError.mockImplementation((err) => { mockedFormatReduxError.mockImplementation(err => {
if (err?.message) return err.message; if (err?.message) return err.message
if (typeof err === "string") return err; if (typeof err === 'string') return err
return JSON.stringify(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({ getState.mockReturnValue({
calendars: { calendars: {
list: { list: {
"cal-existing": { 'cal-existing': {
id: "cal-existing", id: 'cal-existing',
color: { light: "red", dark: "#FFF" }, color: { light: 'red', dark: '#FFF' },
events: { "event-1": {} }, events: { 'event-1': {} }
}, }
}, }
}, },
user: { user: {
userData: { openpaasId: "user-123" }, userData: { openpaasId: 'user-123' }
}, }
}); })
const mockCalendarsResponse = { const mockCalendarsResponse = {
_embedded: { _embedded: {
"dav:calendar": [{ id: "cal-existing" }, { id: "cal-new" }], 'dav:calendar': [{ id: 'cal-existing' }, { id: 'cal-new' }]
}, }
}; }
mockedGetCalendars.mockResolvedValue(mockCalendarsResponse); mockedGetCalendars.mockResolvedValue(mockCalendarsResponse)
mockedNormalizeCalendar mockedNormalizeCalendar
.mockReturnValueOnce({ .mockReturnValueOnce({
cal: { "dav:name": "Existing Cal", "apple:color": "blue" }, cal: { 'dav:name': 'Existing Cal', 'apple:color': 'blue' },
id: "cal-existing", id: 'cal-existing',
ownerId: "user-123", ownerId: 'user-123',
description: "old cal", description: 'old cal',
delegated: false, delegated: false,
link: "/link/1", link: '/link/1',
visibility: 1, visibility: 1,
access: 3, access: 3
}) })
.mockReturnValueOnce({ .mockReturnValueOnce({
cal: { "dav:name": "New Cal" }, cal: { 'dav:name': 'New Cal' },
id: "cal-new", id: 'cal-new',
ownerId: "user-456", ownerId: 'user-456',
description: "new cal", description: 'new cal',
delegated: true, delegated: true,
link: "/link/2", link: '/link/2',
visibility: 2, visibility: 2,
access: 2, access: 2,
invite: [{ href: "", principal: "", access: 3, inviteStatus: 1 }], invite: [{ href: '', principal: '', access: 3, inviteStatus: 1 }]
}); })
mockedFetchOwnerData mockedFetchOwnerData
.mockResolvedValueOnce({ .mockResolvedValueOnce({
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
emails: ["john@example.com"], emails: ['john@example.com']
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
firstname: "Jane", firstname: 'Jane',
lastname: "Smith", lastname: 'Smith',
emails: ["jane@example.com"], emails: ['jane@example.com']
}); })
const thunk = getCalendarsListAsync(); const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined); 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 { const payload = result.payload as {
importedCalendars: any; importedCalendars: any
errors: string; errors: string
}; }
expect(payload.errors).toBe(""); expect(payload.errors).toBe('')
expect(Object.keys(payload.importedCalendars)).toHaveLength(2); expect(Object.keys(payload.importedCalendars)).toHaveLength(2)
expect(payload.importedCalendars["cal-existing"]).toMatchObject({ expect(payload.importedCalendars['cal-existing']).toMatchObject({
id: "cal-existing", id: 'cal-existing',
color: { light: "blue", dark: "#FFF" }, color: { light: 'blue', dark: '#FFF' },
events: { "event-1": {} }, events: { 'event-1': {} }
}); })
expect(payload.importedCalendars["cal-new"]).toMatchObject({ expect(payload.importedCalendars['cal-new']).toMatchObject({
id: "cal-new", id: 'cal-new',
name: "New Cal", name: 'New Cal',
owner: { firstname: "Jane", lastname: "Smith" }, owner: { firstname: 'Jane', lastname: 'Smith' }
}); })
expect(mockedGetOpenPaasUser).not.toHaveBeenCalled(); // User ID existed in state expect(mockedGetOpenPaasUser).not.toHaveBeenCalled() // User ID existed in state
expect(mockedGetCalendars).toHaveBeenCalledWith("user-123"); expect(mockedGetCalendars).toHaveBeenCalledWith('user-123')
}); })
it("should fetch user using getOpenPaasUser if openpaasId is not in state", async () => { it('should fetch user using getOpenPaasUser if openpaasId is not in state', async () => {
getState.mockReturnValue({ calendars: {}, user: {} }); getState.mockReturnValue({ calendars: {}, user: {} })
mockedGetOpenPaasUser.mockResolvedValue({ id: "fetched-user-123" }); mockedGetOpenPaasUser.mockResolvedValue({ id: 'fetched-user-123' })
mockedGetCalendars.mockResolvedValue({ _embedded: { "dav:calendar": [] } }); mockedGetCalendars.mockResolvedValue({ _embedded: { 'dav:calendar': [] } })
const thunk = getCalendarsListAsync(); const thunk = getCalendarsListAsync()
await thunk(dispatch, getState, undefined); await thunk(dispatch, getState, undefined)
expect(mockedGetOpenPaasUser).toHaveBeenCalled(); expect(mockedGetOpenPaasUser).toHaveBeenCalled()
expect(mockedGetCalendars).toHaveBeenCalledWith("fetched-user-123"); expect(mockedGetCalendars).toHaveBeenCalledWith('fetched-user-123')
}); })
it("should handle error when API call fails", async () => { it('should handle error when API call fails', async () => {
getState.mockReturnValue({ calendars: {}, user: {} }); getState.mockReturnValue({ calendars: {}, user: {} })
mockedGetOpenPaasUser.mockResolvedValue({ id: "fetched-user-123" }); mockedGetOpenPaasUser.mockResolvedValue({ id: 'fetched-user-123' })
mockedGetCalendars.mockRejectedValue({ mockedGetCalendars.mockRejectedValue({
response: { status: 500 }, response: { status: 500 },
message: "Server Error", message: 'Server Error'
}); })
// toRejectedError is imported from a mocked module; provide the expected return // toRejectedError is imported from a mocked module; provide the expected return
const { toRejectedError } = jest.requireMock("@/utils/errorUtils") as { const { toRejectedError } = jest.requireMock('@/utils/errorUtils') as {
toRejectedError: jest.Mock; toRejectedError: jest.Mock
}; }
toRejectedError.mockReturnValueOnce({ toRejectedError.mockReturnValueOnce({
status: 500, status: 500,
message: "Server Error", message: 'Server Error'
}); })
const thunk = getCalendarsListAsync(); const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined); 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({ expect(result.payload).toEqual({
status: 500, 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({ getState.mockReturnValue({
calendars: {}, calendars: {},
user: { userData: { openpaasId: "user-123" } }, user: { userData: { openpaasId: 'user-123' } }
}); })
mockedGetCalendars.mockResolvedValue({ mockedGetCalendars.mockResolvedValue({
_embedded: { "dav:calendar": [{ id: "cal-1" }] }, _embedded: { 'dav:calendar': [{ id: 'cal-1' }] }
}); })
mockedNormalizeCalendar.mockReturnValue({ mockedNormalizeCalendar.mockReturnValue({
cal: { "dav:name": "Error Cal" }, cal: { 'dav:name': 'Error Cal' },
id: "cal-1", id: 'cal-1',
ownerId: "error-123", ownerId: 'error-123'
}); })
// fetchOwnerData fails // fetchOwnerData fails
mockedFetchOwnerData.mockRejectedValueOnce(new Error("Network Error")); mockedFetchOwnerData.mockRejectedValueOnce(new Error('Network Error'))
const thunk = getCalendarsListAsync(); const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined); const result = await thunk(dispatch, getState, undefined)
const payload = result.payload as any; const payload = result.payload as any
expect(mockedFetchOwnerData).toHaveBeenCalledWith("error-123"); expect(mockedFetchOwnerData).toHaveBeenCalledWith('error-123')
expect(payload.importedCalendars["cal-1"].owner).toEqual({ expect(payload.importedCalendars['cal-1'].owner).toEqual({
firstname: "", firstname: '',
lastname: "Unknown User", lastname: 'Unknown User',
emails: [], emails: []
}); })
// Errors array should contain the error // 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({ getState.mockReturnValue({
calendars: {}, calendars: {},
user: { userData: { openpaasId: "user-123" } }, user: { userData: { openpaasId: 'user-123' } }
}); })
mockedGetCalendars.mockResolvedValue({ mockedGetCalendars.mockResolvedValue({
_embedded: { "dav:calendar": [{ id: "cal-1" }] }, _embedded: { 'dav:calendar': [{ id: 'cal-1' }] }
}); })
mockedNormalizeCalendar.mockReturnValue({ mockedNormalizeCalendar.mockReturnValue({
cal: { "dav:name": "Resource Cal" }, cal: { 'dav:name': 'Resource Cal' },
id: "cal-1", id: 'cal-1',
ownerId: "resource-123", ownerId: 'resource-123'
}); })
// fetchOwnerData succeeds and returns a resource config structure // fetchOwnerData succeeds and returns a resource config structure
mockedFetchOwnerData.mockResolvedValueOnce({ mockedFetchOwnerData.mockResolvedValueOnce({
firstname: "Creator", firstname: 'Creator',
lastname: "User", lastname: 'User',
emails: [], emails: [],
resource: true, resource: true
}); })
const thunk = getCalendarsListAsync(); const thunk = getCalendarsListAsync()
const result = await thunk(dispatch, getState, undefined); const result = await thunk(dispatch, getState, undefined)
const payload = result.payload as any; const payload = result.payload as any
expect(mockedFetchOwnerData).toHaveBeenCalledWith("resource-123"); expect(mockedFetchOwnerData).toHaveBeenCalledWith('resource-123')
expect(payload.importedCalendars["cal-1"].owner).toEqual({ expect(payload.importedCalendars['cal-1'].owner).toEqual({
firstname: "Creator", firstname: 'Creator',
lastname: "User", lastname: 'User',
emails: [], emails: [],
resource: true, resource: true
}); })
}); })
}); })
@@ -1,90 +1,90 @@
import { fetchOwnerData } from "@/features/Calendars/services/helpers"; import { fetchOwnerData } from '@/features/Calendars/services/helpers'
import { getResourceDetails, getUserDetails } from "@/features/User/userAPI"; import { getResourceDetails, getUserDetails } from '@/features/User/userAPI'
jest.mock("@/features/User/userAPI"); jest.mock('@/features/User/userAPI')
const mockedGetUserDetails = getUserDetails as jest.Mock; const mockedGetUserDetails = getUserDetails as jest.Mock
const mockedGetResourceDetails = getResourceDetails as jest.Mock; const mockedGetResourceDetails = getResourceDetails as jest.Mock
describe("helpers", () => { describe('helpers', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
describe("fetchOwnerData", () => { describe('fetchOwnerData', () => {
it("should return user details successfully", async () => { it('should return user details successfully', async () => {
const mockUser = { const mockUser = {
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
emails: ["john@example.com"], emails: ['john@example.com']
}; }
mockedGetUserDetails.mockResolvedValueOnce(mockUser); mockedGetUserDetails.mockResolvedValueOnce(mockUser)
const result = await fetchOwnerData("user-123"); const result = await fetchOwnerData('user-123')
expect(mockedGetUserDetails).toHaveBeenCalledWith("user-123"); expect(mockedGetUserDetails).toHaveBeenCalledWith('user-123')
expect(mockedGetResourceDetails).not.toHaveBeenCalled(); expect(mockedGetResourceDetails).not.toHaveBeenCalled()
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser)
}); })
it("should fetch resource details and its creator when user is not found", async () => { it('should fetch resource details and its creator when user is not found', async () => {
const mockResource = { creator: "creator-456" }; const mockResource = { creator: 'creator-456' }
const mockCreator = { const mockCreator = {
firstname: "Creator", firstname: 'Creator',
lastname: "User", lastname: 'User',
emails: ["creator@example.com"], emails: ['creator@example.com']
}; }
// Mock getUserDetails to fail with 404 for the initial call // Mock getUserDetails to fail with 404 for the initial call
mockedGetUserDetails.mockRejectedValueOnce({ mockedGetUserDetails.mockRejectedValueOnce({
response: { status: 404 }, response: { status: 404 }
}); })
// Mock getResourceDetails to succeed and return a creator ID // Mock getResourceDetails to succeed and return a creator ID
mockedGetResourceDetails.mockResolvedValueOnce(mockResource); mockedGetResourceDetails.mockResolvedValueOnce(mockResource)
// Mock getUserDetails to succeed when called for the creator // 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(mockedGetUserDetails).toHaveBeenNthCalledWith(1, 'resource-123')
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123"); expect(mockedGetResourceDetails).toHaveBeenCalledWith('resource-123')
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, "creator-456"); expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, 'creator-456')
expect(result).toEqual({ expect(result).toEqual({
...mockCreator, ...mockCreator,
resource: true, resource: true,
administrators: undefined, administrators: undefined,
resourceIcon: undefined, resourceIcon: undefined
}); })
}); })
it("should throw error when getUserDetails fails with non-404 error", async () => { it('should throw error when getUserDetails fails with non-404 error', async () => {
const mockError = { response: { status: 500 } }; const mockError = { response: { status: 500 } }
mockedGetUserDetails.mockRejectedValueOnce(mockError); 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(mockedGetUserDetails).toHaveBeenCalledWith('user-123')
expect(mockedGetResourceDetails).not.toHaveBeenCalled(); expect(mockedGetResourceDetails).not.toHaveBeenCalled()
}); })
it("should throw error when getResourceDetails fails", async () => { it('should throw error when getResourceDetails fails', async () => {
const mockError = new Error("Resource not found"); const mockError = new Error('Resource not found')
// Mock getUserDetails to fail with 404 for the initial call // Mock getUserDetails to fail with 404 for the initial call
mockedGetUserDetails.mockRejectedValueOnce({ mockedGetUserDetails.mockRejectedValueOnce({
response: { status: 404 }, response: { status: 404 }
}); })
// Mock getResourceDetails to fail // 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(mockedGetUserDetails).toHaveBeenCalledWith('resource-123')
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123"); expect(mockedGetResourceDetails).toHaveBeenCalledWith('resource-123')
expect(mockedGetUserDetails).toHaveBeenCalledTimes(1); // Only called once 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", () => { describe('EventErrorHandler', () => {
it("calls the callback when a new error is reported", () => { it('calls the callback when a new error is reported', () => {
const handler = new EventErrorHandler(); const handler = new EventErrorHandler()
const callback = jest.fn(); const callback = jest.fn()
handler.setErrorCallback(callback); handler.setErrorCallback(callback)
handler.reportError("123", "Something broke"); handler.reportError('123', 'Something broke')
expect(callback).toHaveBeenCalledWith(["Something broke"]); expect(callback).toHaveBeenCalledWith(['Something broke'])
}); })
it("does not duplicate errors for same eventId", () => { it('does not duplicate errors for same eventId', () => {
const handler = new EventErrorHandler(); const handler = new EventErrorHandler()
const callback = jest.fn(); const callback = jest.fn()
handler.setErrorCallback(callback); handler.setErrorCallback(callback)
handler.reportError("A", "Error 1"); handler.reportError('A', 'Error 1')
handler.reportError("A", "Error 1 again"); handler.reportError('A', 'Error 1 again')
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1)
}); })
it("clears errors properly", () => { it('clears errors properly', () => {
const handler = new EventErrorHandler(); const handler = new EventErrorHandler()
const callback = jest.fn(); const callback = jest.fn()
handler.setErrorCallback(callback); handler.setErrorCallback(callback)
handler.reportError("A", "Error 1"); handler.reportError('A', 'Error 1')
handler.clearAll(); handler.clearAll()
expect(callback).toHaveBeenLastCalledWith([]); expect(callback).toHaveBeenLastCalledWith([])
}); })
}); })
@@ -1,183 +1,181 @@
import { Calendar } from "@/features/Calendars/CalendarTypes"; import { Calendar } from '@/features/Calendars/CalendarTypes'
import { AttendanceValidation } from "@/features/Events/AttendanceValidation/AttendanceValidation"; import { AttendanceValidation } from '@/features/Events/AttendanceValidation/AttendanceValidation'
import { ContextualizedEvent } from "@/features/Events/EventsTypes"; import { ContextualizedEvent } from '@/features/Events/EventsTypes'
import { userAttendee } from "@/features/User/models/attendee"; import { userAttendee } from '@/features/User/models/attendee'
import { userData } from "@/features/User/userDataTypes"; import { userData } from '@/features/User/userDataTypes'
import { render } from "@testing-library/react"; import { render } from '@testing-library/react'
jest.mock("twake-i18n", () => ({ jest.mock('twake-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }), useI18n: () => ({ t: (key: string) => key })
})); }))
jest.mock("@/app/hooks", () => ({ jest.mock('@/app/hooks', () => ({
useAppDispatch: () => jest.fn(), useAppDispatch: () => jest.fn(),
useAppSelector: jest.fn(), useAppSelector: jest.fn()
})); }))
const makeUser = (email: string): userData => ({ const makeUser = (email: string): userData => ({
email, email,
given_name: "Alice", given_name: 'Alice',
family_name: "User", family_name: 'User',
name: "Alice User", name: 'Alice User',
sid: "user1", sid: 'user1',
sub: "user1", sub: 'user1'
}); })
const makeContext = ( const makeContext = (
overrides: Partial<ContextualizedEvent> = {} overrides: Partial<ContextualizedEvent> = {}
): ContextualizedEvent => ): ContextualizedEvent =>
({ ({
event: { event: {
uid: "event-1", uid: 'event-1',
calId: "user1/cal1", calId: 'user1/cal1',
title: "Test", title: 'Test',
start: "2024-01-15T10:00:00", start: '2024-01-15T10:00:00',
end: "2024-01-15T11:00:00", end: '2024-01-15T11:00:00',
organizer: { cal_address: "owner@example.com" }, organizer: { cal_address: 'owner@example.com' },
attendee: [ attendee: [{ cal_address: 'owner@example.com', partstat: 'NEEDS-ACTION' }]
{ cal_address: "owner@example.com", partstat: "NEEDS-ACTION" },
],
}, },
calendar: { calendar: {
id: "user1/cal1", id: 'user1/cal1',
name: "Test", name: 'Test',
delegated: false, delegated: false,
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: {}, events: {}
}, },
currentUserAttendee: { currentUserAttendee: {
cal_address: "alice@example.com", cal_address: 'alice@example.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION'
}, },
isOwn: true, isOwn: true,
isOrganizer: false, isOrganizer: false,
isRecurring: false, isRecurring: false,
...overrides, ...overrides
}) as unknown as ContextualizedEvent; }) as unknown as ContextualizedEvent
const noopSetFunc = jest.fn(); const noopSetFunc = jest.fn()
describe("AttendanceValidation - delegation", () => { describe('AttendanceValidation - delegation', () => {
describe("non-delegated calendar", () => { describe('non-delegated calendar', () => {
it("renders when isOwn and currentUserAttendee exists", () => { it('renders when isOwn and currentUserAttendee exists', () => {
const { container } = render( const { container } = render(
<AttendanceValidation <AttendanceValidation
contextualizedEvent={makeContext({ isOwn: true })} contextualizedEvent={makeContext({ isOwn: true })}
user={makeUser("alice@example.com")} user={makeUser('alice@example.com')}
setAfterChoiceFunc={noopSetFunc} setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={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( const { container } = render(
<AttendanceValidation <AttendanceValidation
contextualizedEvent={makeContext({ isOwn: false })} contextualizedEvent={makeContext({ isOwn: false })}
user={makeUser("other@example.com")} user={makeUser('other@example.com')}
setAfterChoiceFunc={noopSetFunc} setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc} setOpenEditModePopup={noopSetFunc}
/> />
); )
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull()
}); })
}); })
describe("delegated calendar", () => { describe('delegated calendar', () => {
const delegatedContext = makeContext({ const delegatedContext = makeContext({
isOwn: false, // logged-in user is not the owner isOwn: false, // logged-in user is not the owner
calendar: { calendar: {
id: "user2/cal1", id: 'user2/cal1',
name: "Delegated Cal", name: 'Delegated Cal',
delegated: true, delegated: true,
owner: { emails: ["owner@example.com"] }, owner: { emails: ['owner@example.com'] },
events: {}, events: {}
} as Calendar, } as Calendar,
currentUserAttendee: { currentUserAttendee: {
cal_address: "owner@example.com", cal_address: 'owner@example.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION'
} as userAttendee, } 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( const { container } = render(
<AttendanceValidation <AttendanceValidation
contextualizedEvent={delegatedContext} contextualizedEvent={delegatedContext}
user={makeUser("alice@example.com")} user={makeUser('alice@example.com')}
setAfterChoiceFunc={noopSetFunc} setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={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( const { container } = render(
<AttendanceValidation <AttendanceValidation
contextualizedEvent={{ contextualizedEvent={{
...delegatedContext, ...delegatedContext,
currentUserAttendee: undefined, currentUserAttendee: undefined
}} }}
user={makeUser("alice@example.com")} user={makeUser('alice@example.com')}
setAfterChoiceFunc={noopSetFunc} setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc} setOpenEditModePopup={noopSetFunc}
/> />
); )
// delegated flag alone should keep it visible // 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({ const resourceContext = makeContext({
isOwn: false, isOwn: false,
calendar: { calendar: {
id: "res1/cal1", id: 'res1/cal1',
name: "Resource Cal", name: 'Resource Cal',
delegated: false, delegated: false,
owner: { owner: {
emails: ["resource@example.com"], emails: ['resource@example.com'],
resource: true, resource: true,
administrators: [{ id: "admin1" }], administrators: [{ id: 'admin1' }]
}, },
events: {}, events: {}
} as Calendar, } 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( const { getByText } = render(
<AttendanceValidation <AttendanceValidation
contextualizedEvent={resourceContext} contextualizedEvent={resourceContext}
user={ user={
{ {
...makeUser("admin@example.com"), ...makeUser('admin@example.com'),
openpaasId: "admin1", openpaasId: 'admin1'
} as userData } as userData
} }
setAfterChoiceFunc={noopSetFunc} setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={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( const { container } = render(
<AttendanceValidation <AttendanceValidation
contextualizedEvent={resourceContext} contextualizedEvent={resourceContext}
user={ user={
{ {
...makeUser("other@example.com"), ...makeUser('other@example.com'),
openpaasId: "other1", openpaasId: 'other1'
} as userData } as userData
} }
setAfterChoiceFunc={noopSetFunc} setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={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 * as eventThunks from '@/features/Calendars/services'
import EventUpdateModal from "@/features/Events/EventUpdateModal"; import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("Event Full Display — delegated calendar move", () => { describe('Event Full Display — delegated calendar move', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
const testDate = new Date("2025-01-15T10:00:00.000Z"); const testDate = new Date('2025-01-15T10:00:00.000Z')
const testEndDate = new Date("2025-01-15T11:00:00.000Z"); const testEndDate = new Date('2025-01-15T11:00:00.000Z')
const baseUserState = { const baseUserState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "user1", openpaasId: 'user1'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, }
}; }
const preloadedDelegatedCals = { const preloadedDelegatedCals = {
...baseUserState, ...baseUserState,
calendars: { calendars: {
pending: false, pending: false,
list: { list: {
"user1/cal1": { 'user1/cal1': {
id: "user1/cal1", id: 'user1/cal1',
name: "Calendar One", name: 'Calendar One',
color: "#FF0000", color: '#FF0000',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
calId: "user1/cal1", calId: 'user1/cal1',
start: testDate.toISOString(), start: testDate.toISOString(),
end: testEndDate.toISOString(), end: testEndDate.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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: "/calendar/user1/cal1/event1.ics", URL: '/calendar/user1/cal1/event1.ics'
}
}, },
link: '/calendar/user1/cal1.json'
}, },
link: "/calendar/user1/cal1.json", 'user2/cal2': {
}, id: 'user2/cal2',
"user2/cal2": { name: 'Delegated Calendar',
id: "user2/cal2", color: '#00FF00',
name: "Delegated Calendar",
color: "#00FF00",
delegated: true, delegated: true,
access: { write: true }, access: { write: true },
owner: { owner: {
emails: ["delegate@test.com"], emails: ['delegate@test.com'],
name: "Delegate User", name: 'Delegate User'
}, },
events: {}, 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 const spyPut = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const spyDelete = jest const spyDelete = jest
.spyOn(eventThunks, "deleteEventAsync") .spyOn(eventThunks, 'deleteEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const spyMove = jest const spyMove = jest
.spyOn(eventThunks, "moveEventAsync") .spyOn(eventThunks, 'moveEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
await act(async () => await act(async () =>
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"user1/cal1"} calId={'user1/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedDelegatedCals preloadedDelegatedCals
) )
); )
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const calendarSelect = screen.getByLabelText("event.form.calendar"); const calendarSelect = screen.getByLabelText('event.form.calendar')
await act(async () => fireEvent.mouseDown(calendarSelect)); await act(async () => fireEvent.mouseDown(calendarSelect))
const option = await screen.findByText("Delegated Calendar"); const option = await screen.findByText('Delegated Calendar')
fireEvent.click(option); fireEvent.click(option)
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
}); })
await waitFor( await waitFor(
() => { () => {
expect(spyPut).toHaveBeenCalled(); expect(spyPut).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor( await waitFor(
() => { () => {
expect(spyDelete).toHaveBeenCalled(); expect(spyDelete).toHaveBeenCalled()
}, },
{ timeout: 3000 } { 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 { InfoRow } from '@/components/Event/InfoRow'
import { LONG_DATE_FORMAT } from "@/components/Event/utils/dateTimeFormatters"; import { LONG_DATE_FORMAT } from '@/components/Event/utils/dateTimeFormatters'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import EventUpdateModal from "@/features/Events/EventUpdateModal"; import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import dayjs from "dayjs"; import dayjs from 'dayjs'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("Event Full Display", () => { describe('Event Full Display', () => {
const mockOnClose = jest.fn(); 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 const day = new Date('2025-01-15T10:00:00.000Z') // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "First Calendar", name: 'First Calendar',
color: "#FF0000", color: '#FF0000',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: day.toISOString(), start: day.toISOString(),
end: day.toISOString(), end: day.toISOString(),
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [ attendee: [
{ {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL'
}, },
{ {
cn: "John", cn: 'John',
cal_address: "john@test.com", cal_address: 'john@test.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL'
}
]
}
}
}, },
], 'otherCal/cal': {
}, id: 'otherCal/cal',
}, name: 'Calendar 1',
}, color: '#FF0000',
"otherCal/cal": {
id: "otherCal/cal",
name: "Calendar 1",
color: "#FF0000",
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
calId: "otherCal/cal", calId: 'otherCal/cal',
title: "Test Event Other cal", title: 'Test Event Other cal',
start: day.toISOString(), start: day.toISOString(),
end: 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 // Use fixed timezone UTC for consistent test results across all environments
const fixedDate = new Date("2025-01-15T10:00:00.000Z"); // 10AM UTC const fixedDate = new Date('2025-01-15T10:00:00.000Z') // 10AM UTC
const endDate = new Date(fixedDate.getTime() + 3600000); // 11AM UTC const endDate = new Date(fixedDate.getTime() + 3600000) // 11AM UTC
// With UTC timezone set, formatLocalDateTime produces predictable values // With UTC timezone set, formatLocalDateTime produces predictable values
const expectedStart = "2025-01-15T10:00"; // 10:00 AM UTC const expectedStart = '2025-01-15T10:00' // 10:00 AM UTC
const expectedEnd = "2025-01-15T11:00"; // 11:00 AM UTC const expectedEnd = '2025-01-15T11:00' // 11:00 AM UTC
const stateWithFixedDate = { const stateWithFixedDate = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
pending: false, pending: false,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"], ...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: { events: {
event1: { event1: {
...preloadedState.calendars.list[ ...preloadedState.calendars.list[
"667037022b752d0026472254/cal1" '667037022b752d0026472254/cal1'
].events.event1, ].events.event1,
start: fixedDate.toISOString(), start: fixedDate.toISOString(),
end: endDate.toISOString(), end: endDate.toISOString(),
timezone: "UTC", timezone: 'UTC'
}, }
}, }
}, }
}, }
}, }
}; }
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
stateWithFixedDate stateWithFixedDate
); )
expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); expect(screen.getByDisplayValue('Test Event')).toBeInTheDocument()
// Expand to show date/time inputs (normal mode shows DateTimeSummary) // 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 startDateInput = screen.getByTestId('start-date-input')
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId('start-time-input')
const endTimeInput = screen.getByTestId("end-time-input"); const endTimeInput = screen.getByTestId('end-time-input')
expect(startDateInput).toBeInTheDocument(); expect(startDateInput).toBeInTheDocument()
expect(startTimeInput).toBeInTheDocument(); expect(startTimeInput).toBeInTheDocument()
expect(endTimeInput).toBeInTheDocument(); expect(endTimeInput).toBeInTheDocument()
expect(startDateInput).toHaveValue( expect(startDateInput).toHaveValue(
dayjs(expectedStart).format(LONG_DATE_FORMAT) 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(); expect(screen.getByText('First Calendar')).toBeInTheDocument()
}); })
it("calls onClose when Cancel clicked", () => { it('calls onClose when Cancel clicked', () => {
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedState preloadedState
); )
fireEvent.click(screen.getAllByTestId("CloseIcon")[0]); fireEvent.click(screen.getAllByTestId('CloseIcon')[0])
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick')
}); })
it("toggle Show More reveals extra fields", async () => { it('toggle Show More reveals extra fields', async () => {
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedState preloadedState
); )
act(() => { act(() => {
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" }) screen.getByRole('button', { name: 'common.moreOptions' })
); )
}); })
await waitFor(() => { 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 // EventDisplay modal doesn't have Repeat checkbox, only RepeatEvent component
// which shows repetition settings when repetition data exists // which shows repetition settings when repetition data exists
// Since test event has no repetition data, RepeatEvent component won't show Repeat checkbox // 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( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedState preloadedState
); )
const titleField = screen.getByLabelText("event.form.title"); const titleField = screen.getByLabelText('event.form.title')
fireEvent.change(titleField, { target: { value: "New Title" } }); fireEvent.change(titleField, { target: { value: 'New Title' } })
expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); expect(screen.getByDisplayValue('New Title')).toBeInTheDocument()
}); })
it("toggle all-day updates end date correctly", () => { it('toggle all-day updates end date correctly', () => {
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedState preloadedState
); )
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const allDayCheckbox = screen.getByLabelText("event.form.allDay"); const allDayCheckbox = screen.getByLabelText('event.form.allDay')
fireEvent.click(allDayCheckbox); fireEvent.click(allDayCheckbox)
expect(allDayCheckbox).toBeChecked(); 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 startDateInput = screen.getByTestId('start-date-input')
const endDateInput = screen.getByTestId("end-date-input"); const endDateInput = screen.getByTestId('end-date-input')
expect(startDateInput).toHaveValue(expectedDate); expect(startDateInput).toHaveValue(expectedDate)
expect(endDateInput).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 const spyPut = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const spyMove = jest const spyMove = jest
.spyOn(eventThunks, "moveEventAsync") .spyOn(eventThunks, 'moveEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
const testDate = new Date("2025-01-15T10:00:00.000Z"); const testDate = new Date('2025-01-15T10:00:00.000Z')
const testEndDate = new Date("2025-01-15T11:00:00.000Z"); const testEndDate = new Date('2025-01-15T11:00:00.000Z')
const preloadedTwoCals = { const preloadedTwoCals = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
pending: false, pending: false,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "Calendar One", name: 'Calendar One',
color: "#FF0000", color: '#FF0000',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: testDate.toISOString(), start: testDate.toISOString(),
end: testEndDate.toISOString(), end: testEndDate.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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }]
}
}
}, },
'667037022b752d0026472254/cal2': {
id: '667037022b752d0026472254/cal2',
name: 'Calendar Two',
color: '#00FF00',
events: {}
}
}, },
}, pending: false
"667037022b752d0026472254/cal2": { }
id: "667037022b752d0026472254/cal2", }
name: "Calendar Two",
color: "#00FF00",
events: {},
},
},
pending: false,
},
};
await act(async () => await act(async () =>
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedTwoCals preloadedTwoCals
) )
); )
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const calendarSelect = screen.getByLabelText("event.form.calendar"); const calendarSelect = screen.getByLabelText('event.form.calendar')
await act(async () => fireEvent.mouseDown(calendarSelect)); await act(async () => fireEvent.mouseDown(calendarSelect))
const option = await screen.findByText("Calendar Two"); const option = await screen.findByText('Calendar Two')
fireEvent.click(option); fireEvent.click(option)
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
}); })
await waitFor( await waitFor(
() => { () => {
expect(spyPut).toHaveBeenCalled(); expect(spyPut).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor( await waitFor(
() => { () => {
expect(spyMove).toHaveBeenCalled(); expect(spyMove).toHaveBeenCalled()
}, },
{ timeout: 3000 } { 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 // GIVEN user timezone is UTC+2
// WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok) // 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 // 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 = { const stateWithTimezone = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
pending: false, pending: false,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "Test Calendar", name: 'Test Calendar',
color: "#FF0000", color: '#FF0000',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
title: "Timezone Edit Test", title: 'Timezone Edit Test',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: eventDateUTC7.toISOString(), start: eventDateUTC7.toISOString(),
end: new Date(eventDateUTC7.getTime() + 3600000).toISOString(), end: new Date(eventDateUTC7.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok", timezone: 'Asia/Bangkok',
allday: false, 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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }]
}
}
}
}, },
}, pending: false
}, }
}, }
pending: false,
},
};
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
stateWithTimezone stateWithTimezone
); )
// Expand to show timezone selector (normal mode hides it) // 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 // The timezone select should have Asia/Bangkok selected
const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i); const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i)
expect(timeZone).toBeInTheDocument(); expect(timeZone).toBeInTheDocument()
}); })
it("InfoRow renders error style when error prop is true", () => { it('InfoRow renders error style when error prop is true', () => {
renderWithProviders(<InfoRow icon={<span>i</span>} text="Bad" error />); renderWithProviders(<InfoRow icon={<span>i</span>} text="Bad" error />)
expect(screen.getByText("Bad")).toBeInTheDocument(); expect(screen.getByText('Bad')).toBeInTheDocument()
}); })
it("can remove an attendee with the close button", () => { it('can remove an attendee with the close button', () => {
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
preloadedState preloadedState
); )
const removeBtn = screen.getAllByTestId("CloseIcon").pop()!; const removeBtn = screen.getAllByTestId('CloseIcon').pop()!
fireEvent.click(removeBtn); 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 = { const videoState = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
pending: false, pending: false,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"], ...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: { events: {
event1: { event1: {
...preloadedState.calendars.list[ ...preloadedState.calendars.list[
"667037022b752d0026472254/cal1" '667037022b752d0026472254/cal1'
].events.event1, ].events.event1,
x_openpass_videoconference: "https://meet.test/video", x_openpass_videoconference: 'https://meet.test/video'
}, }
}, }
}, }
}, }
}, }
}; }
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"event1"} eventId={'event1'}
/>, />,
videoState videoState
); )
expect( expect(
screen.getByText("event.form.joinVisioConference") screen.getByText('event.form.joinVisioConference')
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
}); })
+371 -373
View File
@@ -1,115 +1,115 @@
import * as calendarsApi from "@/features/Calendars/CalendarApi"; import * as calendarsApi from '@/features/Calendars/CalendarApi'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import EventPopover from "@/features/Events/EventModal"; import EventPopover from '@/features/Events/EventModal'
import { api } from "@/utils/apiUtils"; import { api } from '@/utils/apiUtils'
import { DateSelectArg } from "@fullcalendar/core"; import { DateSelectArg } from '@fullcalendar/core'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from "@testing-library/user-event"; import userEvent from '@testing-library/user-event'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils"); jest.mock('@/utils/apiUtils')
describe("EventPopover", () => { describe('EventPopover', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear()
}); })
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
const mockSetSelectedRange = jest.fn(); const mockSetSelectedRange = jest.fn()
const mockCalendarRef = { current: { select: jest.fn() } } as any; const mockCalendarRef = { current: { select: jest.fn() } } as any
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "Calendar 1", name: 'Calendar 1',
color: "#FF0000", color: '#FF0000'
}, },
"667037022b752d0026472254/cal2": { '667037022b752d0026472254/cal2': {
id: "667037022b752d0026472254/cal2", id: '667037022b752d0026472254/cal2',
name: "Calendar 2", name: 'Calendar 2',
color: "#00FF00", color: '#00FF00'
}
}, },
}, pending: false
pending: false, }
}, }
};
const mockUsers = [ const mockUsers = [
{ {
id: "john@example.com", id: 'john@example.com',
objectType: "user", objectType: 'user',
emailAddresses: [ emailAddresses: [
{ {
value: "john@example.com", value: 'john@example.com',
type: "default", type: 'default'
}, }
], ],
names: [ names: [
{ {
displayName: "John Doe", displayName: 'John Doe',
type: "default", type: 'default'
}, }
], ]
}, },
{ {
id: "jane@example.com", id: 'jane@example.com',
objectType: "user", objectType: 'user',
emailAddresses: [ emailAddresses: [
{ {
value: "jane@example.com", value: 'jane@example.com',
type: "default", type: 'default'
}, }
], ],
names: [ names: [
{ {
displayName: "Jane Smith", displayName: 'Jane Smith',
type: "default", type: 'default'
}, }
], ]
}, },
{ {
id: "room1@example.com", id: 'room1@example.com',
objectType: "resource", objectType: 'resource',
emailAddresses: [ emailAddresses: [
{ {
value: "room1@example.com", value: 'room1@example.com',
type: "default", type: 'default'
}, }
], ],
names: [ names: [
{ {
displayName: "Room 1", displayName: 'Room 1',
type: "default", type: 'default'
}, }
], ]
}, }
]; ]
(api.post as jest.Mock).mockReturnValue({ ;(api.post as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUsers), json: jest.fn().mockResolvedValue(mockUsers)
}); })
const defaultSelectedRange = { const defaultSelectedRange = {
startStr: "2025-07-18T09:00", startStr: '2025-07-18T09:00',
endStr: "2025-07-18T10:00", endStr: '2025-07-18T10:00',
start: new Date("2025-07-18T09:00"), start: new Date('2025-07-18T09:00'),
end: new Date("2025-07-18T10:00"), end: new Date('2025-07-18T10:00'),
allDay: false, allDay: false,
resource: undefined, resource: undefined
} as unknown as DateSelectArg; } as unknown as DateSelectArg
const renderPopover = (selectedRange = defaultSelectedRange) => { const renderPopover = (selectedRange = defaultSelectedRange) => {
renderWithProviders( renderWithProviders(
@@ -122,461 +122,459 @@ describe("EventPopover", () => {
calendarRef={mockCalendarRef} calendarRef={mockCalendarRef}
/>, />,
preloadedState preloadedState
); )
}; }
it("renders correctly with inputs and calendar options", () => { it('renders correctly with inputs and calendar options', () => {
renderPopover(); renderPopover()
// Check dialog title // Check dialog title
expect(screen.getByText("event.createEvent")).toBeInTheDocument(); expect(screen.getByText('event.createEvent')).toBeInTheDocument()
// Check inputs exist by their roles // Check inputs exist by their roles
const titleInput = screen.getByRole("textbox", { name: /title/i }); const titleInput = screen.getByRole('textbox', { name: /title/i })
expect(titleInput).toBeInTheDocument(); expect(titleInput).toBeInTheDocument()
// Description input is only visible after clicking "Add description" button // Description input is only visible after clicking "Add description" button
const addDescriptionButton = screen.getByRole("button", { const addDescriptionButton = screen.getByRole('button', {
name: "event.form.addDescription", name: 'event.form.addDescription'
}); })
expect(addDescriptionButton).toBeInTheDocument(); expect(addDescriptionButton).toBeInTheDocument()
// In normal mode calendar is a SectionPreviewRow (button), not combobox // In normal mode calendar is a SectionPreviewRow (button), not combobox
expect( expect(
screen.getByRole("button", { name: "Calendar 1" }) screen.getByRole('button', { name: 'Calendar 1' })
).toBeInTheDocument(); ).toBeInTheDocument()
// Check button // Check button
expect( expect(
screen.getByRole("button", { name: "common.moreOptions" }) screen.getByRole('button', { name: 'common.moreOptions' })
).toBeInTheDocument(); ).toBeInTheDocument()
// Extended mode // Extended mode
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
// Back button appears // Back button appears
expect(screen.getByLabelText("show less")).toBeInTheDocument(); expect(screen.getByLabelText('show less')).toBeInTheDocument()
// Extended labels appear // Extended labels appear
expect(screen.getAllByText("event.form.repeat")).toHaveLength(1); expect(screen.getAllByText('event.form.repeat')).toHaveLength(1)
expect(screen.getAllByText("event.form.notification")).toHaveLength(1); expect(screen.getAllByText('event.form.notification')).toHaveLength(1)
expect(screen.getAllByText("event.form.visibleTo")).toHaveLength(1); expect(screen.getAllByText('event.form.visibleTo')).toHaveLength(1)
expect(screen.getAllByText("event.form.showMeAs")).toHaveLength(1); expect(screen.getAllByText('event.form.showMeAs')).toHaveLength(1)
}); })
it("fills start from selectedRange", () => { it('fills start from selectedRange', () => {
renderPopover({ renderPopover({
startStr: "2026-07-20T10:00", startStr: '2026-07-20T10:00',
endStr: "2026-07-20T12:00", endStr: '2026-07-20T12:00',
start: new Date("2026-07-20T10:00"), start: new Date('2026-07-20T10:00'),
end: new Date("2026-07-20T12:00"), end: new Date('2026-07-20T12:00'),
allDay: false, allDay: false
} as unknown as DateSelectArg); } as unknown as DateSelectArg)
// Expand to show date/time inputs (normal mode shows DateTimeSummary) // 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' }))
expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
}); })
it("updates inputs on change", () => { it('updates inputs on change', () => {
renderPopover(); renderPopover()
fireEvent.change(screen.getByLabelText("event.form.title"), { fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: "My Event" }, target: { value: 'My Event' }
}); })
expect(screen.getByLabelText("event.form.title")).toHaveValue("My Event"); expect(screen.getByLabelText('event.form.title')).toHaveValue('My Event')
// Click "Add description" button first // Click "Add description" button first
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "event.form.addDescription" }) screen.getByRole('button', { name: 'event.form.addDescription' })
); )
fireEvent.change(screen.getByLabelText("event.form.description"), { fireEvent.change(screen.getByLabelText('event.form.description'), {
target: { value: "Event Description" }, target: { value: 'Event Description' }
}); })
expect(screen.getByLabelText("event.form.description")).toHaveValue( expect(screen.getByLabelText('event.form.description')).toHaveValue(
"Event Description" 'Event Description'
); )
// Expand location section (normal mode shows SectionPreviewRow) // Expand location section (normal mode shows SectionPreviewRow)
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "event.form.locationPlaceholder" }) screen.getByRole('button', { name: 'event.form.locationPlaceholder' })
); )
fireEvent.change(screen.getByLabelText("event.form.location"), { fireEvent.change(screen.getByLabelText('event.form.location'), {
target: { value: "Conference Room" }, target: { value: 'Conference Room' }
}); })
expect(screen.getByLabelText("event.form.location")).toHaveValue( expect(screen.getByLabelText('event.form.location')).toHaveValue(
"Conference Room" 'Conference Room'
); )
}); })
it("changes selected calendar", async () => { it('changes selected calendar', async () => {
renderPopover(); renderPopover()
// Expand to show calendar combobox (normal mode shows SectionPreviewRow) // Expand to show calendar combobox (normal mode shows SectionPreviewRow)
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' }))
const select = screen.getByLabelText("event.form.calendar"); const select = screen.getByLabelText('event.form.calendar')
fireEvent.mouseDown(select); // Open menu fireEvent.mouseDown(select) // Open menu
const option = await screen.findByText("Calendar 2"); const option = await screen.findByText('Calendar 2')
fireEvent.click(option); fireEvent.click(option)
const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i }); const calendarSelect = screen.getByRole('combobox', { name: /Calendar/i })
expect(calendarSelect).toHaveTextContent("Calendar 2"); expect(calendarSelect).toHaveTextContent('Calendar 2')
}); })
it("adds a attendee", async () => { it('adds a attendee', async () => {
jest.useFakeTimers(); jest.useFakeTimers()
try { try {
jest jest
.spyOn(calendarsApi, "getCalendars") .spyOn(calendarsApi, 'getCalendars')
.mockReturnValue({ json: jest.fn() }); .mockReturnValue({ json: jest.fn() })
renderPopover(); renderPopover()
fireEvent.change(screen.getByLabelText("event.form.title"), { fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: "newEvent" }, target: { value: 'newEvent' }
}); })
const select = screen.getByLabelText("peopleSearch.label"); const select = screen.getByLabelText('peopleSearch.label')
act(() => { act(() => {
select.focus(); select.focus()
fireEvent.mouseDown(select); fireEvent.mouseDown(select)
userEvent.type(select, "john"); userEvent.type(select, 'john')
}); })
await act(async () => { await act(async () => {
jest.advanceTimersByTime(400); jest.advanceTimersByTime(400)
}); })
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1))
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument(); expect(screen.getByText('John Doe')).toBeInTheDocument()
}); })
await act(async () => { await act(async () => {
userEvent.click(screen.getByText("John Doe")); userEvent.click(screen.getByText('John Doe'))
}); })
const spy = jest const spy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => { 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( 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([ expect(receivedPayload.newEvent.attendee).toStrictEqual([
{ {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "FALSE", rsvp: 'FALSE',
role: "CHAIR", role: 'CHAIR',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL'
}, },
{ {
cn: "John Doe", cn: 'John Doe',
cal_address: "john@example.com", cal_address: 'john@example.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION',
rsvp: "FALSE", rsvp: 'FALSE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL'
},
]);
} finally {
jest.useRealTimers();
} }
}); ])
} finally {
jest.useRealTimers()
}
})
it("adds a resource", async () => { it('adds a resource', async () => {
jest.useFakeTimers(); jest.useFakeTimers()
try { try {
renderPopover(); renderPopover()
fireEvent.change(screen.getByLabelText("event.form.title"), { fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: "newEventWithResource" }, target: { value: 'newEventWithResource' }
}); })
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" }) screen.getByRole('button', { name: 'common.moreOptions' })
); )
const resourceCombobox = screen.getByPlaceholderText( const resourceCombobox = screen.getByPlaceholderText(
"resourceSearch.placeholder" 'resourceSearch.placeholder'
); )
act(() => { act(() => {
resourceCombobox.focus(); resourceCombobox.focus()
fireEvent.mouseDown(resourceCombobox); fireEvent.mouseDown(resourceCombobox)
}); })
await userEvent.type(resourceCombobox, "room"); await userEvent.type(resourceCombobox, 'room')
await act(async () => { await act(async () => {
jest.advanceTimersByTime(400); jest.advanceTimersByTime(400)
}); })
await waitFor(() => { 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 const spy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => { 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({ expect(receivedPayload.newEvent.attendee[1]).toStrictEqual({
cn: "Room 1", cn: 'Room 1',
cal_address: "room1@example.com", cal_address: 'room1@example.com',
partstat: "NEEDS-ACTION", partstat: 'NEEDS-ACTION',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "RESOURCE", cutype: 'RESOURCE'
}); })
} finally { } finally {
jest.useRealTimers(); jest.useRealTimers()
} }
}); })
it("dispatches putEventAsync and calls onClose when Save is clicked", async () => { it('dispatches putEventAsync and calls onClose when Save is clicked', async () => {
renderPopover(); renderPopover()
const newEvent = { const newEvent = {
title: "Meeting", title: 'Meeting',
allday: false, allday: false,
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", uid: '6045c603-11ab-43c5-bc30-0641420bb3a8',
description: "Discuss project", description: 'Discuss project',
location: "Zoom", location: 'Zoom',
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
timezone: "Europe/Paris", timezone: 'Europe/Paris',
transp: "TRANSPARENT", transp: 'TRANSPARENT'
}; }
// Fill inputs // Fill inputs
fireEvent.change(screen.getByLabelText("event.form.title"), { fireEvent.change(screen.getByLabelText('event.form.title'), {
target: { value: newEvent.title }, target: { value: newEvent.title }
}); })
// Click "Add description" button first // Click "Add description" button first
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "event.form.addDescription" }) screen.getByRole('button', { name: 'event.form.addDescription' })
); )
fireEvent.change(screen.getByLabelText("event.form.description"), { fireEvent.change(screen.getByLabelText('event.form.description'), {
target: { value: newEvent.description }, target: { value: newEvent.description }
}); })
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "event.form.locationPlaceholder" }) screen.getByRole('button', { name: 'event.form.locationPlaceholder' })
); )
fireEvent.change(screen.getByLabelText("event.form.location"), { fireEvent.change(screen.getByLabelText('event.form.location'), {
target: { value: newEvent.location }, target: { value: newEvent.location }
}); })
const spy = jest const spy = jest
.spyOn(eventThunks, "putEventAsync") .spyOn(eventThunks, 'putEventAsync')
.mockImplementation((payload) => { .mockImplementation(payload => {
const promise = Promise.resolve(payload); const promise = Promise.resolve(payload)
(promise as any).unwrap = () => promise; ;(promise as any).unwrap = () => promise
return () => promise as any; return () => promise as any
}); })
fireEvent.click(screen.getByRole("button", { name: "actions.save" })); fireEvent.click(screen.getByRole('button', { name: 'actions.save' }))
await waitFor(() => { 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( expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"] preloadedState.calendars.list['667037022b752d0026472254/cal1']
); )
expect(receivedPayload.newEvent.title).toBe(newEvent.title); expect(receivedPayload.newEvent.title).toBe(newEvent.title)
expect(receivedPayload.newEvent.description).toBe(newEvent.description); expect(receivedPayload.newEvent.description).toBe(newEvent.description)
expect(receivedPayload.newEvent.location).toBe(newEvent.location); expect(receivedPayload.newEvent.location).toBe(newEvent.location)
expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer)
expect(receivedPayload.newEvent.color).toEqual( expect(receivedPayload.newEvent.color).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].color preloadedState.calendars.list['667037022b752d0026472254/cal1'].color
); )
// onClose should be called // onClose should be called
expect(mockOnClose).toHaveBeenCalledWith(true); expect(mockOnClose).toHaveBeenCalledWith(true)
}); })
it("calls onClose with refresh = false when Cancel clicked", () => { it('calls onClose with refresh = false when Cancel clicked', () => {
renderPopover(); renderPopover()
// Cancel button only appears in expanded mode // 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 () => { it('BUGFIX: Prefill Calendar field', async () => {
renderPopover(); renderPopover()
// In normal mode calendar is a SectionPreviewRow (button) with calendar name // In normal mode calendar is a SectionPreviewRow (button) with calendar name
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByRole("button", { name: "Calendar 1" }) screen.getByRole('button', { name: 'Calendar 1' })
).toHaveTextContent("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) // 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 = { const selectedRange = {
startStr: "2025-07-18", startStr: '2025-07-18',
endStr: "2025-07-19", endStr: '2025-07-19',
start: new Date("2025-07-18"), start: new Date('2025-07-18'),
end: new Date("2025-07-19"), end: new Date('2025-07-19'),
allDay: true, allDay: true
} as unknown as DateSelectArg; } as unknown as DateSelectArg
renderPopover(selectedRange); renderPopover(selectedRange)
fireEvent.click( 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(() => { await waitFor(() => {
expect(allDayCheckbox).toBeChecked(); expect(allDayCheckbox).toBeChecked()
}); })
}); })
// Test 2.2: Drag from allday slot (multiple days) // 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 = { const selectedRange = {
startStr: "2025-07-18", startStr: '2025-07-18',
endStr: "2025-07-21", endStr: '2025-07-21',
start: new Date("2025-07-18"), start: new Date('2025-07-18'),
end: new Date("2025-07-21"), end: new Date('2025-07-21'),
allDay: true, allDay: true
} as unknown as DateSelectArg; } as unknown as DateSelectArg
renderPopover(selectedRange); renderPopover(selectedRange)
fireEvent.click( 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(() => { await waitFor(() => {
expect(allDayCheckbox).toBeChecked(); expect(allDayCheckbox).toBeChecked()
}); })
// Verify only date fields are shown // Verify only date fields are shown
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect( expect(screen.queryByTestId('start-time-input')).not.toBeInTheDocument()
screen.queryByTestId("start-time-input") expect(screen.queryByTestId('end-time-input')).not.toBeInTheDocument()
).not.toBeInTheDocument(); })
expect(screen.queryByTestId("end-time-input")).not.toBeInTheDocument(); })
});
});
// Test 2.3: Drag from week view (single day) // 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 = { const selectedRange = {
startStr: "2025-07-18T09:00", startStr: '2025-07-18T09:00',
endStr: "2025-07-18T10:00", endStr: '2025-07-18T10:00',
start: new Date("2025-07-18T09:00"), start: new Date('2025-07-18T09:00'),
end: new Date("2025-07-18T10:00"), end: new Date('2025-07-18T10:00'),
allDay: false, allDay: false
} as unknown as DateSelectArg; } as unknown as DateSelectArg
renderPopover(selectedRange); renderPopover(selectedRange)
fireEvent.click( 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(() => { await waitFor(() => {
expect(allDayCheckbox).not.toBeChecked(); expect(allDayCheckbox).not.toBeChecked()
}); })
// Verify time fields are shown // Verify time fields are shown
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
}); })
}); })
// Test 2.4: Drag from week view (multiple days) - CRITICAL TEST // 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 = { const selectedRange = {
startStr: "2025-07-18T09:00", startStr: '2025-07-18T09:00',
endStr: "2025-07-20T10:00", endStr: '2025-07-20T10:00',
start: new Date("2025-07-18T09:00"), start: new Date('2025-07-18T09:00'),
end: new Date("2025-07-20T10:00"), end: new Date('2025-07-20T10:00'),
allDay: false, allDay: false
} as unknown as DateSelectArg; } as unknown as DateSelectArg
renderPopover(selectedRange); renderPopover(selectedRange)
fireEvent.click( 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(() => { 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 // Verify all 4 fields are shown
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
}); })
}); })
// Test 2.5: Drag from week view (multiple days) - fallback path // 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 = { const selectedRange = {
start: new Date("2025-07-18T09:00"), start: new Date('2025-07-18T09:00'),
end: new Date("2025-07-20T10:00"), end: new Date('2025-07-20T10:00'),
allDay: false, allDay: false
} as unknown as DateSelectArg; } as unknown as DateSelectArg
renderPopover(selectedRange); renderPopover(selectedRange)
fireEvent.click( 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(() => { await waitFor(() => {
expect(allDayCheckbox).not.toBeChecked(); expect(allDayCheckbox).not.toBeChecked()
}); })
// Verify all 4 fields are shown // Verify all 4 fields are shown
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); expect(screen.getByTestId('start-date-input')).toBeInTheDocument()
expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); expect(screen.getByTestId('start-time-input')).toBeInTheDocument()
expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); expect(screen.getByTestId('end-date-input')).toBeInTheDocument()
expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); expect(screen.getByTestId('end-time-input')).toBeInTheDocument()
}); })
}); })
}); })
}); })
@@ -1,157 +1,157 @@
import { makeRecurrenceString } from "@/features/Events/EventPreview/utils/makeRecurrenceString"; import { makeRecurrenceString } from '@/features/Events/EventPreview/utils/makeRecurrenceString'
import { RepetitionObject } from "@/features/Events/EventsTypes"; import { RepetitionObject } from '@/features/Events/EventsTypes'
describe("makeRecurrenceString", () => { describe('makeRecurrenceString', () => {
const mockT = jest.fn((key: string, params?: any) => { const mockT = jest.fn((key: string, params?: any) => {
if (params) { if (params) {
if (key === "eventPreview.everyInterval") { if (key === 'eventPreview.everyInterval') {
return `Every ${params.interval} ${params.unit}`; return `Every ${params.interval} ${params.unit}`
} }
if (key === "eventPreview.recurrenceOnDays") { if (key === 'eventPreview.recurrenceOnDays') {
return `on ${params.days}`; return `on ${params.days}`
} }
if (key === "eventPreview.forOccurrences") { if (key === 'eventPreview.forOccurrences') {
return `for ${params.count} times`; return `for ${params.count} times`
} }
if (key === "eventPreview.until") { if (key === 'eventPreview.until') {
return `until ${params.date}`; return `until ${params.date}`
} }
} }
const translations: Record<string, string> = { const translations: Record<string, string> = {
"event.repeat.frequency.days": "days", 'event.repeat.frequency.days': 'days',
"event.repeat.frequency.weeks": "weeks", 'event.repeat.frequency.weeks': 'weeks',
"event.repeat.frequency.months": "months", 'event.repeat.frequency.months': 'months',
"event.repeat.frequency.years": "years", 'event.repeat.frequency.years': 'years',
"eventPreview.onDays.MO": "Monday", 'eventPreview.onDays.MO': 'Monday',
"eventPreview.onDays.TU": "Tuesday", 'eventPreview.onDays.TU': 'Tuesday',
"eventPreview.onDays.WE": "Wednesday", 'eventPreview.onDays.WE': 'Wednesday',
"eventPreview.onDays.TH": "Thursday", 'eventPreview.onDays.TH': 'Thursday',
"eventPreview.onDays.FR": "Friday", 'eventPreview.onDays.FR': 'Friday',
"eventPreview.onDays.SA": "Saturday", 'eventPreview.onDays.SA': 'Saturday',
"eventPreview.onDays.SU": "Sunday", 'eventPreview.onDays.SU': 'Sunday',
locale: "en-US", locale: 'en-US'
}; }
return translations[key] || key; return translations[key] || key
}); })
const startText = "Repeats"; const startText = 'Repeats'
beforeEach(() => { beforeEach(() => {
mockT.mockClear(); mockT.mockClear()
}); })
it("formats string for interval === 1 when enableStrForOneTimeInterval is true", () => { it('formats string for interval === 1 when enableStrForOneTimeInterval is true', () => {
const repetition: RepetitionObject = { freq: "daily", interval: 1 }; const repetition: RepetitionObject = { freq: 'daily', interval: 1 }
const result = makeRecurrenceString({ const result = makeRecurrenceString({
repetition, repetition,
t: mockT, t: mockT,
startText, startText,
enableStrForOneTimeInterval: true, enableStrForOneTimeInterval: true
}); })
expect(result).toBe("Repeats, days"); expect(result).toBe('Repeats, days')
}); })
it("formats string when interval is undefined", () => { it('formats string when interval is undefined', () => {
const repetition: RepetitionObject = { freq: "daily" }; const repetition: RepetitionObject = { freq: 'daily' }
const result = makeRecurrenceString({ const result = makeRecurrenceString({
repetition, repetition,
t: mockT, t: mockT,
startText, startText,
enableStrForOneTimeInterval: true, enableStrForOneTimeInterval: true
}); })
expect(result).toBe("Repeats, days"); expect(result).toBe('Repeats, days')
}); })
it("formats string for interval > 1", () => { it('formats string for interval > 1', () => {
const repetition: RepetitionObject = { freq: "weekly", interval: 2 }; const repetition: RepetitionObject = { freq: 'weekly', interval: 2 }
const result = makeRecurrenceString({ repetition, t: mockT, startText }); const result = makeRecurrenceString({ repetition, t: mockT, startText })
expect(result).toBe("Repeats, Every 2 weeks"); expect(result).toBe('Repeats, Every 2 weeks')
expect(mockT).toHaveBeenCalledWith("eventPreview.everyInterval", { expect(mockT).toHaveBeenCalledWith('eventPreview.everyInterval', {
interval: 2, 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 = { const repetition: RepetitionObject = {
freq: "weekly", freq: 'weekly',
interval: 1, interval: 1,
byday: ["WE", "MO", "FR"], byday: ['WE', 'MO', 'FR']
}; }
const result = makeRecurrenceString({ repetition, t: mockT, startText }); const result = makeRecurrenceString({ repetition, t: mockT, startText })
// Expected order: MO, WE, FR // 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 = { const repetition: RepetitionObject = {
freq: "daily", freq: 'daily',
interval: 1, interval: 1,
occurrences: 5, occurrences: 5
}; }
const result = makeRecurrenceString({ repetition, t: mockT, startText }); 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", () => { it('formats string with end date', () => {
const endDate = "2025-12-31T00:00:00.000Z"; const endDate = '2025-12-31T00:00:00.000Z'
const repetition: RepetitionObject = { const repetition: RepetitionObject = {
freq: "daily", freq: 'daily',
interval: 1, interval: 1,
endDate, endDate
}; }
const result = makeRecurrenceString({ repetition, t: mockT, startText }); const result = makeRecurrenceString({ repetition, t: mockT, startText })
const formattedDate = new Date(endDate).toLocaleDateString("en-US", { const formattedDate = new Date(endDate).toLocaleDateString('en-US', {
year: "numeric", year: 'numeric',
month: "long", month: 'long',
day: "numeric", day: 'numeric'
}); })
expect(result).toBe(`Repeats, until ${formattedDate}`); expect(result).toBe(`Repeats, until ${formattedDate}`)
}); })
it("combines multiple properties", () => { it('combines multiple properties', () => {
const endDate = "2025-12-31T00:00:00.000Z"; const endDate = '2025-12-31T00:00:00.000Z'
const repetition: RepetitionObject = { const repetition: RepetitionObject = {
freq: "monthly", freq: 'monthly',
interval: 3, interval: 3,
byday: ["TU", "TH"], byday: ['TU', 'TH'],
endDate, endDate
}; }
const result = makeRecurrenceString({ repetition, t: mockT, startText }); const result = makeRecurrenceString({ repetition, t: mockT, startText })
const formattedDate = new Date(endDate).toLocaleDateString("en-US", { const formattedDate = new Date(endDate).toLocaleDateString('en-US', {
year: "numeric", year: 'numeric',
month: "long", month: 'long',
day: "numeric", day: 'numeric'
}); })
expect(result).toBe( expect(result).toBe(
`Repeats, Every 3 months, on Tuesday, Thursday, until ${formattedDate}` `Repeats, Every 3 months, on Tuesday, Thursday, until ${formattedDate}`
); )
}); })
it("uses custom joinChar when provided", () => { it('uses custom joinChar when provided', () => {
const repetition: RepetitionObject = { const repetition: RepetitionObject = {
freq: "weekly", freq: 'weekly',
interval: 2, interval: 2,
occurrences: 10, occurrences: 10
}; }
const result = makeRecurrenceString({ const result = makeRecurrenceString({
repetition, repetition,
t: mockT, t: mockT,
startText, 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 CalendarApi from '@/features/Calendars/CalendarApi'
import * as eventThunks from "@/features/Calendars/services"; import * as eventThunks from '@/features/Calendars/services'
import * as EventApi from "@/features/Events/EventApi"; import * as EventApi from '@/features/Events/EventApi'
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from '@/features/Events/EventsTypes'
import EventUpdateModal from "@/features/Events/EventUpdateModal"; import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from "@testing-library/user-event"; import userEvent from '@testing-library/user-event'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/features/Events/EventApi"); jest.mock('@/features/Events/EventApi')
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
jest.mock("@/features/Events/api/updateSeries"); jest.mock('@/features/Events/api/updateSeries')
describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest.restoreAllMocks(); jest.restoreAllMocks()
sessionStorage.clear(); sessionStorage.clear()
}); })
const baseUID = "recurring-event-base"; const baseUID = 'recurring-event-base'
const calId = "667037022b752d0026472254/cal1"; const calId = '667037022b752d0026472254/cal1'
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "test-sid", sid: 'test-sid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
[calId]: { [calId]: {
id: calId, id: calId,
name: "Test Calendar", name: 'Test Calendar',
color: "#FF0000", color: '#FF0000',
events: {}, 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 () => { it("should fetch and display master event when editing 'all events' of a recurring series", async () => {
// Given a recurring series with modified instances // Given a recurring series with modified instances
const masterEvent = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Master Event Title", title: 'Master Event Title',
description: "Master Description", description: 'Master Description',
calId, calId,
start: "2025-01-15T10:00:00.000Z", start: '2025-01-15T10:00:00.000Z',
end: "2025-01-15T11:00:00.000Z", end: '2025-01-15T11:00:00.000Z',
repetition: { freq: "daily", interval: 1 }, repetition: { freq: 'daily', interval: 1 },
allday: false, allday: false,
timezone: "America/New_York", timezone: 'America/New_York',
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [{ cn: "test", cal_address: "test@test.com" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent; } as CalendarEvent
// Instance that was moved to different time // Instance that was moved to different time
const modifiedInstance = { const modifiedInstance = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250116`, uid: `${baseUID}/20250116`,
title: "Modified Instance Title", // Different title title: 'Modified Instance Title', // Different title
start: "2025-01-16T14:00:00.000Z", // Different time (2PM instead of 10AM) start: '2025-01-16T14:00:00.000Z', // Different time (2PM instead of 10AM)
end: "2025-01-16T15:00:00.000Z", end: '2025-01-16T15:00:00.000Z'
}; }
const stateWithRecurringEvent = { const stateWithRecurringEvent = {
...preloadedState, ...preloadedState,
@@ -86,17 +86,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
...preloadedState.calendars.list[calId], ...preloadedState.calendars.list[calId],
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250116`]: modifiedInstance, [`${baseUID}/20250116`]: modifiedInstance
}, }
}, }
}, }
}, }
}; }
// Mock getEvent to return master event // Mock getEvent to return master event
const mockGetEvent = jest const mockGetEvent = jest
.spyOn(EventApi, "getEvent") .spyOn(EventApi, 'getEvent')
.mockResolvedValue(masterEvent); .mockResolvedValue(masterEvent)
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -107,51 +107,51 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithRecurringEvent stateWithRecurringEvent
); )
// Wait for master event to be fetched // Wait for master event to be fetched
await waitFor(() => { await waitFor(() => {
expect(mockGetEvent).toHaveBeenCalledWith( expect(mockGetEvent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
uid: baseUID, // Should fetch base UID, not instance uid: baseUID // Should fetch base UID, not instance
}), }),
true true
); )
}); })
// Should display master event data, NOT modified instance data // Should display master event data, NOT modified instance data
await waitFor(() => { await waitFor(() => {
const titleInput = screen.getByDisplayValue("Master Event Title"); const titleInput = screen.getByDisplayValue('Master Event Title')
expect(titleInput).toBeInTheDocument(); expect(titleInput).toBeInTheDocument()
}); })
// Should NOT show modified instance title // Should NOT show modified instance title
expect( expect(
screen.queryByDisplayValue("Modified Instance Title") screen.queryByDisplayValue('Modified Instance Title')
).not.toBeInTheDocument(); ).not.toBeInTheDocument()
// Expand more options to show timezone (normal mode shows summary first) // Expand more options to show timezone (normal mode shows summary first)
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" }) screen.getByRole('button', { name: 'common.moreOptions' })
); )
// Verify timezone dropdown shows master timezone // Verify timezone dropdown shows master timezone
await waitFor(() => { 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 = { const masterEvent = {
uid: baseUID, // No recurrence-id uid: baseUID, // No recurrence-id
title: "Master Event", title: 'Master Event',
calId, calId,
start: "2025-01-15T10:00:00.000Z", start: '2025-01-15T10:00:00.000Z',
end: "2025-01-15T11:00:00.000Z", end: '2025-01-15T11:00:00.000Z',
repetition: { freq: "weekly", interval: 1 }, repetition: { freq: 'weekly', interval: 1 },
allday: false, allday: false,
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`
}; }
const stateWithMaster = { const stateWithMaster = {
...preloadedState, ...preloadedState,
@@ -161,14 +161,14 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
[calId]: { [calId]: {
...preloadedState.calendars.list[calId], ...preloadedState.calendars.list[calId],
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent
}, }
}, }
}, }
}, }
}; }
const mockGetEvent = jest.spyOn(EventApi, "getEvent"); const mockGetEvent = jest.spyOn(EventApi, 'getEvent')
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -179,28 +179,28 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithMaster stateWithMaster
); )
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue("Master Event")).toBeInTheDocument(); expect(screen.getByDisplayValue('Master Event')).toBeInTheDocument()
}); })
// Should NOT fetch from API since we already have master // 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 = { const instance = {
uid: `${baseUID}/20250115`, uid: `${baseUID}/20250115`,
title: "Instance Event", title: 'Instance Event',
calId, calId,
start: "2025-01-15T10:00:00.000Z", start: '2025-01-15T10:00:00.000Z',
end: "2025-01-15T11:00:00.000Z", end: '2025-01-15T11:00:00.000Z',
repetition: { freq: "daily", interval: 1 }, repetition: { freq: 'daily', interval: 1 },
allday: false, allday: false,
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent; } as CalendarEvent
const stateWithInstance = { const stateWithInstance = {
...preloadedState, ...preloadedState,
@@ -210,19 +210,19 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
[calId]: { [calId]: {
...preloadedState.calendars.list[calId], ...preloadedState.calendars.list[calId],
events: { events: {
[`${baseUID}/20250115`]: instance, [`${baseUID}/20250115`]: instance
}, }
}, }
}, }
}, }
}; }
// Mock getEvent to fail // Mock getEvent to fail
jest jest
.spyOn(EventApi, "getEvent") .spyOn(EventApi, 'getEvent')
.mockRejectedValue(new Error("Network error")); .mockRejectedValue(new Error('Network error'))
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -233,48 +233,48 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithInstance stateWithInstance
); )
// Should still display the instance data as fallback // Should still display the instance data as fallback
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue("Instance Event")).toBeInTheDocument(); expect(screen.getByDisplayValue('Instance Event')).toBeInTheDocument()
}); })
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to fetch master event:", 'Failed to fetch master event:',
expect.any(Error) expect.any(Error)
); )
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore()
}); })
}); })
describe("Update All Events - Using Base UID", () => { describe('Update All Events - Using Base UID', () => {
it("should use base UID (not instance UID) when updating series with property changes", async () => { it('should use base UID (not instance UID) when updating series with property changes', async () => {
const masterEvent = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Weekly Meeting", title: 'Weekly Meeting',
description: "Original description", description: 'Original description',
calId, calId,
start: "2025-01-15T10:00:00.000Z", start: '2025-01-15T10:00:00.000Z',
end: "2025-01-15T11:00:00.000Z", end: '2025-01-15T11:00:00.000Z',
repetition: { freq: "weekly", interval: 1 }, repetition: { freq: 'weekly', interval: 1 },
allday: false, allday: false,
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent; } as CalendarEvent
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250115`, uid: `${baseUID}/20250115`
}; }
const instance2 = { const instance2 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250122`, uid: `${baseUID}/20250122`,
start: "2025-01-22T10:00:00.000Z", start: '2025-01-22T10:00:00.000Z',
end: "2025-01-22T11:00:00.000Z", end: '2025-01-22T11:00:00.000Z'
}; }
const stateWithSeries = { const stateWithSeries = {
...preloadedState, ...preloadedState,
@@ -286,16 +286,16 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1, [`${baseUID}/20250115`]: instance1,
[`${baseUID}/20250122`]: instance2, [`${baseUID}/20250122`]: instance2
}, }
}, }
}, }
}, }
}; }
// Mock getEvent to return master // Mock getEvent to return master
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent); jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent)
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync"); const updateSeriesAsyncSpy = jest.spyOn(eventThunks, 'updateSeriesAsync')
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -306,61 +306,61 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithSeries stateWithSeries
); )
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue("Weekly Meeting")).toBeInTheDocument(); expect(screen.getByDisplayValue('Weekly Meeting')).toBeInTheDocument()
}); })
// Change only the title (property change, not recurrence rules) // Change only the title (property change, not recurrence rules)
const titleInput = screen.getByDisplayValue("Weekly Meeting"); const titleInput = screen.getByDisplayValue('Weekly Meeting')
fireEvent.change(titleInput, { target: { value: "Updated 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 () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
}); })
// Verify updateSeriesAsync was dispatched with base UID // Verify updateSeriesAsync was dispatched with base UID
await waitFor(() => { await waitFor(() => {
expect(updateSeriesAsyncSpy).toHaveBeenCalledWith( expect(updateSeriesAsyncSpy).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
event: expect.objectContaining({ event: expect.objectContaining({
uid: "recurring-event-base", uid: 'recurring-event-base',
recurrenceId: undefined, 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 = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Daily Standup", title: 'Daily Standup',
calId, calId,
start: "2025-01-15T09:00:00.000Z", start: '2025-01-15T09:00:00.000Z',
end: "2025-01-15T09:30:00.000Z", end: '2025-01-15T09:30:00.000Z',
repetition: { freq: "daily", interval: 1 }, // Daily repetition: { freq: 'daily', interval: 1 }, // Daily
allday: false, allday: false,
timezone: "America/New_York", timezone: 'America/New_York',
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent; } as CalendarEvent
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250115`, uid: `${baseUID}/20250115`
}; }
const instance2 = { const instance2 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250116`, uid: `${baseUID}/20250116`,
start: "2025-01-16T09:00:00.000Z", start: '2025-01-16T09:00:00.000Z',
end: "2025-01-16T09:30:00.000Z", end: '2025-01-16T09:30:00.000Z'
}; }
const stateWithSeries = { const stateWithSeries = {
...preloadedState, ...preloadedState,
@@ -372,17 +372,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1, [`${baseUID}/20250115`]: instance1,
[`${baseUID}/20250116`]: instance2, [`${baseUID}/20250116`]: instance2
}, }
}, }
}, }
}, }
}; }
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent); jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent)
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({ jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({
_embedded: { "dav:item": [] }, _embedded: { 'dav:item': [] }
} as any); } as any)
const { store } = renderWithProviders( const { store } = renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -393,72 +393,72 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithSeries stateWithSeries
); )
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument(); expect(screen.getByDisplayValue('Daily Standup')).toBeInTheDocument()
}); })
// Click More Options to access repeat settings // Click More Options to access repeat settings
const moreOptionsButton = screen.getByText("common.moreOptions"); const moreOptionsButton = screen.getByText('common.moreOptions')
fireEvent.click(moreOptionsButton); fireEvent.click(moreOptionsButton)
await waitFor(() => { await waitFor(() => {
const repeatCheckbox = screen.getByLabelText("event.form.repeat"); const repeatCheckbox = screen.getByLabelText('event.form.repeat')
expect(repeatCheckbox).toBeInTheDocument(); expect(repeatCheckbox).toBeInTheDocument()
expect(repeatCheckbox).toBeChecked(); expect(repeatCheckbox).toBeChecked()
}); })
// Change from daily to weekly // Change from daily to weekly
const frequencySelect = screen.getByText("event.repeat.frequency.days"); const frequencySelect = screen.getByText('event.repeat.frequency.days')
fireEvent.mouseDown(frequencySelect); fireEvent.mouseDown(frequencySelect)
const weeklyOption = screen.getByRole("option", { const weeklyOption = screen.getByRole('option', {
name: "event.repeat.frequency.weeks", name: 'event.repeat.frequency.weeks'
}); })
fireEvent.click(weeklyOption); fireEvent.click(weeklyOption)
const saveButton = screen.getByRole("button", { name: "actions.save" }); const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100))
}); })
// Verify all old instances were removed from store // Verify all old instances were removed from store
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
const calendar = state.calendars.list[calId]; const calendar = state.calendars.list[calId]
expect(calendar.events[baseUID]).toBeUndefined(); expect(calendar.events[baseUID]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined(); expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250116`]).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 () => { it("should preserve master event date when updating time in 'all events' mode", async () => {
// Master event starts on Jan 15 at 10:00 AM // Master event starts on Jan 15 at 10:00 AM
const masterEvent = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Daily Standup", title: 'Daily Standup',
calId, calId,
start: "2025-01-15T10:00:00.000Z", start: '2025-01-15T10:00:00.000Z',
end: "2025-01-15T10:30:00.000Z", end: '2025-01-15T10:30:00.000Z',
repetition: { freq: "daily", interval: 1 }, repetition: { freq: 'daily', interval: 1 },
allday: false, allday: false,
timezone: "UTC", timezone: 'UTC',
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`
} as CalendarEvent; } as CalendarEvent
// Instance on Jan 17 (different date) // Instance on Jan 17 (different date)
const instance = { const instance = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250117`, uid: `${baseUID}/20250117`,
start: "2025-01-17T10:00:00.000Z", start: '2025-01-17T10:00:00.000Z',
end: "2025-01-17T10:30:00.000Z", end: '2025-01-17T10:30:00.000Z'
}; }
const stateWithSeries = { const stateWithSeries = {
...preloadedState, ...preloadedState,
@@ -469,19 +469,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
...preloadedState.calendars.list[calId], ...preloadedState.calendars.list[calId],
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250117`]: instance, [`${baseUID}/20250117`]: instance
}, }
}, }
}, }
}, }
}; }
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent); jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent)
jest jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201 } as any)
.spyOn(EventApi, "putEvent")
.mockResolvedValue({ status: 201 } as any);
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync"); const updateSeriesAsyncSpy = jest.spyOn(eventThunks, 'updateSeriesAsync')
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -492,40 +490,40 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithSeries stateWithSeries
); )
await waitFor(() => { 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) // Expand more options to show date/time inputs (normal mode shows DateTimeSummary first)
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" }) screen.getByRole('button', { name: 'common.moreOptions' })
); )
const input = await waitFor(() => screen.getByTestId("start-time-input")); const input = await waitFor(() => screen.getByTestId('start-time-input'))
// Change time to 2:00 PM (14:00) // Change time to 2:00 PM (14:00)
await userEvent.click(input); await userEvent.click(input)
await userEvent.clear(input); await userEvent.clear(input)
await userEvent.type(input, "14:00{enter}"); 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 () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100))
}); })
// Verify the API was called with master date (Jan 15) + new time (14:00) // Verify the API was called with master date (Jan 15) + new time (14:00)
await waitFor(() => { await waitFor(() => {
expect(updateSeriesAsyncSpy).toHaveBeenCalled(); expect(updateSeriesAsyncSpy).toHaveBeenCalled()
const callArgs = updateSeriesAsyncSpy.mock.calls[0][0]; const callArgs = updateSeriesAsyncSpy.mock.calls[0][0]
const updatedEvent = callArgs.event; const updatedEvent = callArgs.event
// Should preserve Jan 15 date (master), not Jan 17 (clicked instance) // 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) // 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 CalendarApi from '@/features/Calendars/CalendarApi'
import * as EventApi from "@/features/Events/EventApi"; import * as EventApi from '@/features/Events/EventApi'
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from '@/features/Events/EventsTypes'
import EventUpdateModal from "@/features/Events/EventUpdateModal"; import EventUpdateModal from '@/features/Events/EventUpdateModal'
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/features/Events/EventApi"); jest.mock('@/features/Events/EventApi')
jest.mock("@/features/Calendars/CalendarApi"); jest.mock('@/features/Calendars/CalendarApi')
describe("EventUpdateModal Timezone Handling", () => { describe('EventUpdateModal Timezone Handling', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
sessionStorage.clear(); sessionStorage.clear()
}); })
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "test-sid", sid: 'test-sid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
id: "667037022b752d0026472254/cal1", id: '667037022b752d0026472254/cal1',
name: "Test Calendar", name: 'Test Calendar',
color: "#FF0000", color: '#FF0000',
events: {}, 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 // GIVEN user timezone is UTC+2
// WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok) // WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok)
// THEN the update modal prompts him date with 2PM UTC+7 // THEN the update modal prompts him date with 2PM UTC+7
// Create event at 2PM UTC+7 (Asia/Bangkok) // Create event at 2PM UTC+7 (Asia/Bangkok)
// 2PM UTC+7 = 14:00 in Bangkok = 07:00 UTC // 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 = { const eventData = {
uid: "test-event-1", uid: 'test-event-1',
title: "Timezone Event", title: 'Timezone Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: eventDateUTC.toISOString(), start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(), end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok", timezone: 'Asia/Bangkok',
allday: false, 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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: "/calendars/667037022b752d0026472254/cal1/test-event-1.ics", URL: '/calendars/667037022b752d0026472254/cal1/test-event-1.ics'
} as CalendarEvent; } as CalendarEvent
const stateWithEvent = { const stateWithEvent = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
...preloadedState.calendars, ...preloadedState.calendars,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"], ...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: { events: {
"test-event-1": eventData, 'test-event-1': eventData
}, }
}, }
}, }
}, }
}; }
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"test-event-1"} eventId={'test-event-1'}
eventData={eventData} eventData={eventData}
/>, />,
stateWithEvent stateWithEvent
); )
// Verify title is displayed // Verify title is displayed
const titleInput = screen.getByDisplayValue("Timezone Event"); const titleInput = screen.getByDisplayValue('Timezone Event')
expect(titleInput).toBeInTheDocument(); expect(titleInput).toBeInTheDocument()
// Expand to show date/time inputs (normal mode shows DateTimeSummary) // 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 // Verify the start date and time inputs exist
await waitFor(() => { await waitFor(() => {
const startDateInput = screen.getByTestId("start-date-input"); const startDateInput = screen.getByTestId('start-date-input')
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId('start-time-input')
expect(startDateInput).toBeInTheDocument(); expect(startDateInput).toBeInTheDocument()
expect(startTimeInput).toBeInTheDocument(); expect(startTimeInput).toBeInTheDocument()
// MUI DatePicker/TimePicker values are stored differently - just check elements exist // MUI DatePicker/TimePicker values are stored differently - just check elements exist
// The actual values are verified through the form submission // The actual values are verified through the form submission
}); })
}); })
it("preserves original timezone when editing event fields", async () => { it('preserves original timezone when editing event fields', async () => {
const eventDateUTC = new Date("2025-01-15T07:00:00.000Z"); const eventDateUTC = new Date('2025-01-15T07:00:00.000Z')
const eventData = { const eventData = {
uid: "test-event-2", uid: 'test-event-2',
title: "Original Event", title: 'Original Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: eventDateUTC.toISOString(), start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(), end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok", timezone: 'Asia/Bangkok',
allday: false, 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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: "/calendars/667037022b752d0026472254/cal1/test-event-2.ics", URL: '/calendars/667037022b752d0026472254/cal1/test-event-2.ics'
} as CalendarEvent; } as CalendarEvent
const stateWithEvent = { const stateWithEvent = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
...preloadedState.calendars, ...preloadedState.calendars,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"], ...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: { events: {
"test-event-2": eventData, 'test-event-2': eventData
}, }
}, }
}, }
}, }
}; }
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"test-event-2"} eventId={'test-event-2'}
eventData={eventData} eventData={eventData}
/>, />,
stateWithEvent stateWithEvent
); )
// Edit the title // Edit the title
const titleInput = screen.getByDisplayValue("Original Event"); const titleInput = screen.getByDisplayValue('Original Event')
fireEvent.change(titleInput, { target: { value: "Updated Event" } }); fireEvent.change(titleInput, { target: { value: 'Updated Event' } })
// Verify the timezone is still preserved (should be Asia/Bangkok) // 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 () => { it('preserves resources when editing an event', async () => {
const eventDateUTC = new Date("2025-01-15T07:00:00.000Z"); const eventDateUTC = new Date('2025-01-15T07:00:00.000Z')
const eventData = { const eventData = {
uid: "test-event-resource", uid: 'test-event-resource',
title: "Resource Event", title: 'Resource Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: eventDateUTC.toISOString(), start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(), end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok", timezone: 'Asia/Bangkok',
allday: false, allday: false,
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: 'test', cal_address: 'test@test.com' },
attendee: [ attendee: [
{ cn: "test", cal_address: "test@test.com" }, { cn: 'test', cal_address: 'test@test.com' },
{ {
cn: "Conference Room", cn: 'Conference Room',
cal_address: "room@test.com", cal_address: 'room@test.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "RESOURCE", cutype: 'RESOURCE'
}, }
], ],
URL: "/calendars/667037022b752d0026472254/cal1/test-event-resource.ics", URL: '/calendars/667037022b752d0026472254/cal1/test-event-resource.ics'
} as CalendarEvent; } as CalendarEvent
const stateWithEvent = { const stateWithEvent = {
...preloadedState, ...preloadedState,
calendars: { calendars: {
...preloadedState.calendars, ...preloadedState.calendars,
list: { list: {
"667037022b752d0026472254/cal1": { '667037022b752d0026472254/cal1': {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"], ...preloadedState.calendars.list['667037022b752d0026472254/cal1'],
events: { 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, status: 201,
url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics`, url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics`
} as any); } as any)
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={'667037022b752d0026472254/cal1'}
eventId={"test-event-resource"} eventId={'test-event-resource'}
eventData={eventData} eventData={eventData}
/>, />,
stateWithEvent stateWithEvent
); )
// Edit the title // Edit the title
const titleInput = screen.getByDisplayValue("Resource Event"); const titleInput = screen.getByDisplayValue('Resource Event')
fireEvent.change(titleInput, { fireEvent.change(titleInput, {
target: { value: "Updated Resource Event" }, target: { value: 'Updated Resource Event' }
}); })
// Click Save // Click Save
const saveButton = screen.getByRole("button", { name: "actions.save" }); const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
}); })
await waitFor(() => { await waitFor(() => {
expect(mockPutEvent).toHaveBeenCalled(); expect(mockPutEvent).toHaveBeenCalled()
}); })
const putEventCall = mockPutEvent.mock.calls[0][0]; const putEventCall = mockPutEvent.mock.calls[0][0]
expect(putEventCall.title).toBe("Updated Resource Event"); expect(putEventCall.title).toBe('Updated Resource Event')
// Check that the resource is still in the attendee list! // Check that the resource is still in the attendee list!
const attendees = putEventCall.attendee; const attendees = putEventCall.attendee
const resource = attendees.find((a: any) => a.cutype === "RESOURCE"); const resource = attendees.find((a: any) => a.cutype === 'RESOURCE')
expect(resource).toBeDefined(); expect(resource).toBeDefined()
expect(resource!.cn).toBe("Conference Room"); expect(resource!.cn).toBe('Conference Room')
expect(resource!.cal_address).toBe("room@test.com"); expect(resource!.cal_address).toBe('room@test.com')
expect(resource!.partstat).toBe("ACCEPTED"); expect(resource!.partstat).toBe('ACCEPTED')
expect(resource!.rsvp).toBe("TRUE"); expect(resource!.rsvp).toBe('TRUE')
expect(resource!.role).toBe("REQ-PARTICIPANT"); expect(resource!.role).toBe('REQ-PARTICIPANT')
expect(resource!.cutype).toBe("RESOURCE"); expect(resource!.cutype).toBe('RESOURCE')
}); })
}); })
describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { describe('EventUpdateModal Recurring to Non-Recurring Conversion', () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest.restoreAllMocks(); jest.restoreAllMocks()
}); })
const baseUID = "recurring-event-base"; const baseUID = 'recurring-event-base'
const calId = "667037022b752d0026472254/cal1"; const calId = '667037022b752d0026472254/cal1'
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "test-sid", sid: 'test-sid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: { organiserData: {
cn: "test", cn: 'test',
cal_address: "test@test.com", cal_address: 'test@test.com'
}, }
}, },
calendars: { calendars: {
list: { list: {
[calId]: { [calId]: {
id: calId, id: calId,
name: "Test Calendar", name: 'Test Calendar',
color: "#FF0000", color: '#FF0000',
events: {}, 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 // 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 = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Recurring Meeting", title: 'Recurring Meeting',
calId, calId,
start: eventDate.toISOString(), start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 3600000).toISOString(), end: new Date(eventDate.getTime() + 3600000).toISOString(),
repetition: { freq: "daily", interval: 1 }, repetition: { freq: 'daily', interval: 1 },
allday: false, 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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC", timezone: 'UTC'
} as CalendarEvent; } as CalendarEvent
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250115`, uid: `${baseUID}/20250115`,
start: "2025-01-15T10:00:00.000Z", start: '2025-01-15T10:00:00.000Z',
end: "2025-01-15T11:00:00.000Z", end: '2025-01-15T11:00:00.000Z'
}; }
const instance2 = { const instance2 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250116`, uid: `${baseUID}/20250116`,
start: "2025-01-16T10:00:00.000Z", start: '2025-01-16T10:00:00.000Z',
end: "2025-01-16T11:00:00.000Z", end: '2025-01-16T11:00:00.000Z'
}; }
const instance3 = { const instance3 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250117`, uid: `${baseUID}/20250117`,
start: "2025-01-17T10:00:00.000Z", start: '2025-01-17T10:00:00.000Z',
end: "2025-01-17T11:00:00.000Z", end: '2025-01-17T11:00:00.000Z'
}; }
const stateWithRecurringEvent = { const stateWithRecurringEvent = {
...preloadedState, ...preloadedState,
@@ -336,29 +336,29 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1, [`${baseUID}/20250115`]: instance1,
[`${baseUID}/20250116`]: instance2, [`${baseUID}/20250116`]: instance2,
[`${baseUID}/20250117`]: instance3, [`${baseUID}/20250117`]: instance3
}, }
}, }
}, }
}, }
}; }
// Mock API calls // Mock API calls
const mockDeleteEvent = jest const mockDeleteEvent = jest
.spyOn(EventApi, "deleteEvent") .spyOn(EventApi, 'deleteEvent')
.mockResolvedValue({} as any); .mockResolvedValue({} as any)
const mockPutEvent = jest.spyOn(EventApi, "putEvent").mockResolvedValue({ const mockPutEvent = jest.spyOn(EventApi, 'putEvent').mockResolvedValue({
status: 201, status: 201,
url: `/calendars/${calId}/new-event.ics`, url: `/calendars/${calId}/new-event.ics`
} as any); } as any)
const mockGetMasterEvent = jest const mockGetMasterEvent = jest
.spyOn(EventApi, "getEvent") .spyOn(EventApi, 'getEvent')
.mockResolvedValue(masterEvent); .mockResolvedValue(masterEvent)
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({ jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({
_embedded: { _embedded: {
"dav:item": [], 'dav:item': []
}, }
} as any); } as any)
const { store } = renderWithProviders( const { store } = renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -369,97 +369,97 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithRecurringEvent stateWithRecurringEvent
); )
// Wait for master event to be fetched AND form to be initialized // Wait for master event to be fetched AND form to be initialized
await waitFor(() => { 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) // Wait for the repeat checkbox to be checked (indicating form is initialized)
const repeatCheckbox = await waitFor(() => { const repeatCheckbox = await waitFor(() => {
const checkbox = screen.getByLabelText("event.form.repeat"); const checkbox = screen.getByLabelText('event.form.repeat')
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked()
return checkbox; return checkbox
}); })
// Now uncheck repeat checkbox // Now uncheck repeat checkbox
await act(async () => { await act(async () => {
fireEvent.click(repeatCheckbox); fireEvent.click(repeatCheckbox)
}); })
// Wait for checkbox to be unchecked // Wait for checkbox to be unchecked
await waitFor(() => { await waitFor(() => {
expect(repeatCheckbox).not.toBeChecked(); expect(repeatCheckbox).not.toBeChecked()
}); })
// Click Save button // Click Save button
const saveButton = screen.getByRole("button", { name: "actions.save" }); const saveButton = screen.getByRole('button', { name: 'actions.save' })
await act(async () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
}); })
await waitFor(() => { await waitFor(() => {
expect(mockPutEvent).toHaveBeenCalled(); expect(mockPutEvent).toHaveBeenCalled()
}); })
// Verify API calls - should delete all instances // Verify API calls - should delete all instances
await waitFor( await waitFor(
() => { () => {
// Should have called deleteEvent for each instance (4 total: base + 3 recurrences) // 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 // At least one instance should be deleted
expect(mockDeleteEvent.mock.calls.length).toBeGreaterThan(0); expect(mockDeleteEvent.mock.calls.length).toBeGreaterThan(0)
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
// Verify new event was created via putEvent // Verify new event was created via putEvent
expect(mockPutEvent).toHaveBeenCalled(); expect(mockPutEvent).toHaveBeenCalled()
const putEventCall = mockPutEvent.mock.calls[0][0]; const putEventCall = mockPutEvent.mock.calls[0][0]
expect(putEventCall.title).toBe("Recurring Meeting"); expect(putEventCall.title).toBe('Recurring Meeting')
expect(putEventCall.repetition?.freq).toBeFalsy(); expect(putEventCall.repetition?.freq).toBeFalsy()
expect(putEventCall.uid).not.toContain(baseUID); expect(putEventCall.uid).not.toContain(baseUID)
// Verify Redux store state changes // Verify Redux store state changes
const finalState = store.getState(); const finalState = store.getState()
const calendar = finalState.calendars.list[calId]; const calendar = finalState.calendars.list[calId]
// Old recurring instances should be removed from store // Old recurring instances should be removed from store
expect(calendar.events[baseUID]).toBeUndefined(); expect(calendar.events[baseUID]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined(); expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined(); expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined()
expect(calendar.events[`${baseUID}/20250117`]).toBeUndefined(); expect(calendar.events[`${baseUID}/20250117`]).toBeUndefined()
// Verify modal was closed after completion // Verify modal was closed after completion
await waitFor(() => { await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled()
}); })
}); })
it("gracefully handles deletion errors and continues to create new event", async () => { it('gracefully handles deletion errors and continues to create new event', async () => {
const eventDate = new Date("2025-01-15T10:00:00.000Z"); const eventDate = new Date('2025-01-15T10:00:00.000Z')
const masterEvent = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Recurring Meeting", title: 'Recurring Meeting',
calId, calId,
start: eventDate.toISOString(), start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 3600000).toISOString(), end: new Date(eventDate.getTime() + 3600000).toISOString(),
repetition: { freq: "weekly", interval: 1 }, repetition: { freq: 'weekly', interval: 1 },
allday: false, 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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC", timezone: 'UTC'
} as CalendarEvent; } as CalendarEvent
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250115`, uid: `${baseUID}/20250115`
}; }
const stateWithRecurringEvent = { const stateWithRecurringEvent = {
...preloadedState, ...preloadedState,
@@ -470,24 +470,24 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
...preloadedState.calendars.list[calId], ...preloadedState.calendars.list[calId],
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1, [`${baseUID}/20250115`]: instance1
}, }
}, }
}, }
}, }
}; }
// Mock deleteEvent to fail with a non-404 error // Mock deleteEvent to fail with a non-404 error
const mockDeleteEvent = jest const mockDeleteEvent = jest
.spyOn(EventApi, "deleteEvent") .spyOn(EventApi, 'deleteEvent')
.mockRejectedValue(new Error("Network error")); .mockRejectedValue(new Error('Network error'))
const mockPutEvent = jest const mockPutEvent = jest
.spyOn(EventApi, "putEvent") .spyOn(EventApi, 'putEvent')
.mockResolvedValue({ status: 201 } as any); .mockResolvedValue({ status: 201 } as any)
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const mockGetMasterEvent = jest const mockGetMasterEvent = jest
.spyOn(EventApi, "getEvent") .spyOn(EventApi, 'getEvent')
.mockResolvedValue(masterEvent); .mockResolvedValue(masterEvent)
const { store } = renderWithProviders( const { store } = renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -498,106 +498,106 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithRecurringEvent stateWithRecurringEvent
); )
// Wait for master event to be fetched // Wait for master event to be fetched
await waitFor(() => { await waitFor(() => {
expect(mockGetMasterEvent).toHaveBeenCalled(); expect(mockGetMasterEvent).toHaveBeenCalled()
}); })
// Wait for component to load // Wait for component to load
await waitFor(() => { 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 // Wait for repeat checkbox to be checked
const repeatCheckbox = await waitFor(() => { const repeatCheckbox = await waitFor(() => {
const checkbox = screen.getByLabelText("event.form.repeat"); const checkbox = screen.getByLabelText('event.form.repeat')
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked()
return checkbox; return checkbox
}); })
// Uncheck repeat checkbox and save // Uncheck repeat checkbox and save
await act(async () => { await act(async () => {
fireEvent.click(repeatCheckbox); fireEvent.click(repeatCheckbox)
}); })
// Wait for checkbox to be unchecked // Wait for checkbox to be unchecked
await waitFor(() => { 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 () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
}); })
// Wait for delete to be attempted // Wait for delete to be attempted
await waitFor( await waitFor(
() => { () => {
expect(mockDeleteEvent).toHaveBeenCalled(); expect(mockDeleteEvent).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
// Verify error was logged for failed deletion // Verify error was logged for failed deletion
await waitFor( await waitFor(
() => { () => {
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete event file") expect.stringContaining('Failed to delete event file')
); )
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
// New implementation gracefully handles deletion errors: // New implementation gracefully handles deletion errors:
// Even if some instances fail to delete, we still create the new event // Even if some instances fail to delete, we still create the new event
// This ensures user gets their non-recurring event // This ensures user gets their non-recurring event
await waitFor( await waitFor(
() => { () => {
expect(mockPutEvent).toHaveBeenCalled(); expect(mockPutEvent).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
const putEventCall = mockPutEvent.mock.calls[0][0]; const putEventCall = mockPutEvent.mock.calls[0][0]
expect(putEventCall.title).toBe("Recurring Meeting"); expect(putEventCall.title).toBe('Recurring Meeting')
expect(putEventCall.repetition?.freq).toBeFalsy(); expect(putEventCall.repetition?.freq).toBeFalsy()
// Modal should close after operation completes // Modal should close after operation completes
await waitFor( await waitFor(
() => { () => {
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore()
}); })
it("closes all modals after converting recurring to non-recurring", async () => { it('closes all modals after converting recurring to non-recurring', async () => {
const eventDate = new Date("2025-01-15T10:00:00.000Z"); const eventDate = new Date('2025-01-15T10:00:00.000Z')
const masterEvent = { const masterEvent = {
uid: baseUID, uid: baseUID,
title: "Recurring Meeting", title: 'Recurring Meeting',
calId, calId,
start: eventDate.toISOString(), start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 3600000).toISOString(), end: new Date(eventDate.getTime() + 3600000).toISOString(),
repetition: { freq: "monthly", interval: 1 }, repetition: { freq: 'monthly', interval: 1 },
allday: false, 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" }], attendee: [{ cn: 'test', cal_address: 'test@test.com' }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC", timezone: 'UTC'
} as CalendarEvent; } as CalendarEvent
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
uid: `${baseUID}/20250115`, uid: `${baseUID}/20250115`
}; }
const stateWithRecurringEvent = { const stateWithRecurringEvent = {
...preloadedState, ...preloadedState,
@@ -608,24 +608,24 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
...preloadedState.calendars.list[calId], ...preloadedState.calendars.list[calId],
events: { events: {
[baseUID]: masterEvent, [baseUID]: masterEvent,
[`${baseUID}/20250115`]: instance1, [`${baseUID}/20250115`]: instance1
}, }
}, }
}, }
}, }
}; }
// Mock API calls // Mock API calls
jest.spyOn(EventApi, "deleteEvent").mockResolvedValue({} as any); jest.spyOn(EventApi, 'deleteEvent').mockResolvedValue({} as any)
jest.spyOn(EventApi, "putEvent").mockResolvedValue({ status: 201 } as any); jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201 } as any)
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({ jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({
_embedded: { _embedded: {
"dav:item": [], 'dav:item': []
}, }
} as any); } as any)
// Mock onCloseAll to test closing both modals // Mock onCloseAll to test closing both modals
const mockOnCloseAll = jest.fn(); const mockOnCloseAll = jest.fn()
renderWithProviders( renderWithProviders(
<EventUpdateModal <EventUpdateModal
@@ -637,36 +637,36 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
typeOfAction="all" typeOfAction="all"
/>, />,
stateWithRecurringEvent stateWithRecurringEvent
); )
// Wait for component to load // Wait for component to load
await waitFor(() => { 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 // Uncheck repeat and save
const repeatCheckbox = screen.getByLabelText("event.form.repeat"); const repeatCheckbox = screen.getByLabelText('event.form.repeat')
await act(async () => { 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 () => { await act(async () => {
fireEvent.click(saveButton); fireEvent.click(saveButton)
}); })
// Verify onCloseAll was called to close both preview and update modals // Verify onCloseAll was called to close both preview and update modals
await waitFor( await waitFor(
() => { () => {
expect(mockOnCloseAll).toHaveBeenCalled(); expect(mockOnCloseAll).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
// Verify onClose was NOT called (onCloseAll is used instead) // 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 { import {
hasFreeBusyConflict, hasFreeBusyConflict,
useAttendeesFreeBusy, useAttendeesFreeBusy
} from "@/components/Attendees/useFreeBusy"; } from '@/components/Attendees/useFreeBusy'
import * as getFreeBusyREPORT from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"; import * as getFreeBusyREPORT from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
import * as getFreeBusyPOST from "@/features/Events/api/getFreeBusyForEventAttendeesPOST"; import * as getFreeBusyPOST from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
import * as getUserData from "@/features/Events/api/getUserDataFromEmail"; import * as getUserData from '@/features/Events/api/getUserDataFromEmail'
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from '@testing-library/react'
jest.mock("moment-timezone", () => { jest.mock('moment-timezone', () => {
const actual = jest.requireActual("moment-timezone"); const actual = jest.requireActual('moment-timezone')
return actual; return actual
}); })
jest.mock("@/features/Events/api/getUserDataFromEmail"); jest.mock('@/features/Events/api/getUserDataFromEmail')
jest.mock("@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"); jest.mock('@/features/Events/api/getFreeBusyForAddedAttendeesREPORT')
jest.mock("@/features/Events/api/getFreeBusyForEventAttendeesPOST"); jest.mock('@/features/Events/api/getFreeBusyForEventAttendeesPOST')
const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction< const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction<
typeof getUserData.getUserDataFromEmail typeof getUserData.getUserDataFromEmail
>; >
const mockREPORT = const mockREPORT =
getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction< getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction<
typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT
>; >
const mockPOST = const mockPOST =
getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction< getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction<
typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST
>; >
const START = "2026-03-14T14:00:00"; const START = '2026-03-14T14:00:00'
const END = "2026-03-14T15:00:00"; const END = '2026-03-14T15:00:00'
const TZ = "Europe/Paris"; const TZ = 'Europe/Paris'
describe("hasFreeBusyConflict", () => { describe('hasFreeBusyConflict', () => {
it("returns false for non-vcalendar data", () => { it('returns false for non-vcalendar data', () => {
expect(hasFreeBusyConflict({ data: ["not-vcalendar", [], []] })).toBe( expect(hasFreeBusyConflict({ data: ['not-vcalendar', [], []] })).toBe(false)
false })
);
});
it("returns false when vfreebusy has no freebusy property", () => { it('returns false when vfreebusy has no freebusy property', () => {
const data = { const data = {
data: [ data: [
"vcalendar", 'vcalendar',
[], [],
[ [
[ [
"vfreebusy", 'vfreebusy',
[["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"]], [['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z']],
[], []
], ]
], ]
], ]
}; }
expect(hasFreeBusyConflict(data)).toBe(false); 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 = { const data = {
data: [ data: [
"vcalendar", 'vcalendar',
[], [],
[ [
[ [
"vfreebusy", 'vfreebusy',
[ [
["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"], ['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
["freebusy", {}, "period", "2026-03-14T14:00:00Z/PT1H"], ['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", () => { it('returns false for malformed data without throwing', () => {
expect(hasFreeBusyConflict(null)).toBe(false); expect(hasFreeBusyConflict(null)).toBe(false)
expect(hasFreeBusyConflict("garbage")).toBe(false); expect(hasFreeBusyConflict('garbage')).toBe(false)
expect(hasFreeBusyConflict({ data: null })).toBe(false); expect(hasFreeBusyConflict({ data: null })).toBe(false)
}); })
}); })
describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
beforeEach(() => jest.clearAllMocks()); 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 () => { it('shows loading then free for a new attendee', async () => {
mockREPORT.mockResolvedValue(false); mockREPORT.mockResolvedValue(false)
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -97,17 +95,17 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee], newAttendees: [newAttendee],
start: START, start: START,
end: END, 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 () => { it('shows busy when REPORT returns true', async () => {
mockREPORT.mockResolvedValue(true); mockREPORT.mockResolvedValue(true)
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -115,15 +113,15 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee], newAttendees: [newAttendee],
start: START, start: START,
end: END, 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 () => { it('shows unknown when REPORT throws', async () => {
mockREPORT.mockRejectedValue(new Error("network error")); mockREPORT.mockRejectedValue(new Error('network error'))
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -131,20 +129,20 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee], newAttendees: [newAttendee],
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ
}) })
); )
await waitFor(() => 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 () => { it('resolves userId via getUserDataFromEmail when not provided', async () => {
mockGetUserData.mockResolvedValue([{ _id: "resolved-id" }] as never); mockGetUserData.mockResolvedValue([{ _id: 'resolved-id' }] as never)
mockREPORT.mockResolvedValue(false); mockREPORT.mockResolvedValue(false)
const attendeeWithoutId = { email: "bob@example.com" }; const attendeeWithoutId = { email: 'bob@example.com' }
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -152,42 +150,42 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [attendeeWithoutId], newAttendees: [attendeeWithoutId],
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ
}) })
); )
await waitFor(() => await waitFor(() =>
expect(result.current[attendeeWithoutId.email]).toBe("free") expect(result.current[attendeeWithoutId.email]).toBe('free')
); )
expect(mockGetUserData).toHaveBeenCalledWith("bob@example.com"); expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com')
expect(mockREPORT).toHaveBeenCalledWith( expect(mockREPORT).toHaveBeenCalledWith(
"resolved-id", 'resolved-id',
expect.any(String), expect.any(String),
expect.any(String) expect.any(String)
); )
}); })
it("shows unknown when userId cannot be resolved", async () => { it('shows unknown when userId cannot be resolved', async () => {
mockGetUserData.mockResolvedValue([]); mockGetUserData.mockResolvedValue([])
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [], existingAttendees: [],
newAttendees: [{ email: "ghost@example.com" }], newAttendees: [{ email: 'ghost@example.com' }],
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ
}) })
); )
await waitFor(() => await waitFor(() =>
expect(result.current["ghost@example.com"]).toBe("unknown") expect(result.current['ghost@example.com']).toBe('unknown')
); )
expect(mockREPORT).not.toHaveBeenCalled(); expect(mockREPORT).not.toHaveBeenCalled()
}); })
it("does not re-fetch an attendee already fetched", async () => { it('does not re-fetch an attendee already fetched', async () => {
mockREPORT.mockResolvedValue(false); mockREPORT.mockResolvedValue(false)
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ attendees }) => ({ attendees }) =>
@@ -196,19 +194,19 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: attendees, newAttendees: attendees,
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ
}), }),
{ initialProps: { attendees: [newAttendee] } } { initialProps: { attendees: [newAttendee] } }
); )
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); await waitFor(() => expect(result.current[newAttendee.email]).toBe('free'))
rerender({ attendees: [newAttendee] }); rerender({ attendees: [newAttendee] })
expect(mockREPORT).toHaveBeenCalledTimes(1); expect(mockREPORT).toHaveBeenCalledTimes(1)
}); })
it("removes departed attendee from the map", async () => { it('removes departed attendee from the map', async () => {
mockREPORT.mockResolvedValue(false); mockREPORT.mockResolvedValue(false)
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ attendees }) => ({ attendees }) =>
@@ -217,22 +215,22 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: attendees, newAttendees: attendees,
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ
}), }),
{ initialProps: { attendees: [newAttendee] } } { 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(() => await waitFor(() =>
expect(result.current[newAttendee.email]).toBeUndefined() expect(result.current[newAttendee.email]).toBeUndefined()
); )
}); })
it("invalidates cache and re-fetches when time window changes", async () => { it('invalidates cache and re-fetches when time window changes', async () => {
mockREPORT.mockResolvedValue(false); mockREPORT.mockResolvedValue(false)
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ start, end }) => ({ start, end }) =>
@@ -241,65 +239,65 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
newAttendees: [newAttendee], newAttendees: [newAttendee],
start, start,
end, end,
timezone: TZ, timezone: TZ
}), }),
{ initialProps: { start: START, end: END } } { 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 () => { it('passes UTC-converted iCal times to the API', async () => {
mockREPORT.mockResolvedValue(false); mockREPORT.mockResolvedValue(false)
renderHook(() => renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [], existingAttendees: [],
newAttendees: [newAttendee], newAttendees: [newAttendee],
start: "2026-03-14T16:00:00", // 16:00 Paris = 15:00 UTC in winter start: '2026-03-14T16:00:00', // 16:00 Paris = 15:00 UTC in winter
end: "2026-03-14T17:00:00", end: '2026-03-14T17:00:00',
timezone: "Europe/Paris", 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 // Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC
expect(mockREPORT).toHaveBeenCalledWith( expect(mockREPORT).toHaveBeenCalledWith(
"user-alice", 'user-alice',
"20260314T150000", '20260314T150000',
"20260314T160000" '20260314T160000'
); )
}); })
}); })
describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
beforeEach(() => jest.clearAllMocks()); 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(() => renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [existingAttendee], existingAttendees: [existingAttendee],
newAttendees: [], newAttendees: [],
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ
}) })
); )
expect(mockPOST).not.toHaveBeenCalled(); expect(mockPOST).not.toHaveBeenCalled()
}); })
it("shows loading then free for existing attendees", async () => { it('shows loading then free for existing attendees', async () => {
mockPOST.mockResolvedValue({ "user-carol": false }); mockPOST.mockResolvedValue({ 'user-carol': false })
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -308,19 +306,19 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ,
eventUid: "event-123", eventUid: 'event-123'
}) })
); )
expect(result.current[existingAttendee.email]).toBe("loading"); expect(result.current[existingAttendee.email]).toBe('loading')
await waitFor(() => 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 () => { it('shows busy when POST returns busy for userId', async () => {
mockPOST.mockResolvedValue({ "user-carol": true }); mockPOST.mockResolvedValue({ 'user-carol': true })
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -329,17 +327,17 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ,
eventUid: "event-123", eventUid: 'event-123'
}) })
); )
await waitFor(() => await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("busy") expect(result.current[existingAttendee.email]).toBe('busy')
); )
}); })
it("shows unknown when POST throws", async () => { it('shows unknown when POST throws', async () => {
mockPOST.mockRejectedValue(new Error("server error")); mockPOST.mockRejectedValue(new Error('server error'))
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -348,17 +346,17 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ,
eventUid: "event-123", eventUid: 'event-123'
}) })
); )
await waitFor(() => 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 () => { it('passes the eventUid to the POST API', async () => {
mockPOST.mockResolvedValue({ "user-carol": false }); mockPOST.mockResolvedValue({ 'user-carol': false })
renderHook(() => renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -367,35 +365,35 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ,
eventUid: "event-abc", eventUid: 'event-abc'
}) })
); )
await waitFor(() => expect(mockPOST).toHaveBeenCalled()); await waitFor(() => expect(mockPOST).toHaveBeenCalled())
expect(mockPOST).toHaveBeenCalledWith( expect(mockPOST).toHaveBeenCalledWith(
["user-carol"], ['user-carol'],
expect.any(String), expect.any(String),
expect.any(String), expect.any(String),
"event-abc" 'event-abc'
); )
}); })
}); })
describe("useAttendeesFreeBusy — enabled flag", () => { describe('useAttendeesFreeBusy — enabled flag', () => {
it("does not fetch when disabled", () => { it('does not fetch when disabled', () => {
renderHook(() => renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [{ email: "x@x.com", userId: "uid" }], existingAttendees: [{ email: 'x@x.com', userId: 'uid' }],
newAttendees: [{ email: "y@y.com", userId: "uid2" }], newAttendees: [{ email: 'y@y.com', userId: 'uid2' }],
start: START, start: START,
end: END, end: END,
timezone: TZ, timezone: TZ,
eventUid: "event-123", eventUid: 'event-123',
enabled: false, enabled: false
}) })
); )
expect(mockPOST).not.toHaveBeenCalled(); expect(mockPOST).not.toHaveBeenCalled()
expect(mockREPORT).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 ImportAlert from '@/features/Events/ImportAlert'
import { fireEvent, screen } from "@testing-library/react"; import { fireEvent, screen } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
// Mock the ErrorSnackbar component since we want to test ImportAlert logic, // Mock the ErrorSnackbar component since we want to test ImportAlert logic,
// but we can also test integration if we don't mock it. // but we can also test integration if we don't mock it.
// Given ImportAlert uses EventErrorSnackbar which uses MUI Snackbar, // Given ImportAlert uses EventErrorSnackbar which uses MUI Snackbar,
// it's better to render it and check for text presence. // it's better to render it and check for text presence.
describe("ImportAlert", () => { describe('ImportAlert', () => {
const preloadedState = { const preloadedState = {
calendars: { calendars: {
list: { list: {
cal1: { cal1: {
id: "cal1", id: 'cal1',
name: "Calendar 1", name: 'Calendar 1',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
title: "Event 1", title: 'Event 1',
error: "Error message 1", error: 'Error message 1'
}, },
event2: { event2: {
uid: "event2", uid: 'event2',
title: "Event 2", title: 'Event 2'
// No error // No error
}, }
}, }
}, },
cal2: { cal2: {
id: "cal2", id: 'cal2',
name: "Calendar 2", name: 'Calendar 2',
events: { events: {
event3: { event3: {
uid: "event3", uid: 'event3',
title: "Event 3", title: 'Event 3',
error: "Error message 2", error: 'Error message 2'
}, }
}, }
}, }
}, }
}, }
}; }
it("renders nothing when there are no errors", () => { it('renders nothing when there are no errors', () => {
const state = { const state = {
calendars: { calendars: {
list: { list: {
cal1: { cal1: {
id: "cal1", id: 'cal1',
events: { events: {
event1: { uid: "event1" }, event1: { uid: 'event1' }
}, }
}, }
}, }
}, }
}; }
const { container } = renderWithProviders(<ImportAlert />, state); const { container } = renderWithProviders(<ImportAlert />, state)
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement()
}); })
it("renders snackbar with error message when there is one error", () => { it('renders snackbar with error message when there is one error', () => {
renderWithProviders(<ImportAlert />, preloadedState); renderWithProviders(<ImportAlert />, preloadedState)
// EventErrorSnackbar shows "multipleEvents" if > 1, or the message if 1. // EventErrorSnackbar shows "multipleEvents" if > 1, or the message if 1.
// Here we have 2 errors in preloadedState. // Here we have 2 errors in preloadedState.
@@ -70,24 +70,24 @@ describe("ImportAlert", () => {
calendars: { calendars: {
list: { list: {
cal1: { cal1: {
id: "cal1", id: 'cal1',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
error: "Single Error", error: 'Single Error'
}, }
}, }
}, }
}, }
}, }
}; }
renderWithProviders(<ImportAlert />, singleErrorState); renderWithProviders(<ImportAlert />, singleErrorState)
expect(screen.getByText("Single Error")).toBeInTheDocument(); expect(screen.getByText('Single Error')).toBeInTheDocument()
}); })
it("renders summary message when there are multiple errors", () => { it('renders summary message when there are multiple errors', () => {
renderWithProviders(<ImportAlert />, preloadedState); renderWithProviders(<ImportAlert />, preloadedState)
// 2 errors in preloadedState // 2 errors in preloadedState
// "error.multipleEvents" key is used. // "error.multipleEvents" key is used.
// In test environment, t function usually returns the key or formatted string. // 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 }); // const summary = messages.length === 1 ? messages[0] : t("error.multipleEvents", { count: messages.length });
// We can check if the Alert is present. // 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 = { const singleErrorState = {
calendars: { calendars: {
list: { list: {
cal1: { cal1: {
id: "cal1", id: 'cal1',
events: { events: {
event1: { event1: {
uid: "event1", uid: 'event1',
error: "Dismiss Me", error: 'Dismiss Me'
}, }
}, }
}, }
}, }
}, }
}; }
renderWithProviders(<ImportAlert />, singleErrorState); renderWithProviders(<ImportAlert />, singleErrorState)
expect(screen.getByText("Dismiss Me")).toBeInTheDocument(); expect(screen.getByText('Dismiss Me')).toBeInTheDocument()
const closeButton = screen.getByRole("button", { name: /ok/i }); // EventErrorSnackbar has an "OK" button const closeButton = screen.getByRole('button', { name: /ok/i }) // EventErrorSnackbar has an "OK" button
fireEvent.click(closeButton); fireEvent.click(closeButton)
// After clicking, it should disappear (or at least be removed from DOM or hidden) // 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. // 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 { CalendarEvent } from '@/features/Events/EventsTypes'
import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange'
import { userAttendee } from "@/features/User/models/attendee"; import { userAttendee } from '@/features/User/models/attendee'
import { userOrganiser } from "@/features/User/userDataTypes"; import { userOrganiser } from '@/features/User/userDataTypes'
describe("updateAttendeesAfterTimeChange", () => { describe('updateAttendeesAfterTimeChange', () => {
const mockAttendee: userAttendee = { const mockAttendee: userAttendee = {
cal_address: "attendee@example.com", cal_address: 'attendee@example.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "FALSE", rsvp: 'FALSE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cn: "attendee", cn: 'attendee'
}; }
const mockOrganizer: userOrganiser = { const mockOrganizer: userOrganiser = {
cn: "Organizer", cn: 'Organizer',
cal_address: "organizer@example.com", cal_address: 'organizer@example.com'
}; }
const organizerAttendee: userAttendee = { const organizerAttendee: userAttendee = {
cal_address: "organizer@example.com", cal_address: 'organizer@example.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "FALSE", rsvp: 'FALSE',
role: "CHAIR", role: 'CHAIR',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cn: "organizer", cn: 'organizer'
}; }
const baseEvent: CalendarEvent = { const baseEvent: CalendarEvent = {
uid: "test-event", uid: 'test-event',
URL: "/calendar/667037022b752d0026472254/cal1/test-event.ics", URL: '/calendar/667037022b752d0026472254/cal1/test-event.ics',
title: "Test Event", title: 'Test Event',
calId: "667037022b752d0026472254/cal1", calId: '667037022b752d0026472254/cal1',
start: "2025-01-15T07:00:00.000Z", start: '2025-01-15T07:00:00.000Z',
end: "2025-01-15T08:00:00.000Z", end: '2025-01-15T08:00:00.000Z',
allday: false, allday: false,
organizer: mockOrganizer, organizer: mockOrganizer,
attendee: [mockAttendee, organizerAttendee], attendee: [mockAttendee, organizerAttendee],
timezone: "ETC/UTC", timezone: 'ETC/UTC'
}; }
describe("early returns", () => { describe('early returns', () => {
it("should return the event unchanged when attendee list is undefined", () => { it('should return the event unchanged when attendee list is undefined', () => {
const event = { ...baseEvent, attendee: undefined }; const event = { ...baseEvent, attendee: undefined }
const result = updateAttendeesAfterTimeChange( const result = updateAttendeesAfterTimeChange(
event as unknown as CalendarEvent event as unknown as CalendarEvent
); )
expect(result).toBe(event); expect(result).toBe(event)
}); })
it("should NOT bail out early when organizer is undefined", () => { it('should NOT bail out early when organizer is undefined', () => {
const event = { ...baseEvent, organizer: undefined }; const event = { ...baseEvent, organizer: undefined }
const result = updateAttendeesAfterTimeChange(event); const result = updateAttendeesAfterTimeChange(event)
expect(result.attendee).toBeDefined(); expect(result.attendee).toBeDefined()
expect(result.attendee).toHaveLength(2); expect(result.attendee).toHaveLength(2)
}); })
}); })
describe("without provided attendees list", () => { describe('without provided attendees list', () => {
it("should return attendees unchanged when timeChanged is false", () => { it('should return attendees unchanged when timeChanged is false', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, false); const result = updateAttendeesAfterTimeChange(baseEvent, false)
expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); expect(result.attendee?.[0].partstat).toBe('ACCEPTED')
expect(result.attendee?.[0].rsvp).toBe("FALSE"); expect(result.attendee?.[0].rsvp).toBe('FALSE')
}); })
it("should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true", () => { it('should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true); const result = updateAttendeesAfterTimeChange(baseEvent, true)
const attendee = result.attendee?.find( const attendee = result.attendee?.find(
(a) => a.cal_address === "attendee@example.com" a => a.cal_address === 'attendee@example.com'
); )
expect(attendee?.partstat).toBe("NEEDS-ACTION"); expect(attendee?.partstat).toBe('NEEDS-ACTION')
expect(attendee?.rsvp).toBe("TRUE"); expect(attendee?.rsvp).toBe('TRUE')
}); })
it("should NOT mark the organizer as NEEDS-ACTION when timeChanged is true", () => { it('should NOT mark the organizer as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(baseEvent, true); const result = updateAttendeesAfterTimeChange(baseEvent, true)
const organizer = result.attendee?.find( const organizer = result.attendee?.find(
(a) => a.cal_address === "organizer@example.com" a => a.cal_address === 'organizer@example.com'
); )
expect(organizer?.partstat).toBe("ACCEPTED"); expect(organizer?.partstat).toBe('ACCEPTED')
expect(organizer?.rsvp).toBe("FALSE"); expect(organizer?.rsvp).toBe('FALSE')
}); })
it("should return attendees unchanged when timeChanged is undefined", () => { it('should return attendees unchanged when timeChanged is undefined', () => {
const result = updateAttendeesAfterTimeChange(baseEvent); const result = updateAttendeesAfterTimeChange(baseEvent)
expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); expect(result.attendee?.[0].partstat).toBe('ACCEPTED')
}); })
}); })
describe("with provided attendees list", () => { describe('with provided attendees list', () => {
const newAttendee: userAttendee = { const newAttendee: userAttendee = {
cal_address: "new@example.com", cal_address: 'new@example.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "FALSE", rsvp: 'FALSE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cn: "new", 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, [ const result = updateAttendeesAfterTimeChange(baseEvent, false, [
mockAttendee, mockAttendee
]); ])
const attendee = result.attendee?.find( const attendee = result.attendee?.find(
(a) => a.cal_address === "attendee@example.com" a => a.cal_address === 'attendee@example.com'
); )
expect(attendee?.partstat).toBe("ACCEPTED"); 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, [ const result = updateAttendeesAfterTimeChange(baseEvent, false, [
newAttendee, newAttendee
]); ])
const attendee = result.attendee?.find( const attendee = result.attendee?.find(
(a) => a.cal_address === "new@example.com" a => a.cal_address === 'new@example.com'
); )
expect(attendee?.partstat).toBe("NEEDS-ACTION"); expect(attendee?.partstat).toBe('NEEDS-ACTION')
expect(attendee?.rsvp).toBe("TRUE"); 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, [ const result = updateAttendeesAfterTimeChange(baseEvent, true, [
mockAttendee, mockAttendee,
newAttendee, newAttendee
]); ])
const nonOrganizerAttendees = result.attendee?.filter( const nonOrganizerAttendees = result.attendee?.filter(
(a) => a.cal_address !== "organizer@example.com" a => a.cal_address !== 'organizer@example.com'
); )
nonOrganizerAttendees?.forEach((a) => { nonOrganizerAttendees?.forEach(a => {
expect(a.partstat).toBe("NEEDS-ACTION"); expect(a.partstat).toBe('NEEDS-ACTION')
expect(a.rsvp).toBe("TRUE"); 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, [ const result = updateAttendeesAfterTimeChange(baseEvent, false, [
mockAttendee, mockAttendee
]); ])
const last = result.attendee?.[result.attendee.length - 1]; const last = result.attendee?.[result.attendee.length - 1]
expect(last?.cal_address).toBe("organizer@example.com"); 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, [ const result = updateAttendeesAfterTimeChange(baseEvent, false, [
mockAttendee, mockAttendee
]); ])
const organizer = result.attendee?.find( const organizer = result.attendee?.find(
(a) => a.cal_address === "organizer@example.com" a => a.cal_address === 'organizer@example.com'
); )
expect(organizer?.partstat).toBe("ACCEPTED"); 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 = { const eventWithoutOrganizerInAttendees: CalendarEvent = {
...baseEvent, ...baseEvent,
attendee: [mockAttendee], // organizer not in attendee list attendee: [mockAttendee] // organizer not in attendee list
}; }
const result = updateAttendeesAfterTimeChange( const result = updateAttendeesAfterTimeChange(
eventWithoutOrganizerInAttendees, eventWithoutOrganizerInAttendees,
false, false,
[mockAttendee] [mockAttendee]
); )
const organizer = result.attendee?.find( const organizer = result.attendee?.find(
(a) => a.cal_address === "organizer@example.com" a => a.cal_address === 'organizer@example.com'
); )
expect(organizer?.role).toBe("CHAIR"); expect(organizer?.role).toBe('CHAIR')
expect(organizer?.partstat).toBe("NEEDS-ACTION"); expect(organizer?.partstat).toBe('NEEDS-ACTION')
}); })
}); })
describe("organizer undefined", () => { describe('organizer undefined', () => {
const eventNoOrganizer: CalendarEvent = { const eventNoOrganizer: CalendarEvent = {
...baseEvent, ...baseEvent,
organizer: undefined, organizer: undefined
}; }
it("should return attendees without bailing out", () => { it('should return attendees without bailing out', () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer); const result = updateAttendeesAfterTimeChange(eventNoOrganizer)
expect(result.attendee).toHaveLength(2); expect(result.attendee).toHaveLength(2)
}); })
it("should mark attendees as NEEDS-ACTION when timeChanged is true", () => { it('should mark attendees as NEEDS-ACTION when timeChanged is true', () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true); const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true)
result.attendee?.forEach((a) => { result.attendee?.forEach(a => {
expect(a.partstat).toBe("NEEDS-ACTION"); expect(a.partstat).toBe('NEEDS-ACTION')
expect(a.rsvp).toBe("TRUE"); expect(a.rsvp).toBe('TRUE')
}); })
}); })
it("should not mutate attendees when timeChanged is false", () => { it('should not mutate attendees when timeChanged is false', () => {
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false); const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false)
expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); expect(result.attendee?.[0].partstat).toBe('ACCEPTED')
expect(result.attendee?.[0].rsvp).toBe("FALSE"); 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 = { const newAttendee: userAttendee = {
cal_address: "new@example.com", cal_address: 'new@example.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "FALSE", rsvp: 'FALSE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cn: "new", cn: 'new'
}; }
const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false, [ const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false, [
newAttendee, newAttendee
]); ])
expect(result.attendee).toHaveLength(1); expect(result.attendee).toHaveLength(1)
expect(result.attendee?.[0].cal_address).toBe("new@example.com"); 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 SearchBar from '@/components/Menubar/EventSearchBar'
import * as searchThunk from "@/features/Search/SearchSlice"; import * as searchThunk from '@/features/Search/SearchSlice'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from "@testing-library/user-event"; import userEvent from '@testing-library/user-event'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("EventSearchBar", () => { describe('EventSearchBar', () => {
const today = new Date(); const today = new Date()
const start = new Date(today); const start = new Date(today)
start.setHours(10, 0, 0, 0); start.setHours(10, 0, 0, 0)
const end = new Date(today); const end = new Date(today)
end.setHours(11, 0, 0, 0); end.setHours(11, 0, 0, 0)
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}, },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
name: "Calendar personal", name: 'Calendar personal',
id: "user1/cal1", id: 'user1/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user1/cal1", calId: 'user1/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}
]
}
}
}, },
], 'user2/cal1': {
}, name: 'Calendar delegated',
},
},
"user2/cal1": {
name: "Calendar delegated",
delegated: true, delegated: true,
id: "user2/cal1", id: 'user2/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user2/cal1", calId: 'user2/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}
]
}
}
}, },
], 'user3/cal1': {
}, name: 'Calendar shared',
}, id: 'user3/cal1',
}, color: { light: '#FF0000', dark: '#000' },
"user3/cal1": { owner: { emails: ['alice@example.com'] },
name: "Calendar shared",
id: "user3/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user3/cal1", calId: 'user3/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}
]
}
}
}
}, },
], pending: false
}, }
}, }
},
},
pending: false,
},
};
it("should render search icon button initially", () => { it('should render search icon button initially', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
expect(searchButton).toBeInTheDocument(); expect(searchButton).toBeInTheDocument()
}); })
it("should expand search bar when icon is clicked", () => { it('should expand search bar when icon is clicked', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
expect(searchInput).toBeInTheDocument(); expect(searchInput).toBeInTheDocument()
}); })
it("should unexpand search bar when field is unfocused and empty", () => { it('should unexpand search bar when field is unfocused and empty', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.mouseDown(document.body); fireEvent.mouseDown(document.body)
expect(searchInput).not.toBeInTheDocument(); expect(searchInput).not.toBeInTheDocument()
}); })
it("should not unexpand search bar when field is unfocused but not empty", () => { it('should not unexpand search bar when field is unfocused but not empty', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
userEvent.type(searchInput, "test"); userEvent.type(searchInput, 'test')
fireEvent.blur(searchInput); fireEvent.blur(searchInput)
expect(searchInput).toBeInTheDocument(); expect(searchInput).toBeInTheDocument()
expect(searchInput).toHaveValue("test"); expect(searchInput).toHaveValue('test')
}); })
it("should update search value on input", () => { it('should update search value on input', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.change(searchInput, { target: { value: "meeting" } }); 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 () => { it('should open filter popover when tune icon is clicked', async () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
// Expand search bar // Expand search bar
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
// Click tune icon // Click tune icon
const tuneButton = screen.getByTestId("TuneIcon"); const tuneButton = screen.getByTestId('TuneIcon')
if (tuneButton) fireEvent.click(tuneButton); if (tuneButton) fireEvent.click(tuneButton)
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("search.searchIn")).toBeInTheDocument(); expect(screen.getByText('search.searchIn')).toBeInTheDocument()
}); })
}); })
it("should clear search value when clear button is clicked", () => { it('should clear search value when clear button is clicked', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.change(searchInput, { target: { value: "meeting" } }); fireEvent.change(searchInput, { target: { value: 'meeting' } })
const clearButton = screen.getByTestId("HighlightOffIcon"); const clearButton = screen.getByTestId('HighlightOffIcon')
fireEvent.click(clearButton); fireEvent.click(clearButton)
expect(searchInput).toHaveValue(""); expect(searchInput).toHaveValue('')
}); })
it("should trigger search on Enter key", async () => { it('should trigger search on Enter key', async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync"); const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync')
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.change(searchInput, { target: { value: "test" } }); fireEvent.change(searchInput, { target: { value: 'test' } })
fireEvent.keyDown(searchInput, { key: "Enter" }); fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => { await waitFor(() => {
expect(searchSpy).toHaveBeenCalledWith({ expect(searchSpy).toHaveBeenCalledWith({
filters: { filters: {
keywords: "", keywords: '',
organizers: [], organizers: [],
attendees: [], attendees: [],
searchIn: ["user1/cal1"], searchIn: ['user1/cal1']
}, },
search: "test", search: 'test'
}); })
}); })
}); })
it("should not trigger search on Enter key when search is empty", async () => { it('should not trigger search on Enter key when search is empty', async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync"); const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync')
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
const searchInput = screen.getByPlaceholderText("common.search"); const searchInput = screen.getByPlaceholderText('common.search')
fireEvent.keyDown(searchInput, { key: "Enter" }); fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => { await waitFor(() => {
expect(searchSpy).not.toHaveBeenCalled(); expect(searchSpy).not.toHaveBeenCalled()
}); })
}); })
it("should not trigger search when filter keywords is empty", async () => { it('should not trigger search when filter keywords is empty', async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync"); const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync')
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
const searchButton = screen.getByRole("button"); const searchButton = screen.getByRole('button')
fireEvent.click(searchButton); fireEvent.click(searchButton)
// Click tune icon // Click tune icon
const tuneButton = screen.getByTestId("TuneIcon"); const tuneButton = screen.getByTestId('TuneIcon')
if (tuneButton) fireEvent.click(tuneButton); if (tuneButton) fireEvent.click(tuneButton)
fireEvent.click(screen.getByText("common.search")); fireEvent.click(screen.getByText('common.search'))
await waitFor(() => { await waitFor(() => {
expect(searchSpy).not.toHaveBeenCalled(); expect(searchSpy).not.toHaveBeenCalled()
}); })
}); })
it("keeps search bar expanded when popover opens", () => { it('keeps search bar expanded when popover opens', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen const tuneBtn = screen
.getAllByRole("button") .getAllByRole('button')
.find((b) => b.querySelector('[data-testid="TuneIcon"]')); .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", () => { it('does NOT collapse search when clicking inside popover', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen const tuneBtn = screen
.getAllByRole("button") .getAllByRole('button')
.find((b) => b.querySelector('[data-testid="TuneIcon"]')); .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 () => { it('clicking Cancel closes popover and search collapses if empty', async () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen const tuneBtn = screen
.getAllByRole("button") .getAllByRole('button')
.find((b) => b.querySelector('[data-testid="TuneIcon"]')); .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(() => await waitFor(() =>
expect(screen.queryByText("search.searchIn")).not.toBeInTheDocument() expect(screen.queryByText('search.searchIn')).not.toBeInTheDocument()
); )
expect(screen.queryByPlaceholderText("common.search")).toBeNull(); expect(screen.queryByPlaceholderText('common.search')).toBeNull()
}); })
it("does not collapse when selecting a filter value", async () => { it('does not collapse when selecting a filter value', async () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole('button'))
const tuneBtn = screen const tuneBtn = screen
.getAllByRole("button") .getAllByRole('button')
.find((b) => b.querySelector('[data-testid="TuneIcon"]')); .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", () => { it('keeps input value after popover closes if not empty', () => {
renderWithProviders(<SearchBar />, preloadedState); renderWithProviders(<SearchBar />, preloadedState)
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole('button'))
userEvent.type(screen.getByPlaceholderText("common.search"), "hello"); userEvent.type(screen.getByPlaceholderText('common.search'), 'hello')
const tuneBtn = screen const tuneBtn = screen
.getAllByRole("button") .getAllByRole('button')
.find((b) => b.querySelector('[data-testid="TuneIcon"]')); .find(b => b.querySelector('[data-testid="TuneIcon"]'))
fireEvent.click(tuneBtn!); fireEvent.click(tuneBtn!)
fireEvent.click(document.body); fireEvent.click(document.body)
const inputAfter = screen.getByPlaceholderText("common.search"); const inputAfter = screen.getByPlaceholderText('common.search')
expect(inputAfter).toHaveValue("hello"); expect(inputAfter).toHaveValue('hello')
}); })
}); })
@@ -1,224 +1,224 @@
import SearchResultsPage from "@/features/Search/SearchResultsPage"; import SearchResultsPage from '@/features/Search/SearchResultsPage'
import { screen } from "@testing-library/react"; import { screen } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
describe("SearchResultsPage", () => { describe('SearchResultsPage', () => {
const today = new Date(); const today = new Date()
const start = new Date(today); const start = new Date(today)
start.setHours(10, 0, 0, 0); start.setHours(10, 0, 0, 0)
const end = new Date(today); const end = new Date(today)
end.setHours(11, 0, 0, 0); end.setHours(11, 0, 0, 0)
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "mockSid", sid: 'mockSid',
openpaasId: "user1", openpaasId: 'user1'
}, },
tokens: { accessToken: "token" }, tokens: { accessToken: 'token' }
}, },
calendars: { calendars: {
list: { list: {
"user1/cal1": { 'user1/cal1': {
name: "Calendar personal", name: 'Calendar personal',
id: "user1/cal1", id: 'user1/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user1/cal1", calId: 'user1/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}
]
}
}
}, },
], 'user2/cal1': {
}, name: 'Calendar delegated',
},
},
"user2/cal1": {
name: "Calendar delegated",
delegated: true, delegated: true,
id: "user2/cal1", id: 'user2/cal1',
color: { light: "#FF0000", dark: "#000" }, color: { light: '#FF0000', dark: '#000' },
owner: { emails: ["alice@example.com"] }, owner: { emails: ['alice@example.com'] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user2/cal1", calId: 'user2/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}
]
}
}
}, },
], 'user3/cal1': {
}, name: 'Calendar shared',
}, id: 'user3/cal1',
}, color: { light: '#FF0000', dark: '#000' },
"user3/cal1": { owner: { emails: ['alice@example.com'] },
name: "Calendar shared",
id: "user3/cal1",
color: { light: "#FF0000", dark: "#000" },
owner: { emails: ["alice@example.com"] },
events: { events: {
event1: { event1: {
id: "event1", id: 'event1',
calId: "user3/cal1", calId: 'user3/cal1',
uid: "event1", uid: 'event1',
title: "Test Event", title: 'Test Event',
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
partstat: "ACCEPTED", partstat: 'ACCEPTED',
organizer: { organizer: {
cn: "Alice", cn: 'Alice',
cal_address: "alice@example.com", cal_address: 'alice@example.com'
}, },
attendee: [ attendee: [
{ {
cn: "Alice", cn: 'Alice',
partstat: "ACCEPTED", partstat: 'ACCEPTED',
rsvp: "TRUE", rsvp: 'TRUE',
role: "REQ-PARTICIPANT", role: 'REQ-PARTICIPANT',
cutype: "INDIVIDUAL", cutype: 'INDIVIDUAL',
cal_address: "alice@example.com", 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 />, { renderWithProviders(<SearchResultsPage />, {
...preloadedState, ...preloadedState,
searchResult: { loading: true, error: null, hits: 0, results: [] }, searchResult: { loading: true, error: null, hits: 0, results: [] }
}); })
expect(screen.getByRole("progressbar")).toBeInTheDocument(); expect(screen.getByRole('progressbar')).toBeInTheDocument()
}); })
it("should show error message when error occurs", () => { it('should show error message when error occurs', () => {
renderWithProviders(<SearchResultsPage />, { renderWithProviders(<SearchResultsPage />, {
...preloadedState, ...preloadedState,
searchResult: { searchResult: {
loading: false, loading: false,
error: "Network error", error: 'Network error',
hits: 0, hits: 0,
results: [], results: []
}, }
}); })
expect(screen.getByText("Network error")).toBeInTheDocument(); 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 />, { renderWithProviders(<SearchResultsPage />, {
...preloadedState, ...preloadedState,
searchResult: { loading: false, error: null, hits: null, results: [] }, searchResult: { loading: false, error: null, hits: null, results: [] }
}); })
expect(screen.getByText("search.noResults")).toBeInTheDocument(); expect(screen.getByText('search.noResults')).toBeInTheDocument()
}); })
it("should render search results", () => { it('should render search results', () => {
const mockResults = [ const mockResults = [
{ {
data: { data: {
uid: "1", uid: '1',
summary: "Team Meeting", summary: 'Team Meeting',
start: "2025-06-26T15:00:00Z", start: '2025-06-26T15:00:00Z',
organizer: { cn: "John Doe", email: "john@example.com" }, organizer: { cn: 'John Doe', email: 'john@example.com' },
allDay: false, allDay: false
}, }
}, },
{ {
data: { data: {
uid: "2", uid: '2',
summary: "Project Review", summary: 'Project Review',
start: "2025-06-27T10:00:00Z", start: '2025-06-27T10:00:00Z',
organizer: { cn: "Jane Smith", email: "jane@example.com" }, organizer: { cn: 'Jane Smith', email: 'jane@example.com' },
allDay: false, allDay: false
}, }
}, }
]; ]
renderWithProviders(<SearchResultsPage />, { renderWithProviders(<SearchResultsPage />, {
...preloadedState, ...preloadedState,
searchResult: { results: mockResults, hits: 2 }, searchResult: { results: mockResults, hits: 2 }
}); })
expect(screen.getByText("search.resultsTitle")).toBeInTheDocument(); expect(screen.getByText('search.resultsTitle')).toBeInTheDocument()
expect(screen.getByText("Team Meeting")).toBeInTheDocument(); expect(screen.getByText('Team Meeting')).toBeInTheDocument()
expect(screen.getByText("Project Review")).toBeInTheDocument(); expect(screen.getByText('Project Review')).toBeInTheDocument()
expect(screen.getByText("John Doe")).toBeInTheDocument(); expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText("Jane Smith")).toBeInTheDocument(); expect(screen.getByText('Jane Smith')).toBeInTheDocument()
}); })
it("should handle events without organizer", () => { it('should handle events without organizer', () => {
const mockResults = [ const mockResults = [
{ {
data: { data: {
uid: "1", uid: '1',
summary: "Untitled Event", summary: 'Untitled Event',
start: "2025-06-26T15:00:00Z", start: '2025-06-26T15:00:00Z',
allDay: false, allDay: false
}, }
}, }
]; ]
renderWithProviders(<SearchResultsPage />, { renderWithProviders(<SearchResultsPage />, {
...preloadedState, ...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 = [ const mockResults = [
{ {
data: { data: {
uid: "1", uid: '1',
summary: "All Day Event", summary: 'All Day Event',
start: "2025-06-26T00:00:00Z", start: '2025-06-26T00:00:00Z',
organizer: { cn: "Organizer" }, organizer: { cn: 'Organizer' },
allDay: true, allDay: true
}, }
}, }
]; ]
renderWithProviders(<SearchResultsPage />, { renderWithProviders(<SearchResultsPage />, {
...preloadedState, ...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, { import searchResultReducer, {
searchEventsAsync, searchEventsAsync,
setHits, setHits,
setResults, setResults
} from "@/features/Search/SearchSlice"; } from '@/features/Search/SearchSlice'
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from '@reduxjs/toolkit'
jest.mock("@/features/Events/EventApi"); jest.mock('@/features/Events/EventApi')
describe("SearchSlice", () => { describe('SearchSlice', () => {
let store: any; let store: any
beforeEach(() => { beforeEach(() => {
store = configureStore({ store = configureStore({
reducer: { reducer: {
searchResult: searchResultReducer, searchResult: searchResultReducer
}, }
}); })
}); })
describe("initial state", () => { describe('initial state', () => {
it("should have correct initial state", () => { it('should have correct initial state', () => {
const state = store.getState().searchResult; const state = store.getState().searchResult
expect(state).toEqual({ expect(state).toEqual({
results: [], results: [],
hits: 0, hits: 0,
error: null, error: null,
loading: false, loading: false
}); })
}); })
}); })
describe("reducers", () => { describe('reducers', () => {
it("should handle setResults", () => { it('should handle setResults', () => {
const mockResults = [ const mockResults = [
{ uid: "1", summary: "Event 1" }, { uid: '1', summary: 'Event 1' },
{ uid: "2", summary: "Event 2" }, { uid: '2', summary: 'Event 2' }
]; ]
store.dispatch(setResults(mockResults as any)); store.dispatch(setResults(mockResults as any))
expect(store.getState().searchResult.results).toEqual(mockResults); expect(store.getState().searchResult.results).toEqual(mockResults)
}); })
it("should handle setHits", () => { it('should handle setHits', () => {
store.dispatch(setHits(42)); store.dispatch(setHits(42))
expect(store.getState().searchResult.hits).toBe(42); expect(store.getState().searchResult.hits).toBe(42)
}); })
}); })
describe("searchEventsAsync", () => { describe('searchEventsAsync', () => {
const mockFilters = { const mockFilters = {
searchIn: ["user1/calendar1"], searchIn: ['user1/calendar1'],
keywords: "meeting", keywords: 'meeting',
organizers: ["user@example.com"], organizers: ['user@example.com'],
attendees: ["attendee@example.com"], attendees: ['attendee@example.com']
}; }
it("should handle successful search", async () => { it('should handle successful search', async () => {
const mockResponse = { const mockResponse = {
_total_hits: 5, _total_hits: 5,
_embedded: { _embedded: {
events: [ events: [
{ data: { uid: "1", summary: "Test Event" } }, { data: { uid: '1', summary: 'Test Event' } },
{ data: { uid: "2", summary: "Another Event" } }, { data: { uid: '2', summary: 'Another Event' } }
], ]
}, }
}; }
(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse); ;(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse)
await store.dispatch( await store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters }) searchEventsAsync({ search: 'test', filters: mockFilters })
); )
const state = store.getState().searchResult; const state = store.getState().searchResult
expect(state.loading).toBe(false); expect(state.loading).toBe(false)
expect(state.hits).toBe(5); expect(state.hits).toBe(5)
expect(state.results).toEqual(mockResponse._embedded.events); expect(state.results).toEqual(mockResponse._embedded.events)
expect(state.error).toBeNull(); expect(state.error).toBeNull()
}); })
it("should handle search with no results", async () => { it('should handle search with no results', async () => {
const mockResponse = { const mockResponse = {
_total_hits: 0, _total_hits: 0,
_embedded: { events: [] }, _embedded: { events: [] }
}; }
(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse); ;(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse)
await store.dispatch( await store.dispatch(
searchEventsAsync({ search: "nonexistent", filters: mockFilters }) searchEventsAsync({ search: 'nonexistent', filters: mockFilters })
); )
const state = store.getState().searchResult; const state = store.getState().searchResult
expect(state.hits).toBe(0); expect(state.hits).toBe(0)
expect(state.results).toEqual([]); expect(state.results).toEqual([])
}); })
it("should handle search error", async () => { it('should handle search error', async () => {
const mockError = new Error("Network error"); const mockError = new Error('Network error')
(EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError); ;(EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError)
await store.dispatch( await store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters }) searchEventsAsync({ search: 'test', filters: mockFilters })
); )
const state = store.getState().searchResult; const state = store.getState().searchResult
expect(state.loading).toBe(false); expect(state.loading).toBe(false)
expect(state.error).toBeTruthy(); expect(state.error).toBeTruthy()
expect(state.results).toEqual([]); expect(state.results).toEqual([])
}); })
it("should set loading state during search", async () => { it('should set loading state during search', async () => {
(EventApi.searchEvent as jest.Mock).mockImplementation( ;(EventApi.searchEvent as jest.Mock).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100)) () => new Promise(resolve => setTimeout(resolve, 100))
); )
const promise = store.dispatch( const promise = store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters }) searchEventsAsync({ search: 'test', filters: mockFilters })
); )
expect(store.getState().searchResult.loading).toBe(true); expect(store.getState().searchResult.loading).toBe(true)
await promise; await promise
}); })
}); })
}); })
@@ -1,482 +1,480 @@
import SettingsPage from "@/features/Settings/SettingsPage"; import SettingsPage from '@/features/Settings/SettingsPage'
import settingsReducer, { import settingsReducer, {
setIsBrowserDefaultTimeZone, setIsBrowserDefaultTimeZone,
setTimeZone, setTimeZone
} from "@/features/Settings/SettingsSlice"; } from '@/features/Settings/SettingsSlice'
import userReducer, { import userReducer, {
getOpenPaasUserDataAsync, getOpenPaasUserDataAsync,
setTimezone as setUserTimeZone, setTimezone as setUserTimeZone
} from "@/features/User/userSlice"; } from '@/features/User/userSlice'
import { api } from "@/utils/apiUtils"; import { api } from '@/utils/apiUtils'
import { browserDefaultTimeZone } from "@/utils/timezone"; import { browserDefaultTimeZone } from '@/utils/timezone'
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from '@reduxjs/toolkit'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils"); jest.mock('@/utils/apiUtils')
describe("Timezone synchronization after getOpenPaasUserDataAsync", () => { describe('Timezone synchronization after getOpenPaasUserDataAsync', () => {
let apiGetSpy: jest.SpyInstance; let apiGetSpy: jest.SpyInstance
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
localStorage.clear(); localStorage.clear()
apiGetSpy = jest.spyOn(api, "get"); apiGetSpy = jest.spyOn(api, 'get')
}); })
afterEach(() => { 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 = { const mockUserData = {
id: "667037022b752d0026472254", id: '667037022b752d0026472254',
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
preferredEmail: ["test@test.com"], preferredEmail: ['test@test.com'],
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
{ name: "language", value: "fr" }, { name: 'language', value: 'fr' },
{ name: "datetime", value: { timeZone: "Europe/Paris" } }, { name: 'datetime', value: { timeZone: 'Europe/Paris' } }
], ]
}, }
], ]
}, }
}; }
// Return a fake fetch-like response // Return a fake fetch-like response
apiGetSpy.mockResolvedValue({ apiGetSpy.mockResolvedValue({
json: async () => mockUserData, json: async () => mockUserData
}); })
const store = configureStore({ const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer }, reducer: { user: userReducer, settings: settingsReducer },
preloadedState: { preloadedState: {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
name: "John", name: 'John',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
loading: false, 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(); const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe("Europe/Paris"); expect(state.user.coreConfig.datetime.timeZone).toBe('Europe/Paris')
expect(state.settings.timeZone).toBe("Europe/Paris"); expect(state.settings.timeZone).toBe('Europe/Paris')
expect(localStorage.getItem("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 = { const mockUserData = {
id: "667037022b752d0026472254", id: '667037022b752d0026472254',
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
preferredEmail: "[test@test.com](mailto:test@test.com)", preferredEmail: '[test@test.com](mailto:test@test.com)',
configurations: { configurations: {
modules: [ modules: [
{ name: "core", configurations: [{ name: "language", value: "en" }] }, { name: 'core', configurations: [{ name: 'language', value: 'en' }] }
], ]
}, }
}; }
apiGetSpy.mockResolvedValue({ apiGetSpy.mockResolvedValue({
json: async () => mockUserData, json: async () => mockUserData
}); })
const browserTimezone = browserDefaultTimeZone ?? "UTC"; const browserTimezone = browserDefaultTimeZone ?? 'UTC'
const store = configureStore({ const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer }, reducer: { user: userReducer, settings: settingsReducer },
preloadedState: { preloadedState: {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
name: "John", name: 'John',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { coreConfig: {
language: "en", language: 'en',
datetime: { timeZone: browserTimezone }, datetime: { timeZone: browserTimezone }
}, },
loading: false, loading: false,
error: null, error: null
}, },
settings: { settings: {
language: "en", language: 'en',
timeZone: browserTimezone, 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(); const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe(null); expect(state.user.coreConfig.datetime.timeZone).toBe(null)
expect(state.settings.timeZone).toBe(browserTimezone); expect(state.settings.timeZone).toBe(browserTimezone)
}); })
}); })
// Mock localStorage // Mock localStorage
const localStorageMock = (() => { const localStorageMock = (() => {
let store: Record<string, string> = {}; let store: Record<string, string> = {}
return { return {
getItem: (key: string) => store[key] || null, getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { setItem: (key: string, value: string) => {
store[key] = value; store[key] = value
}, },
removeItem: (key: string) => { removeItem: (key: string) => {
delete store[key]; delete store[key]
}, },
clear: () => { clear: () => {
store = {}; store = {}
}, }
}; }
})(); })()
Object.defineProperty(window, "localStorage", { Object.defineProperty(window, 'localStorage', {
value: localStorageMock, value: localStorageMock
}); })
describe("Timezone Logic - Backend to Frontend Flow", () => { describe('Timezone Logic - Backend to Frontend Flow', () => {
let store: any; let store: any
beforeEach(() => { beforeEach(() => {
localStorageMock.clear(); localStorageMock.clear()
store = configureStore({ store = configureStore({
reducer: { reducer: {
settings: settingsReducer, settings: settingsReducer,
user: userReducer, user: userReducer
}, },
preloadedState: { preloadedState: {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
name: "John", name: 'John',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
loading: false, loading: false,
error: null, error: null
}, },
settings: { language: "en", timeZone: "UTC", view: "calendar" }, settings: { language: 'en', timeZone: 'UTC', view: 'calendar' }
}, }
}); })
}); })
describe("Backend Response Handling", () => { describe('Backend Response Handling', () => {
test("Backend returns NULL => Should use browser default timezone", async () => { test('Backend returns NULL => Should use browser default timezone', async () => {
const backendResponse = { const backendResponse = {
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
id: "123", id: '123',
preferredEmail: "john@example.com", preferredEmail: 'john@example.com',
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
{ {
name: "datetime", name: 'datetime',
value: { value: {
timeZone: null, timeZone: null
}, }
}, }
], ]
}, }
], ]
}, }
}; }
await store.dispatch( await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined) getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined)
); )
const settingsState = store.getState().settings; const settingsState = store.getState().settings
const userState = store.getState().user; const userState = store.getState().user
expect(settingsState.timeZone).toBe(browserDefaultTimeZone); expect(settingsState.timeZone).toBe(browserDefaultTimeZone)
expect(settingsState.isBrowserDefaultTimeZone).toBe(true); expect(settingsState.isBrowserDefaultTimeZone).toBe(true)
expect(userState.coreConfig.datetime.timeZone).toBe(null); expect(userState.coreConfig.datetime.timeZone).toBe(null)
expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone); expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone)
}); })
test("Backend returns SPECIFIC VALUE => Should use that value", async () => { test('Backend returns SPECIFIC VALUE => Should use that value', async () => {
const specificTimezone = "America/New_York"; const specificTimezone = 'America/New_York'
const backendResponse = { const backendResponse = {
firstname: "Jane", firstname: 'Jane',
lastname: "Smith", lastname: 'Smith',
id: "456", id: '456',
preferredEmail: "jane@example.com", preferredEmail: 'jane@example.com',
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
{ {
name: "datetime", name: 'datetime',
value: { value: {
timeZone: specificTimezone, timeZone: specificTimezone
}, }
}, }
], ]
}, }
], ]
}, }
}; }
await store.dispatch( await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined) getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined)
); )
const settingsState = store.getState().settings; const settingsState = store.getState().settings
const userState = store.getState().user; const userState = store.getState().user
expect(settingsState.timeZone).toBe(specificTimezone); expect(settingsState.timeZone).toBe(specificTimezone)
expect(settingsState.isBrowserDefaultTimeZone).toBe(false); expect(settingsState.isBrowserDefaultTimeZone).toBe(false)
expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone); expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone)
expect(localStorage.getItem("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 = { const backendResponse = {
firstname: "Bob", firstname: 'Bob',
lastname: "Johnson", lastname: 'Johnson',
id: "789", id: '789',
preferredEmail: "bob@example.com", preferredEmail: 'bob@example.com',
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
// No datetime config // No datetime config
], ]
}, }
], ]
}, }
}; }
await store.dispatch( await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined) getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined)
); )
const settingsState = store.getState().settings; const settingsState = store.getState().settings
const userState = store.getState().user; const userState = store.getState().user
expect(settingsState.timeZone).toBe(browserDefaultTimeZone); expect(settingsState.timeZone).toBe(browserDefaultTimeZone)
expect(settingsState.isBrowserDefaultTimeZone).toBe(true); expect(settingsState.isBrowserDefaultTimeZone).toBe(true)
expect(userState.coreConfig.datetime.timeZone).toBe(null); expect(userState.coreConfig.datetime.timeZone).toBe(null)
expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone); 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 // Test 1: Browser default scenario
store.dispatch(setIsBrowserDefaultTimeZone(true)); store.dispatch(setIsBrowserDefaultTimeZone(true))
let settingsState = store.getState().settings; let settingsState = store.getState().settings
expect(settingsState.timeZone).toBe(browserDefaultTimeZone); expect(settingsState.timeZone).toBe(browserDefaultTimeZone)
expect(settingsState.timeZone).not.toBe(null); expect(settingsState.timeZone).not.toBe(null)
// Test 2: Specific timezone scenario // Test 2: Specific timezone scenario
store.dispatch(setTimeZone("Europe/Paris")); store.dispatch(setTimeZone('Europe/Paris'))
settingsState = store.getState().settings; settingsState = store.getState().settings
expect(settingsState.timeZone).toBe("Europe/Paris"); expect(settingsState.timeZone).toBe('Europe/Paris')
expect(settingsState.timeZone).not.toBe(null); expect(settingsState.timeZone).not.toBe(null)
}); })
describe("User Actions - Changing Timezone", () => { describe('User Actions - Changing Timezone', () => {
beforeEach(() => { beforeEach(() => {
(api.patch as jest.Mock).mockResolvedValue({ status: 204 }); ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 })
}); })
test("User enables browser default => Settings gets browser TZ, User gets null", async () => { test('User enables browser default => Settings gets browser TZ, User gets null', async () => {
const { store } = renderWithProviders(<SettingsPage />, { const { store } = renderWithProviders(<SettingsPage />, {
user: { user: {
userData: { sub: "test" }, userData: { sub: 'test' },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { coreConfig: {
language: "en", language: 'en',
datetime: { timeZone: "America/Los_Angeles" }, datetime: { timeZone: 'America/Los_Angeles' }
}, },
loading: false, loading: false,
error: null, error: null
}, },
settings: { settings: {
language: "en", language: 'en',
timeZone: "America/Los_Angeles", timeZone: 'America/Los_Angeles',
isBrowserDefaultTimeZone: false, isBrowserDefaultTimeZone: false,
view: "settings", view: 'settings'
}, }
}); })
const browserDefaultSwitch = screen.getAllByLabelText( const browserDefaultSwitch = screen.getAllByLabelText(
"settings.timeZoneBrowserDefault" 'settings.timeZoneBrowserDefault'
)[0]; )[0]
expect(browserDefaultSwitch).toBeInTheDocument(); expect(browserDefaultSwitch).toBeInTheDocument()
// Enable browser default // Enable browser default
fireEvent.click(browserDefaultSwitch); fireEvent.click(browserDefaultSwitch)
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
expect(state.settings.isBrowserDefaultTimeZone).toBe(true); expect(state.settings.isBrowserDefaultTimeZone).toBe(true)
}); })
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
expect(state.settings.timeZone).toBe(browserDefaultTimeZone); expect(state.settings.timeZone).toBe(browserDefaultTimeZone)
}); })
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe(null); 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 />, { const { store } = renderWithProviders(<SettingsPage />, {
user: { user: {
userData: { sub: "test" }, userData: { sub: 'test' },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { language: "en", datetime: { timeZone: null } }, coreConfig: { language: 'en', datetime: { timeZone: null } },
loading: false, loading: false,
error: null, error: null
}, },
settings: { settings: {
language: "en", language: 'en',
timeZone: browserDefaultTimeZone, timeZone: browserDefaultTimeZone,
isBrowserDefaultTimeZone: true, isBrowserDefaultTimeZone: true,
view: "settings", view: 'settings'
}, }
}); })
const browserDefaultSwitch = screen.getAllByLabelText( const browserDefaultSwitch = screen.getAllByLabelText(
"settings.timeZoneBrowserDefault" 'settings.timeZoneBrowserDefault'
)[0]; )[0]
// Disable browser default so manual selector appears // Disable browser default so manual selector appears
fireEvent.click(browserDefaultSwitch); fireEvent.click(browserDefaultSwitch)
// Now timezone combobox is visible // Now timezone combobox is visible
const timezoneInput = screen.getAllByRole("combobox")[1]; const timezoneInput = screen.getAllByRole('combobox')[1]
// Type to filter options // Type to filter options
fireEvent.change(timezoneInput, { fireEvent.change(timezoneInput, {
target: { value: "Australia/Sydney" }, target: { value: 'Australia/Sydney' }
}); })
// Select from autocomplete dropdown // Select from autocomplete dropdown
const option = await screen.findByText(/Australia\/Sydney/i); const option = await screen.findByText(/Australia\/Sydney/i)
fireEvent.click(option); fireEvent.click(option)
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
expect(state.settings.isBrowserDefaultTimeZone).toBe(false); expect(state.settings.isBrowserDefaultTimeZone).toBe(false)
}); })
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
expect(state.settings.timeZone).toBe("Australia/Sydney"); expect(state.settings.timeZone).toBe('Australia/Sydney')
}); })
await waitFor(() => { await waitFor(() => {
const state = store.getState(); const state = store.getState()
expect(state.user.coreConfig.datetime.timeZone).toBe( expect(state.user.coreConfig.datetime.timeZone).toBe('Australia/Sydney')
"Australia/Sydney" })
); })
}); })
});
});
describe("LocalStorage Persistence", () => { describe('LocalStorage Persistence', () => {
test("LocalStorage always stores concrete values (browser TZ or specific TZ)", () => { test('LocalStorage always stores concrete values (browser TZ or specific TZ)', () => {
// Scenario 1: Browser default // Scenario 1: Browser default
store.dispatch(setTimeZone(browserDefaultTimeZone)); store.dispatch(setTimeZone(browserDefaultTimeZone))
expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone); expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone)
expect(localStorage.getItem("timeZone")).not.toBe("null"); expect(localStorage.getItem('timeZone')).not.toBe('null')
// Scenario 2: Specific timezone // Scenario 2: Specific timezone
store.dispatch(setTimeZone("Africa/Cairo")); store.dispatch(setTimeZone('Africa/Cairo'))
expect(localStorage.getItem("timeZone")).toBe("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 // Step 1: Backend returns null
const backendResponseNull = { const backendResponseNull = {
firstname: "Test", firstname: 'Test',
lastname: "User", lastname: 'User',
id: "999", id: '999',
preferredEmail: "test@example.com", preferredEmail: 'test@example.com',
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
{ {
name: "datetime", name: 'datetime',
value: { timeZone: null }, value: { timeZone: null }
}, }
], ]
}, }
], ]
}, }
}; }
await store.dispatch( await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(backendResponseNull, "", undefined) getOpenPaasUserDataAsync.fulfilled(backendResponseNull, '', undefined)
); )
// Verify initial state // Verify initial state
expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone); expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone)
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null); expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null)
// Step 2: User changes to specific timezone // Step 2: User changes to specific timezone
const userSelectedTZ = "Europe/London"; const userSelectedTZ = 'Europe/London'
store.dispatch(setIsBrowserDefaultTimeZone(false)); store.dispatch(setIsBrowserDefaultTimeZone(false))
store.dispatch(setTimeZone(userSelectedTZ)); store.dispatch(setTimeZone(userSelectedTZ))
store.dispatch(setUserTimeZone(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( expect(store.getState().user.coreConfig.datetime.timeZone).toBe(
userSelectedTZ userSelectedTZ
); )
// Step 3: User switches back to browser default // Step 3: User switches back to browser default
store.dispatch(setIsBrowserDefaultTimeZone(true)); store.dispatch(setIsBrowserDefaultTimeZone(true))
store.dispatch(setUserTimeZone(null)); store.dispatch(setUserTimeZone(null))
store.dispatch(setTimeZone(browserDefaultTimeZone)); store.dispatch(setTimeZone(browserDefaultTimeZone))
expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone); expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone)
expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null); 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 SettingsPage from '@/features/Settings/SettingsPage'
import settingsReducer from "@/features/Settings/SettingsSlice"; import settingsReducer from '@/features/Settings/SettingsSlice'
import userReducer, { import userReducer, {
getOpenPaasUserDataAsync, getOpenPaasUserDataAsync
} from "@/features/User/userSlice"; } from '@/features/User/userSlice'
import { api } from "@/utils/apiUtils"; import { api } from '@/utils/apiUtils'
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from '@reduxjs/toolkit'
import "@testing-library/jest-dom"; import '@testing-library/jest-dom'
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
jest.mock("@/utils/apiUtils"); jest.mock('@/utils/apiUtils')
const basePreloadedState = { const basePreloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
name: "John", name: 'John',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { coreConfig: {
language: "en", language: 'en',
datetime: { timeZone: "UTC" }, datetime: { timeZone: 'UTC' }
}, },
alarmEmailsEnabled: false, alarmEmailsEnabled: false,
loading: false, loading: false,
error: null, error: null
}, },
settings: { settings: {
language: "en", language: 'en',
timeZone: "UTC", timeZone: 'UTC',
isBrowserDefaultTimeZone: false, isBrowserDefaultTimeZone: false,
view: "settings", view: 'settings',
businessHours: { businessHours: {
start: "8:0", start: '8:0',
end: "19:0", end: '19:0',
daysOfWeek: [1, 2, 3, 4, 5], 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(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
(api.patch as jest.Mock).mockResolvedValue({ status: 204 }); ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 })
}); })
describe("Initial rendering", () => { describe('Initial rendering', () => {
it("renders the working days section with day selector", () => { it('renders the working days section with day selector', () => {
renderWithProviders(<SettingsPage />, basePreloadedState); renderWithProviders(<SettingsPage />, basePreloadedState)
expect(screen.getByText('settings.chooseWorkingDays')).toBeInTheDocument()
})
it('renders the show only working days toggle', () => {
renderWithProviders(<SettingsPage />, basePreloadedState)
expect( expect(
screen.getByText("settings.chooseWorkingDays") screen.getByRole('switch', { name: /settings.showOnlyWorkingDays/i })
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
it("renders the show only working days toggle", () => { it('shows working days toggle as unchecked when workingDays is false', () => {
renderWithProviders(<SettingsPage />, basePreloadedState); renderWithProviders(<SettingsPage />, basePreloadedState)
expect( const toggle = screen.getByRole('switch', {
screen.getByRole("switch", { name: /settings.showOnlyWorkingDays/i }) name: /settings.showOnlyWorkingDays/i
).toBeInTheDocument(); }) as HTMLInputElement
}); expect(toggle.checked).toBe(false)
})
it("shows working days toggle as unchecked when workingDays is false", () => { it('shows working days toggle as checked when workingDays is true', () => {
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", () => {
renderWithProviders(<SettingsPage />, { renderWithProviders(<SettingsPage />, {
...basePreloadedState, ...basePreloadedState,
settings: { ...basePreloadedState.settings, workingDays: true }, settings: { ...basePreloadedState.settings, workingDays: true }
}); })
const toggle = screen.getByRole("switch", { const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i, name: /settings.showOnlyWorkingDays/i
}) as HTMLInputElement; }) as HTMLInputElement
expect(toggle.checked).toBe(true); 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 />, { renderWithProviders(<SettingsPage />, {
...basePreloadedState, ...basePreloadedState,
settings: { ...basePreloadedState.settings, workingDays: null }, settings: { ...basePreloadedState.settings, workingDays: null }
}); })
const toggle = screen.getByRole("switch", { const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i, name: /settings.showOnlyWorkingDays/i
}) as HTMLInputElement; }) as HTMLInputElement
expect(toggle.checked).toBe(false); expect(toggle.checked).toBe(false)
}); })
}); })
describe("workingDays toggle", () => { describe('workingDays toggle', () => {
it("updates workingDays immediately (optimistic update) and calls API", async () => { it('updates workingDays immediately (optimistic update) and calls API', async () => {
const { store } = renderWithProviders( const { store } = renderWithProviders(
<SettingsPage />, <SettingsPage />,
basePreloadedState basePreloadedState
); )
const toggle = screen.getByRole("switch", { const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i, name: /settings.showOnlyWorkingDays/i
}); })
fireEvent.click(toggle); fireEvent.click(toggle)
await waitFor(() => { await waitFor(() => {
expect(store.getState().settings.workingDays).toBe(true); expect(store.getState().settings.workingDays).toBe(true)
}); })
await waitFor(() => { await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith( expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user", 'api/configurations?scope=user',
expect.objectContaining({ expect.objectContaining({
json: expect.arrayContaining([ json: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: "linagora.esn.calendar", name: 'linagora.esn.calendar',
configurations: expect.arrayContaining([ configurations: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: "workingDays", name: 'workingDays',
value: true, value: true
}), })
]), ])
}), })
]), ])
})
)
})
}) })
);
});
});
it("rolls back workingDays if API call fails", async () => { it('rolls back workingDays if API call fails', async () => {
(api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error'))
const { store } = renderWithProviders( const { store } = renderWithProviders(
<SettingsPage />, <SettingsPage />,
basePreloadedState basePreloadedState
); )
const toggle = screen.getByRole("switch", { const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i, name: /settings.showOnlyWorkingDays/i
}); })
fireEvent.click(toggle); fireEvent.click(toggle)
await waitFor( await waitFor(
() => { () => {
expect(store.getState().settings.workingDays).toBe(false); expect(store.getState().settings.workingDays).toBe(false)
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText("settings.workingDaysUpdateError") screen.getByText('settings.workingDaysUpdateError')
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
}); })
it("toggles workingDays from true to false", async () => { it('toggles workingDays from true to false', async () => {
const { store } = renderWithProviders(<SettingsPage />, { const { store } = renderWithProviders(<SettingsPage />, {
...basePreloadedState, ...basePreloadedState,
settings: { ...basePreloadedState.settings, workingDays: true }, settings: { ...basePreloadedState.settings, workingDays: true }
}); })
const toggle = screen.getByRole("switch", { const toggle = screen.getByRole('switch', {
name: /settings.showOnlyWorkingDays/i, name: /settings.showOnlyWorkingDays/i
}); })
fireEvent.click(toggle); fireEvent.click(toggle)
await waitFor(() => { await waitFor(() => {
expect(store.getState().settings.workingDays).toBe(false); expect(store.getState().settings.workingDays).toBe(false)
}); })
await waitFor(() => { await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith( expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user", 'api/configurations?scope=user',
expect.objectContaining({ expect.objectContaining({
json: expect.arrayContaining([ json: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: "linagora.esn.calendar", name: 'linagora.esn.calendar',
configurations: expect.arrayContaining([ configurations: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: "workingDays", name: 'workingDays',
value: false, value: false
}), })
]), ])
}), })
]), ])
})
)
})
})
}) })
);
});
});
});
describe("businessHours day selector", () => { describe('businessHours day selector', () => {
it("updates businessHours in Redux immediately when a day is clicked", async () => { it('updates businessHours in Redux immediately when a day is clicked', async () => {
const { store } = renderWithProviders( const { store } = renderWithProviders(
<SettingsPage />, <SettingsPage />,
basePreloadedState basePreloadedState
); )
// Sunday (0) is not in initial daysOfWeek [1,2,3,4,5], click it to add // Sunday (0) is not in initial daysOfWeek [1,2,3,4,5], click it to add
const sundayButton = screen.getByRole("button", { const sundayButton = screen.getByRole('button', {
name: /sunday/i, name: /sunday/i
}); })
fireEvent.click(sundayButton); fireEvent.click(sundayButton)
await waitFor(() => { await waitFor(() => {
expect(store.getState().settings.businessHours?.daysOfWeek).toContain( expect(store.getState().settings.businessHours?.daysOfWeek).toContain(0)
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( const { store } = renderWithProviders(
<SettingsPage />, <SettingsPage />,
basePreloadedState basePreloadedState
); )
// Monday (1) is in initial daysOfWeek, click to remove // Monday (1) is in initial daysOfWeek, click to remove
const mondayButton = screen.getByRole("button", { name: /monday/i }); const mondayButton = screen.getByRole('button', { name: /monday/i })
fireEvent.click(mondayButton); fireEvent.click(mondayButton)
await waitFor(() => { await waitFor(() => {
expect( expect(
store.getState().settings.businessHours?.daysOfWeek store.getState().settings.businessHours?.daysOfWeek
).not.toContain(1); ).not.toContain(1)
}); })
}); })
it("debounces API call when multiple days are clicked rapidly", async () => { it('debounces API call when multiple days are clicked rapidly', async () => {
jest.useFakeTimers(); jest.useFakeTimers()
renderWithProviders(<SettingsPage />, basePreloadedState); renderWithProviders(<SettingsPage />, basePreloadedState)
const sundayButton = screen.getByRole("button", { name: /sunday/i }); const sundayButton = screen.getByRole('button', { name: /sunday/i })
const saturdayButton = screen.getByRole("button", { name: /saturday/i }); const saturdayButton = screen.getByRole('button', { name: /saturday/i })
fireEvent.click(sundayButton); fireEvent.click(sundayButton)
fireEvent.click(saturdayButton); fireEvent.click(saturdayButton)
fireEvent.click(sundayButton); fireEvent.click(sundayButton)
// API should not have been called yet // API should not have been called yet
expect(api.patch).not.toHaveBeenCalled(); expect(api.patch).not.toHaveBeenCalled()
// Advance timers past debounce threshold // Advance timers past debounce threshold
jest.runAllTimers(); jest.runAllTimers()
await waitFor(() => { await waitFor(() => {
expect(api.patch).toHaveBeenCalledTimes(1); expect(api.patch).toHaveBeenCalledTimes(1)
}); })
jest.useRealTimers(); jest.useRealTimers()
}); })
it("sends businessHours as array to API", async () => { it('sends businessHours as array to API', async () => {
jest.useFakeTimers(); jest.useFakeTimers()
renderWithProviders(<SettingsPage />, basePreloadedState); renderWithProviders(<SettingsPage />, basePreloadedState)
const sundayButton = screen.getByRole("button", { name: /sunday/i }); const sundayButton = screen.getByRole('button', { name: /sunday/i })
fireEvent.click(sundayButton); fireEvent.click(sundayButton)
jest.runAllTimers(); jest.runAllTimers()
await waitFor(() => { await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith( expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user", 'api/configurations?scope=user',
expect.objectContaining({ expect.objectContaining({
json: expect.arrayContaining([ json: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: "core", name: 'core',
configurations: expect.arrayContaining([ configurations: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: "businessHours", name: 'businessHours',
value: expect.arrayContaining([ value: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
start: "8:0", start: '8:0',
end: "19:0", end: '19:0',
daysOfWeek: expect.arrayContaining([0]), daysOfWeek: expect.arrayContaining([0])
}), })
]), ])
}), })
]), ])
}), })
]), ])
})
)
}) })
);
});
jest.useRealTimers(); jest.useRealTimers()
}); })
it("rolls back businessHours if API call fails", async () => { it('rolls back businessHours if API call fails', async () => {
jest.useFakeTimers(); jest.useFakeTimers()
(api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error'))
const { store } = renderWithProviders( const { store } = renderWithProviders(
<SettingsPage />, <SettingsPage />,
basePreloadedState basePreloadedState
); )
const sundayButton = screen.getByRole("button", { name: /sunday/i }); const sundayButton = screen.getByRole('button', { name: /sunday/i })
fireEvent.click(sundayButton); fireEvent.click(sundayButton)
jest.runAllTimers(); jest.runAllTimers()
await waitFor( await waitFor(
() => { () => {
expect( expect(
store.getState().settings.businessHours?.daysOfWeek store.getState().settings.businessHours?.daysOfWeek
).not.toContain(0); ).not.toContain(0)
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText("settings.workingDaysUpdateError") screen.getByText('settings.workingDaysUpdateError')
).toBeInTheDocument(); ).toBeInTheDocument()
}); })
jest.useRealTimers(); jest.useRealTimers()
}); })
}); })
describe("getOpenPaasUserDataAsync integration", () => { describe('getOpenPaasUserDataAsync integration', () => {
it("populates businessHours from core module configurations", async () => { it('populates businessHours from core module configurations', async () => {
const store = configureStore({ const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer }, reducer: { user: userReducer, settings: settingsReducer },
preloadedState: { preloadedState: {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
name: "John", name: 'John',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
alarmEmailsEnabled: null, alarmEmailsEnabled: null,
loading: false, loading: false,
error: null, error: null
}, },
settings: { settings: {
language: "en", language: 'en',
timeZone: "UTC", timeZone: 'UTC',
isBrowserDefaultTimeZone: false, isBrowserDefaultTimeZone: false,
view: "calendar", view: 'calendar',
businessHours: null, businessHours: null,
workingDays: null, workingDays: null
}, }
}, }
}); })
const mockResponse = { const mockResponse = {
id: "667037022b752d0026472254", id: '667037022b752d0026472254',
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
preferredEmail: "test@test.com", preferredEmail: 'test@test.com',
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
{ {
name: "businessHours", name: 'businessHours',
value: [ 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", name: 'linagora.esn.calendar',
configurations: [{ name: "workingDays", value: true }], configurations: [{ name: 'workingDays', value: true }]
}, }
], ]
}, }
}; }
await store.dispatch( await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined) getOpenPaasUserDataAsync.fulfilled(mockResponse, '', undefined)
); )
const state = store.getState(); const state = store.getState()
expect(state.settings.businessHours).toEqual({ expect(state.settings.businessHours).toEqual({
start: "8:0", start: '8:0',
end: "19:0", end: '19:0',
daysOfWeek: [1, 2, 3, 4, 5], daysOfWeek: [1, 2, 3, 4, 5]
}); })
expect(state.settings.workingDays).toBe(true); 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({ const store = configureStore({
reducer: { user: userReducer, settings: settingsReducer }, reducer: { user: userReducer, settings: settingsReducer },
preloadedState: { preloadedState: {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
name: "John", name: 'John',
sid: "mockSid", sid: 'mockSid',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
organiserData: null, organiserData: null,
tokens: null, tokens: null,
coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } },
alarmEmailsEnabled: null, alarmEmailsEnabled: null,
loading: false, loading: false,
error: null, error: null
}, },
settings: { settings: {
language: "en", language: 'en',
timeZone: "UTC", timeZone: 'UTC',
isBrowserDefaultTimeZone: false, isBrowserDefaultTimeZone: false,
view: "calendar", view: 'calendar',
businessHours: null, businessHours: null,
workingDays: null, workingDays: null
}, }
}, }
}); })
const mockResponse = { const mockResponse = {
id: "667037022b752d0026472254", id: '667037022b752d0026472254',
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
preferredEmail: "test@test.com", preferredEmail: 'test@test.com',
configurations: { configurations: {
modules: [ modules: [
{ {
name: "core", name: 'core',
configurations: [], configurations: []
}, },
{ {
name: "linagora.esn.calendar", name: 'linagora.esn.calendar',
configurations: [{ name: "workingDays", value: null }], configurations: [{ name: 'workingDays', value: null }]
}, }
], ]
}, }
}; }
await store.dispatch( await store.dispatch(
getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined) getOpenPaasUserDataAsync.fulfilled(mockResponse, '', undefined)
); )
const state = store.getState(); const state = store.getState()
expect(state.settings.businessHours).toBeNull(); expect(state.settings.businessHours).toBeNull()
expect(state.settings.workingDays).toBeNull(); expect(state.settings.workingDays).toBeNull()
}); })
}); })
}); })
+97 -97
View File
@@ -1,45 +1,45 @@
import * as appHooks from "@/app/hooks"; import * as appHooks from '@/app/hooks'
import { AppDispatch, setupStore } from "@/app/store"; import { AppDispatch, setupStore } from '@/app/store'
import HandleLogin from "@/features/User/HandleLogin"; import HandleLogin from '@/features/User/HandleLogin'
import * as oidcAuth from "@/features/User/oidcAuth"; import * as oidcAuth from '@/features/User/oidcAuth'
import { clientConfig } from "@/features/User/oidcAuth"; import { clientConfig } from '@/features/User/oidcAuth'
import { useInitializeApp } from "@/features/User/useInitializeApp"; import { useInitializeApp } from '@/features/User/useInitializeApp'
import * as apiUtils from "@/utils/apiUtils"; import * as apiUtils from '@/utils/apiUtils'
import { renderHook, screen, waitFor } from "@testing-library/react"; import { renderHook, screen, waitFor } from '@testing-library/react'
import { Provider } from "react-redux"; import { Provider } from 'react-redux'
import { push } from "redux-first-history"; import { push } from 'redux-first-history'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
clientConfig.url = "https://example.com"; clientConfig.url = 'https://example.com'
describe("HandleLogin", () => { describe('HandleLogin', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest.spyOn(apiUtils, "redirectTo").mockImplementation(() => {}); jest.spyOn(apiUtils, 'redirectTo').mockImplementation(() => {})
const dispatch = jest.fn() as AppDispatch; const dispatch = jest.fn() as AppDispatch
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch)
sessionStorage.clear(); sessionStorage.clear()
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: jest.fn().mockImplementation((query) => ({ value: jest.fn().mockImplementation(query => ({
matches: false, matches: false,
media: query, media: query,
onchange: null, onchange: null,
addEventListener: jest.fn(), addEventListener: jest.fn(),
removeEventListener: 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 = { const loginUrlMock = {
code_verifier: "verifier123", code_verifier: 'verifier123',
state: "state123", state: 'state123',
redirectTo: new URL("http://login.url"), redirectTo: new URL('http://login.url')
}; }
jest.spyOn(oidcAuth, "Auth").mockResolvedValue(loginUrlMock); jest.spyOn(oidcAuth, 'Auth').mockResolvedValue(loginUrlMock)
const { result } = renderHook(() => useInitializeApp(), { const { result } = renderHook(() => useInitializeApp(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
@@ -50,118 +50,118 @@ describe("HandleLogin", () => {
tokens: null, tokens: null,
loading: false, loading: false,
error: null, error: null,
coreConfig: { language: "en" }, coreConfig: { language: 'en' }
}, },
calendars: { list: {}, pending: false, error: null }, calendars: { list: {}, pending: false, error: null }
})} })}
> >
{children} {children}
</Provider> </Provider>
), )
});
await waitFor(
() => {
expect(oidcAuth.Auth).toHaveBeenCalled();
},
{ timeout: 3000 }
);
await waitFor(
() => {
expect(sessionStorage.getItem("redirectState")).toEqual(
JSON.stringify({
code_verifier: "verifier123",
state: "state123",
}) })
);
await waitFor(
() => {
expect(oidcAuth.Auth).toHaveBeenCalled()
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor(
() => {
expect(sessionStorage.getItem('redirectState')).toEqual(
JSON.stringify({
code_verifier: 'verifier123',
state: 'state123'
})
)
},
{ timeout: 3000 }
)
await waitFor( await waitFor(
() => { () => {
expect(apiUtils.redirectTo).toHaveBeenCalledWith( expect(apiUtils.redirectTo).toHaveBeenCalledWith(
loginUrlMock.redirectTo loginUrlMock.redirectTo
); )
}, },
{ timeout: 3000 } { 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 = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
tokens: { access_token: "test" }, tokens: { access_token: 'test' },
loading: false, loading: false
}, },
calendars: { list: {}, pending: true }, 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 // HandleLogin now returns null, loading is shown at App level via appLoading state
expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
}); })
test("does not render loading element when userData exists and calendars pending is false", () => { test('does not render loading element when userData exists and calendars pending is false', () => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
sub: "cmoussu", sub: 'cmoussu',
email: "cmoussu@linagora.com", email: 'cmoussu@linagora.com',
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro',
openpaasId: "667037022b752d0026472254", openpaasId: '667037022b752d0026472254'
}, },
tokens: { access_token: "test" }, tokens: { access_token: 'test' },
loading: false, loading: false
}, },
calendars: { list: {}, pending: false }, calendars: { list: {}, pending: false },
loading: { isLoading: false }, loading: { isLoading: false }
}; }
renderWithProviders(<HandleLogin />, preloadedState); renderWithProviders(<HandleLogin />, preloadedState)
// HandleLogin now returns null, loading is shown at App level via appLoading state // HandleLogin now returns null, loading is shown at App level via appLoading state
expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
}); })
test("goes to error page when there is error in user data", async () => { test('goes to error page when there is error in user data', async () => {
const mockDispatch = jest.fn(); const mockDispatch = jest.fn()
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(mockDispatch); jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(mockDispatch)
jest.spyOn(oidcAuth, "Auth").mockResolvedValue({ jest.spyOn(oidcAuth, 'Auth').mockResolvedValue({
code_verifier: "verifier123", code_verifier: 'verifier123',
state: "state123", state: 'state123',
redirectTo: new URL("http://login.url"), redirectTo: new URL('http://login.url')
}); })
renderWithProviders(<HandleLogin />, { renderWithProviders(<HandleLogin />, {
user: { user: {
error: true, error: true,
loading: false, loading: false,
userData: { userData: {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
sid: "testSid", sid: 'testSid',
openpaasId: "testId", openpaasId: 'testId'
}, },
tokens: { access_token: "test" }, tokens: { access_token: 'test' }
}, },
calendars: { calendars: {
list: {}, list: {},
pending: false, pending: false,
error: null, error: null
}, }
}); })
await waitFor( await waitFor(
() => { () => {
expect(mockDispatch).toHaveBeenCalledWith(push("/error")); expect(mockDispatch).toHaveBeenCalledWith(push('/error'))
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
}); })
}); })
+99 -100
View File
@@ -1,183 +1,182 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync"; import { getCalendarsListAsync } from '@/features/Calendars/services/getCalendarsListAsync'
import { CallbackResume } from "@/features/User/LoginCallback"; import { CallbackResume } from '@/features/User/LoginCallback'
import * as oidcAuth from "@/features/User/oidcAuth"; import * as oidcAuth from '@/features/User/oidcAuth'
import { import {
getOpenPaasUserDataAsync, getOpenPaasUserDataAsync,
setTokens, setTokens,
setUserData, setUserData
} from "@/features/User/userSlice"; } from '@/features/User/userSlice'
import { render, waitFor } from "@testing-library/react"; import { render, waitFor } from '@testing-library/react'
import { replace } from "redux-first-history"; import { replace } from 'redux-first-history'
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from '../../utils/Renderwithproviders'
import { setAppLoading } from "@/app/loadingSlice"; import { setAppLoading } from '@/app/loadingSlice'
// Mocks // Mocks
jest.mock("@/app/hooks", () => ({ jest.mock('@/app/hooks', () => ({
useAppDispatch: jest.fn(), useAppDispatch: jest.fn(),
useAppSelector: jest.fn(() => ({ useAppSelector: jest.fn(() => ({
user: { user: {
userData: null, userData: null,
tokens: null, tokens: null,
loading: false, loading: false,
error: null, error: null
}, },
calendars: { calendars: {
list: {}, list: {},
pending: false, pending: false,
error: null, error: null
}, }
})), }))
})); }))
jest.mock("@/features/User/oidcAuth", () => ({ jest.mock('@/features/User/oidcAuth', () => ({
Callback: jest.fn(), Callback: jest.fn()
})); }))
jest.mock("@/features/User/userSlice", () => { jest.mock('@/features/User/userSlice', () => {
const mockGetUser = Object.assign( const mockGetUser = Object.assign(
jest.fn(() => ({ type: "GET_USER_ID" })), jest.fn(() => ({ type: 'GET_USER_ID' })),
{ {
pending: { type: "GET_USER_ID/pending" }, pending: { type: 'GET_USER_ID/pending' },
fulfilled: { type: "GET_USER_ID/fulfilled" }, fulfilled: { type: 'GET_USER_ID/fulfilled' },
rejected: { type: "GET_USER_ID/rejected" }, rejected: { type: 'GET_USER_ID/rejected' }
} }
); )
return { return {
setUserData: jest.fn((data) => ({ type: "SET_USER", payload: data })), setUserData: jest.fn(data => ({ type: 'SET_USER', payload: data })),
setTokens: jest.fn((tokens) => ({ type: "SET_TOKENS", payload: tokens })), setTokens: jest.fn(tokens => ({ type: 'SET_TOKENS', payload: tokens })),
getOpenPaasUserDataAsync: mockGetUser, getOpenPaasUserDataAsync: mockGetUser
}; }
}); })
jest.mock("@/features/Calendars/services/getCalendarsListAsync", () => { jest.mock('@/features/Calendars/services/getCalendarsListAsync', () => {
const mockGetCalendars = Object.assign( const mockGetCalendars = Object.assign(
jest.fn(() => ({ type: "GET_CALENDARS" })), jest.fn(() => ({ type: 'GET_CALENDARS' })),
{ {
pending: { type: "GET_CALENDARS/pending" }, pending: { type: 'GET_CALENDARS/pending' },
fulfilled: { type: "GET_CALENDARS/fulfilled" }, fulfilled: { type: 'GET_CALENDARS/fulfilled' },
rejected: { type: "GET_CALENDARS/rejected" }, rejected: { type: 'GET_CALENDARS/rejected' }
} }
); )
return { return {
getCalendarsListAsync: mockGetCalendars, getCalendarsListAsync: mockGetCalendars
}; }
}); })
describe("CallbackResume", () => { describe('CallbackResume', () => {
const dispatch = jest.fn(); const dispatch = jest.fn()
let mockUserState: any; let mockUserState: any
let mockCalendarsState: any; let mockCalendarsState: any
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch); ;(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch)
// Initialize mock states // Initialize mock states
mockUserState = { mockUserState = {
userData: null, userData: null,
tokens: null, tokens: null,
loading: false, loading: false,
error: null, error: null
}; }
mockCalendarsState = { mockCalendarsState = {
list: {}, list: {},
pending: false, pending: false,
error: null, error: null
}; }
;(useAppSelector as jest.Mock).mockImplementation(selector => {
(useAppSelector as jest.Mock).mockImplementation((selector) => {
const state = { const state = {
user: mockUserState, user: mockUserState,
calendars: mockCalendarsState, calendars: mockCalendarsState
}; }
return selector(state); return selector(state)
}); })
}); })
it("should call Callback and dispatch necessary actions", async () => { it('should call Callback and dispatch necessary actions', async () => {
const mockTokenSet = { access_token: "abc" }; const mockTokenSet = { access_token: 'abc' }
const mockUserInfo = { name: "Test User" }; const mockUserInfo = { name: 'Test User' }
const mockData = { const mockData = {
tokenSet: mockTokenSet, tokenSet: mockTokenSet,
userinfo: mockUserInfo, userinfo: mockUserInfo
}; }
(oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData); ;(oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData)
sessionStorage.setItem( sessionStorage.setItem(
"redirectState", 'redirectState',
JSON.stringify({ code_verifier: "verifier123", state: "state456" }) JSON.stringify({ code_verifier: 'verifier123', state: 'state456' })
); )
const { rerender } = render(<CallbackResume />); const { rerender } = render(<CallbackResume />)
await waitFor(() => { await waitFor(() => {
expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456"); expect(oidcAuth.Callback).toHaveBeenCalledWith('verifier123', 'state456')
}); })
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setAppLoading(true)); expect(dispatch).toHaveBeenCalledWith(setAppLoading(true))
}); })
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo)); expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo))
}); })
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet)); expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet))
}); })
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync()); expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync())
}); })
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync()); expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync())
}); })
// Simulate async actions completing by updating mock state // Simulate async actions completing by updating mock state
mockUserState = { mockUserState = {
userData: mockUserInfo, userData: mockUserInfo,
tokens: mockTokenSet, tokens: mockTokenSet,
loading: false, loading: false,
error: null, error: null
}; }
mockCalendarsState = { mockCalendarsState = {
list: { calendar1: {} }, list: { calendar1: {} },
pending: false, pending: false,
error: null, error: null
}; }
// Re-render to trigger navigation effect // Re-render to trigger navigation effect
rerender(<CallbackResume />); rerender(<CallbackResume />)
await waitFor( await waitFor(
() => { () => {
expect(dispatch).toHaveBeenCalledWith(setAppLoading(false)); expect(dispatch).toHaveBeenCalledWith(setAppLoading(false))
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor( await waitFor(
() => { () => {
expect(dispatch).toHaveBeenCalledWith(replace("/calendar")); expect(dispatch).toHaveBeenCalledWith(replace('/calendar'))
}, },
{ timeout: 3000 } { timeout: 3000 }
); )
await waitFor(() => { await waitFor(() => {
expect(sessionStorage.getItem("redirectState")).toBe(null); expect(sessionStorage.getItem('redirectState')).toBe(null)
}); })
await waitFor(() => { await waitFor(() => {
expect(sessionStorage.getItem("tokenSet")).toEqual( expect(sessionStorage.getItem('tokenSet')).toEqual(
JSON.stringify(mockTokenSet) JSON.stringify(mockTokenSet)
); )
}); })
}); })
it("should handle missing redirectState gracefully", async () => { it('should handle missing redirectState gracefully', async () => {
sessionStorage.removeItem("redirectState"); sessionStorage.removeItem('redirectState')
renderWithProviders(<CallbackResume />); renderWithProviders(<CallbackResume />)
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(replace("/")); expect(dispatch).toHaveBeenCalledWith(replace('/'))
}); })
}); })
}); })
+112 -113
View File
@@ -4,169 +4,168 @@ import {
Callback, Callback,
clientConfig, clientConfig,
getClientConfig, getClientConfig,
Logout, Logout
} from "@/features/User/oidcAuth"; } from '@/features/User/oidcAuth'
import * as apiUtils from "@/utils/apiUtils"; import * as apiUtils from '@/utils/apiUtils'
import * as client from "openid-client"; import * as client from 'openid-client'
clientConfig.url = "https://example.com"; clientConfig.url = 'https://example.com'
const localAdress = "https://local.exemple.com"; const localAdress = 'https://local.exemple.com'
describe("OpenID Client Auth Module", () => { describe('OpenID Client Auth Module', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
jest.spyOn(apiUtils, "getLocation").mockImplementation(() => localAdress); jest.spyOn(apiUtils, 'getLocation').mockImplementation(() => localAdress)
}); })
describe("getClientConfig", () => { describe('getClientConfig', () => {
it("should call discovery with clientConfig.url", async () => { it('should call discovery with clientConfig.url', async () => {
const discoveryMock = client.discovery as jest.Mock; const discoveryMock = client.discovery as jest.Mock
discoveryMock.mockResolvedValue("discoveredClient"); discoveryMock.mockResolvedValue('discoveredClient')
const result = await getClientConfig(); const result = await getClientConfig()
expect(discoveryMock).toHaveBeenCalledWith( expect(discoveryMock).toHaveBeenCalledWith(
new URL(clientConfig.url), new URL(clientConfig.url),
clientConfig.client_id clientConfig.client_id
); )
expect(result).toBe("discoveredClient"); expect(result).toBe('discoveredClient')
}); })
}); })
describe("Auth", () => { describe('Auth', () => {
it("should generate PKCE and build authorization URL with PKCE", async () => { it('should generate PKCE and build authorization URL with PKCE', async () => {
(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue( ;(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
"verifier123" 'verifier123'
); )
(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue( ;(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
"challenge123" 'challenge123'
); )
// Mock discovery returning an object with serverMetadata() // Mock discovery returning an object with serverMetadata()
const discoveredClient = { const discoveredClient = {
serverMetadata: jest.fn(() => ({ serverMetadata: jest.fn(() => ({
supportsPKCE: () => true, supportsPKCE: () => true
})), }))
}; }
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient); ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
;(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
'https://auth.url'
)
(client.buildAuthorizationUrl as jest.Mock).mockReturnValue( const result = await Auth()
"https://auth.url"
);
const result = await Auth(); expect(client.randomPKCECodeVerifier).toHaveBeenCalled()
expect(client.randomPKCECodeVerifier).toHaveBeenCalled();
expect(client.calculatePKCECodeChallenge).toHaveBeenCalledWith( expect(client.calculatePKCECodeChallenge).toHaveBeenCalledWith(
"verifier123" 'verifier123'
); )
expect(client.buildAuthorizationUrl).toHaveBeenCalledWith( expect(client.buildAuthorizationUrl).toHaveBeenCalledWith(
discoveredClient, discoveredClient,
expect.objectContaining({ expect.objectContaining({
code_challenge: "challenge123", code_challenge: 'challenge123',
code_challenge_method: clientConfig.code_challenge_method, code_challenge_method: clientConfig.code_challenge_method,
redirect_uri: clientConfig.redirect_uri, redirect_uri: clientConfig.redirect_uri,
scope: clientConfig.scope, scope: clientConfig.scope
}) })
); )
expect(result).toEqual({ expect(result).toEqual({
redirectTo: "https://auth.url", redirectTo: 'https://auth.url',
code_verifier: "verifier123", code_verifier: 'verifier123',
state: undefined, state: undefined
}); })
}); })
it("should generate state when PKCE not supported", async () => { it('should generate state when PKCE not supported', async () => {
(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue( ;(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
"verifier123" 'verifier123'
); )
(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue( ;(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
"challenge123" 'challenge123'
); )
const discoveredClient = { const discoveredClient = {
serverMetadata: jest.fn(() => ({ serverMetadata: jest.fn(() => ({
supportsPKCE: () => false, supportsPKCE: () => false
})), }))
}; }
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient); ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
(client.randomState as jest.Mock).mockReturnValue("state123"); ;(client.randomState as jest.Mock).mockReturnValue('state123')
(client.buildAuthorizationUrl as jest.Mock).mockReturnValue( ;(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
"https://auth.url" 'https://auth.url'
); )
const result = await Auth(); const result = await Auth()
expect(client.randomState).toHaveBeenCalled(); expect(client.randomState).toHaveBeenCalled()
expect(result.state).toBe("state123"); expect(result.state).toBe('state123')
expect(result.redirectTo).toBe("https://auth.url"); expect(result.redirectTo).toBe('https://auth.url')
}); })
}); })
describe("Logout", () => { describe('Logout', () => {
it("should build end session URL", async () => { it('should build end session URL', async () => {
const discoveredClient = {}; const discoveredClient = {}
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient); ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
(client.buildEndSessionUrl as jest.Mock).mockReturnValue( ;(client.buildEndSessionUrl as jest.Mock).mockReturnValue(
"https://logout.url" 'https://logout.url'
); )
const result = await Logout(); const result = await Logout()
expect(client.buildEndSessionUrl).toHaveBeenCalledWith(discoveredClient, { expect(client.buildEndSessionUrl).toHaveBeenCalledWith(discoveredClient, {
post_logout_redirect_uri: clientConfig.post_logout_redirect_uri, post_logout_redirect_uri: clientConfig.post_logout_redirect_uri
}); })
expect(result).toBe("https://logout.url"); expect(result).toBe('https://logout.url')
}); })
}); })
describe("Callback", () => { describe('Callback', () => {
it("should perform authorization code grant and fetch user info", async () => { it('should perform authorization code grant and fetch user info', async () => {
const discoveredClient = {}; const discoveredClient = {}
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient); ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient)
const mockTokenSet = { const mockTokenSet = {
access_token: "access123", access_token: 'access123',
claims: jest.fn(() => ({ sub: "user123" })), claims: jest.fn(() => ({ sub: 'user123' }))
}; }
(client.authorizationCodeGrant as jest.Mock).mockResolvedValue( ;(client.authorizationCodeGrant as jest.Mock).mockResolvedValue(
mockTokenSet mockTokenSet
); )
(client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: "User" }); ;(client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: 'User' })
const result = await Callback("verifier123", "state123"); const result = await Callback('verifier123', 'state123')
expect(client.authorizationCodeGrant).toHaveBeenCalledWith( expect(client.authorizationCodeGrant).toHaveBeenCalledWith(
discoveredClient, discoveredClient,
new URL(localAdress), new URL(localAdress),
{ pkceCodeVerifier: "verifier123", expectedState: "state123" } { pkceCodeVerifier: 'verifier123', expectedState: 'state123' }
); )
expect(client.fetchUserInfo).toHaveBeenCalledWith( expect(client.fetchUserInfo).toHaveBeenCalledWith(
discoveredClient, discoveredClient,
"access123", 'access123',
"user123" 'user123'
); )
expect(result).toEqual({ expect(result).toEqual({
tokenSet: mockTokenSet, tokenSet: mockTokenSet,
userinfo: { name: "User" }, userinfo: { name: 'User' }
}); })
}); })
it("should catch and log errors", async () => { it('should catch and log errors', async () => {
const error = new Error("fail"); const error = new Error('fail')
(client.discovery as jest.Mock).mockResolvedValue({}); ;(client.discovery as jest.Mock).mockResolvedValue({})
(client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error); ;(client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error)
const consoleErrorSpy = jest const consoleErrorSpy = jest
.spyOn(console, "error") .spyOn(console, 'error')
.mockImplementation(() => {}); .mockImplementation(() => {})
const result = await Callback("verifier", "state"); const result = await Callback('verifier', 'state')
expect(consoleErrorSpy).toHaveBeenCalledWith("Token grant error:", error); expect(consoleErrorSpy).toHaveBeenCalledWith('Token grant error:', error)
expect(result).toBeUndefined(); 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 { import {
getOpenPaasUser, getOpenPaasUser,
updateUserConfigurations, updateUserConfigurations,
getResourceDetails, getResourceDetails,
getUserDetails, getUserDetails
} from "@/features/User/userAPI"; } from '@/features/User/userAPI'
import { api } from "@/utils/apiUtils"; 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", () => { describe('getOpenPaasUser', () => {
it("should fetch and return user data", async () => { it('should fetch and return user data', async () => {
const mockUser = { id: "123", name: "OpenPaas User" }; const mockUser = { id: '123', name: 'OpenPaas User' }
(api.get as jest.Mock).mockReturnValue({ ;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser), json: jest.fn().mockResolvedValue(mockUser)
}); })
const result = await getOpenPaasUser(); const result = await getOpenPaasUser()
expect(api.get).toHaveBeenCalledWith("api/user"); expect(api.get).toHaveBeenCalledWith('api/user')
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser)
}); })
}); })
describe("getUserDetails", () => { describe('getUserDetails', () => {
it("should fetch and return user details", async () => { it('should fetch and return user details', async () => {
const mockUser = { const mockUser = {
firstname: "John", firstname: 'John',
lastname: "Doe", lastname: 'Doe',
emails: ["john@test.com"], emails: ['john@test.com']
}; }
const userId = "123"; const userId = '123'
(api.get as jest.Mock).mockReturnValue({ ;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser), json: jest.fn().mockResolvedValue(mockUser)
}); })
const result = await getUserDetails(userId); const result = await getUserDetails(userId)
expect(api.get).toHaveBeenCalledWith(`api/users/${userId}`); expect(api.get).toHaveBeenCalledWith(`api/users/${userId}`)
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser)
}); })
}); })
describe("getResourceDetails", () => { describe('getResourceDetails', () => {
it("should fetch and return resource details", async () => { it('should fetch and return resource details', async () => {
const mockResource = { _id: "res-123", name: "Meeting Room A" }; const mockResource = { _id: 'res-123', name: 'Meeting Room A' }
const resourceId = "res-123"; const resourceId = 'res-123'
(api.get as jest.Mock).mockReturnValue({ ;(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResource), json: jest.fn().mockResolvedValue(mockResource)
}); })
const result = await getResourceDetails(resourceId); const result = await getResourceDetails(resourceId)
expect(api.get).toHaveBeenCalledWith(`api/resources/${resourceId}`); expect(api.get).toHaveBeenCalledWith(`api/resources/${resourceId}`)
expect(result).toEqual(mockResource); expect(result).toEqual(mockResource)
}); })
}); })
describe("updateUserConfigurations", () => { describe('updateUserConfigurations', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("should PATCH configurations with language update", async () => { it('should PATCH configurations with language update', async () => {
const mockResponse = { status: 204 }; const mockResponse = { status: 204 }
(api.patch as jest.Mock).mockResolvedValue(mockResponse); ;(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: [ json: [
{ {
name: "core", name: 'core',
configurations: [{ name: "language", value: "vi" }], configurations: [{ name: 'language', value: 'vi' }]
}, }
], ]
}); })
}); })
it("should PATCH configurations with multiple updates", async () => { it('should PATCH configurations with multiple updates', async () => {
const mockResponse = { status: 204 }; const mockResponse = { status: 204 }
(api.patch as jest.Mock).mockResolvedValue(mockResponse); ;(api.patch as jest.Mock).mockResolvedValue(mockResponse)
await updateUserConfigurations({ await updateUserConfigurations({
language: "fr", language: 'fr',
timezone: "Europe/Paris", timezone: 'Europe/Paris'
}); })
expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", { expect(api.patch).toHaveBeenCalledWith('api/configurations?scope=user', {
json: [ json: [
{ {
name: "core", name: 'core',
configurations: [ configurations: [
{ name: "language", value: "fr" }, { name: 'language', value: 'fr' },
{ name: "datetime", value: { timeZone: "Europe/Paris" } }, { name: 'datetime', value: { timeZone: 'Europe/Paris' } }
], ]
}, }
], ]
}); })
}); })
it("should handle empty updates without calling API", async () => { it('should handle empty updates without calling API', async () => {
const result = await updateUserConfigurations({}); const result = await updateUserConfigurations({})
expect(api.patch).not.toHaveBeenCalled(); expect(api.patch).not.toHaveBeenCalled()
expect(result).toEqual({ status: 204 }); 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 { api } from '@/utils/apiUtils'
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket"; import { fetchWebSocketTicket } from '@/websocket/api/fetchWebSocketTicket'
jest.mock("@/utils/apiUtils"); jest.mock('@/utils/apiUtils')
describe("fetchWebSocketTicket", () => { describe('fetchWebSocketTicket', () => {
const mockTicket = { const mockTicket = {
clientAddress: "127.0.0.1", clientAddress: '127.0.0.1',
value: "test-ticket-123", value: 'test-ticket-123',
generatedOn: "2025-01-12T10:00:00Z", generatedOn: '2025-01-12T10:00:00Z',
validUntil: "2025-01-12T11:00:00Z", validUntil: '2025-01-12T11:00:00Z',
username: "testuser", username: 'testuser'
}; }
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("should fetch ticket successfully", async () => { it('should fetch ticket successfully', async () => {
(api.post as jest.Mock).mockResolvedValue({ ;(api.post as jest.Mock).mockResolvedValue({
ok: true, ok: true,
json: async () => mockTicket, json: async () => mockTicket
}); })
const ticket = await fetchWebSocketTicket(); const ticket = await fetchWebSocketTicket()
expect(api.post).toHaveBeenCalledWith("ws/ticket"); expect(api.post).toHaveBeenCalledWith('ws/ticket')
expect(ticket).toEqual(mockTicket); expect(ticket).toEqual(mockTicket)
}); })
it("should throw error when response is not ok", async () => { it('should throw error when response is not ok', async () => {
(api.post as jest.Mock).mockResolvedValue({ ;(api.post as jest.Mock).mockResolvedValue({
ok: false, ok: false
}); })
await expect(fetchWebSocketTicket()).rejects.toThrow( await expect(fetchWebSocketTicket()).rejects.toThrow(
"Failed to fetch WebSocket ticket" 'Failed to fetch WebSocket ticket'
); )
}); })
it("should throw error when network fails", async () => { it('should throw error when network fails', async () => {
(api.post as jest.Mock).mockRejectedValue(new Error("Network error")); ;(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 { fetchWebSocketTicket } from '@/websocket/api/fetchWebSocketTicket'
import { createWebSocketConnection } from "@/websocket/connection/createConnection"; import { createWebSocketConnection } from '@/websocket/connection/createConnection'
import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; import { WS_INBOUND_EVENTS } from '@/websocket/protocols'
import { waitFor } from "@testing-library/dom"; import { waitFor } from '@testing-library/dom'
import { setupWebsocket } from "./utils/setupWebsocket"; import { setupWebsocket } from './utils/setupWebsocket'
jest.mock("@/websocket/api/fetchWebSocketTicket"); jest.mock('@/websocket/api/fetchWebSocketTicket')
describe("createWebSocketConnection", () => { describe('createWebSocketConnection', () => {
let mockWebSocket: jest.Mock; let mockWebSocket: jest.Mock
let cleanup: () => void; let cleanup: () => void
let webSocketInstances: any[] = []; let webSocketInstances: any[] = []
const mockTicket = { const mockTicket = {
value: "test-ticket-123", value: 'test-ticket-123',
clientAddress: "127.0.0.1", clientAddress: '127.0.0.1',
generatedOn: "2025-01-12T10:00:00Z", generatedOn: '2025-01-12T10:00:00Z',
validUntil: "2025-01-12T11:00:00Z", validUntil: '2025-01-12T11:00:00Z',
username: "testuser", username: 'testuser'
}; }
/** ---------- Helpers ---------- */ /** ---------- Helpers ---------- */
const getWs = () => webSocketInstances[0]; const getWs = () => webSocketInstances[0]
const triggerEvent = (ws: any, event: string, payload?: any) => { const triggerEvent = (ws: any, event: string, payload?: any) => {
ws._listeners[event]?.[0]?.(payload); ws._listeners[event]?.[0]?.(payload)
}; }
const createAndOpenConnection = async () => { const createAndOpenConnection = async () => {
const mockCallbacks = { const mockCallbacks = {
onMessage: jest.fn(), onMessage: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
onError: jest.fn(), onError: jest.fn()
}; }
const promise = createWebSocketConnection(mockCallbacks); const promise = createWebSocketConnection(mockCallbacks)
await waitFor(() => { await waitFor(() => {
expect(webSocketInstances.length).toBe(1); expect(webSocketInstances.length).toBe(1)
}); })
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED); triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED)
const socket = await promise; const socket = await promise
return { socket, ws: getWs(), promise, mockCallbacks }; return { socket, ws: getWs(), promise, mockCallbacks }
}; }
/** ---------- Setup ---------- */ /** ---------- Setup ---------- */
beforeEach(() => { beforeEach(() => {
({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket()); ;({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket())
window.WEBSOCKET_URL = "wss://calendar.example.com"; window.WEBSOCKET_URL = 'wss://calendar.example.com'
;(fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket)
(fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket); })
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
delete window.WEBSOCKET_URL; delete window.WEBSOCKET_URL
delete window.CALENDAR_BASE_URL; delete window.CALENDAR_BASE_URL
cleanup(); cleanup()
}); })
/** ---------- Tests ---------- */ /** ---------- Tests ---------- */
it("throws when WEBSOCKET_URL is not defined", async () => { it('throws when WEBSOCKET_URL is not defined', async () => {
delete window.WEBSOCKET_URL; delete window.WEBSOCKET_URL
const mockCallbacks = { const mockCallbacks = {
onMessage: jest.fn(), onMessage: jest.fn()
}; }
await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow( await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow(
"WEBSOCKET_URL is not defined" 'WEBSOCKET_URL is not defined'
); )
}); })
it("fetches WebSocket ticket", async () => { it('fetches WebSocket ticket', async () => {
await createAndOpenConnection(); await createAndOpenConnection()
expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1); expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1)
}); })
it("creates WebSocket with correct URL and ticket", async () => { it('creates WebSocket with correct URL and ticket', async () => {
await createAndOpenConnection(); await createAndOpenConnection()
expect(mockWebSocket).toHaveBeenCalledWith( 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 () => { it('creates WebSocket with correct URL and ticket without the WEBSOCKET_URL', async () => {
delete window.WEBSOCKET_URL; delete window.WEBSOCKET_URL
window.CALENDAR_BASE_URL = "https://calendar.example.com"; window.CALENDAR_BASE_URL = 'https://calendar.example.com'
await createAndOpenConnection(); await createAndOpenConnection()
expect(mockWebSocket).toHaveBeenCalledWith( 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 () => { it('resolves with socket when connection opens', async () => {
const { socket, ws } = await createAndOpenConnection(); const { socket, ws } = await createAndOpenConnection()
expect(socket).toBe(ws); expect(socket).toBe(ws)
}); })
it("rejects when connection fails", async () => { it('rejects when connection fails', async () => {
const mockCallbacks = { const mockCallbacks = {
onMessage: jest.fn(), onMessage: jest.fn()
}; }
const promise = createWebSocketConnection(mockCallbacks); const promise = createWebSocketConnection(mockCallbacks)
await waitFor(() => { 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 () => { it('attaches message event listener', async () => {
const { ws } = await createAndOpenConnection(); const { ws } = await createAndOpenConnection()
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE]).toBeDefined(); 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].length).toBeGreaterThan(0)
}); })
it("attaches close event listener", async () => { it('attaches close event listener', async () => {
const { ws } = await createAndOpenConnection(); const { ws } = await createAndOpenConnection()
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined(); expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined()
}); })
it("handles invalid JSON messages", async () => { it('handles invalid JSON messages', async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation(); 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( expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse WebSocket message:", 'Failed to parse WebSocket message:',
expect.any(Error) expect.any(Error)
); )
errorSpy.mockRestore(); errorSpy.mockRestore()
}); })
it("rejects on timeout", async () => { it('rejects on timeout', async () => {
jest.useFakeTimers(); jest.useFakeTimers()
const mockCallbacks = { const mockCallbacks = {
onMessage: jest.fn(), onMessage: jest.fn()
}; }
const promise = createWebSocketConnection(mockCallbacks); const promise = createWebSocketConnection(mockCallbacks)
await waitFor(() => { 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 () => { it('calls onMessage callback when message received', async () => {
const { ws, mockCallbacks } = await createAndOpenConnection(); const { ws, mockCallbacks } = await createAndOpenConnection()
const testMessage = { type: "test", payload: "data" }; const testMessage = { type: 'test', payload: 'data' }
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { 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 () => { it('does not call onMessage when JSON parsing fails', async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation(); const errorSpy = jest.spyOn(console, 'error').mockImplementation()
const { ws, mockCallbacks } = await createAndOpenConnection(); 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( expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse WebSocket message:", 'Failed to parse WebSocket message:',
expect.any(Error) expect.any(Error)
); )
errorSpy.mockRestore(); errorSpy.mockRestore()
}); })
it("calls onClose callback when connection closes", async () => { it('calls onClose callback when connection closes', async () => {
const { ws, mockCallbacks } = await createAndOpenConnection(); const { ws, mockCallbacks } = await createAndOpenConnection()
const closeEvent = new CloseEvent("close", { const closeEvent = new CloseEvent('close', {
code: 1000, code: 1000,
reason: "Normal closure", reason: 'Normal closure'
}); })
triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent); 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 () => { it('calls onError callback when error occurs', async () => {
const { ws, mockCallbacks } = await createAndOpenConnection(); const { ws, mockCallbacks } = await createAndOpenConnection()
const errorEvent = new Event("error"); const errorEvent = new Event('error')
triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent); 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 { import {
MAX_RECONNECT_ATTEMPTS, MAX_RECONNECT_ATTEMPTS,
RECONNECT_CONFIG, RECONNECT_CONFIG,
useWebSocketReconnect, useWebSocketReconnect
} from "@/websocket/connection/lifecycle/useWebSocketReconnect"; } from '@/websocket/connection/lifecycle/useWebSocketReconnect'
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from '@testing-library/react'
import { MutableRefObject } from "react"; import { MutableRefObject } from 'react'
// Mock the retry delay utility // Mock the retry delay utility
jest.mock("@/utils/getRetryDelay"); jest.mock('@/utils/getRetryDelay')
const mockGetRetryDelay = getRetryDelay as jest.MockedFunction< const mockGetRetryDelay = getRetryDelay as jest.MockedFunction<
typeof getRetryDelay typeof getRetryDelay
>; >
describe("useWebSocketReconnect", () => { describe('useWebSocketReconnect', () => {
let reconnectTimeoutRef: MutableRefObject<NodeJS.Timeout | null>; let reconnectTimeoutRef: MutableRefObject<NodeJS.Timeout | null>
let isAuthenticatedRef: MutableRefObject<boolean>; let isAuthenticatedRef: MutableRefObject<boolean>
let reconnectAttemptsRef: MutableRefObject<number>; let reconnectAttemptsRef: MutableRefObject<number>
let setShouldConnect: jest.Mock; let setShouldConnect: jest.Mock
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers()
reconnectTimeoutRef = { current: null }; reconnectTimeoutRef = { current: null }
isAuthenticatedRef = { current: true }; isAuthenticatedRef = { current: true }
reconnectAttemptsRef = { current: 0 }; reconnectAttemptsRef = { current: 0 }
setShouldConnect = jest.fn(); setShouldConnect = jest.fn()
mockGetRetryDelay.mockReturnValue(1000); // Default 1 second delay mockGetRetryDelay.mockReturnValue(1000) // Default 1 second delay
}); })
afterEach(() => { afterEach(() => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers()
jest.useRealTimers(); jest.useRealTimers()
jest.clearAllMocks(); jest.clearAllMocks()
}); })
describe("scheduleReconnect", () => { describe('scheduleReconnect', () => {
it("should schedule a reconnection with correct delay", () => { it('should schedule a reconnection with correct delay', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -43,26 +43,26 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG); expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG)
expect(reconnectTimeoutRef.current).not.toBeNull(); expect(reconnectTimeoutRef.current).not.toBeNull()
expect(setShouldConnect).not.toHaveBeenCalled(); expect(setShouldConnect).not.toHaveBeenCalled()
act(() => { act(() => {
jest.advanceTimersByTime(1000); jest.advanceTimersByTime(1000)
}); })
expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function)); expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function))
expect(reconnectAttemptsRef.current).toBe(1); expect(reconnectAttemptsRef.current).toBe(1)
}); })
it("should not schedule reconnection if not authenticated", () => { it('should not schedule reconnection if not authenticated', () => {
isAuthenticatedRef = { current: false }; isAuthenticatedRef = { current: false }
const { result } = renderHook(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -70,19 +70,19 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
expect(reconnectTimeoutRef.current).toBeNull(); expect(reconnectTimeoutRef.current).toBeNull()
expect(mockGetRetryDelay).not.toHaveBeenCalled(); expect(mockGetRetryDelay).not.toHaveBeenCalled()
}); })
it("should stop after MAX_RECONNECT_ATTEMPTS", () => { it('should stop after MAX_RECONNECT_ATTEMPTS', () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS; reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS
const { result } = renderHook(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
@@ -91,23 +91,23 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
`Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS})` `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(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -115,26 +115,26 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
expect(reconnectAttemptsRef.current).toBe(0); expect(reconnectAttemptsRef.current).toBe(0)
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
jest.advanceTimersByTime(1000); jest.advanceTimersByTime(1000)
}); })
expect(reconnectAttemptsRef.current).toBe(1); expect(reconnectAttemptsRef.current).toBe(1)
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
jest.advanceTimersByTime(1000); 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(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -142,24 +142,24 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
jest.advanceTimersByTime(1000); jest.advanceTimersByTime(1000)
}); })
expect(setShouldConnect).toHaveBeenCalledTimes(1); expect(setShouldConnect).toHaveBeenCalledTimes(1)
// Test the toggle function // Test the toggle function
const toggleFn = setShouldConnect.mock.calls[0][0]; const toggleFn = setShouldConnect.mock.calls[0][0]
expect(toggleFn(false)).toBe(true); expect(toggleFn(false)).toBe(true)
expect(toggleFn(true)).toBe(false); expect(toggleFn(true)).toBe(false)
}); })
}); })
describe("clearReconnectTimeout", () => { describe('clearReconnectTimeout', () => {
it("should clear pending timeout", () => { it('should clear pending timeout', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -167,29 +167,29 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
expect(reconnectTimeoutRef.current).not.toBeNull(); expect(reconnectTimeoutRef.current).not.toBeNull()
act(() => { act(() => {
result.current.clearReconnectTimeout(); result.current.clearReconnectTimeout()
}); })
expect(reconnectTimeoutRef.current).toBeNull(); expect(reconnectTimeoutRef.current).toBeNull()
// Timeout should not fire // Timeout should not fire
act(() => { 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(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -197,18 +197,18 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
act(() => { 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(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -216,35 +216,35 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
// Schedule first reconnection // Schedule first reconnection
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
const firstTimeout = reconnectTimeoutRef.current; const firstTimeout = reconnectTimeoutRef.current
expect(firstTimeout).not.toBeNull(); expect(firstTimeout).not.toBeNull()
// Schedule second reconnection (should clear first) // Schedule second reconnection (should clear first)
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
const secondTimeout = reconnectTimeoutRef.current; const secondTimeout = reconnectTimeoutRef.current
expect(secondTimeout).not.toBeNull(); expect(secondTimeout).not.toBeNull()
expect(secondTimeout).not.toBe(firstTimeout); expect(secondTimeout).not.toBe(firstTimeout)
// Only second timeout should fire // Only second timeout should fire
act(() => { act(() => {
jest.advanceTimersByTime(1000); jest.advanceTimersByTime(1000)
}); })
expect(setShouldConnect).toHaveBeenCalledTimes(1); expect(setShouldConnect).toHaveBeenCalledTimes(1)
}); })
}); })
it("should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)", () => { it('should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)', () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const { result } = renderHook(() => const { result } = renderHook(() =>
useWebSocketReconnect( useWebSocketReconnect(
reconnectTimeoutRef, reconnectTimeoutRef,
@@ -252,32 +252,32 @@ describe("useWebSocketReconnect", () => {
reconnectAttemptsRef, reconnectAttemptsRef,
setShouldConnect setShouldConnect
) )
); )
// Simulate 10 reconnection attempts // Simulate 10 reconnection attempts
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) { for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
jest.advanceTimersByTime( jest.advanceTimersByTime(
mockGetRetryDelay.mock.results[i]?.value || 1000 mockGetRetryDelay.mock.results[i]?.value || 1000
); )
}); })
} }
expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS); expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS)
expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS)
// Try to schedule one more reconnection - should fail // Try to schedule one more reconnection - should fail
act(() => { act(() => {
result.current.scheduleReconnect(); result.current.scheduleReconnect()
}); })
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
`Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.` `Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`
); )
expect(reconnectTimeoutRef.current).toBeNull(); expect(reconnectTimeoutRef.current).toBeNull()
expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); // Should not increment 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 type { AppDispatch, RootState } from '@/app/store'
import { store } from "@/app/store"; import { store } from '@/app/store'
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services"; import { refreshCalendarWithSyncToken } from '@/features/Calendars/services'
import { getDisplayedCalendarRange } from "@/utils"; import { getDisplayedCalendarRange } from '@/utils'
import { updateCalendars } from "@/websocket/messaging/updateCalendars"; import { updateCalendars } from '@/websocket/messaging/updateCalendars'
jest.mock("@/features/Calendars/services", () => ({ jest.mock('@/features/Calendars/services', () => ({
refreshCalendarWithSyncToken: jest.fn(), refreshCalendarWithSyncToken: jest.fn()
})); }))
jest.mock("@/utils", () => ({ jest.mock('@/utils', () => ({
getDisplayedCalendarRange: jest.fn(), getDisplayedCalendarRange: jest.fn(),
findCalendarById: jest.requireActual("@/utils").findCalendarById, findCalendarById: jest.requireActual('@/utils').findCalendarById
})); }))
jest.mock("@/app/store", () => ({ jest.mock('@/app/store', () => ({
store: { store: {
getState: jest.fn(), getState: jest.fn()
}, }
})); }))
jest.useFakeTimers(); jest.useFakeTimers()
const mockDispatch = jest.fn(); const mockDispatch = jest.fn()
const mockRange = { const mockRange = {
start: new Date("2025-01-15T10:00:00Z"), start: new Date('2025-01-15T10:00:00Z'),
end: new Date("2025-01-16T10:00:00Z"), end: new Date('2025-01-16T10:00:00Z')
}; }
const mockState = { const mockState = {
calendars: { calendars: {
list: { list: {
"cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 }, 'cal1/entry1': { id: 'cal1/entry1', name: 'Calendar 1', syncToken: 1 },
"cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 }, 'cal2/entry2': { id: 'cal2/entry2', name: 'Calendar 2', syncToken: 1 },
"cal/A": { id: "cal/A", name: "Cal A", syncToken: 1 }, 'cal/A': { id: 'cal/A', name: 'Cal A', syncToken: 1 },
"cal/B": { id: "cal/B", name: "Cal B", syncToken: 1 }, 'cal/B': { id: 'cal/B', name: 'Cal B', syncToken: 1 },
"cal/C": { id: "cal/C", name: "Cal C", syncToken: 1 }, 'cal/C': { id: 'cal/C', name: 'Cal C', syncToken: 1 }
}, },
templist: {}, templist: {}
}, }
} as unknown as RootState; } as unknown as RootState
const mockAccumulators: { const mockAccumulators: {
calendarsToRefresh: Map<string, any>; calendarsToRefresh: Map<string, any>
calendarsToHide: Set<string>; calendarsToHide: Set<string>
debouncedUpdateFn?: (dispatch: AppDispatch) => void; debouncedUpdateFn?: (dispatch: AppDispatch) => void
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>; shouldRefreshCalendarListRef: React.MutableRefObject<boolean>
currentDebouncePeriod?: number; currentDebouncePeriod?: number
} = { } = {
calendarsToRefresh: new Map<string, any>(), calendarsToRefresh: new Map<string, any>(),
calendarsToHide: new Set(), calendarsToHide: new Set(),
shouldRefreshCalendarListRef: { current: false }, shouldRefreshCalendarListRef: { current: false },
currentDebouncePeriod: 0, currentDebouncePeriod: 0,
debouncedUpdateFn: undefined, debouncedUpdateFn: undefined
}; }
describe("websocket messages storm", () => { describe('websocket messages storm', () => {
beforeEach(() => { beforeEach(() => {
(refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear(); ;(refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear()
jest.clearAllMocks(); jest.clearAllMocks()
jest.clearAllTimers(); jest.clearAllTimers()
jest.resetModules(); jest.resetModules()
(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); ;(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange)
(store.getState as jest.Mock).mockReturnValue(mockState); ;(store.getState as jest.Mock).mockReturnValue(mockState)
window.WS_DEBOUNCE_PERIOD_MS = 500; window.WS_DEBOUNCE_PERIOD_MS = 500
mockAccumulators.calendarsToRefresh = new Map<string, any>(); mockAccumulators.calendarsToRefresh = new Map<string, any>()
mockAccumulators.calendarsToHide = new Set(); mockAccumulators.calendarsToHide = new Set()
mockAccumulators.shouldRefreshCalendarListRef.current = false; mockAccumulators.shouldRefreshCalendarListRef.current = false
mockAccumulators.currentDebouncePeriod = 0; mockAccumulators.currentDebouncePeriod = 0
mockAccumulators.debouncedUpdateFn = undefined; mockAccumulators.debouncedUpdateFn = undefined
}); })
it("debounces calendar updates during message storm", () => { it('debounces calendar updates during message storm', () => {
const mockMessage = { const mockMessage = {
"/calendars/cal1/entry1": { '/calendars/cal1/entry1': {
syncToken: "ldsk", syncToken: 'ldsk'
}, }
}; }
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
updateCalendars(mockMessage, mockDispatch, mockAccumulators); updateCalendars(mockMessage, mockDispatch, mockAccumulators)
} }
// Dispatch called once because of leading edge // Dispatch called once because of leading edge
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1)
// Trailing edge // Trailing edge
jest.advanceTimersByTime(500); jest.advanceTimersByTime(500)
// only one call for the last message + leading edge message // 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 // Send a storm with mixed messages
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
if (i % 3 === 0) if (i % 3 === 0)
updateCalendars( updateCalendars(
{ "/calendars/cal/A": { syncToken: "ldskfjsld" + i } }, { '/calendars/cal/A': { syncToken: 'ldskfjsld' + i } },
mockDispatch, mockDispatch,
mockAccumulators mockAccumulators
); )
else if (i % 3 === 1) else if (i % 3 === 1)
updateCalendars( updateCalendars(
{ "/calendars/cal/B": { syncToken: "ldskfjsld" + i } }, { '/calendars/cal/B': { syncToken: 'ldskfjsld' + i } },
mockDispatch, mockDispatch,
mockAccumulators mockAccumulators
); )
else else
updateCalendars( updateCalendars(
{ "/calendars/cal/C": { syncToken: "ldskfjsld" + i } }, { '/calendars/cal/C': { syncToken: 'ldskfjsld' + i } },
mockDispatch, mockDispatch,
mockAccumulators mockAccumulators
); )
} }
// Dispatch called once because of leading edge // Dispatch called once because of leading edge
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1)
// Trailing edge // Trailing edge
jest.advanceTimersByTime(500); jest.advanceTimersByTime(500)
// Trailing edge updates once per calendar + the original leading edge // 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", () => { it('executes immediately when debounce is disabled', () => {
window.WS_DEBOUNCE_PERIOD_MS = 0; window.WS_DEBOUNCE_PERIOD_MS = 0
updateCalendars( updateCalendars(
{ "/calendars/cal1/entry1": { syncToken: "abc" } }, { '/calendars/cal1/entry1': { syncToken: 'abc' } },
mockDispatch, mockDispatch,
mockAccumulators mockAccumulators
); )
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1)
}); })
}); })
@@ -1,6 +1,6 @@
export function setupWebsocket() { export function setupWebsocket() {
const originalWebSocket = global.WebSocket; const originalWebSocket = global.WebSocket
const webSocketInstances: any[] = []; const webSocketInstances: any[] = []
const mockWebSocket = jest.fn().mockImplementation((url: string) => { const mockWebSocket = jest.fn().mockImplementation((url: string) => {
const ws = { const ws = {
url, url,
@@ -9,27 +9,27 @@ export function setupWebsocket() {
removeEventListener: jest.fn(), removeEventListener: jest.fn(),
send: jest.fn(), send: jest.fn(),
close: jest.fn(), close: jest.fn(),
_listeners: {} as Record<string, Function[]>, _listeners: {} as Record<string, Function[]>
}; }
ws.addEventListener.mockImplementation((event, handler) => { ws.addEventListener.mockImplementation((event, handler) => {
ws._listeners[event] ??= []; ws._listeners[event] ??= []
ws._listeners[event].push(handler); ws._listeners[event].push(handler)
}); })
ws.removeEventListener.mockImplementation((event, handler) => { ws.removeEventListener.mockImplementation((event, handler) => {
ws._listeners[event] = ws._listeners[event] =
ws._listeners[event]?.filter((h) => h !== handler) ?? []; ws._listeners[event]?.filter(h => h !== handler) ?? []
}); })
webSocketInstances.push(ws); webSocketInstances.push(ws)
return ws; return ws
}); })
global.WebSocket = mockWebSocket as any; global.WebSocket = mockWebSocket as any
const cleanup = () => { const cleanup = () => {
global.WebSocket = originalWebSocket; global.WebSocket = originalWebSocket
webSocketInstances.length = 0; webSocketInstances.length = 0
}; }
return { webSocketInstances, mockWebSocket, cleanup }; return { webSocketInstances, mockWebSocket, cleanup }
} }
@@ -1,147 +1,145 @@
import { AppDispatch, RootState, store } from "@/app/store"; import { AppDispatch, RootState, store } from '@/app/store'
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar"; import { refreshCalendarWithSyncToken } from '@/features/Calendars/services/refreshCalendar'
import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager"; import { getDisplayedCalendarRange } from '@/utils/CalendarRangeManager'
import { updateCalendars } from "@/websocket/messaging/updateCalendars"; import { updateCalendars } from '@/websocket/messaging/updateCalendars'
import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; import { WS_INBOUND_EVENTS } from '@/websocket/protocols'
import { waitFor } from "@testing-library/dom"; import { waitFor } from '@testing-library/dom'
jest.mock("@/features/Calendars/services/refreshCalendar"); jest.mock('@/features/Calendars/services/refreshCalendar')
jest.mock("@/utils/CalendarRangeManager"); jest.mock('@/utils/CalendarRangeManager')
jest.mock("@/app/store", () => ({ jest.mock('@/app/store', () => ({
store: { store: {
getState: jest.fn(), getState: jest.fn()
}, }
})); }))
describe("updateCalendars", () => { describe('updateCalendars', () => {
let mockDispatch: jest.Mock; let mockDispatch: jest.Mock
const mockRange = { const mockRange = {
start: new Date("2025-01-15T10:00:00Z"), start: new Date('2025-01-15T10:00:00Z'),
end: new Date("2025-01-16T10:00:00Z"), end: new Date('2025-01-16T10:00:00Z')
}; }
const mockState = { const mockState = {
calendars: { calendars: {
list: { list: {
"cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 }, 'cal1/entry1': { id: 'cal1/entry1', name: 'Calendar 1', syncToken: 1 },
"cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 }, 'cal2/entry2': { id: 'cal2/entry2', name: 'Calendar 2', syncToken: 1 }
}, },
templist: {}, templist: {}
}, }
} as unknown as RootState; } as unknown as RootState
const mockAccumulators: { const mockAccumulators: {
calendarsToRefresh: Map<string, any>; calendarsToRefresh: Map<string, any>
calendarsToHide: Set<string>; calendarsToHide: Set<string>
debouncedUpdateFn?: (dispatch: AppDispatch) => void; debouncedUpdateFn?: (dispatch: AppDispatch) => void
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>; shouldRefreshCalendarListRef: React.MutableRefObject<boolean>
currentDebouncePeriod?: number; currentDebouncePeriod?: number
} = { } = {
calendarsToRefresh: new Map<string, any>(), calendarsToRefresh: new Map<string, any>(),
calendarsToHide: new Set(), calendarsToHide: new Set(),
shouldRefreshCalendarListRef: { current: false }, shouldRefreshCalendarListRef: { current: false },
currentDebouncePeriod: 0, currentDebouncePeriod: 0,
debouncedUpdateFn: jest.fn(), debouncedUpdateFn: jest.fn()
}; }
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
mockDispatch = jest.fn(); mockDispatch = jest.fn()
(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); ;(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange)
(store.getState as jest.Mock).mockReturnValue(mockState); ;(store.getState as jest.Mock).mockReturnValue(mockState)
mockAccumulators.calendarsToRefresh = new Map<string, any>(); mockAccumulators.calendarsToRefresh = new Map<string, any>()
mockAccumulators.calendarsToHide = new Set(); mockAccumulators.calendarsToHide = new Set()
mockAccumulators.currentDebouncePeriod = 0; mockAccumulators.currentDebouncePeriod = 0
mockAccumulators.shouldRefreshCalendarListRef.current = false; mockAccumulators.shouldRefreshCalendarListRef.current = false
mockAccumulators.debouncedUpdateFn = jest.fn(); mockAccumulators.debouncedUpdateFn = jest.fn()
}); })
it("should not dispatch for non-object messages", () => { it('should not dispatch for non-object messages', () => {
updateCalendars(null, mockDispatch, mockAccumulators); updateCalendars(null, mockDispatch, mockAccumulators)
updateCalendars("string", mockDispatch, mockAccumulators); updateCalendars('string', mockDispatch, mockAccumulators)
updateCalendars(123, 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 = { const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
"/calendars/cal1/entry1", '/calendars/cal1/entry1',
"/calendars/cal2/entry2", '/calendars/cal2/entry2'
], ]
}; }
updateCalendars(message, mockDispatch, mockAccumulators); updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() => await waitFor(() =>
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2) expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2)
); )
}); })
it("should dispatch for calendar path updates", async () => { it('should dispatch for calendar path updates', async () => {
const message = { const message = {
"/calendars/cal1/entry1": { updated: true }, '/calendars/cal1/entry1': { updated: true }
}; }
updateCalendars(message, mockDispatch, mockAccumulators); updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() => await waitFor(() => expect(refreshCalendarWithSyncToken).toHaveBeenCalled())
expect(refreshCalendarWithSyncToken).toHaveBeenCalled()
);
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
calendar: mockState.calendars.list["cal1/entry1"], calendar: mockState.calendars.list['cal1/entry1'],
calType: undefined, calType: undefined,
calendarRange: mockRange, calendarRange: mockRange
}); })
}); })
it("should use displayed calendar range", async () => { it('should use displayed calendar range', async () => {
const message = { const message = {
"/calendars/cal1/entry1": {}, '/calendars/cal1/entry1': {}
}; }
updateCalendars(message, mockDispatch, mockAccumulators); updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled()); await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled())
}); })
it("should handle temp calendars", async () => { it('should handle temp calendars', async () => {
const stateWithTemp = { const stateWithTemp = {
calendars: { calendars: {
list: {}, list: {},
templist: { templist: {
"temp1/entry1": { 'temp1/entry1': {
id: "temp1/entry1", id: 'temp1/entry1',
name: "Temp Calendar", name: 'Temp Calendar',
syncToken: 1, syncToken: 1
}, }
}, }
}, }
}; }
(store.getState as jest.Mock).mockReturnValue(stateWithTemp); ;(store.getState as jest.Mock).mockReturnValue(stateWithTemp)
const message = { const message = {
"/calendars/temp1/entry1": {}, '/calendars/temp1/entry1': {}
}; }
updateCalendars(message, mockDispatch, mockAccumulators); updateCalendars(message, mockDispatch, mockAccumulators)
await waitFor(() => await waitFor(() =>
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
calendar: stateWithTemp.calendars.templist["temp1/entry1"], calendar: stateWithTemp.calendars.templist['temp1/entry1'],
calType: "temp", calType: 'temp',
calendarRange: mockRange, calendarRange: mockRange
})
)
}) })
);
});
it("should handle invalid calendar paths gracefully", () => { it('should handle invalid calendar paths gracefully', () => {
const message = { const message = {
"/invalid/path": {}, '/invalid/path': {},
"not-a-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 { parseMessage } from '@/websocket/messaging/parseMessage'
import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; import { WS_INBOUND_EVENTS } from '@/websocket/protocols'
describe("parseMessage", () => { describe('parseMessage', () => {
it("should return empty set for non-object messages", () => { it('should return empty set for non-object messages', () => {
const result1 = parseMessage(null); const result1 = parseMessage(null)
const result2 = parseMessage("string"); const result2 = parseMessage('string')
const result3 = parseMessage(123); const result3 = parseMessage(123)
expect(result1.calendarsToRefresh).toEqual(new Set<string>()); expect(result1.calendarsToRefresh).toEqual(new Set<string>())
expect(result2.calendarsToRefresh).toEqual(new Set<string>()); expect(result2.calendarsToRefresh).toEqual(new Set<string>())
expect(result3.calendarsToRefresh).toEqual(new Set<string>()); expect(result3.calendarsToRefresh).toEqual(new Set<string>())
}); })
it("should handle registered event", () => { it('should handle registered event', () => {
const message = { const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
"/calendars/cal1/entry1", '/calendars/cal1/entry1',
"/calendars/cal2/entry2", '/calendars/cal2/entry2'
], ]
}; }
const result = parseMessage(message); const result = parseMessage(message)
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1')
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2"); expect(result.calendarsToRefresh).toContain('/calendars/cal2/entry2')
expect(result.calendarsToRefresh.size).toBe(2); expect(result.calendarsToRefresh.size).toBe(2)
}); })
it("should handle unregistered event", () => { it('should handle unregistered event', () => {
const message = { 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 = { 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).toContain('/calendars/cal1/entry1')
expect(result.calendarsToRefresh.size).toBe(1); expect(result.calendarsToRefresh.size).toBe(1)
}); })
it("should parse multiple calendar paths", () => { it('should parse multiple calendar paths', () => {
const message = { const message = {
"/calendars/cal1/entry1": {}, '/calendars/cal1/entry1': {},
"/calendars/cal2/entry2": {}, '/calendars/cal2/entry2': {}
}; }
const result = parseMessage(message); const result = parseMessage(message)
expect(result.calendarsToRefresh.size).toBe(2); expect(result.calendarsToRefresh.size).toBe(2)
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1')
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2"); 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 = { const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ["/calendars/cal1/entry1"], [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ['/calendars/cal1/entry1'],
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal2/entry2"], [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ['/calendars/cal2/entry2'],
"/calendars/cal1/entry1": {}, '/calendars/cal1/entry1': {}
}; }
const result = parseMessage(message); const result = parseMessage(message)
expect(result.calendarsToRefresh.size).toBe(1); expect(result.calendarsToRefresh.size).toBe(1)
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); 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", () => { describe('registerToCalendars', () => {
let mockSocket: any; let mockSocket: any
beforeEach(() => { beforeEach(() => {
mockSocket = { mockSocket = {
readyState: WebSocket.OPEN, readyState: WebSocket.OPEN,
send: jest.fn(), send: jest.fn()
}; }
}); })
it("should send registration message with calendar URIs", () => { it('should send registration message with calendar URIs', () => {
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
registerToCalendars(mockSocket, calendarURIs); registerToCalendars(mockSocket, calendarURIs)
expect(mockSocket.send).toHaveBeenCalledWith( expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({ JSON.stringify({
register: calendarURIs, register: calendarURIs
})
)
}) })
);
});
it("should throw error if socket is not open", () => { it('should throw error if socket is not open', () => {
mockSocket.readyState = WebSocket.CONNECTING; mockSocket.readyState = WebSocket.CONNECTING
const calendarURIs = ["/calendars/cal1"]; const calendarURIs = ['/calendars/cal1']
expect(() => registerToCalendars(mockSocket, calendarURIs)).toThrow( expect(() => registerToCalendars(mockSocket, calendarURIs)).toThrow(
"Cannot register: WebSocket is not open" 'Cannot register: WebSocket is not open'
); )
}); })
it("should handle empty calendar list", () => { it('should handle empty calendar list', () => {
registerToCalendars(mockSocket, []); registerToCalendars(mockSocket, [])
expect(mockSocket.send).toHaveBeenCalledWith( expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({ JSON.stringify({
register: [], register: []
})
)
}) })
);
});
it("should log registration", () => { it('should log registration', () => {
const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(); const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation()
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
registerToCalendars(mockSocket, calendarURIs); registerToCalendars(mockSocket, calendarURIs)
expect(consoleInfoSpy).toHaveBeenCalledWith( expect(consoleInfoSpy).toHaveBeenCalledWith(
"Registered to calendars", 'Registered to calendars',
calendarURIs calendarURIs
); )
consoleInfoSpy.mockRestore(); consoleInfoSpy.mockRestore()
}); })
}); })
@@ -1,58 +1,58 @@
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars"; import { unregisterToCalendars } from '@/websocket/operations/unregisterToCalendars'
describe("unregisterToCalendars", () => { describe('unregisterToCalendars', () => {
let mockSocket: any; let mockSocket: any
beforeEach(() => { beforeEach(() => {
mockSocket = { mockSocket = {
readyState: WebSocket.OPEN, readyState: WebSocket.OPEN,
send: jest.fn(), send: jest.fn()
}; }
jest.clearAllMocks(); jest.clearAllMocks()
}); })
it("should send unregistration message with calendar URIs", () => { it('should send unregistration message with calendar URIs', () => {
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
unregisterToCalendars(mockSocket, calendarURIs); unregisterToCalendars(mockSocket, calendarURIs)
expect(mockSocket.send).toHaveBeenCalledWith( expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({ JSON.stringify({
unregister: calendarURIs, unregister: calendarURIs
})
)
}) })
);
});
it("should throw error if socket is not open", () => { it('should throw error if socket is not open', () => {
mockSocket.readyState = WebSocket.CONNECTING; mockSocket.readyState = WebSocket.CONNECTING
const calendarURIs = ["/calendars/cal1"]; const calendarURIs = ['/calendars/cal1']
expect(() => unregisterToCalendars(mockSocket, calendarURIs)).toThrow( expect(() => unregisterToCalendars(mockSocket, calendarURIs)).toThrow(
"Cannot unregister: WebSocket is not open" 'Cannot unregister: WebSocket is not open'
); )
}); })
it("should handle empty calendar list", () => { it('should handle empty calendar list', () => {
unregisterToCalendars(mockSocket, []); unregisterToCalendars(mockSocket, [])
expect(mockSocket.send).toHaveBeenCalledWith( expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({ JSON.stringify({
unregister: [], unregister: []
})
)
}) })
);
});
it("should log unregistration", () => { it('should log unregistration', () => {
const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(); const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation()
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; const calendarURIs = ['/calendars/cal1', '/calendars/cal2']
unregisterToCalendars(mockSocket, calendarURIs); unregisterToCalendars(mockSocket, calendarURIs)
expect(consoleInfoSpy).toHaveBeenCalledWith( expect(consoleInfoSpy).toHaveBeenCalledWith(
"Unregistered to calendars", 'Unregistered to calendars',
calendarURIs calendarURIs
); )
consoleInfoSpy.mockRestore(); consoleInfoSpy.mockRestore()
}); })
}); })
+51 -51
View File
@@ -2,72 +2,72 @@ import {
calendarRangeManager, calendarRangeManager,
getDisplayedCalendarRange, getDisplayedCalendarRange,
getDisplayedDate, getDisplayedDate,
setDisplayedDateAndRange, setDisplayedDateAndRange
} from "@/utils/CalendarRangeManager"; } from '@/utils/CalendarRangeManager'
describe("CalendarRangeManager", () => { describe('CalendarRangeManager', () => {
beforeEach(() => { beforeEach(() => {
setDisplayedDateAndRange(new Date()); setDisplayedDateAndRange(new Date())
}); })
it("should return a singleton instance", () => { it('should return a singleton instance', () => {
const instance1 = calendarRangeManager; const instance1 = calendarRangeManager
const instance2 = calendarRangeManager; const instance2 = calendarRangeManager
expect(instance1).toStrictEqual(instance2); expect(instance1).toStrictEqual(instance2)
}); })
it("should compute and return calendar range when date is set", () => { it('should compute and return calendar range when date is set', () => {
const testDate = new Date("2025-06-15T10:00:00Z"); const testDate = new Date('2025-06-15T10:00:00Z')
setDisplayedDateAndRange(testDate); setDisplayedDateAndRange(testDate)
const range = getDisplayedCalendarRange(); const range = getDisplayedCalendarRange()
expect(range.start).toBeInstanceOf(Date); expect(range.start).toBeInstanceOf(Date)
expect(range.end).toBeInstanceOf(Date); expect(range.end).toBeInstanceOf(Date)
expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime()); expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime())
expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime()); expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime())
}); })
it("should get the default date", () => { it('should get the default date', () => {
const date = getDisplayedDate(); const date = getDisplayedDate()
expect(date).toBeInstanceOf(Date); expect(date).toBeInstanceOf(Date)
}); })
it("should set and get a date", () => { it('should set and get a date', () => {
const testDate = new Date("2025-01-15T10:00:00Z"); const testDate = new Date('2025-01-15T10:00:00Z')
setDisplayedDateAndRange(testDate); setDisplayedDateAndRange(testDate)
const retrievedDate = getDisplayedDate(); const retrievedDate = getDisplayedDate()
expect(retrievedDate).toStrictEqual(testDate); expect(retrievedDate).toStrictEqual(testDate)
}); })
it("should persist date across multiple calls", () => { it('should persist date across multiple calls', () => {
const testDate = new Date("2025-06-20T15:30:00Z"); const testDate = new Date('2025-06-20T15:30:00Z')
setDisplayedDateAndRange(testDate); setDisplayedDateAndRange(testDate)
const date1 = getDisplayedDate(); const date1 = getDisplayedDate()
const date2 = getDisplayedDate(); const date2 = getDisplayedDate()
expect(date1).toStrictEqual(date2); expect(date1).toStrictEqual(date2)
expect(date1).toStrictEqual(testDate); expect(date1).toStrictEqual(testDate)
}); })
it("should update date when set multiple times", () => { it('should update date when set multiple times', () => {
const date1 = new Date("2025-01-01T00:00:00Z"); const date1 = new Date('2025-01-01T00:00:00Z')
const date2 = new Date("2025-12-31T23:59:59Z"); const date2 = new Date('2025-12-31T23:59:59Z')
setDisplayedDateAndRange(date1); setDisplayedDateAndRange(date1)
expect(getDisplayedDate()).toStrictEqual(date1); expect(getDisplayedDate()).toStrictEqual(date1)
setDisplayedDateAndRange(date2); setDisplayedDateAndRange(date2)
expect(getDisplayedDate()).toStrictEqual(date2); expect(getDisplayedDate()).toStrictEqual(date2)
}); })
it("should maintain shared state (singleton behavior)", () => { it('should maintain shared state (singleton behavior)', () => {
const testDate = new Date("2025-03-01"); const testDate = new Date('2025-03-01')
setDisplayedDateAndRange(testDate); setDisplayedDateAndRange(testDate)
// Verify CalendarRangeManager reflects the same state // 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 type { AppStore, RootState } from '@/app/store'
import { setupStore } from "@/app/store"; import { setupStore } from '@/app/store'
import { TwakeMuiThemeProvider } from "@linagora/twake-mui"; import { TwakeMuiThemeProvider } from '@linagora/twake-mui'
import type { RenderOptions } from "@testing-library/react"; import type { RenderOptions } from '@testing-library/react'
import { render } from "@testing-library/react"; import { render } from '@testing-library/react'
import React, { PropsWithChildren } from "react"; import React, { PropsWithChildren } from 'react'
import { Provider } from "react-redux"; import { Provider } from 'react-redux'
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from 'react-router-dom'
import { I18nContext } from "twake-i18n"; import { I18nContext } from 'twake-i18n'
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> { interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>; preloadedState?: Partial<RootState>
store?: AppStore; store?: AppStore
} }
export function renderWithProviders( export function renderWithProviders(
@@ -18,7 +18,7 @@ export function renderWithProviders(
extendedRenderOptions: ExtendedRenderOptions = {} extendedRenderOptions: ExtendedRenderOptions = {}
) { ) {
const { store = setupStore(preloadedState), ...renderOptions } = const { store = setupStore(preloadedState), ...renderOptions } =
extendedRenderOptions; extendedRenderOptions
const Wrapper = ({ children }: PropsWithChildren) => { const Wrapper = ({ children }: PropsWithChildren) => {
return ( return (
@@ -26,31 +26,31 @@ export function renderWithProviders(
<I18nContext.Provider <I18nContext.Provider
value={{ value={{
t: (key: string, vars?: Record<string, string>) => { t: (key: string, vars?: Record<string, string>) => {
if (key === "locale") { if (key === 'locale') {
return "en"; return 'en'
} }
if (vars) { if (vars) {
const params = Object.entries(vars) const params = Object.entries(vars)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(","); .join(',')
return `${key}(${params})`; return `${key}(${params})`
} }
return key; return key
}, },
f: (date: Date, formatStr: string) => date.toString(), f: (date: Date, formatStr: string) => date.toString(),
lang: "en", lang: 'en'
}} }}
> >
<MemoryRouter initialEntries={["/"]}> <MemoryRouter initialEntries={['/']}>
<Provider store={store}>{children}</Provider> <Provider store={store}>{children}</Provider>
</MemoryRouter> </MemoryRouter>
</I18nContext.Provider> </I18nContext.Provider>
</TwakeMuiThemeProvider> </TwakeMuiThemeProvider>
); )
}; }
return { return {
store, 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) => { nameToColor: jest.fn((name: string) => {
if (!name) return undefined; if (!name) return undefined
const colors = [ const colors = [
"sunrise", 'sunrise',
"downy", 'downy',
"sugarCoral", 'sugarCoral',
"pinkBonnet", 'pinkBonnet',
"blueMana", 'blueMana',
"nightBlue", 'nightBlue',
"snowPea", 'snowPea',
"pluviophile", 'pluviophile',
"cornflower", 'cornflower',
"paleGreen", 'paleGreen',
"moonBlue", 'moonBlue'
]; ]
const hash = Array.from(name.toUpperCase()) const hash = Array.from(name.toUpperCase())
.map((letter) => letter.charCodeAt(0)) .map(letter => letter.charCodeAt(0))
.reduce((sum, number) => sum + number, 0); .reduce((sum, number) => sum + number, 0)
return colors[hash % colors.length]; return colors[hash % colors.length]
}), })
})); }))
describe("avatarUtils", () => { describe('avatarUtils', () => {
describe("getInitials", () => { describe('getInitials', () => {
it("returns 2-letter initials for full name", () => { it('returns 2-letter initials for full name', () => {
expect(getInitials("John Doe")).toBe("JD"); expect(getInitials('John Doe')).toBe('JD')
expect(getInitials("Alice Smith")).toBe("AS"); expect(getInitials('Alice Smith')).toBe('AS')
expect(getInitials("Nguyễn Văn")).toBe("NV"); expect(getInitials('Nguyễn Văn')).toBe('NV')
}); })
it("returns single letter for single word", () => { it('returns single letter for single word', () => {
expect(getInitials("Alice")).toBe("A"); expect(getInitials('Alice')).toBe('A')
expect(getInitials("John")).toBe("J"); expect(getInitials('John')).toBe('J')
}); })
it("returns uppercase initials", () => { it('returns uppercase initials', () => {
expect(getInitials("john doe")).toBe("JD"); expect(getInitials('john doe')).toBe('JD')
expect(getInitials("ALICE SMITH")).toBe("AS"); expect(getInitials('ALICE SMITH')).toBe('AS')
expect(getInitials("a")).toBe("A"); expect(getInitials('a')).toBe('A')
}); })
it("handles multiple spaces between words", () => { it('handles multiple spaces between words', () => {
expect(getInitials("John Doe")).toBe("JD"); expect(getInitials('John Doe')).toBe('JD')
expect(getInitials("Alice Smith Brown")).toBe("AS"); expect(getInitials('Alice Smith Brown')).toBe('AS')
}); })
it("handles email addresses", () => { it('handles email addresses', () => {
expect(getInitials("john.doe@email.com")).toBe("J"); expect(getInitials('john.doe@email.com')).toBe('J')
expect(getInitials("test@example.com")).toBe("T"); expect(getInitials('test@example.com')).toBe('T')
}); })
it("handles empty string", () => { it('handles empty string', () => {
expect(getInitials("")).toBe(""); expect(getInitials('')).toBe('')
}); })
it("handles whitespace-only string", () => { it('handles whitespace-only string', () => {
expect(getInitials(" ")).toBe(""); expect(getInitials(' ')).toBe('')
}); })
it("handles single character", () => { it('handles single character', () => {
expect(getInitials("A")).toBe("A"); expect(getInitials('A')).toBe('A')
expect(getInitials("a")).toBe("A"); expect(getInitials('a')).toBe('A')
}); })
it("handles names with special characters", () => { it('handles names with special characters', () => {
expect(getInitials("Jean-Pierre")).toBe("J"); expect(getInitials('Jean-Pierre')).toBe('J')
expect(getInitials("Mary Jane Watson")).toBe("MJ"); expect(getInitials('Mary Jane Watson')).toBe('MJ')
}); })
it("handles unicode characters", () => { it('handles unicode characters', () => {
expect(getInitials("Nguyễn Văn")).toBe("NV"); expect(getInitials('Nguyễn Văn')).toBe('NV')
expect(getInitials("José María")).toBe("JM"); expect(getInitials('José María')).toBe('JM')
}); })
it("takes first two words for names with more than two words", () => { it('takes first two words for names with more than two words', () => {
expect(getInitials("John Michael Smith")).toBe("JM"); expect(getInitials('John Michael Smith')).toBe('JM')
expect(getInitials("Alice Bob Charlie")).toBe("AB"); expect(getInitials('Alice Bob Charlie')).toBe('AB')
}); })
}); })
describe("stringToGradient", () => { describe('stringToGradient', () => {
it("returns color for valid string", () => { it('returns color for valid string', () => {
const result = stringToGradient("John Doe"); const result = stringToGradient('John Doe')
expect(result).toBeDefined(); expect(result).toBeDefined()
expect(typeof result).toBe("string"); expect(typeof result).toBe('string')
}); })
it("returns same color for same input", () => { it('returns same color for same input', () => {
const result1 = stringToGradient("John Doe"); const result1 = stringToGradient('John Doe')
const result2 = stringToGradient("John Doe"); const result2 = stringToGradient('John Doe')
expect(result1).toBe(result2); expect(result1).toBe(result2)
}); })
it("returns undefined for empty string", () => { it('returns undefined for empty string', () => {
expect(stringToGradient("")).toBeUndefined(); expect(stringToGradient('')).toBeUndefined()
}); })
it("returns color for single character", () => { it('returns color for single character', () => {
const result = stringToGradient("A"); const result = stringToGradient('A')
expect(result).toBeDefined(); expect(result).toBeDefined()
expect(typeof result).toBe("string"); expect(typeof result).toBe('string')
}); })
it("returns color for email address", () => { it('returns color for email address', () => {
const result = stringToGradient("test@example.com"); const result = stringToGradient('test@example.com')
expect(result).toBeDefined(); expect(result).toBeDefined()
expect(typeof result).toBe("string"); expect(typeof result).toBe('string')
}); })
}); })
}); })
+36 -36
View File
@@ -1,51 +1,51 @@
import { Calendar } from "@/features/Calendars/CalendarTypes"; import { Calendar } from '@/features/Calendars/CalendarTypes'
import { buildDelegatedEventURL } from "@/features/Events/utils"; import { buildDelegatedEventURL } from '@/features/Events/utils'
const makeCalendar = (link: string): Calendar => const makeCalendar = (link: string): Calendar =>
({ ({
id: "user2/cal1", id: 'user2/cal1',
delegated: true, delegated: true,
link, link,
owner: { emails: ["owner@example.com"] }, owner: { emails: ['owner@example.com'] }
}) as Calendar; }) as Calendar
describe("buildDelegatedEventURL", () => { describe('buildDelegatedEventURL', () => {
it("rebases event filename onto calendar link base path", () => { it('rebases event filename onto calendar link base path', () => {
const calendar = makeCalendar("/calendars/user2/cal1.json"); const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = "/calendars/someother/path/event-abc.ics"; const eventURL = '/calendars/someother/path/event-abc.ics'
expect(buildDelegatedEventURL(calendar, eventURL)).toBe( expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/calendars/user2/cal1/event-abc.ics" '/calendars/user2/cal1/event-abc.ics'
); )
}); })
it("strips .json suffix from calendar link", () => { it('strips .json suffix from calendar link', () => {
const calendar = makeCalendar("/calendars/user2/cal1.json"); const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = "/calendars/user2/cal1/event-abc.ics"; const eventURL = '/calendars/user2/cal1/event-abc.ics'
const result = buildDelegatedEventURL(calendar, eventURL); const result = buildDelegatedEventURL(calendar, eventURL)
expect(result).not.toContain(".json"); expect(result).not.toContain('.json')
}); })
it("preserves the exact filename from the event URL", () => { it('preserves the exact filename from the event URL', () => {
const calendar = makeCalendar("/calendars/user2/cal1.json"); const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = "/calendars/user2/cal1/some-uid-with-dashes.ics"; const eventURL = '/calendars/user2/cal1/some-uid-with-dashes.ics'
expect(buildDelegatedEventURL(calendar, eventURL)).toBe( 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", () => { it('throws when event URL has no filename', () => {
const calendar = makeCalendar("/calendars/user2/cal1.json"); const calendar = makeCalendar('/calendars/user2/cal1.json')
const eventURL = ""; const eventURL = ''
expect(() => buildDelegatedEventURL(calendar, eventURL)).toThrow( expect(() => buildDelegatedEventURL(calendar, eventURL)).toThrow(
/Cannot extract filename from event URL/ /Cannot extract filename from event URL/
); )
}); })
it("works with nested calendar link paths", () => { it('works with nested calendar link paths', () => {
const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json"); const calendar = makeCalendar('/dav/calendars/users/user2/cal1.json')
const eventURL = "/other/path/event-xyz.ics"; const eventURL = '/other/path/event-xyz.ics'
expect(buildDelegatedEventURL(calendar, eventURL)).toBe( 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 { import {
eventToFullCalendarFormat, eventToFullCalendarFormat,
getCalendarVisibility, getCalendarVisibility
} from "@/components/Calendar/utils/calendarUtils"; } from '@/components/Calendar/utils/calendarUtils'
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes"; import { Calendar, DelegationAccess } from '@/features/Calendars/CalendarTypes'
import { AclEntry } from "@/features/Calendars/types/CalendarData"; import { AclEntry } from '@/features/Calendars/types/CalendarData'
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from '@/features/Events/EventsTypes'
import { userOrganiser } from "@/features/User/userDataTypes"; import { userOrganiser } from '@/features/User/userDataTypes'
describe("getCalendarVisibility", () => { describe('getCalendarVisibility', () => {
it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => { it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => {
const acl: AclEntry[] = [ const acl: AclEntry[] = [
{ {
privilege: "{DAV:}read", privilege: '{DAV:}read',
principal: "{DAV:}authenticated", principal: '{DAV:}authenticated',
protected: true, protected: true
}, }
]; ]
expect(getCalendarVisibility(acl)).toBe("public"); expect(getCalendarVisibility(acl)).toBe('public')
}); })
it("returns 'private' when {DAV:}authenticated only has read-free-busy", () => { it("returns 'private' when {DAV:}authenticated only has read-free-busy", () => {
const acl: AclEntry[] = [ const acl: AclEntry[] = [
{ {
privilege: "{urn:ietf:params:xml:ns:caldav}read-free-busy", privilege: '{urn:ietf:params:xml:ns:caldav}read-free-busy',
principal: "{DAV:}authenticated", principal: '{DAV:}authenticated',
protected: true, protected: true
}, }
]; ]
expect(getCalendarVisibility(acl)).toBe("private"); expect(getCalendarVisibility(acl)).toBe('private')
}); })
it("returns 'private' when {DAV:}authenticated is not present", () => { it("returns 'private' when {DAV:}authenticated is not present", () => {
const acl: AclEntry[] = [ const acl: AclEntry[] = [
{ {
privilege: "{DAV:}read", privilege: '{DAV:}read',
principal: "principals/users/123", principal: 'principals/users/123',
protected: true, protected: true
}, }
]; ]
expect(getCalendarVisibility(acl)).toBe("private"); expect(getCalendarVisibility(acl)).toBe('private')
}); })
it("ignores non-authenticated principals and still returns 'private'", () => { it("ignores non-authenticated principals and still returns 'private'", () => {
const acl: AclEntry[] = [ const acl: AclEntry[] = [
{ {
privilege: "{DAV:}read", privilege: '{DAV:}read',
principal: "principals/users/other", principal: 'principals/users/other',
protected: true, protected: true
}, },
{ {
privilege: "{DAV:}write", privilege: '{DAV:}write',
principal: "principals/users/other", principal: 'principals/users/other',
protected: true, protected: true
}, }
]; ]
expect(getCalendarVisibility(acl)).toBe("private"); 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[] = [ const acl: AclEntry[] = [
{ {
privilege: "{DAV:}read", privilege: '{DAV:}read',
principal: "{DAV:}authenticated", principal: '{DAV:}authenticated',
protected: true, protected: true
}, },
{ {
privilege: "{DAV:}write", privilege: '{DAV:}write',
principal: "{DAV:}authenticated", principal: '{DAV:}authenticated',
protected: true, protected: true
}, }
]; ]
expect(getCalendarVisibility(acl)).toBe("public"); expect(getCalendarVisibility(acl)).toBe('public')
}); })
}); })
jest.mock("twake-i18n", () => ({ jest.mock('twake-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }), useI18n: () => ({ t: (key: string) => key })
})); }))
const baseEvent: CalendarEvent = { const baseEvent: CalendarEvent = {
uid: "event-1", uid: 'event-1',
calId: "user1/cal1", calId: 'user1/cal1',
title: "Test Event", title: 'Test Event',
start: "2024-01-15T10:00:00", start: '2024-01-15T10:00:00',
end: "2024-01-15T11:00:00", end: '2024-01-15T11:00:00',
allday: false, allday: false,
organizer: { cal_address: "owner@example.com" } as userOrganiser, organizer: { cal_address: 'owner@example.com' } as userOrganiser,
color: { light: "#FF0000" }, color: { light: '#FF0000' },
class: "PUBLIC", class: 'PUBLIC'
} as unknown as CalendarEvent; } as unknown as CalendarEvent
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar => const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
({ ({
id: "user1/cal1", id: 'user1/cal1',
name: "Test Calendar", name: 'Test Calendar',
delegated: false, delegated: false,
access: {} as DelegationAccess, access: {} as DelegationAccess,
owner: { emails: ["alice@example.com"], firstname: "Alice" }, owner: { emails: ['alice@example.com'], firstname: 'Alice' },
color: { light: "#FF0000" }, color: { light: '#FF0000' },
events: {}, events: {},
...overrides, ...overrides
}) as Calendar; }) as Calendar
const callFormat = ( const callFormat = (
event: CalendarEvent, event: CalendarEvent,
calendars: Record<string, Calendar>, calendars: Record<string, Calendar>,
userAddress = "alice@example.com", userAddress = 'alice@example.com',
userId = "user1" userId = 'user1'
) => ) =>
eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars); eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars)
describe("eventToFullCalendarFormat - editable flag", () => { describe('eventToFullCalendarFormat - editable flag', () => {
describe("personal calendar (non-delegated)", () => { describe('personal calendar (non-delegated)', () => {
const calendar = makeCalendar({ delegated: false }); const calendar = makeCalendar({ delegated: false })
it("is editable when user is organizer", () => { it('is editable when user is organizer', () => {
const event = { const event = {
...baseEvent, ...baseEvent,
organizer: { cal_address: "alice@example.com" }, organizer: { cal_address: 'alice@example.com' }
} as CalendarEvent; } as CalendarEvent
const [result] = callFormat(event, { "user1/cal1": calendar }); const [result] = callFormat(event, { 'user1/cal1': calendar })
expect(result.editable).toBe(true); 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 = { const event = {
...baseEvent, ...baseEvent,
organizer: { cal_address: "other@example.com" }, organizer: { cal_address: 'other@example.com' }
} as CalendarEvent; } as CalendarEvent
const [result] = callFormat(event, { "user1/cal1": calendar }); const [result] = callFormat(event, { 'user1/cal1': calendar })
expect(result.editable).toBe(false); expect(result.editable).toBe(false)
}); })
it("is not editable when pending", () => { it('is not editable when pending', () => {
const event = { const event = {
...baseEvent, ...baseEvent,
organizer: { cal_address: "alice@example.com" }, organizer: { cal_address: 'alice@example.com' }
} as CalendarEvent; } as CalendarEvent
const [result] = eventToFullCalendarFormat( const [result] = eventToFullCalendarFormat(
[event], [event],
[], [],
"user1", 'user1',
"alice@example.com", 'alice@example.com',
true, true,
{ "user1/cal1": calendar } { 'user1/cal1': calendar }
); )
expect(result.editable).toBe(false); expect(result.editable).toBe(false)
}); })
}); })
describe("delegated calendar", () => { describe('delegated calendar', () => {
const writeDelegatedCalendar = makeCalendar({ const writeDelegatedCalendar = makeCalendar({
id: "user2/cal1", id: 'user2/cal1',
delegated: true, delegated: true,
access: { write: true } as DelegationAccess, access: { write: true } as DelegationAccess,
owner: { emails: ["owner@example.com"], firstname: "Owner" }, owner: { emails: ['owner@example.com'], firstname: 'Owner' }
}); })
const readDelegatedCalendar = makeCalendar({ const readDelegatedCalendar = makeCalendar({
id: "user2/cal1", id: 'user2/cal1',
delegated: true, delegated: true,
access: { read: true } as DelegationAccess, access: { read: true } as DelegationAccess,
owner: { emails: ["owner@example.com"], firstname: "Owner" }, owner: { emails: ['owner@example.com'], firstname: 'Owner' }
}); })
const event = { ...baseEvent, calId: "user2/cal1" }; 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( const [result] = callFormat(
{ {
...event, ...event,
organizer: { cal_address: "owner@example.com" }, organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent, } as CalendarEvent,
{ "user2/cal1": writeDelegatedCalendar } { 'user2/cal1': writeDelegatedCalendar }
); )
expect(result.editable).toBe(true); 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( const [result] = callFormat(
{ {
...event, ...event,
organizer: { cal_address: "owner@example.com" }, organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent, } as CalendarEvent,
{ "user2/cal1": readDelegatedCalendar } { 'user2/cal1': readDelegatedCalendar }
); )
expect(result.editable).toBe(false); 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( const [result] = callFormat(
{ {
...event, ...event,
organizer: { cal_address: "someone-else@example.com" }, organizer: { cal_address: 'someone-else@example.com' }
} as CalendarEvent, } as CalendarEvent,
{ "user2/cal1": writeDelegatedCalendar } { 'user2/cal1': writeDelegatedCalendar }
); )
expect(result.editable).toBe(false); 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( const [result] = eventToFullCalendarFormat(
[ [
{ {
...event, ...event,
organizer: { cal_address: "owner@example.com" }, organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent, } as CalendarEvent
], ],
[], [],
"user1", 'user1',
"alice@example.com", 'alice@example.com',
true, true,
{ "user2/cal1": writeDelegatedCalendar } { 'user2/cal1': writeDelegatedCalendar }
); )
expect(result.editable).toBe(false); 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 // logged-in user is alice, owner is owner@example.com
// organizer matches owner → editable // organizer matches owner → editable
const [result] = callFormat( const [result] = callFormat(
{ {
...event, ...event,
organizer: { cal_address: "owner@example.com" }, organizer: { cal_address: 'owner@example.com' }
} as CalendarEvent, } as CalendarEvent,
{ "user2/cal1": writeDelegatedCalendar }, { 'user2/cal1': writeDelegatedCalendar },
"alice@example.com" // logged-in user, should NOT be used for delegated 'alice@example.com' // logged-in user, should NOT be used for delegated
); )
expect(result.editable).toBe(true); expect(result.editable).toBe(true)
}); })
}); })
}); })
+88 -88
View File
@@ -1,120 +1,120 @@
import { createEventContext } from "@/features/Events/createEventContext"; import { createEventContext } from '@/features/Events/createEventContext'
import { Calendar } from "@/features/Calendars/CalendarTypes"; import { Calendar } from '@/features/Calendars/CalendarTypes'
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from '@/features/Events/EventsTypes'
import { userData } from "@/features/User/userDataTypes"; import { userData } from '@/features/User/userDataTypes'
import { userAttendee } from "@/features/User/models/attendee"; import { userAttendee } from '@/features/User/models/attendee'
const makeUser = (email: string): userData => ({ const makeUser = (email: string): userData => ({
email, email,
given_name: "Alice", given_name: 'Alice',
family_name: "User", family_name: 'User',
name: "Alice User", name: 'Alice User',
sid: "user1", sid: 'user1',
sub: "user1", sub: 'user1'
}); })
const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent => const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent =>
({ ({
uid: "event-1", uid: 'event-1',
calId: "user1/cal1", calId: 'user1/cal1',
title: "Test Event", title: 'Test Event',
start: "2024-01-15T10:00:00", start: '2024-01-15T10:00:00',
end: "2024-01-15T11:00:00", end: '2024-01-15T11:00:00',
organizer: { cal_address: "alice@example.com" }, organizer: { cal_address: 'alice@example.com' },
attendee: [ attendee: [
{ cal_address: "alice@example.com", partstat: "ACCEPTED" }, { cal_address: 'alice@example.com', partstat: 'ACCEPTED' },
{ cal_address: "owner@example.com", partstat: "ACCEPTED" }, { cal_address: 'owner@example.com', partstat: 'ACCEPTED' },
{ cal_address: "other@example.com", partstat: "NEEDS-ACTION" }, { cal_address: 'other@example.com', partstat: 'NEEDS-ACTION' }
], ],
...overrides, ...overrides
}) as CalendarEvent; }) as CalendarEvent
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar => const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
({ ({
id: "user1/cal1", id: 'user1/cal1',
name: "Test Calendar", name: 'Test Calendar',
delegated: false, delegated: false,
owner: { emails: ["alice@example.com"], firstname: "Alice" }, owner: { emails: ['alice@example.com'], firstname: 'Alice' },
events: {}, events: {},
...overrides, ...overrides
}) as Calendar; }) as Calendar
describe("createEventContext", () => { describe('createEventContext', () => {
describe("non-delegated calendar", () => { describe('non-delegated calendar', () => {
const calendar = makeCalendar({ delegated: false }); const calendar = makeCalendar({ delegated: false })
const user = makeUser("alice@example.com"); const user = makeUser('alice@example.com')
it("finds currentUserAttendee by logged-in user email", () => { it('finds currentUserAttendee by logged-in user email', () => {
const event = makeEvent(); const event = makeEvent()
const ctx = createEventContext(event, calendar, user); const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee?.cal_address).toBe("alice@example.com"); expect(ctx.currentUserAttendee?.cal_address).toBe('alice@example.com')
}); })
it("returns null currentUserAttendee when user is not an attendee", () => { it('returns null currentUserAttendee when user is not an attendee', () => {
const event = makeEvent({ attendee: [] }); const event = makeEvent({ attendee: [] })
const ctx = createEventContext(event, calendar, user); const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee).toBeUndefined(); expect(ctx.currentUserAttendee).toBeUndefined()
}); })
it("isOwn is true when user email is in owner emails", () => { it('isOwn is true when user email is in owner emails', () => {
const ctx = createEventContext(makeEvent(), calendar, user); const ctx = createEventContext(makeEvent(), calendar, user)
expect(ctx.isOwn).toBe(true); expect(ctx.isOwn).toBe(true)
}); })
it("isOwn is false when user email is not in owner emails", () => { it('isOwn is false when user email is not in owner emails', () => {
const other = makeUser("other@example.com"); const other = makeUser('other@example.com')
const ctx = createEventContext(makeEvent(), calendar, other); const ctx = createEventContext(makeEvent(), calendar, other)
expect(ctx.isOwn).toBe(false); expect(ctx.isOwn).toBe(false)
}); })
}); })
describe("delegated calendar", () => { describe('delegated calendar', () => {
const calendar = makeCalendar({ const calendar = makeCalendar({
id: "user2/cal1", id: 'user2/cal1',
delegated: true, delegated: true,
owner: { emails: ["owner@example.com"], firstname: "Owner" }, owner: { emails: ['owner@example.com'], firstname: 'Owner' }
}); })
const user = makeUser("alice@example.com"); // logged-in user, not the owner const user = makeUser('alice@example.com') // logged-in user, not the owner
it("finds currentUserAttendee by owner email, not logged-in user email", () => { it('finds currentUserAttendee by owner email, not logged-in user email', () => {
const event = makeEvent({ calId: "user2/cal1" }); const event = makeEvent({ calId: 'user2/cal1' })
const ctx = createEventContext(event, calendar, user); const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com"); 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({ const event = makeEvent({
calId: "user2/cal1", calId: 'user2/cal1',
attendee: [ attendee: [
{ {
cal_address: "owner@example.com", cal_address: 'owner@example.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED'
} as userAttendee, } as userAttendee
], ]
}); })
const ctx = createEventContext(event, calendar, user); const ctx = createEventContext(event, calendar, user)
// currentUserAttendee should be owner's, not alice's // 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({ const event = makeEvent({
calId: "user2/cal1", calId: 'user2/cal1',
attendee: [ attendee: [
{ {
cal_address: "alice@example.com", cal_address: 'alice@example.com',
partstat: "ACCEPTED", partstat: 'ACCEPTED'
} as userAttendee, } as userAttendee
], ]
}); })
const ctx = createEventContext(event, calendar, user); const ctx = createEventContext(event, calendar, user)
expect(ctx.currentUserAttendee).toBeUndefined(); expect(ctx.currentUserAttendee).toBeUndefined()
}); })
it("passes event through unchanged", () => { it('passes event through unchanged', () => {
const event = makeEvent({ calId: "user2/cal1" }); const event = makeEvent({ calId: 'user2/cal1' })
const ctx = createEventContext(event, calendar, user); const ctx = createEventContext(event, calendar, user)
expect(ctx.event).toBe(event); 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", () => { describe('getCalendarRange', () => {
it("Nov 2025 (5 weeks): 2025-10-27 to 2025-11-30", () => { it('Nov 2025 (5 weeks): 2025-10-27 to 2025-11-30', () => {
const { start, end } = getCalendarRange( const { start, end } = getCalendarRange(
new Date("2025-11-01T00:00:00.000Z") new Date('2025-11-01T00:00:00.000Z')
); )
expect(start.toISOString().slice(0, 10)).toBe("2025-10-27"); expect(start.toISOString().slice(0, 10)).toBe('2025-10-27')
expect(end.toISOString().slice(0, 10)).toBe("2025-11-30"); 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( const { start, end } = getCalendarRange(
new Date("2025-12-01T00:00:00.000Z") new Date('2025-12-01T00:00:00.000Z')
); )
expect(start.toISOString().slice(0, 10)).toBe("2025-12-01"); expect(start.toISOString().slice(0, 10)).toBe('2025-12-01')
expect(end.toISOString().slice(0, 10)).toBe("2026-01-04"); 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( const { start, end } = getCalendarRange(
new Date("2025-02-01T00:00:00.000Z") new Date('2025-02-01T00:00:00.000Z')
); )
expect(start.toISOString().slice(0, 10)).toBe("2025-01-27"); expect(start.toISOString().slice(0, 10)).toBe('2025-01-27')
expect(end.toISOString().slice(0, 10)).toBe("2025-03-02"); 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( const { start, end } = getCalendarRange(
new Date("2025-11-01T00:00:00.000Z") new Date('2025-11-01T00:00:00.000Z')
); )
expect(start.getHours()).toBe(0); expect(start.getHours()).toBe(0)
expect(start.getMinutes()).toBe(0); expect(start.getMinutes()).toBe(0)
expect(start.getSeconds()).toBe(0); expect(start.getSeconds()).toBe(0)
expect(end.getHours()).toBe(23); expect(end.getHours()).toBe(23)
expect(end.getMinutes()).toBe(59); expect(end.getMinutes()).toBe(59)
}); })
}); })
+62 -62
View File
@@ -1,99 +1,99 @@
import { RootState } from "@/app/store"; import { RootState } from '@/app/store'
import { Calendar } from "@/features/Calendars/CalendarTypes"; import { Calendar } from '@/features/Calendars/CalendarTypes'
import { findCalendarById } from "@/utils"; import { findCalendarById } from '@/utils'
describe("findCalendarById", () => { describe('findCalendarById', () => {
const mockCalendar1: Calendar = { const mockCalendar1: Calendar = {
id: "cal1", id: 'cal1',
name: "Personal", name: 'Personal',
color: { light: "#FF0000" }, color: { light: '#FF0000' }
} as unknown as Calendar; } as unknown as Calendar
const mockCalendar2: Calendar = { const mockCalendar2: Calendar = {
id: "cal2", id: 'cal2',
name: "Work", name: 'Work',
color: { light: "#0000FF" }, color: { light: '#0000FF' }
} as unknown as Calendar; } as unknown as Calendar
const mockTempCalendar: Calendar = { const mockTempCalendar: Calendar = {
id: "temp1", id: 'temp1',
name: "Temporary", name: 'Temporary',
color: { light: "#00FF00" }, color: { light: '#00FF00' }
} as unknown as Calendar; } as unknown as Calendar
const mockState = { const mockState = {
calendars: { calendars: {
list: { list: {
cal1: mockCalendar1, cal1: mockCalendar1,
cal2: mockCalendar2, cal2: mockCalendar2
}, },
templist: { templist: {
temp1: mockTempCalendar, temp1: mockTempCalendar
}, }
}, }
} as unknown as Partial<RootState>; } as unknown as Partial<RootState>
it("should find calendar in main list", () => { it('should find calendar in main list', () => {
const result = findCalendarById(mockState, "cal1"); const result = findCalendarById(mockState, 'cal1')
expect(result?.calendar).toEqual(mockCalendar1); expect(result?.calendar).toEqual(mockCalendar1)
expect(result?.type).toBeUndefined(); expect(result?.type).toBeUndefined()
}); })
it("should find calendar in temp list", () => { it('should find calendar in temp list', () => {
const result = findCalendarById(mockState, "temp1"); const result = findCalendarById(mockState, 'temp1')
expect(result?.calendar).toEqual(mockTempCalendar); expect(result?.calendar).toEqual(mockTempCalendar)
expect(result?.type).toBe("temp"); expect(result?.type).toBe('temp')
}); })
it("should not return calendar for non-existent id", () => { it('should not return calendar for non-existent id', () => {
const result = findCalendarById(mockState, "nonexistent"); const result = findCalendarById(mockState, 'nonexistent')
expect(result).toBeUndefined(); expect(result).toBeUndefined()
}); })
it("should not return calendar for empty string", () => { it('should not return calendar for empty string', () => {
const result = findCalendarById(mockState, ""); 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 = { const stateWithDuplicate = {
calendars: { calendars: {
list: { list: {
dup1: mockCalendar1, dup1: mockCalendar1
}, },
templist: { templist: {
dup1: mockTempCalendar, dup1: mockTempCalendar
}, }
}, }
} as unknown as Partial<RootState>; } as unknown as Partial<RootState>
const result = findCalendarById(stateWithDuplicate, "dup1"); const result = findCalendarById(stateWithDuplicate, 'dup1')
expect(result?.calendar).toEqual(mockTempCalendar); expect(result?.calendar).toEqual(mockTempCalendar)
expect(result?.type).toBe("temp"); expect(result?.type).toBe('temp')
}); })
it("should handle undefined list or templist", () => { it('should handle undefined list or templist', () => {
const stateWithPartialCalendars = { const stateWithPartialCalendars = {
calendars: { calendars: {
list: undefined, list: undefined,
templist: { temp1: mockTempCalendar }, templist: { temp1: mockTempCalendar }
}, }
} as unknown as Partial<RootState>; } as unknown as Partial<RootState>
const result = findCalendarById(stateWithPartialCalendars, "temp1"); const result = findCalendarById(stateWithPartialCalendars, 'temp1')
expect(result?.calendar).toEqual(mockTempCalendar); expect(result?.calendar).toEqual(mockTempCalendar)
}); })
it("should handle missing calendars state", () => { it('should handle missing calendars state', () => {
const emptyState = {}; 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 { userData } from '@/features/User/userDataTypes'
import { getUserDisplayName } from "@/utils/userUtils"; import { getUserDisplayName } from '@/utils/userUtils'
describe("userUtils", () => { describe('userUtils', () => {
describe("getUserDisplayName", () => { describe('getUserDisplayName', () => {
it("returns full name when user has name and family_name", () => { it('returns full name when user has name and family_name', () => {
const user: userData = { const user: userData = {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
given_name: "John", given_name: 'John',
name: "John", name: 'John',
sid: "mockSid", 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 = { const user: userData = {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "", family_name: '',
given_name: "", given_name: '',
name: "", name: '',
sid: "mockSid", 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 = { const user: userData = {
sub: "test", sub: 'test',
email: "user@example.com", email: 'user@example.com',
family_name: "", family_name: '',
given_name: "", given_name: '',
name: "", name: '',
sid: "mockSid", sid: 'mockSid'
}; }
expect(getUserDisplayName(user)).toBe("user@example.com"); expect(getUserDisplayName(user)).toBe('user@example.com')
}); })
it("returns empty string when user is null", () => { it('returns empty string when user is null', () => {
expect(getUserDisplayName(null)).toBe(""); expect(getUserDisplayName(null)).toBe('')
}); })
it("returns empty string when user is undefined", () => { it('returns empty string when user is undefined', () => {
expect(getUserDisplayName(undefined)).toBe(""); 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 = { const user: userData = {
sub: "test", sub: 'test',
email: "", email: '',
family_name: "", family_name: '',
given_name: "", given_name: '',
name: "", name: '',
sid: "mockSid", 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 = { const user: userData = {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "", family_name: '',
given_name: "John", given_name: 'John',
name: "John", name: 'John',
sid: "mockSid", 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 = { const user: userData = {
sub: "test", sub: 'test',
email: "test@test.com", email: 'test@test.com',
family_name: "Doe", family_name: 'Doe',
given_name: "", given_name: '',
name: "", name: '',
sid: "mockSid", 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 = { module.exports = {
presets: [ presets: [
["@babel/preset-env", { targets: { node: "current" } }], ['@babel/preset-env', { targets: { node: 'current' } }],
["@babel/preset-react", { runtime: "automatic", importSource: "preact" }], ['@babel/preset-react', { runtime: 'automatic', importSource: 'preact' }]
], ]
}; }
+65 -40
View File
@@ -1,74 +1,99 @@
import js from "@eslint/js"; import js from '@eslint/js'
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint'
import react from "eslint-plugin-react"; import tanstackQuery from '@tanstack/eslint-plugin-query'
import reactHooks from "eslint-plugin-react-hooks"; import cozyReact from 'eslint-config-cozy-app/react'
import importPlugin from "eslint-plugin-import";
import jest from "eslint-plugin-jest"; // Flatten cozyReact config (may be an object or array)
import prettier from "eslint-config-prettier"; const cozyReactConfigs = Array.isArray(cozyReact) ? cozyReact : [cozyReact]
export default [ export default [
{ {
ignores: ["dist", "build", "node_modules"], ignores: [
'dist/',
'build/',
'node_modules/',
'coverage/',
'public/',
'*.config.js',
'*.config.ts',
'fileTransformer.ts'
]
}, },
js.configs.recommended, js.configs.recommended,
...tseslint.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: { languageOptions: {
parser: tseslint.parser, parser: tseslint.parser,
parserOptions: { parserOptions: {
ecmaVersion: "latest", ecmaVersion: 'latest',
sourceType: "module", sourceType: 'module',
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
project: true, project: true
}, }
},
plugins: {
react,
"react-hooks": reactHooks,
import: importPlugin,
jest,
}, },
settings: { settings: {
react: { react: {
version: "detect", version: 'detect'
}, }
}, },
rules: { rules: {
/* React */ /* React */
"react/react-in-jsx-scope": "off", // React 18+ 'react/react-in-jsx-scope': 'off', // React 18+
"react/prop-types": "off", 'react/prop-types': 'off',
/* Hooks */ /* Hooks */
"react-hooks/rules-of-hooks": "error", 'react-hooks/rules-of-hooks': 'error',
"react-hooks/exhaustive-deps": "warn", 'react-hooks/exhaustive-deps': 'warn',
/* Imports */ /* Imports */
"import/order": "off", 'import/order': 'off',
/* TS */ /* TS */
"@typescript-eslint/no-unused-vars": [ '@typescript-eslint/no-unused-vars': [
"error", 'error',
{ argsIgnorePattern: "^_" }, { argsIgnorePattern: '^_' }
], ],
/* Jest */ // TO DO: Turn these back on warning
"jest/no-disabled-tests": "warn", '@typescript-eslint/no-explicit-any': 'warn',
"jest/no-focused-tests": "error", '@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 */ /* Jest */
...prettier.rules, '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 noises */
"no-debugger": "error", 'no-debugger': 'error',
"no-console": ["warn", { allow: ["info", "warn", "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 = { module.exports = {
process(sourceText, sourcePath, options) { process(sourceText, sourcePath, options) {
return { 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 // Set timezone to UTC for consistent test results across all environments
process.env.TZ = "UTC"; process.env.TZ = 'UTC'
const config: Config = { const config: Config = {
collectCoverage: true, collectCoverage: true,
coverageDirectory: "coverage", coverageDirectory: 'coverage',
projects: [ projects: [
{ {
displayName: "dom", displayName: 'dom',
clearMocks: true, clearMocks: true,
moduleFileExtensions: [ moduleFileExtensions: [
"js", 'js',
"mjs", 'mjs',
"cjs", 'cjs',
"jsx", 'jsx',
"ts", 'ts',
"tsx", 'tsx',
"json", 'json',
"node", 'node'
], ],
testEnvironment: "jsdom", testEnvironment: 'jsdom',
testMatch: ["**/*.test.tsx"], testMatch: ['**/*.test.tsx'],
testTimeout: 15000, testTimeout: 15000,
transform: { transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }], '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
"^.+\\.(js|jsx|mjs)$": "babel-jest", '^.+\\.(js|jsx|mjs)$': 'babel-jest',
"^.+\\.(css|scss|sass|less|styl|stylus)$": '^.+\\.(css|scss|sass|less|styl|stylus)$':
"jest-preview/transforms/css", 'jest-preview/transforms/css',
"^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": '^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)':
"jest-preview/transforms/file", 'jest-preview/transforms/file',
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
"<rootDir>/fileTransformer.ts", '<rootDir>/fileTransformer.ts'
}, },
transformIgnorePatterns: [ 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: { moduleNameMapper: {
"^preact(/(.*)|$)": "preact$1", '^preact(/(.*)|$)': 'preact$1',
"^react$": "<rootDir>/node_modules/react", '^react$': '<rootDir>/node_modules/react',
"^react-dom$": "<rootDir>/node_modules/react-dom", '^react-dom$': '<rootDir>/node_modules/react-dom',
"^@/(.*)$": "<rootDir>/src/$1", '^@/(.*)$': '<rootDir>/src/$1'
}, },
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"], setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts']
}, },
{ {
displayName: "node", displayName: 'node',
clearMocks: true, clearMocks: true,
moduleFileExtensions: [ moduleFileExtensions: [
"js", 'js',
"mjs", 'mjs',
"cjs", 'cjs',
"jsx", 'jsx',
"ts", 'ts',
"tsx", 'tsx',
"json", 'json',
"node", 'node'
], ],
testEnvironment: "node", testEnvironment: 'node',
testMatch: ["**/*.test.ts"], testMatch: ['**/*.test.ts'],
testTimeout: 15000, testTimeout: 15000,
transform: { transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }], '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
"^.+\\.(js|jsx|mjs)$": "babel-jest", '^.+\\.(js|jsx|mjs)$': 'babel-jest'
}, },
transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"], transformIgnorePatterns: ['/node_modules/(?!(ky|@linagora/twake-mui)/)'],
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"], setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: { 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", "@mui/x-date-pickers": "^8.14.0",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"cozy-external-bridge": "^1.2.1", "cozy-external-bridge": "^1.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"ical.js": "^2.2.0", "ical.js": "^2.2.0",
@@ -50,6 +51,7 @@
"@rsbuild/plugin-react": "^1.4.2", "@rsbuild/plugin-react": "^1.4.2",
"@rsbuild/plugin-stylus": "^1.2.0", "@rsbuild/plugin-stylus": "^1.2.0",
"@rsbuild/plugin-svgr": "^1.2.3", "@rsbuild/plugin-svgr": "^1.2.3",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -63,13 +65,9 @@
"@typescript-eslint/parser": "^8.48.1", "@typescript-eslint/parser": "^8.48.1",
"babel-jest": "^30.2.0", "babel-jest": "^30.2.0",
"babel-plugin-istanbul": "^7.0.1", "babel-plugin-istanbul": "^7.0.1",
"eslint": "^9.39.1", "eslint": "10.0.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-cozy-app": "7.0.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.13.0",
"eslint-plugin-promise": "^7.2.1", "eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"history": "^5.3.0", "history": "^5.3.0",
"i18next-resources-for-ts": "1.4.0", "i18next-resources-for-ts": "1.4.0",
"jest": "^30.2.0", "jest": "^30.2.0",
@@ -2332,198 +2330,111 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0" "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
} }
}, },
"node_modules/@eslint/config-array": { "node_modules/@eslint/compat": {
"version": "0.21.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "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", "debug": "^4.3.1",
"minimatch": "^3.1.2" "minimatch": "^10.2.4"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"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_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.4.2", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.17.0" "@eslint/core": "^1.1.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.17.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"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_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.3", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://eslint.org/donate" "url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
} }
}, },
"node_modules/@eslint/object-schema": { "node_modules/@eslint/object-schema": {
"version": "2.1.7", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.4.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^1.1.1",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@fullcalendar/core": { "node_modules/@fullcalendar/core": {
@@ -4333,6 +4244,29 @@
"tslib": "^2.8.0" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4516,6 +4450,13 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -6751,19 +6692,13 @@
} }
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "2.30.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": { "funding": {
"type": "opencollective", "type": "github",
"url": "https://opencollective.com/date-fns" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
@@ -7365,33 +7300,30 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.39.3", "version": "10.0.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.21.1", "@eslint/config-array": "^0.23.2",
"@eslint/config-helpers": "^0.4.2", "@eslint/config-helpers": "^0.5.2",
"@eslint/core": "^0.17.0", "@eslint/core": "^1.1.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/plugin-kit": "^0.6.0",
"@eslint/js": "9.39.3",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"ajv": "^6.12.4", "ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"debug": "^4.3.2", "debug": "^4.3.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.4.0", "eslint-scope": "^9.1.1",
"eslint-visitor-keys": "^4.2.1", "eslint-visitor-keys": "^5.0.1",
"espree": "^10.4.0", "espree": "^11.1.1",
"esquery": "^1.5.0", "esquery": "^1.7.0",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0", "file-entry-cache": "^8.0.0",
@@ -7401,8 +7333,7 @@
"imurmurhash": "^0.1.4", "imurmurhash": "^0.1.4",
"is-glob": "^4.0.0", "is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2", "minimatch": "^10.2.1",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3"
}, },
@@ -7410,7 +7341,7 @@
"eslint": "bin/eslint.js" "eslint": "bin/eslint.js"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://eslint.org/donate" "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": { "node_modules/eslint-config-prettier": {
"version": "10.1.8", "version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "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": { "node_modules/eslint-plugin-promise": {
"version": "7.2.1", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz",
@@ -7756,49 +7748,33 @@
"node": ">=10" "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": { "node_modules/eslint/node_modules/eslint-scope": {
"version": "8.4.0", "version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0", "esrecurse": "^4.3.0",
"estraverse": "^5.2.0" "estraverse": "^5.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/eslint-visitor-keys": { "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
@@ -7847,19 +7823,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/eslint/node_modules/p-locate": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
@@ -7877,31 +7840,31 @@
} }
}, },
"node_modules/espree": { "node_modules/espree": {
"version": "10.4.0", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"acorn": "^8.15.0", "acorn": "^8.16.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^5.0.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/espree/node_modules/eslint-visitor-keys": { "node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
@@ -8046,6 +8009,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -8579,9 +8549,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "14.0.0", "version": "17.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -11193,13 +11163,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -11363,13 +11326,13 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.3", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"brace-expansion": "^5.0.2" "brace-expansion": "^5.0.5"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -12261,6 +12224,19 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "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": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -14104,6 +14080,22 @@
"react-dom": "^16 || ^17 || ^18" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "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", "@mui/x-date-pickers": "^8.14.0",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"cozy-external-bridge": "^1.2.1", "cozy-external-bridge": "^1.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"ical.js": "^2.2.0", "ical.js": "^2.2.0",
@@ -69,6 +70,7 @@
"@rsbuild/plugin-react": "^1.4.2", "@rsbuild/plugin-react": "^1.4.2",
"@rsbuild/plugin-stylus": "^1.2.0", "@rsbuild/plugin-stylus": "^1.2.0",
"@rsbuild/plugin-svgr": "^1.2.3", "@rsbuild/plugin-svgr": "^1.2.3",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -82,13 +84,9 @@
"@typescript-eslint/parser": "^8.48.1", "@typescript-eslint/parser": "^8.48.1",
"babel-jest": "^30.2.0", "babel-jest": "^30.2.0",
"babel-plugin-istanbul": "^7.0.1", "babel-plugin-istanbul": "^7.0.1",
"eslint": "^9.39.1", "eslint": "10.0.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-cozy-app": "7.0.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.13.0",
"eslint-plugin-promise": "^7.2.1", "eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"history": "^5.3.0", "history": "^5.3.0",
"i18next-resources-for-ts": "1.4.0", "i18next-resources-for-ts": "1.4.0",
"jest": "^30.2.0", "jest": "^30.2.0",
@@ -99,5 +97,9 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"typescript-eslint": "^8.54.0" "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_BASE_URL = 'https://example.com'
var SSO_CLIENT_ID = "example"; var SSO_CLIENT_ID = 'example'
var SSO_SCOPE = "openid profile email"; var SSO_SCOPE = 'openid profile email'
var SSO_REDIRECT_URI = "https://example.com/callback"; var SSO_REDIRECT_URI = 'https://example.com/callback'
var SSO_RESPONSE_TYPE = "code"; var SSO_RESPONSE_TYPE = 'code'
var SSO_CODE_CHALLENGE_METHOD = "S256"; var SSO_CODE_CHALLENGE_METHOD = 'S256'
var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1"; var SSO_POST_LOGOUT_REDIRECT = 'http://example.com?logout=1'
var CALENDAR_BASE_URL = "https://calendar.example.com"; var CALENDAR_BASE_URL = 'https://calendar.example.com'
var DAV_BASE_URL = "https://dav.example.com"; var DAV_BASE_URL = 'https://dav.example.com'
var MAIL_SPA_URL = "https://mail.example.com"; var MAIL_SPA_URL = 'https://mail.example.com'
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com"; var VIDEO_CONFERENCE_BASE_URL = 'https://meet.linagora.com'
var SUPPORT_URL = "https://twake.app/support/"; var SUPPORT_URL = 'https://twake.app/support/'
var DEBUG = false; var DEBUG = false
var LANG = "en"; var LANG = 'en'
var WEBSOCKET_URL = "wss://calendar.example.com"; var WEBSOCKET_URL = 'wss://calendar.example.com'
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce var WS_DEBOUNCE_PERIOD_MS = 100 // milliseconds, remove or set to 0 to disable debounce
var WS_PING_PERIOD_MS = 30000; var WS_PING_PERIOD_MS = 30000
var WS_PING_TIMEOUT_PERIOD_MS = 35000; var WS_PING_TIMEOUT_PERIOD_MS = 35000
+11 -11
View File
@@ -1,17 +1,17 @@
var appList = [ var appList = [
{ {
name: "Chat", name: 'Chat',
link: "/twake", link: '/twake',
icon: "/assets/images/svg/app-chat.svg", icon: '/assets/images/svg/app-chat.svg'
}, },
{ {
name: "Drive", name: 'Drive',
link: "/drive", link: '/drive',
icon: "/assets/images/svg/app-drive.svg", icon: '/assets/images/svg/app-drive.svg'
}, },
{ {
name: "Mail", name: 'Mail',
link: "/mail", link: '/mail',
icon: "/assets/images/svg/app-mail.svg", icon: '/assets/images/svg/app-mail.svg'
}, }
]; ]
+6 -6
View File
@@ -1,22 +1,22 @@
@font-face { @font-face {
font-family: "Inter"; font-family: 'Inter';
src: url("InterVariable.ttf") format("truetype"); src: url('InterVariable.ttf') format('truetype');
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Roboto"; font-family: 'Roboto';
src: url("Roboto-VariableFont_wdth,wght.ttf") format("truetype"); src: url('Roboto-VariableFont_wdth,wght.ttf') format('truetype');
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Cal Sans"; font-family: 'Cal Sans';
src: url("CalSans-SemiBold.woff2") format("woff2"); src: url('CalSans-SemiBold.woff2') format('woff2');
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
+15 -15
View File
@@ -1,32 +1,32 @@
import { defineConfig } from "@rsbuild/core"; import { defineConfig } from '@rsbuild/core'
import { pluginReact } from "@rsbuild/plugin-react"; import { pluginReact } from '@rsbuild/plugin-react'
import { pluginStylus } from "@rsbuild/plugin-stylus"; import { pluginStylus } from '@rsbuild/plugin-stylus'
import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginSvgr } from '@rsbuild/plugin-svgr'
export default defineConfig({ export default defineConfig({
plugins: [pluginReact(), pluginStylus(), pluginSvgr()], plugins: [pluginReact(), pluginStylus(), pluginSvgr()],
html: { html: {
template: "./public/index.html", template: './public/index.html'
}, },
server: { server: {
port: 5000, port: 5000,
historyApiFallback: true, historyApiFallback: true
}, },
source: { source: {
entry: { entry: {
index: "./src/index.tsx", index: './src/index.tsx'
}, }
}, },
output: { output: {
distPath: { distPath: {
root: "dist", root: 'dist'
}, },
minify: false, minify: false
}, },
resolve: { resolve: {
alias: { alias: {
react: require.resolve("react"), react: require.resolve('react'),
"react-dom": require.resolve("react-dom"), 'react-dom': require.resolve('react-dom')
}, }
}, }
}); })
+55 -55
View File
@@ -1,84 +1,84 @@
import { TwakeMuiThemeProvider } from "@linagora/twake-mui"; import { TwakeMuiThemeProvider } from '@linagora/twake-mui'
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from 'react'
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from 'react-router-dom'
import { push } from "redux-first-history"; import { push } from 'redux-first-history'
import { HistoryRouter as Router } from "redux-first-history/rr6"; import { HistoryRouter as Router } from 'redux-first-history/rr6'
import "./App.styl"; import './App.styl'
import { useAppDispatch, useAppSelector } from "./app/hooks"; import { useAppDispatch, useAppSelector } from './app/hooks'
import { history } from "./app/store"; import { history } from './app/store'
import CalendarLayout from "./components/Calendar/CalendarLayout"; import CalendarLayout from './components/Calendar/CalendarLayout'
import { Error as ErrorPage } from "./components/Error/Error"; import { Error as ErrorPage } from './components/Error/Error'
import { ErrorSnackbar } from "./components/Error/ErrorSnackbar"; import { ErrorSnackbar } from './components/Error/ErrorSnackbar'
import { Loading } from "./components/Loading/Loading"; import { Loading } from './components/Loading/Loading'
import { AVAILABLE_LANGUAGES } from "./features/Settings/constants"; import { AVAILABLE_LANGUAGES } from './features/Settings/constants'
import HandleLogin from "./features/User/HandleLogin"; import HandleLogin from './features/User/HandleLogin'
import { CallbackResume } from "./features/User/LoginCallback"; import { CallbackResume } from './features/User/LoginCallback'
import { useInitializeApp } from "./features/User/useInitializeApp"; import { useInitializeApp } from './features/User/useInitializeApp'
import { ScreenTooSmall } from "./ScreenTooSmall"; import { ScreenTooSmall } from './ScreenTooSmall'
import { WebSocketGate } from "./websocket/WebSocketGate"; import { WebSocketGate } from './websocket/WebSocketGate'
import { import {
enGB, enGB,
fr as frLocale, fr as frLocale,
ru as ruLocale, ru as ruLocale,
vi as viLocale, vi as viLocale
} from "date-fns/locale"; } from 'date-fns/locale'
import I18n from "twake-i18n"; import I18n from 'twake-i18n'
import en from "./locales/en.json"; import en from './locales/en.json'
import fr from "./locales/fr.json"; import fr from './locales/fr.json'
import ru from "./locales/ru.json"; import ru from './locales/ru.json'
import vi from "./locales/vi.json"; import vi from './locales/vi.json'
const locale = { en, fr, ru, vi }; const locale = { en, fr, ru, vi }
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale }; const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale }
const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map((lang) => lang.code); const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map(lang => lang.code)
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]
const isValidLanguage = ( const isValidLanguage = (
lang: string | null | undefined lang: string | null | undefined
): lang is SupportedLanguage => { ): lang is SupportedLanguage => {
return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage); return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)
}; }
function App() { function App() {
const error = useAppSelector((state) => state.user.error); const error = useAppSelector(state => state.user.error)
const appLoading = useAppSelector((state) => state.loading.isLoading); const appLoading = useAppSelector(state => state.loading.isLoading)
const userLanguage = useAppSelector( const userLanguage = useAppSelector(state => state.user.coreConfig.language)
(state) => state.user.coreConfig.language const settingsLanguage = useAppSelector(state => state.settings.language)
); const savedLang = localStorage.getItem('lang')
const settingsLanguage = useAppSelector((state) => state.settings.language); const defaultLang = window.LANG
const savedLang = localStorage.getItem("lang");
const defaultLang = window.LANG;
const lang = const lang =
[userLanguage, settingsLanguage, savedLang, defaultLang].find( [userLanguage, settingsLanguage, savedLang, defaultLang].find(
(l) => !!l && isValidLanguage(l) l => !!l && isValidLanguage(l)
) || "en"; ) || 'en'
const dispatch = useAppDispatch(); const dispatch = useAppDispatch()
useEffect(() => { useEffect(() => {
if (error) { 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( const [isTooSmall, setIsTooSmall] = useState(
() => window.matchMedia(SMALL_SCREEN_QUERY).matches () => window.matchMedia(SMALL_SCREEN_QUERY).matches
); )
useInitializeApp(); useInitializeApp()
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia(SMALL_SCREEN_QUERY); const mediaQuery = window.matchMedia(SMALL_SCREEN_QUERY)
const onChange = (event: MediaQueryListEvent) => const onChange = (event: MediaQueryListEvent) =>
setIsTooSmall(event.matches); setIsTooSmall(event.matches)
setIsTooSmall(mediaQuery.matches); const onChangeInitial = () => setIsTooSmall(mediaQuery.matches)
mediaQuery.addEventListener("change", onChange);
return () => mediaQuery.removeEventListener("change", onChange); onChangeInitial()
}, []); mediaQuery.addEventListener('change', onChange)
return () => mediaQuery.removeEventListener('change', onChange)
}, [])
return ( return (
<TwakeMuiThemeProvider> <TwakeMuiThemeProvider>
@@ -108,7 +108,7 @@ function App() {
)} )}
</I18n> </I18n>
</TwakeMuiThemeProvider> </TwakeMuiThemeProvider>
); )
} }
export default App; export default App
+7 -7
View File
@@ -1,11 +1,11 @@
declare namespace Intl { declare namespace Intl {
type Key = type Key =
| "calendar" | 'calendar'
| "collation" | 'collation'
| "currency" | 'currency'
| "numberingSystem" | 'numberingSystem'
| "timeZone" | 'timeZone'
| "unit"; | '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 calendarImage from '@/static/images/calendar.svg'
import { Box, Typography } from "@linagora/twake-mui"; import { Box, Typography } from '@linagora/twake-mui'
import { useI18n } from "twake-i18n"; import { useI18n } from 'twake-i18n'
export function ScreenTooSmall() { export function ScreenTooSmall() {
const { t } = useI18n(); const { t } = useI18n()
return ( return (
<Box <Box
sx={{ sx={{
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
textAlign: "center", textAlign: 'center',
gap: 2, gap: 2
}} }}
> >
<img src={calendarImage} style={{ width: 80, height: 80 }} alt="logo" /> <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 <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ maxWidth: "264px" }} sx={{ maxWidth: '264px' }}
> >
{t("mobile.comingSoon.subtitle")} {t('mobile.comingSoon.subtitle')}
</Typography> </Typography>
</Box> </Box>
); )
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from "./store"; import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>(); export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>(); 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 { interface LoadingState {
isLoading: boolean; isLoading: boolean
message?: string; message?: string
} }
const initialState: LoadingState = { const initialState: LoadingState = {
isLoading: false, isLoading: false,
message: undefined, message: undefined
}; }
const loadingSlice = createSlice({ const loadingSlice = createSlice({
name: "loading", name: 'loading',
initialState, initialState,
reducers: { reducers: {
setAppLoading: (state, action: PayloadAction<boolean>) => { setAppLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload; state.isLoading = action.payload
if (!action.payload) { if (!action.payload) {
state.message = undefined; state.message = undefined
} }
}, },
setLoadingMessage: (state, action: PayloadAction<string | undefined>) => { setLoadingMessage: (state, action: PayloadAction<string | undefined>) => {
state.message = action.payload; state.message = action.payload
}, }
}, }
}); })
export const { setAppLoading, setLoadingMessage } = loadingSlice.actions; export const { setAppLoading, setLoadingMessage } = loadingSlice.actions
export default loadingSlice.reducer; export default loadingSlice.reducer
+4 -4
View File
@@ -1,7 +1,7 @@
import { RootState } from "@/app/store"; import { RootState } from '@/app/store'
import { createSelector } from "@reduxjs/toolkit"; import { createSelector } from '@reduxjs/toolkit'
export const selectCalendars = createSelector( export const selectCalendars = createSelector(
(state: RootState) => state.calendars.list, (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 eventsCalendar from '@/features/Calendars/CalendarSlice'
import searchResultReducer from "@/features/Search/SearchSlice"; import searchResultReducer from '@/features/Search/SearchSlice'
import settingsReducer from "@/features/Settings/SettingsSlice"; import settingsReducer from '@/features/Settings/SettingsSlice'
import userReducer from "@/features/User/userSlice"; import userReducer from '@/features/User/userSlice'
import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { combineReducers, configureStore } from '@reduxjs/toolkit'
import loadingReducer from "./loadingSlice"; import loadingReducer from './loadingSlice'
import { createBrowserHistory } from "history"; import { createBrowserHistory } from 'history'
import { createReduxHistoryContext } from "redux-first-history"; import { createReduxHistoryContext } from 'redux-first-history'
const { createReduxHistory, routerMiddleware, routerReducer } = const { createReduxHistory, routerMiddleware, routerReducer } =
createReduxHistoryContext({ history: createBrowserHistory() }); createReduxHistoryContext({ history: createBrowserHistory() })
const rootReducer = combineReducers({ const rootReducer = combineReducers({
router: routerReducer, router: routerReducer,
@@ -16,22 +16,22 @@ const rootReducer = combineReducers({
calendars: eventsCalendar, calendars: eventsCalendar,
settings: settingsReducer, settings: settingsReducer,
searchResult: searchResultReducer, searchResult: searchResultReducer,
loading: loadingReducer, loading: loadingReducer
}); })
export const setupStore = (preloadedState?: Partial<RootState>) => { export const setupStore = (preloadedState?: Partial<RootState>) => {
return configureStore({ return configureStore({
reducer: rootReducer, reducer: rootReducer,
preloadedState, preloadedState,
middleware: (getDefaultMiddleware) => middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(routerMiddleware), 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 RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>; export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore["dispatch"]; export type AppDispatch = AppStore['dispatch']
+74 -63
View File
@@ -1,22 +1,22 @@
import { userAttendee } from "@/features/User/models/attendee"; import { userAttendee } from '@/features/User/models/attendee'
import { createAttendee } from "@/features/User/models/attendee.mapper"; import { createAttendee } from '@/features/User/models/attendee.mapper'
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
import { FreeBusyIndicator } from "./FreeBusyIndicator"; import { FreeBusyIndicator } from './FreeBusyIndicator'
import { import {
ExtendedAutocompleteRenderInputParams, ExtendedAutocompleteRenderInputParams,
PeopleSearch, PeopleSearch,
User, User
} from "./PeopleSearch"; } from './PeopleSearch'
import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy"; import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy'
const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({ const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({
email: a.cal_address, email: a.cal_address,
displayName: a.cn ?? "", displayName: a.cn ?? '',
avatarUrl: "", avatarUrl: '',
openpaasId, openpaasId
}); })
const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId; const hasCalendar = (u: User) => u.objectType === 'user' && !!u.openpaasId
export default function AttendeeSearch({ export default function AttendeeSearch({
attendees, attendees,
@@ -27,98 +27,109 @@ export default function AttendeeSearch({
start, start,
end, end,
timezone, timezone,
eventUid, eventUid
}: { }: {
attendees: userAttendee[]; attendees: userAttendee[]
setAttendees: (attendees: userAttendee[]) => void; setAttendees: (attendees: userAttendee[]) => void
disabled?: boolean; disabled?: boolean
inputSlot?: ( inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
params: ExtendedAutocompleteRenderInputParams placeholder?: string
) => React.ReactNode; start?: string
placeholder?: string; end?: string
start?: string; timezone?: string
end?: string; eventUid?: string | null
timezone?: string;
eventUid?: string | null;
}) { }) {
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({}); const [userIdMap, setUserIdMap] = useState<Record<string, string>>({})
const [addedUsers, setAddedUsers] = useState<User[]>([]); const [addedUsers, setAddedUsers] = useState<User[]>([])
const initialEmailsRef = useRef<Set<string> | null>(null); const initialEmailsRef = useRef<Set<string> | null>(null)
if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) { const [initialEmailsSet, setInitialEmailsSet] = useState<Set<string>>(
initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address)); 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)
} }
const initialEmails = eventUid }
? (initialEmailsRef.current ?? new Set<string>()) updateInitialEmailsSet()
: new Set<string>(); }, [eventUid, attendees])
const initialEmails = eventUid ? initialEmailsSet : new Set<string>()
const selectedUsers: User[] = [ const selectedUsers: User[] = [
...addedUsers, ...addedUsers,
...attendees ...attendees
.map((a) => attendeeToUser(a, userIdMap[a.cal_address])) .map(a => attendeeToUser(a, userIdMap[a.cal_address]))
.filter((a) => !addedUsers.find((u) => a.email === u.email)), .filter(a => !addedUsers.find(u => a.email === u.email))
]; ]
const toAttendee = (u: User) => ({ const toAttendee = (u: User) => ({
email: u.email, email: u.email,
userId: u.openpaasId || userIdMap[u.email] || null, userId: u.openpaasId || userIdMap[u.email] || null
}); })
const existingAttendees = selectedUsers const existingAttendees = selectedUsers
.filter((u) => initialEmails.has(u.email)) .filter(u => initialEmails.has(u.email))
.map(toAttendee); .map(toAttendee)
const newAttendees = selectedUsers const newAttendees = selectedUsers
.filter((u) => !initialEmails.has(u.email) && hasCalendar(u)) .filter(u => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee); .map(toAttendee)
// Contacts and freeSolo users get a static "contact" status — no API call needed // Contacts and freeSolo users get a static "contact" status — no API call needed
const contactMap: FreeBusyMap = Object.fromEntries( const contactMap: FreeBusyMap = Object.fromEntries(
selectedUsers selectedUsers
.filter((u) => !initialEmails.has(u.email) && !hasCalendar(u)) .filter(u => !initialEmails.has(u.email) && !hasCalendar(u))
.map((u) => [u.email, "contact" as const]) .map(u => [u.email, 'contact' as const])
); )
const freeBusyMap = useAttendeesFreeBusy({ const freeBusyMap = useAttendeesFreeBusy({
existingAttendees, existingAttendees,
newAttendees, newAttendees,
start: start ?? "", start: start ?? '',
end: end ?? "", end: end ?? '',
timezone: timezone ?? "", timezone: timezone ?? '',
eventUid, eventUid,
enabled: !!(start && end && selectedUsers.length > 0), enabled: !!(start && end && selectedUsers.length > 0)
}); })
const statusMap = { ...freeBusyMap, ...contactMap }; const statusMap = { ...freeBusyMap, ...contactMap }
return ( return (
<PeopleSearch <PeopleSearch
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
objectTypes={["user", "contact"]} objectTypes={['user', 'contact']}
disabled={disabled} disabled={disabled}
inputSlot={inputSlot} inputSlot={inputSlot}
placeholder={placeholder} placeholder={placeholder}
getChipIcon={ getChipIcon={
start && end start && end
? (user) => ( ? user => (
<FreeBusyIndicator status={statusMap[user.email] ?? "unknown"} /> <FreeBusyIndicator status={statusMap[user.email] ?? 'unknown'} />
) )
: undefined : undefined
} }
onChange={(_event, value: User[]) => { onChange={(_event, value: User[]) => {
setUserIdMap((prev) => { setUserIdMap(prev => {
const next = { ...prev }; const next = { ...prev }
for (const u of value) { 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; return next
}); })
setAddedUsers(value.filter((u) => !initialEmails.has(u.email))); setAddedUsers(value.filter(u => !initialEmails.has(u.email)))
setAttendees( setAttendees(
value.map((u) => value.map(u =>
createAttendee({ cal_address: u.email, cn: u.displayName }) createAttendee({ cal_address: u.email, cn: u.displayName })
) )
); )
}} }}
freeSolo freeSolo
/> />
); )
} }
+24 -21
View File
@@ -1,32 +1,35 @@
import { Tooltip } from "@linagora/twake-mui"; import { Tooltip } from '@linagora/twake-mui'
import AccessTimeFilledIcon from "@mui/icons-material/AccessTimeFilled"; import AccessTimeFilledIcon from '@mui/icons-material/AccessTimeFilled'
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from '@mui/icons-material/Close'
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { useI18n } from "twake-i18n"; import { useI18n } from 'twake-i18n'
import { FreeBusyStatus } from "./useFreeBusy"; import { FreeBusyStatus } from './useFreeBusy'
interface FreeBusyIndicatorProps { interface FreeBusyIndicatorProps {
status: FreeBusyStatus; status: FreeBusyStatus
size?: number; size?: number
} }
export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) { export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
const { t } = useI18n(); const { t } = useI18n()
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (status === "busy") setOpen(true); const triggerOpen = () => {
}, [status]); if (status === 'busy') setOpen(true)
}
triggerOpen()
}, [status])
if (status !== "busy") return null; if (status !== 'busy') return null
return ( return (
<Tooltip <Tooltip
title={ title={
<span style={{ display: "flex", alignItems: "center", gap: 4 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t("event.freeBusy.busy")} {t('event.freeBusy.busy')}
<CloseIcon <CloseIcon
fontSize="inherit" fontSize="inherit"
style={{ cursor: "pointer" }} style={{ cursor: 'pointer' }}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
/> />
</span> </span>
@@ -35,16 +38,16 @@ export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
disableHoverListener disableHoverListener
placement="bottom-start" placement="bottom-start"
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }} slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: 'grey.900' } } }}
> >
<AccessTimeFilledIcon <AccessTimeFilledIcon
aria-label={t("event.freeBusy.busy")} aria-label={t('event.freeBusy.busy')}
color="warning" color="warning"
style={{ style={{
margin: "0 -6px 0 5px", margin: '0 -6px 0 5px',
flexShrink: 0, flexShrink: 0
}} }}
/> />
</Tooltip> </Tooltip>
); )
} }
+128 -132
View File
@@ -1,8 +1,8 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair"; import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { stringAvatar } from "@/components/Event/utils/eventUtils"; import { stringAvatar } from '@/components/Event/utils/eventUtils'
import { useUserSearch } from "./useUserSearch"; import { useUserSearch } from './useUserSearch'
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from '@mui/icons-material/Close'
import { import {
Autocomplete, Autocomplete,
Avatar, Avatar,
@@ -15,35 +15,35 @@ import {
PopperProps, PopperProps,
TextField, TextField,
useTheme, useTheme,
type AutocompleteRenderInputParams, type AutocompleteRenderInputParams
} from "@linagora/twake-mui"; } from '@linagora/twake-mui'
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import { import {
HTMLAttributes, HTMLAttributes,
useCallback, useCallback,
type ReactNode, type ReactNode,
type SyntheticEvent, type SyntheticEvent
} from "react"; } from 'react'
import { useI18n } from "twake-i18n"; import { useI18n } from 'twake-i18n'
import { ResourceIcon } from "./ResourceIcon"; import { ResourceIcon } from './ResourceIcon'
import { isValidEmail } from "../../utils/isValidEmail"; import { isValidEmail } from '../../utils/isValidEmail'
import { usePasteHandler } from "./usePasteHandler"; import { usePasteHandler } from './usePasteHandler'
export interface User { export interface User {
email: string; email: string
displayName: string; displayName: string
avatarUrl?: string; avatarUrl?: string
openpaasId?: string; openpaasId?: string
color?: Record<string, string>; color?: Record<string, string>
objectType?: string; objectType?: string
} }
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams { export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean; error?: boolean
helperText?: string | null; helperText?: string | null
placeholder?: string; placeholder?: string
label?: string; label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
} }
export function PeopleSearch({ export function PeopleSearch({
@@ -57,33 +57,31 @@ export function PeopleSearch({
inputSlot, inputSlot,
customRenderInput, customRenderInput,
customSlotProps, customSlotProps,
getChipIcon, getChipIcon
}: { }: {
selectedUsers: User[]; selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void; onChange: (event: SyntheticEvent, users: User[]) => void
objectTypes: string[]; objectTypes: string[]
disabled?: boolean; disabled?: boolean
freeSolo?: boolean; freeSolo?: boolean
onToggleEventPreview?: () => void; onToggleEventPreview?: () => void
placeholder?: string; placeholder?: string
inputSlot?: ( inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
customRenderInput?: ( customRenderInput?: (
params: AutocompleteRenderInputParams, params: AutocompleteRenderInputParams,
query: string, query: string,
setQuery: (value: string) => void setQuery: (value: string) => void
) => ReactNode; ) => ReactNode
customSlotProps?: { customSlotProps?: {
popper?: Partial<PopperProps>; popper?: Partial<PopperProps>
paper?: Partial<PaperProps>; paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>; listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}; }
getChipIcon?: (user: User) => ReactNode; getChipIcon?: (user: User) => ReactNode
}) { }) {
const { t } = useI18n(); const { t } = useI18n()
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder"); const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder')
const errorMessage = t("peopleSearch.searchError"); const errorMessage = t('peopleSearch.searchError')
const { const {
query, query,
@@ -98,32 +96,32 @@ export function PeopleSearch({
snackbarOpen, snackbarOpen,
setSnackbarOpen, setSnackbarOpen,
snackbarMessage, snackbarMessage,
setSnackbarMessage, setSnackbarMessage
} = useUserSearch<User>({ objectTypes, errorMessage }); } = useUserSearch<User>({ objectTypes, errorMessage })
const theme = useTheme(); const theme = useTheme()
const handleBlurCommit = useCallback( const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => { (event: React.SyntheticEvent) => {
const trimmed = query.trim(); const trimmed = query.trim()
if (!trimmed) return; if (!trimmed) return
if (!isValidEmail(trimmed)) { if (!isValidEmail(trimmed)) {
setInputError( setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", trimmed) t('peopleSearch.invalidEmail').replace('%{email}', trimmed)
); )
return; return
} }
if (selectedUsers.find((u) => u.email === trimmed)) { if (selectedUsers.find(u => u.email === trimmed)) {
setQuery(""); setQuery('')
return; return
} }
setInputError(null); setInputError(null)
const newUser: User = { email: trimmed, displayName: trimmed }; const newUser: User = { email: trimmed, displayName: trimmed }
onChange(event, [...selectedUsers, newUser]); onChange(event, [...selectedUsers, newUser])
setQuery(""); setQuery('')
}, },
[query, selectedUsers, onChange, t, setInputError, setQuery] [query, selectedUsers, onChange, t, setInputError, setQuery]
); )
const handlePaste = usePasteHandler({ const handlePaste = usePasteHandler({
freeSolo, freeSolo,
@@ -131,8 +129,8 @@ export function PeopleSearch({
onChange, onChange,
setQuery, setQuery,
setInputError, setInputError,
t, t
}); })
const defaultRenderInput = useCallback( const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => { (params: AutocompleteRenderInputParams) => {
@@ -143,7 +141,7 @@ export function PeopleSearch({
) : ( ) : (
<PeopleOutlineOutlinedIcon <PeopleOutlineOutlinedIcon
fontSize="small" fontSize="small"
sx={{ mr: 1, color: "action.active" }} sx={{ mr: 1, color: 'action.active' }}
/> />
), ),
endAdornment: ( endAdornment: (
@@ -151,61 +149,61 @@ export function PeopleSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null} {loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment} {params.InputProps.endAdornment}
</> </>
), )
}; }
const enhancedParams = { const enhancedParams = {
...params, ...params,
InputProps: inputProps, InputProps: inputProps,
inputProps: { inputProps: {
...params.inputProps, ...params.inputProps,
autoComplete: "off", autoComplete: 'off',
onPaste: handlePaste, onPaste: handlePaste
}, }
}; }
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) { if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault(); e.preventDefault()
onToggleEventPreview(); onToggleEventPreview()
}
} }
};
const defaultTextFieldProps = { const defaultTextFieldProps = {
error: !!inputError, error: !!inputError,
helperText: inputError, helperText: inputError,
placeholder: searchPlaceholder, placeholder: searchPlaceholder,
label: "", label: '',
onKeyDown: handleEnterKey, onKeyDown: handleEnterKey,
slotProps: { slotProps: {
input: { input: {
...inputProps, ...inputProps
}, }
}, }
}; }
if (inputSlot) { if (inputSlot) {
return ( return (
<> <>
<label htmlFor={params.id} className="visually-hidden"> <label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")} {t('peopleSearch.label')}
</label> </label>
{inputSlot({ {inputSlot({
...enhancedParams, ...enhancedParams,
error: !!inputError, error: !!inputError,
helperText: inputError, helperText: inputError,
placeholder: searchPlaceholder, placeholder: searchPlaceholder,
label: "", label: '',
onKeyDown: handleEnterKey, onKeyDown: handleEnterKey
})} })}
</> </>
); )
} }
return ( return (
<> <>
<label htmlFor={params.id} className="visually-hidden"> <label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")} {t('peopleSearch.label')}
</label> </label>
<TextField <TextField
{...enhancedParams} {...enhancedParams}
@@ -214,7 +212,7 @@ export function PeopleSearch({
size="medium" size="medium"
/> />
</> </>
); )
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
@@ -223,9 +221,9 @@ export function PeopleSearch({
onToggleEventPreview, onToggleEventPreview,
loading, loading,
searchPlaceholder, searchPlaceholder,
handlePaste, handlePaste
] ]
); )
return ( return (
<> <>
@@ -246,68 +244,68 @@ export function PeopleSearch({
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
disabled={disabled} disabled={disabled}
loading={loading} loading={loading}
filterOptions={(x) => x} filterOptions={x => x}
fullWidth fullWidth
noOptionsText={t("peopleSearch.noResults")} noOptionsText={t('peopleSearch.noResults')}
loadingText={t("peopleSearch.loading")} loadingText={t('peopleSearch.loading')}
getOptionLabel={(option) => { getOptionLabel={option => {
if (typeof option === "object") { if (typeof option === 'object') {
return option.displayName || option.email; return option.displayName || option.email
} else { } else {
return option; return option
} }
}} }}
sx={{ sx={{
"& .MuiAutocomplete-inputRoot": { '& .MuiAutocomplete-inputRoot': {
py: 0, py: 0
}, }
}} }}
filterSelectedOptions filterSelectedOptions
value={selectedUsers} value={selectedUsers}
inputValue={query} inputValue={query}
onInputChange={(_event, value) => setQuery(value)} onInputChange={(_event, value) => setQuery(value)}
onChange={(event, value) => { onChange={(event, value) => {
const last = value[value.length - 1]; const last = value[value.length - 1]
if (typeof last === "string" && !isValidEmail(last.trim())) { if (typeof last === 'string' && !isValidEmail(last.trim())) {
const invalidEmailMessage = t("peopleSearch.invalidEmail").replace( const invalidEmailMessage = t('peopleSearch.invalidEmail').replace(
"%{email}", '%{email}',
last last
); )
setInputError(invalidEmailMessage); setInputError(invalidEmailMessage)
return; return
} }
setInputError(null); setInputError(null)
const mapped = value const mapped = value
.map((v: string | User) => .map((v: string | User) =>
typeof v === "string" typeof v === 'string'
? { email: v.trim(), displayName: v.trim() } ? { email: v.trim(), displayName: v.trim() }
: v : v
) )
.filter( .filter(
(user, index, self) => (user, index, self) =>
self.findIndex((u) => u.email === user.email) === index self.findIndex(u => u.email === user.email) === index
); )
onChange(event, mapped); onChange(event, mapped)
}} }}
slotProps={{ slotProps={{
...customSlotProps, ...customSlotProps,
popper: { popper: {
placement: "bottom-start", placement: 'bottom-start',
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx }, sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper, ...customSlotProps?.popper
}, }
}} }}
forcePopupIcon={false} forcePopupIcon={false}
disableClearable disableClearable
renderInput={(params) => renderInput={params =>
customRenderInput customRenderInput
? customRenderInput(params, query, setQuery) ? customRenderInput(params, query, setQuery)
: defaultRenderInput(params) : defaultRenderInput(params)
} }
renderOption={(props, option) => { renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null; if (selectedUsers.find(u => u.email === option.email)) return null
const { key, ...otherProps } = props; const { key, ...otherProps } = props
const isResource = option.objectType === "resource"; const isResource = option.objectType === 'resource'
return ( return (
<ListItem key={key + option?.email} {...otherProps} disableGutters> <ListItem key={key + option?.email} {...otherProps} disableGutters>
<ListItemAvatar> <ListItemAvatar>
@@ -321,25 +319,23 @@ export function PeopleSearch({
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={option.displayName} primary={option.displayName}
secondary={isResource ? "" : option.email} secondary={isResource ? '' : option.email}
slotProps={{ slotProps={{
primary: { variant: "body2" }, primary: { variant: 'body2' },
secondary: { variant: "caption" }, secondary: { variant: 'caption' }
}} }}
/> />
</ListItem> </ListItem>
); )
}} }}
renderValue={(value, getTagProps) => renderValue={(value, getTagProps) =>
value.map((option, index) => { value.map((option, index) => {
const isString = typeof option === "string"; const isString = typeof option === 'string'
const label = isString const label = isString ? option : option.displayName || option.email
? option
: option.displayName || option.email;
const chipColor = isString const chipColor = isString
? theme.palette.grey[200] ? theme.palette.grey[200]
: (option.color?.light ?? theme.palette.grey[200]); : (option.color?.light ?? theme.palette.grey[200])
const textColor = getAccessiblePair(chipColor, theme); const textColor = getAccessiblePair(chipColor, theme)
return ( return (
<Chip <Chip
@@ -351,25 +347,25 @@ export function PeopleSearch({
deleteIcon={<CloseIcon />} deleteIcon={<CloseIcon />}
style={{ style={{
backgroundColor: chipColor, backgroundColor: chipColor,
color: textColor, color: textColor
}} }}
label={label} label={label}
/> />
); )
}) })
} }
/> />
<SnackbarAlert <SnackbarAlert
open={snackbarOpen} open={snackbarOpen}
setOpen={(open: boolean) => { setOpen={(open: boolean) => {
setSnackbarOpen(open); setSnackbarOpen(open)
if (!open) { if (!open) {
setSnackbarMessage(""); setSnackbarMessage('')
} }
}} }}
message={snackbarMessage} message={snackbarMessage}
severity="error" severity="error"
/> />
</> </>
); )
} }
+13 -13
View File
@@ -1,41 +1,41 @@
import { Avatar, Box } from "@linagora/twake-mui"; import { Avatar, Box } from '@linagora/twake-mui'
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'
interface ResourceIconProps { interface ResourceIconProps {
avatarUrl?: string; avatarUrl?: string
colorIcon?: boolean; colorIcon?: boolean
color?: string; color?: string
} }
export function ResourceIcon({ export function ResourceIcon({
avatarUrl, avatarUrl,
colorIcon, colorIcon,
color, color
}: ResourceIconProps) { }: ResourceIconProps) {
if (colorIcon && avatarUrl) { if (colorIcon && avatarUrl) {
return ( return (
<Box <Box
sx={{ sx={{
width: "24px", width: '24px',
height: "24px", height: '24px',
backgroundColor: color, backgroundColor: color,
maskImage: `url(${avatarUrl})`, maskImage: `url(${avatarUrl})`,
maskSize: "cover", maskSize: 'cover'
}} }}
/> />
); )
} }
return avatarUrl ? ( return avatarUrl ? (
<Avatar <Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }} sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
src={avatarUrl} src={avatarUrl}
/> />
) : ( ) : (
<Avatar <Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }} sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
> >
<LayersOutlinedIcon /> <LayersOutlinedIcon />
</Avatar> </Avatar>
); )
} }
+106 -110
View File
@@ -1,7 +1,7 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair"; import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { useUserSearch } from "./useUserSearch"; import { useUserSearch } from './useUserSearch'
import { ResourceIcon } from "./ResourceIcon"; import { ResourceIcon } from './ResourceIcon'
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import { import {
Autocomplete, Autocomplete,
Chip, Chip,
@@ -14,31 +14,31 @@ import {
TextField, TextField,
useTheme, useTheme,
Typography, Typography,
type AutocompleteRenderInputParams, type AutocompleteRenderInputParams
} from "@linagora/twake-mui"; } from '@linagora/twake-mui'
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from '@mui/icons-material/Search'
import { import {
HTMLAttributes, HTMLAttributes,
useCallback, useCallback,
type ReactNode, type ReactNode,
type SyntheticEvent, type SyntheticEvent
} from "react"; } from 'react'
import { useI18n } from "twake-i18n"; import { useI18n } from 'twake-i18n'
export interface Resource { export interface Resource {
email?: string; email?: string
displayName: string; displayName: string
avatarUrl?: string; avatarUrl?: string
openpaasId?: string; openpaasId?: string
color?: Record<string, string>; color?: Record<string, string>
} }
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams { export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean; error?: boolean
helperText?: string | null; helperText?: string | null
placeholder?: string; placeholder?: string
label?: string; label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
} }
export function ResourceSearch({ export function ResourceSearch({
@@ -52,33 +52,31 @@ export function ResourceSearch({
inputSlot, inputSlot,
customRenderInput, customRenderInput,
customSlotProps, customSlotProps,
hideLabel, hideLabel
}: { }: {
selectedResources: Resource[]; selectedResources: Resource[]
onChange: (event: SyntheticEvent, users: Resource[]) => void; onChange: (event: SyntheticEvent, users: Resource[]) => void
objectTypes: string[]; objectTypes: string[]
disabled?: boolean; disabled?: boolean
freeSolo?: boolean; freeSolo?: boolean
onToggleEventPreview?: () => void; onToggleEventPreview?: () => void
placeholder?: string; placeholder?: string
inputSlot?: ( inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
customRenderInput?: ( customRenderInput?: (
params: AutocompleteRenderInputParams, params: AutocompleteRenderInputParams,
query: string, query: string,
setQuery: (value: string) => void setQuery: (value: string) => void
) => ReactNode; ) => ReactNode
customSlotProps?: { customSlotProps?: {
popper?: Partial<PopperProps>; popper?: Partial<PopperProps>
paper?: Partial<PaperProps>; paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>; listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}; }
hideLabel?: boolean; hideLabel?: boolean
}) { }) {
const { t } = useI18n(); const { t } = useI18n()
const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder"); const searchPlaceholder = placeholder ?? t('resourceSearch.placeholder')
const errorMessage = t("resourceSearch.searchError"); const errorMessage = t('resourceSearch.searchError')
const { const {
query, query,
@@ -93,26 +91,26 @@ export function ResourceSearch({
snackbarOpen, snackbarOpen,
setSnackbarOpen, setSnackbarOpen,
snackbarMessage, snackbarMessage,
setSnackbarMessage, setSnackbarMessage
} = useUserSearch<Resource>({ objectTypes, errorMessage }); } = useUserSearch<Resource>({ objectTypes, errorMessage })
const theme = useTheme(); const theme = useTheme()
const handleBlurCommit = useCallback( const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => { (event: React.SyntheticEvent) => {
const trimmed = query.trim(); const trimmed = query.trim()
if (!trimmed) return; if (!trimmed) return
if (selectedResources.find((u) => u.displayName === trimmed)) { if (selectedResources.find(u => u.displayName === trimmed)) {
setQuery(""); setQuery('')
return; return
} }
setInputError(null); setInputError(null)
const newResource: Resource = { displayName: trimmed }; const newResource: Resource = { displayName: trimmed }
onChange(event, [...selectedResources, newResource]); onChange(event, [...selectedResources, newResource])
setQuery(""); setQuery('')
}, },
[query, selectedResources, onChange, setInputError, setQuery] [query, selectedResources, onChange, setInputError, setQuery]
); )
const defaultRenderInput = useCallback( const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => { (params: AutocompleteRenderInputParams) => {
@@ -123,7 +121,7 @@ export function ResourceSearch({
{!selectedResources?.length ? ( {!selectedResources?.length ? (
<SearchIcon <SearchIcon
fontSize="small" fontSize="small"
sx={{ mr: 1, color: "action.active" }} sx={{ mr: 1, color: 'action.active' }}
/> />
) : null} ) : null}
{params.InputProps.startAdornment} {params.InputProps.startAdornment}
@@ -134,44 +132,44 @@ export function ResourceSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null} {loading ? <CircularProgress color="inherit" size={20} /> : null}
{!selectedResources?.length ? params.InputProps.endAdornment : null} {!selectedResources?.length ? params.InputProps.endAdornment : null}
</> </>
), )
}; }
const enhancedParams = { const enhancedParams = {
...params, ...params,
InputProps: inputProps, InputProps: inputProps,
inputProps: { inputProps: {
...params.inputProps, ...params.inputProps,
autoComplete: "off", autoComplete: 'off'
}, }
}; }
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) { if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault(); e.preventDefault()
onToggleEventPreview(); onToggleEventPreview()
}
} }
};
const defaultTextFieldProps = { const defaultTextFieldProps = {
error: !!inputError, error: !!inputError,
helperText: inputError, helperText: inputError,
placeholder: searchPlaceholder, placeholder: searchPlaceholder,
label: "", label: '',
onKeyDown: handleEnterKey, onKeyDown: handleEnterKey,
slotProps: { slotProps: {
input: { input: {
...inputProps, ...inputProps
}, }
}, }
}; }
if (inputSlot) { if (inputSlot) {
return ( return (
<> <>
{!hideLabel && ( {!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}> <Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t("resourceSearch.label")} {t('resourceSearch.label')}
</Typography> </Typography>
)} )}
{inputSlot({ {inputSlot({
@@ -179,18 +177,18 @@ export function ResourceSearch({
error: !!inputError, error: !!inputError,
helperText: inputError, helperText: inputError,
placeholder: searchPlaceholder, placeholder: searchPlaceholder,
label: "", label: '',
onKeyDown: handleEnterKey, onKeyDown: handleEnterKey
})} })}
</> </>
); )
} }
return ( return (
<> <>
{!hideLabel && ( {!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}> <Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t("resourceSearch.label")} {t('resourceSearch.label')}
</Typography> </Typography>
)} )}
<TextField <TextField
@@ -200,7 +198,7 @@ export function ResourceSearch({
size="medium" size="medium"
/> />
</> </>
); )
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
@@ -209,9 +207,9 @@ export function ResourceSearch({
onToggleEventPreview, onToggleEventPreview,
loading, loading,
searchPlaceholder, searchPlaceholder,
selectedResources?.length, selectedResources?.length
] ]
); )
return ( return (
<> <>
@@ -234,55 +232,53 @@ export function ResourceSearch({
loading={loading} loading={loading}
filterOptions={(options: Resource[]) => options} filterOptions={(options: Resource[]) => options}
fullWidth fullWidth
noOptionsText={t("resourceSearch.noResults")} noOptionsText={t('resourceSearch.noResults')}
loadingText={t("resourceSearch.loading")} loadingText={t('resourceSearch.loading')}
getOptionLabel={(option: Resource | string) => { getOptionLabel={(option: Resource | string) => {
if (typeof option === "object") { if (typeof option === 'object') {
return option.displayName; return option.displayName
} else { } else {
return option; return option
} }
}} }}
sx={{ sx={{
"& .MuiAutocomplete-inputRoot": { '& .MuiAutocomplete-inputRoot': {
py: 0, py: 0
}, }
}} }}
filterSelectedOptions filterSelectedOptions
value={selectedResources} value={selectedResources}
inputValue={query} inputValue={query}
onInputChange={(_event, value: string) => setQuery(value)} onInputChange={(_event, value: string) => setQuery(value)}
onChange={(event, value: string[] | Resource[]) => { onChange={(event, value: string[] | Resource[]) => {
setInputError(null); setInputError(null)
const mapped = value const mapped = value
.map((v: string | Resource) => .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); .filter(v => v.displayName.trim().length > 0)
onChange(event, mapped); onChange(event, mapped)
}} }}
slotProps={{ slotProps={{
...customSlotProps, ...customSlotProps,
popper: { popper: {
placement: "bottom-start", placement: 'bottom-start',
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx }, sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper, ...customSlotProps?.popper
}, }
}} }}
// When render input is custom, the adornments should be handled by the custom component // When render input is custom, the adornments should be handled by the custom component
forcePopupIcon={!customRenderInput} forcePopupIcon={!customRenderInput}
disableClearable={!!customRenderInput} disableClearable={!!customRenderInput}
renderInput={(params) => renderInput={params =>
customRenderInput customRenderInput
? customRenderInput(params, query, setQuery) ? customRenderInput(params, query, setQuery)
: defaultRenderInput(params) : defaultRenderInput(params)
} }
renderOption={(props, option: Resource) => { renderOption={(props, option: Resource) => {
if ( if (selectedResources.find(u => u.displayName === option.displayName))
selectedResources.find((u) => u.displayName === option.displayName) return null
) const { key, ...otherProps } = props
return null;
const { key, ...otherProps } = props;
return ( return (
<ListItem <ListItem
key={key + option?.displayName} key={key + option?.displayName}
@@ -294,16 +290,16 @@ export function ResourceSearch({
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={option.displayName} /> <ListItemText primary={option.displayName} />
</ListItem> </ListItem>
); )
}} }}
renderValue={(value: string[] | Resource[], getTagProps) => renderValue={(value: string[] | Resource[], getTagProps) =>
value.map((option: string | Resource, index) => { value.map((option: string | Resource, index) => {
const isString = typeof option === "string"; const isString = typeof option === 'string'
const label = isString ? option : option.displayName; const label = isString ? option : option.displayName
const chipColor = isString const chipColor = isString
? theme.palette.grey[300] ? theme.palette.grey[300]
: (option.color?.light ?? theme.palette.grey[300]); : (option.color?.light ?? theme.palette.grey[300])
const textColor = getAccessiblePair(chipColor, theme); const textColor = getAccessiblePair(chipColor, theme)
return ( return (
<Chip <Chip
@@ -311,25 +307,25 @@ export function ResourceSearch({
key={label} key={label}
style={{ style={{
backgroundColor: chipColor, backgroundColor: chipColor,
color: textColor, color: textColor
}} }}
label={label} label={label}
/> />
); )
}) })
} }
/> />
<SnackbarAlert <SnackbarAlert
open={snackbarOpen} open={snackbarOpen}
setOpen={(open: boolean) => { setOpen={(open: boolean) => {
setSnackbarOpen(open); setSnackbarOpen(open)
if (!open) { if (!open) {
setSnackbarMessage(""); setSnackbarMessage('')
} }
}} }}
message={snackbarMessage} message={snackbarMessage}
severity="error" severity="error"
/> />
</> </>
); )
} }
+119 -122
View File
@@ -1,99 +1,92 @@
import { getFreeBusyForAddedAttendeesREPORT } from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"; import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
import { getFreeBusyForEventAttendeesPOST } from "@/features/Events/api/getFreeBusyForEventAttendeesPOST"; import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
import { getUserDataFromEmail } from "@/features/Events/api/getUserDataFromEmail"; import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail'
import moment from "moment-timezone"; import moment from 'moment-timezone'
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
export type FreeBusyStatus = export type FreeBusyStatus = 'free' | 'busy' | 'loading' | 'contact' | 'unknown'
| "free" export type FreeBusyMap = Record<string, FreeBusyStatus>
| "busy"
| "loading"
| "contact"
| "unknown";
export type FreeBusyMap = Record<string, FreeBusyStatus>;
interface Attendee { interface Attendee {
email: string; email: string
userId?: string | null; userId?: string | null
} }
interface ResolvedAttendee { interface ResolvedAttendee {
email: string; email: string
userId: string; userId: string
} }
// Helpers // Helpers
async function resolveUserId(attendee: Attendee): Promise<string | null> { async function resolveUserId(attendee: Attendee): Promise<string | null> {
if (attendee.userId) return attendee.userId; if (attendee.userId) return attendee.userId
return getUserDataFromEmail(attendee.email) return getUserDataFromEmail(attendee.email)
.then((u) => u[0]?._id ?? null) .then(u => u[0]?._id ?? null)
.catch(() => null); .catch(() => null)
} }
async function resolveAll(attendees: Attendee[]): Promise<ResolvedAttendee[]> { async function resolveAll(attendees: Attendee[]): Promise<ResolvedAttendee[]> {
const results = await Promise.all( const results = await Promise.all(
attendees.map(async (a) => { attendees.map(async a => {
const userId = await resolveUserId(a); const userId = await resolveUserId(a)
return userId ? { email: a.email, userId } : null; 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 { export function hasFreeBusyConflict(data: unknown): boolean {
try { try {
const jcal = (data as { data: unknown[] }).data; const jcal = (data as { data: unknown[] }).data
if (!Array.isArray(jcal) || jcal[0] !== "vcalendar") return false; if (!Array.isArray(jcal) || jcal[0] !== 'vcalendar') return false
const components = jcal[2] as unknown[][]; const components = jcal[2] as unknown[][]
return ( return Array.isArray(components) && components.some(isVFreeBusyWithConflict)
Array.isArray(components) && components.some(isVFreeBusyWithConflict)
);
} catch { } catch {
return false; return false
} }
} }
function isVFreeBusyWithConflict(component: unknown): boolean { function isVFreeBusyWithConflict(component: unknown): boolean {
if (!Array.isArray(component) || component[0] !== "vfreebusy") return false; if (!Array.isArray(component) || component[0] !== 'vfreebusy') return false
const props = component[1] as unknown[][]; const props = component[1] as unknown[][]
return ( return (
Array.isArray(props) && 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 { 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( async function fetchFreeBusyMap(
attendees: Attendee[], attendees: Attendee[],
fetcher: (resolved: ResolvedAttendee[]) => Promise<FreeBusyMap> fetcher: (resolved: ResolvedAttendee[]) => Promise<FreeBusyMap>
): Promise<FreeBusyMap> { ): Promise<FreeBusyMap> {
const resolved = await resolveAll(attendees); const resolved = await resolveAll(attendees)
const unresolved: FreeBusyMap = Object.fromEntries( const unresolved: FreeBusyMap = Object.fromEntries(
attendees attendees
.filter((a) => !resolved.find((r) => r.email === a.email)) .filter(a => !resolved.find(r => r.email === a.email))
.map((a) => [a.email, "unknown" as FreeBusyStatus]) .map(a => [a.email, 'unknown' as FreeBusyStatus])
); )
if (resolved.length === 0) return unresolved; if (resolved.length === 0) return unresolved
const fetched = await fetcher(resolved); const fetched = await fetcher(resolved)
return { ...unresolved, ...fetched }; return { ...unresolved, ...fetched }
} }
function toLoadingMap(attendees: Attendee[]): FreeBusyMap { function toLoadingMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries( return Object.fromEntries(
attendees.map((a) => [a.email, "loading" as FreeBusyStatus]) attendees.map(a => [a.email, 'loading' as FreeBusyStatus])
); )
} }
function toUnknownMap(attendees: Attendee[]): FreeBusyMap { function toUnknownMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries( return Object.fromEntries(
attendees.map((a) => [a.email, "unknown" as FreeBusyStatus]) attendees.map(a => [a.email, 'unknown' as FreeBusyStatus])
); )
} }
function toFreeBusyMap( function toFreeBusyMap(
@@ -101,26 +94,26 @@ function toFreeBusyMap(
): (busyByUserId: Record<string, boolean>) => FreeBusyMap { ): (busyByUserId: Record<string, boolean>) => FreeBusyMap {
const userIdToEmail = Object.fromEntries( const userIdToEmail = Object.fromEntries(
resolved.map(({ email, userId }) => [userId, email]) resolved.map(({ email, userId }) => [userId, email])
); )
return (busyByUserId) => return busyByUserId =>
Object.fromEntries( Object.fromEntries(
Object.entries(busyByUserId).flatMap(([uid, busy]) => { Object.entries(busyByUserId).flatMap(([uid, busy]) => {
const email = userIdToEmail[uid]; const email = userIdToEmail[uid]
return email return email
? [[email, (busy ? "busy" : "free") as FreeBusyStatus]] ? [[email, (busy ? 'busy' : 'free') as FreeBusyStatus]]
: []; : []
}) })
); )
} }
interface UseAttendeesFreeBusyOptions { interface UseAttendeesFreeBusyOptions {
existingAttendees: Attendee[]; existingAttendees: Attendee[]
newAttendees: Attendee[]; newAttendees: Attendee[]
start: string; start: string
end: string; end: string
timezone: string; timezone: string
eventUid?: string | null; eventUid?: string | null
enabled?: boolean; enabled?: boolean
} }
export function useAttendeesFreeBusy({ export function useAttendeesFreeBusy({
@@ -130,18 +123,18 @@ export function useAttendeesFreeBusy({
end, end,
timezone, timezone,
eventUid, eventUid,
enabled = true, enabled = true
}: UseAttendeesFreeBusyOptions): FreeBusyMap { }: UseAttendeesFreeBusyOptions): FreeBusyMap {
const [statusMap, setStatusMap] = useState<FreeBusyMap>({}); const [statusMap, setStatusMap] = useState<FreeBusyMap>({})
const fetchedNewEmailsRef = useRef<Set<string>>(new Set()); const fetchedNewEmailsRef = useRef<Set<string>>(new Set())
const existingKey = existingAttendees.map((a) => a.email).join(","); const existingKey = existingAttendees.map(a => a.email).join(',')
const newKey = newAttendees.map((a) => a.email).join(","); const newKey = newAttendees.map(a => a.email).join(',')
useEffect(() => { useEffect(() => {
fetchedNewEmailsRef.current = new Set(); fetchedNewEmailsRef.current = new Set()
setStatusMap({}); setStatusMap({})
}, [start, end, timezone]); }, [start, end, timezone])
useEffect(() => { useEffect(() => {
if ( if (
@@ -151,101 +144,105 @@ export function useAttendeesFreeBusy({
!eventUid || !eventUid ||
existingAttendees.length === 0 existingAttendees.length === 0
) )
return; return
let cancelled = false; let cancelled = false
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) })); setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) }))
fetchFreeBusyMap(existingAttendees, (resolved) => fetchFreeBusyMap(existingAttendees, resolved =>
getFreeBusyForEventAttendeesPOST( getFreeBusyForEventAttendeesPOST(
resolved.map((r) => r.userId), resolved.map(r => r.userId),
toUtcIcal(start, timezone), toUtcIcal(start, timezone),
toUtcIcal(end, timezone), toUtcIcal(end, timezone),
eventUid! eventUid
).then(toFreeBusyMap(resolved)) ).then(toFreeBusyMap(resolved))
) )
.then((updates) => { .then(updates => {
if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates })); if (!cancelled) {
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
}) })
.catch(() => { .catch(() => {
if (!cancelled) if (!cancelled)
setStatusMap((prev) => ({ setStatusMap(prev => ({
...prev, ...prev,
...toUnknownMap(existingAttendees), ...toUnknownMap(existingAttendees)
})); }))
}); })
return () => { return () => {
cancelled = true; cancelled = true
}; }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingKey, start, end, eventUid, enabled, timezone]); }, [existingKey, start, end, eventUid, enabled, timezone])
useEffect(() => { 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( const removedEmails = [...fetchedNewEmailsRef.current].filter(
(e) => !currentEmails.has(e) e => !currentEmails.has(e)
); )
if (removedEmails.length > 0) { if (removedEmails.length > 0) {
removedEmails.forEach((e) => { removedEmails.forEach(e => {
fetchedNewEmailsRef.current.delete(e); fetchedNewEmailsRef.current.delete(e)
}); })
setStatusMap((prev) => { setStatusMap(prev => {
const next = { ...prev }; const next = { ...prev }
removedEmails.forEach((e) => { removedEmails.forEach(e => {
delete next[e]; delete next[e]
}); })
return next; return next
}); })
} }
const toFetch = newAttendees.filter( const toFetch = newAttendees.filter(
(a) => !fetchedNewEmailsRef.current.has(a.email) a => !fetchedNewEmailsRef.current.has(a.email)
); )
if (toFetch.length === 0) return; if (toFetch.length === 0) return
let cancelled = false; let cancelled = false
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(toFetch) })); setStatusMap(prev => ({ ...prev, ...toLoadingMap(toFetch) }))
fetchFreeBusyMap(toFetch, (resolved) => fetchFreeBusyMap(toFetch, resolved =>
Promise.all( Promise.all(
resolved.map(async ({ email, userId }) => { resolved.map(async ({ email, userId }) => {
try { try {
const busy = await getFreeBusyForAddedAttendeesREPORT( const busy = await getFreeBusyForAddedAttendeesREPORT(
userId, userId,
moment.tz(start, timezone).utc().format("YYYYMMDDTHHmmss"), moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'),
moment.tz(end, timezone).utc().format("YYYYMMDDTHHmmss") moment.tz(end, timezone).utc().format('YYYYMMDDTHHmmss')
); )
return [email, (busy ? "busy" : "free") as FreeBusyStatus] as const; return [email, (busy ? 'busy' : 'free') as FreeBusyStatus] as const
} catch { } catch {
return [email, "unknown" as FreeBusyStatus] as const; return [email, 'unknown' as FreeBusyStatus] as const
} }
}) })
).then(Object.fromEntries) ).then(Object.fromEntries)
) )
.then((updates) => { .then(updates => {
if (!cancelled) { if (!cancelled) {
Object.keys(updates).forEach((e) => { Object.keys(updates).forEach(e => {
fetchedNewEmailsRef.current.add(e); fetchedNewEmailsRef.current.add(e)
}); })
setStatusMap((prev) => ({ ...prev, ...updates })); setStatusMap(prev => ({ ...prev, ...updates }))
} }
return
}) })
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
toFetch.forEach((a) => { toFetch.forEach(a => {
fetchedNewEmailsRef.current.add(a.email); fetchedNewEmailsRef.current.add(a.email)
}); })
setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) })); setStatusMap(prev => ({ ...prev, ...toUnknownMap(toFetch) }))
} }
}); })
return () => { return () => {
cancelled = true; cancelled = true
}; }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 { useCallback } from 'react'
import type { SyntheticEvent } from "react"; import type { SyntheticEvent } from 'react'
import { isValidEmail } from "../../utils/isValidEmail"; import { isValidEmail } from '../../utils/isValidEmail'
import type { User } from "./PeopleSearch"; import type { User } from './PeopleSearch'
export function usePasteHandler({ export function usePasteHandler({
freeSolo, freeSolo,
@@ -9,62 +9,62 @@ export function usePasteHandler({
onChange, onChange,
setQuery, setQuery,
setInputError, setInputError,
t, t
}: { }: {
freeSolo?: boolean; freeSolo?: boolean
selectedUsers: User[]; selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void; onChange: (event: SyntheticEvent, users: User[]) => void
setQuery: (value: string) => void; setQuery: (value: string) => void
setInputError: (error: string | null) => void; setInputError: (error: string | null) => void
t: (key: string) => string; t: (key: string) => string
}) { }) {
return useCallback( return useCallback(
(event: React.ClipboardEvent<HTMLInputElement>) => { (event: React.ClipboardEvent<HTMLInputElement>) => {
if (!freeSolo) return; if (!freeSolo) return
const pasted = event.clipboardData.getData("text/plain"); const pasted = event.clipboardData.getData('text/plain')
if (!pasted) return; if (!pasted) return
// Split by comma, semicolon, newline, or whitespace // Split by comma, semicolon, newline, or whitespace
const chunks = pasted const chunks = pasted
.split(/[,;\n\r\s]+/) .split(/[,;\n\r\s]+/)
.map((s) => s.trim()) .map(s => s.trim())
.filter(Boolean); .filter(Boolean)
// If there is only one chunk, let the default Autocomplete behaviour handle it // 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 existingEmails = new Set(selectedUsers.map(u => u.email))
const validUsers: User[] = []; const validUsers: User[] = []
const invalid: string[] = []; const invalid: string[] = []
for (const chunk of chunks) { for (const chunk of chunks) {
if (!isValidEmail(chunk)) { if (!isValidEmail(chunk)) {
invalid.push(chunk); invalid.push(chunk)
} else if (!existingEmails.has(chunk)) { } else if (!existingEmails.has(chunk)) {
existingEmails.add(chunk); existingEmails.add(chunk)
validUsers.push({ email: chunk, displayName: chunk }); validUsers.push({ email: chunk, displayName: chunk })
} }
// silently skip duplicates // silently skip duplicates
} }
if (validUsers.length > 0) { if (validUsers.length > 0) {
onChange(event, [...selectedUsers, ...validUsers]); onChange(event, [...selectedUsers, ...validUsers])
} }
if (invalid.length > 0) { if (invalid.length > 0) {
// Leave the invalid text in the input for manual correction // Leave the invalid text in the input for manual correction
setQuery(invalid.join(", ")); setQuery(invalid.join(', '))
setInputError( setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", ")) t('peopleSearch.invalidEmail').replace('%{email}', invalid.join(', '))
); )
} else { } else {
setQuery(""); setQuery('')
setInputError(null); setInputError(null)
} }
}, },
[freeSolo, selectedUsers, onChange, setQuery, setInputError, t] [freeSolo, selectedUsers, onChange, setQuery, setInputError, t]
); )
} }
+34 -34
View File
@@ -1,67 +1,67 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react'
import { searchUsers } from "@/features/User/userAPI"; import { searchUsers } from '@/features/User/userAPI'
export interface UseUserSearchProps { export interface UseUserSearchProps {
objectTypes: string[]; objectTypes: string[]
errorMessage: string; errorMessage: string
} }
export function useUserSearch<T>({ export function useUserSearch<T>({
objectTypes, objectTypes,
errorMessage, errorMessage
}: UseUserSearchProps) { }: UseUserSearchProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<T[]>([]); const [options, setOptions] = useState<T[]>([])
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false)
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false)
const [inputError, setInputError] = useState<string | null>(null); const [inputError, setInputError] = useState<string | null>(null)
const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarOpen, setSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState(""); const [snackbarMessage, setSnackbarMessage] = useState('')
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false
const delayDebounceFn = setTimeout(async () => { const delayDebounceFn = setTimeout(async () => {
if (!query.trim()) { if (!query.trim()) {
if (!cancelled) { if (!cancelled) {
setOptions([]); setOptions([])
setLoading(false); setLoading(false)
setHasSearched(false); setHasSearched(false)
} }
return; return
} }
if (!cancelled) { if (!cancelled) {
setLoading(true); setLoading(true)
setHasSearched(false); setHasSearched(false)
} }
try { try {
const res = await searchUsers(query, objectTypes); const res = await searchUsers(query, objectTypes)
if (!cancelled) { if (!cancelled) {
setOptions(res as unknown as T[]); setOptions(res as unknown as T[])
setHasSearched(true); setHasSearched(true)
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setHasSearched(false); setHasSearched(false)
setSnackbarMessage(errorMessage); setSnackbarMessage(errorMessage)
setSnackbarOpen(true); setSnackbarOpen(true)
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoading(false); setLoading(false)
} }
} }
}, 300); }, 300)
return () => { return () => {
cancelled = true; cancelled = true
clearTimeout(delayDebounceFn); clearTimeout(delayDebounceFn)
}; }
}, [objectTypes, query, errorMessage]); }, [objectTypes, query, errorMessage])
return { return {
query, query,
@@ -76,6 +76,6 @@ export function useUserSearch<T>({
snackbarOpen, snackbarOpen,
setSnackbarOpen, setSnackbarOpen,
snackbarMessage, snackbarMessage,
setSnackbarMessage, setSnackbarMessage
}; }
} }
+69 -72
View File
@@ -1,8 +1,5 @@
import { import { exportCalendar, getSecretLink } from '@/features/Calendars/CalendarApi'
exportCalendar, import { Calendar } from '@/features/Calendars/CalendarTypes'
getSecretLink,
} from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { import {
Box, Box,
Button, Button,
@@ -10,90 +7,90 @@ import {
IconButton, IconButton,
InputAdornment, InputAdornment,
TextField, TextField,
Typography, Typography
} from "@linagora/twake-mui"; } from '@linagora/twake-mui'
import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react'
import { useI18n } from "twake-i18n"; import { useI18n } from 'twake-i18n'
import { ErrorSnackbar } from "../Error/ErrorSnackbar"; import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { FieldWithLabel } from "../Event/components/FieldWithLabel"; import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { SnackbarAlert } from "../Loading/SnackBarAlert"; import { SnackbarAlert } from '../Loading/SnackBarAlert'
import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights"; import { CalendarAccessRights, UserWithAccess } from './CalendarAccessRights'
interface AccessTabProps { interface AccessTabProps {
calendar: Calendar; calendar: Calendar
usersWithAccess: UserWithAccess[]; usersWithAccess: UserWithAccess[]
onUsersWithAccessChange: (users: UserWithAccess[]) => void; onUsersWithAccessChange: (users: UserWithAccess[]) => void
onInvitesLoaded: (users: UserWithAccess[]) => void; onInvitesLoaded: (users: UserWithAccess[]) => void
} }
export function AccessTab({ export function AccessTab({
calendar, calendar,
usersWithAccess, usersWithAccess,
onUsersWithAccessChange, onUsersWithAccessChange,
onInvitesLoaded, onInvitesLoaded
}: AccessTabProps) { }: 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( const isResource = useMemo(
() => calendar?.owner?.resource, () => calendar?.owner?.resource,
[calendar?.owner?.resource] [calendar?.owner?.resource]
); )
const [secretLink, setSecretLink] = useState(""); const [secretLink, setSecretLink] = useState('')
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
async function fetchSecret() { async function fetchSecret() {
const existing = await getSecretLink( const existing = await getSecretLink(
calendar.link.replace(".json", ""), calendar.link.replace('.json', ''),
false false
); )
setSecretLink(existing.secretLink); setSecretLink(existing.secretLink)
} }
fetchSecret(); fetchSecret()
}, [calendar.link]); }, [calendar.link])
const handleCopy = (content: string) => { const handleCopy = (content: string) => {
navigator.clipboard.writeText(content); navigator.clipboard.writeText(content)
setOpen(true); setOpen(true)
}; }
const handleResetSecretLink = async () => { const handleResetSecretLink = async () => {
const newSecret = await getSecretLink( const newSecret = await getSecretLink(
calendar.link.replace(".json", ""), calendar.link.replace('.json', ''),
true true
); )
setSecretLink(newSecret.secretLink); setSecretLink(newSecret.secretLink)
}; }
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false)
const [exportError, setExportError] = useState(""); const [exportError, setExportError] = useState('')
const handleExport = async () => { const handleExport = async () => {
try { try {
setExportLoading(true); setExportLoading(true)
const exportedData = await exportCalendar( const exportedData = await exportCalendar(
calendar.link.replace(".json", "") calendar.link.replace('.json', '')
); )
const blob = new Blob([exportedData], { type: "text/calendar" }); const blob = new Blob([exportedData], { type: 'text/calendar' })
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob)
const link = document.createElement("a"); const link = document.createElement('a')
link.href = url; link.href = url
link.download = `${calendar.id.split("/")[1]}.ics`; link.download = `${calendar.id.split('/')[1]}.ics`
document.body.appendChild(link); document.body.appendChild(link)
link.click(); link.click()
document.body.removeChild(link); document.body.removeChild(link)
URL.revokeObjectURL(url); URL.revokeObjectURL(url)
} catch (e) { } catch (e) {
setExportError((e as Error).message); setExportError((e as Error).message)
} finally { } finally {
setExportLoading(false); setExportLoading(false)
}
} }
};
return ( return (
<> <>
@@ -105,13 +102,13 @@ export function AccessTab({
/> />
{!!window.DAV_BASE_URL && !isResource && ( {!!window.DAV_BASE_URL && !isResource && (
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}> <FieldWithLabel label={t('calendar.caldav_access')} isExpanded={false}>
<Box mt={2}> <Box mt={2}>
<TextField <TextField
disabled disabled
fullWidth fullWidth
label="" label=""
inputProps={{ "aria-label": t("calendar.caldav_access") }} inputProps={{ 'aria-label': t('calendar.caldav_access') }}
value={calDAVLink} value={calDAVLink}
size="small" size="small"
InputProps={{ InputProps={{
@@ -124,20 +121,20 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" /> <ContentCopyIcon fontSize="small" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
), )
}} }}
/> />
</Box> </Box>
</FieldWithLabel> </FieldWithLabel>
)} )}
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}> <FieldWithLabel label={t('calendar.secretUrl')} isExpanded={false}>
<Box mt={3} display="flex" alignItems="center" gap={1}> <Box mt={3} display="flex" alignItems="center" gap={1}>
<TextField <TextField
disabled disabled
fullWidth fullWidth
label="" label=""
inputProps={{ "aria-label": t("calendar.secretUrl") }} inputProps={{ 'aria-label': t('calendar.secretUrl') }}
value={secretLink} value={secretLink}
size="small" size="small"
InputProps={{ InputProps={{
@@ -147,32 +144,32 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" /> <ContentCopyIcon fontSize="small" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
), )
}} }}
/> />
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
onClick={handleResetSecretLink} onClick={handleResetSecretLink}
sx={{ borderRadius: "4px" }} sx={{ borderRadius: '4px' }}
> >
{t("actions.reset")} {t('actions.reset')}
</Button> </Button>
</Box> </Box>
<Typography <Typography
variant="body2" 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> </Typography>
</FieldWithLabel> </FieldWithLabel>
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}> <FieldWithLabel label={t('calendar.exportCalendar')} isExpanded={false}>
<Typography <Typography
variant="body2" 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> </Typography>
<Button <Button
variant="contained" variant="contained"
@@ -180,15 +177,15 @@ export function AccessTab({
onClick={handleExport} onClick={handleExport}
startIcon={!exportLoading && <FileDownloadOutlinedIcon />} startIcon={!exportLoading && <FileDownloadOutlinedIcon />}
disabled={exportLoading} disabled={exportLoading}
sx={{ borderRadius: "4px" }} sx={{ borderRadius: '4px' }}
> >
{exportLoading ? ( {exportLoading ? (
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<CircularProgress size={18} /> <CircularProgress size={18} />
{t("actions.exporting")} {t('actions.exporting')}
</Box> </Box>
) : ( ) : (
t("actions.export") t('actions.export')
)} )}
</Button> </Button>
</FieldWithLabel> </FieldWithLabel>
@@ -196,9 +193,9 @@ export function AccessTab({
<SnackbarAlert <SnackbarAlert
setOpen={setOpen} setOpen={setOpen}
open={open} open={open}
message={t("common.link_copied")} message={t('common.link_copied')}
/> />
<ErrorSnackbar error={exportError} type="calendar" /> <ErrorSnackbar error={exportError} type="calendar" />
</> </>
); )
} }

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