[#38] Adding several missing tests and correct CI setup

This commit is contained in:
Camille Moussu
2025-07-18 12:55:42 +02:00
parent 7c8b14e961
commit a52ded03fb
19 changed files with 2938 additions and 1278 deletions
-11
View File
@@ -1,11 +0,0 @@
import React from "react";
import { screen } from "@testing-library/react";
import App from "../src/App";
import { JSX } from "react/jsx-runtime";
import { renderWithProviders } from "./utils/Renderwithproviders";
test("renders app", () => {
renderWithProviders(<App />);
const linkElement = screen.getByText("Twake");
expect(linkElement).toBeInTheDocument();
});
@@ -0,0 +1,62 @@
// __test__/features/calendars/calendarApi.test.ts
import {
getCalendar,
getCalendars,
} from "../../../src/features/Calendars/CalendarApi";
import { clientConfig } from "../../../src/features/User/oidcAuth";
import { api } from "../../../src/utils/apiUtils";
clientConfig.url = "https://example.com";
jest.mock("../../../src/utils/apiUtils");
describe("Calendar API", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("getCalendars", () => {
it("fetches calendar list for a user", async () => {
const mockUserId = "user123";
const mockResponse = [{ id: "calendar1" }, { id: "calendar2" }];
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResponse),
});
const calendars = await getCalendars(mockUserId);
expect(api.get).toHaveBeenCalledWith(
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`,
{
headers: { Accept: "application/calendar+json" },
}
);
expect(calendars).toEqual(mockResponse);
});
});
describe("getCalendar", () => {
it("fetches calendar events for a given ID and match window", async () => {
const calendarId = "calendar1";
const match = { start: "2025-07-01", end: "2025-07-31" };
const mockCalendarData = { events: ["event1", "event2"] };
(api as unknown as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockCalendarData),
});
const result = await getCalendar(calendarId, match);
expect(api).toHaveBeenCalledWith(`dav/calendars/${calendarId}.json`, {
method: "REPORT",
headers: {
Accept: "application/json, text/plain, */*",
},
body: JSON.stringify({ match }),
});
expect(result).toEqual(mockCalendarData);
});
});
});
@@ -0,0 +1,80 @@
import { screen, waitFor } from "@testing-library/react";
import thunk from "redux-thunk";
import HandleLogin from "../../../src/features/User/HandleLogin";
import * as oidcAuth from "../../../src/features/User/oidcAuth";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import { clientConfig } from "../../../src/features/User/oidcAuth";
import * as apiUtils from "../../../src/utils/apiUtils";
clientConfig.url = "https://example.com";
describe("HandleLogin", () => {
beforeEach(() => {
jest.spyOn(apiUtils, "redirectTo").mockImplementation(() => {});
jest.clearAllMocks();
sessionStorage.clear();
});
test("redirects and sets sessionStorage when no userData", async () => {
const loginUrlMock = {
code_verifier: "verifier123",
state: "state123",
redirectTo: new URL("http://login.url"),
};
jest.spyOn(oidcAuth, "Auth").mockResolvedValue(loginUrlMock);
renderWithProviders(<HandleLogin />);
await waitFor(() => {
expect(oidcAuth.Auth).toHaveBeenCalled();
expect(sessionStorage.getItem("redirectState")).toEqual(
JSON.stringify({
code_verifier: "verifier123",
state: "state123",
})
);
expect(apiUtils.redirectTo).toHaveBeenCalledWith(loginUrlMock.redirectTo);
});
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
test("shows Loading when userData exists and calendars pending is true", () => {
const preloadedState = {
user: {
userData: {
sub: "cmoussu",
email: "cmoussu@linagora.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
},
},
calendars: { list: {}, pending: true },
};
renderWithProviders(<HandleLogin />, preloadedState);
expect(screen.getByAltText(/loading/i)).toBeInTheDocument();
});
test("shows Loading when userData exists and calendars pending is false", () => {
const preloadedState = {
user: {
userData: {
sub: "cmoussu",
email: "cmoussu@linagora.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<HandleLogin />, preloadedState);
expect(screen.getByAltText("loading")).toBeInTheDocument();
});
test("shows Error when userData doesnt exists and calendars pending is false", () => {
renderWithProviders(<HandleLogin />);
expect(screen.getByText("Error")).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
// __test__/features/user/CallbackResume.test.tsx
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { CallbackResume } from "../../../src/features/User/LoginCallback";
import { useAppDispatch } from "../../../src/app/hooks";
import * as oidcAuth from "../../../src/features/User/oidcAuth";
import { push } from "redux-first-history";
import {
setTokens,
setUserData,
getOpenPaasUserIdAsync,
} from "../../../src/features/User/userSlice";
import { getCalendarsListAsync } from "../../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../../utils/Renderwithproviders";
// Mocks
jest.mock("../../../src/app/hooks", () => ({
useAppDispatch: jest.fn(),
useAppSelector: jest.fn(() => ({})),
}));
jest.mock("../../../src/features/User/oidcAuth", () => ({
Callback: jest.fn(),
}));
jest.mock("../../../src/features/User/userSlice", () => ({
setUserData: jest.fn((data) => ({ type: "SET_USER", payload: data })),
setTokens: jest.fn((tokens) => ({ type: "SET_TOKENS", payload: tokens })),
getOpenPaasUserIdAsync: jest.fn(() => ({ type: "GET_USER_ID" })),
}));
jest.mock("../../../src/features/Calendars/CalendarSlice", () => ({
getCalendarsListAsync: jest.fn(() => ({ type: "GET_CALENDARS" })),
}));
describe("CallbackResume", () => {
const dispatch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch);
});
it("should call Callback and dispatch necessary actions", async () => {
const mockTokenSet = { access_token: "abc" };
const mockUserInfo = { name: "Test User" };
const mockData = {
tokenSet: mockTokenSet,
userinfo: mockUserInfo,
};
(oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData);
sessionStorage.setItem(
"redirectState",
JSON.stringify({ code_verifier: "verifier123", state: "state456" })
);
render(<CallbackResume />);
await waitFor(() => {
expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456");
expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo));
expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet));
expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserIdAsync());
expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync());
expect(dispatch).toHaveBeenCalledWith(push("/"));
expect(sessionStorage.getItem("redirectState")).toBe(null);
expect(sessionStorage.getItem("tokenSet")).toEqual(
JSON.stringify(mockTokenSet)
);
});
});
it("should handle missing redirectState gracefully", async () => {
sessionStorage.removeItem("redirectState");
renderWithProviders(<CallbackResume />);
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(push("/"));
});
});
});
+172
View File
@@ -0,0 +1,172 @@
// __tests__/auth.test.ts
import * as client from "openid-client";
import {
clientConfig,
getClientConfig,
Auth,
Logout,
Callback,
} from "../../../src/features/User/oidcAuth";
import * as apiUtils from "../../../src/utils/apiUtils";
clientConfig.url = "https://example.com";
const localAdress = "https://local.exemple.com";
describe("OpenID Client Auth Module", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(apiUtils, "getLocation").mockImplementation(() => localAdress);
});
describe("getClientConfig", () => {
it("should call discovery with clientConfig.url", async () => {
const discoveryMock = client.discovery as jest.Mock;
discoveryMock.mockResolvedValue("discoveredClient");
const result = await getClientConfig();
expect(discoveryMock).toHaveBeenCalledWith(
new URL(clientConfig.url),
clientConfig.client_id
);
expect(result).toBe("discoveredClient");
});
});
describe("Auth", () => {
it("should generate PKCE and build authorization URL with PKCE", async () => {
(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
"verifier123"
);
(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
"challenge123"
);
// Mock discovery returning an object with serverMetadata()
const discoveredClient = {
serverMetadata: jest.fn(() => ({
supportsPKCE: () => true,
})),
};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
"https://auth.url"
);
const result = await Auth();
expect(client.randomPKCECodeVerifier).toHaveBeenCalled();
expect(client.calculatePKCECodeChallenge).toHaveBeenCalledWith(
"verifier123"
);
expect(client.buildAuthorizationUrl).toHaveBeenCalledWith(
discoveredClient,
expect.objectContaining({
code_challenge: "challenge123",
code_challenge_method: clientConfig.code_challenge_method,
redirect_uri: clientConfig.redirect_uri,
scope: clientConfig.scope,
})
);
expect(result).toEqual({
redirectTo: "https://auth.url",
code_verifier: "verifier123",
state: undefined,
});
});
it("should generate state when PKCE not supported", async () => {
(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue(
"verifier123"
);
(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue(
"challenge123"
);
const discoveredClient = {
serverMetadata: jest.fn(() => ({
supportsPKCE: () => false,
})),
};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
(client.randomState as jest.Mock).mockReturnValue("state123");
(client.buildAuthorizationUrl as jest.Mock).mockReturnValue(
"https://auth.url"
);
const result = await Auth();
expect(client.randomState).toHaveBeenCalled();
expect(result.state).toBe("state123");
expect(result.redirectTo).toBe("https://auth.url");
});
});
describe("Logout", () => {
it("should build end session URL", async () => {
const discoveredClient = {};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
(client.buildEndSessionUrl as jest.Mock).mockReturnValue(
"https://logout.url"
);
const result = await Logout();
expect(client.buildEndSessionUrl).toHaveBeenCalledWith(discoveredClient, {
post_logout_redirect_uri: clientConfig.post_logout_redirect_uri,
});
expect(result).toBe("https://logout.url");
});
});
describe("Callback", () => {
it("should perform authorization code grant and fetch user info", async () => {
const discoveredClient = {};
(client.discovery as jest.Mock).mockResolvedValue(discoveredClient);
const mockTokenSet = {
access_token: "access123",
claims: jest.fn(() => ({ sub: "user123" })),
};
(client.authorizationCodeGrant as jest.Mock).mockResolvedValue(
mockTokenSet
);
(client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: "User" });
const result = await Callback("verifier123", "state123");
expect(client.authorizationCodeGrant).toHaveBeenCalledWith(
discoveredClient,
new URL(localAdress),
{ pkceCodeVerifier: "verifier123", expectedState: "state123" }
);
expect(client.fetchUserInfo).toHaveBeenCalledWith(
discoveredClient,
"access123",
"user123"
);
expect(result).toEqual({
tokenSet: mockTokenSet,
userinfo: { name: "User" },
});
});
it("should catch and log errors", async () => {
const error = new Error("fail");
(client.discovery as jest.Mock).mockResolvedValue({});
(client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error);
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await Callback("verifier", "state");
expect(consoleErrorSpy).toHaveBeenCalledWith("Token grant error:", error);
expect(result).toBeUndefined();
consoleErrorSpy.mockRestore();
});
});
});
+22
View File
@@ -0,0 +1,22 @@
import { clientConfig } from "../../../src/features/User/oidcAuth";
import getOpenPaasUserId from "../../../src/features/User/userAPI";
import { api } from "../../../src/utils/apiUtils";
jest.mock("../../../src/utils/apiUtils");
clientConfig.url = "https://example.com";
describe("getOpenPaasUserId", () => {
it("should fetch and return user data", async () => {
const mockUser = { id: "123", name: "OpenPaas User" };
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUser),
});
const result = await getOpenPaasUserId();
expect(api.get).toHaveBeenCalledWith("api/user");
expect(result).toEqual(mockUser);
});
});
+6 -30
View File
@@ -1,21 +1,12 @@
import type { RenderOptions } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { render } from "@testing-library/react";
import React, { PropsWithChildren } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "react-redux";
import "../i18n";
import { MemoryRouter } from "react-router-dom";
import type { AppStore, RootState } from "../../src/app/store";
import { setupStore } from "../../src/app/store";
import { t } from "i18next";
import { userData } from "../../src/features/User/userDataTypes";
import { userData, userOrganiser } from "../../src/features/User/userDataTypes";
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
preloadedState?: Partial<RootState>;
@@ -24,30 +15,15 @@ interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
export function renderWithProviders(
ui: React.ReactElement,
preloadedState = {},
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {
user: { userData: null as unknown as userData },
router: {
location: {
pathname: "",
search: "",
hash: "",
state: null,
key: "o01z0jry",
},
},
},
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions;
const { store = setupStore(preloadedState), ...renderOptions } =
extendedRenderOptions;
const Wrapper = ({ children }: PropsWithChildren) => {
useTranslation();
return (
<MemoryRouter initialEntries={["/manager.html"]}>
<MemoryRouter initialEntries={["/"]}>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
);
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic', importSource: 'preact' }],
],
};
+9
View File
@@ -0,0 +1,9 @@
const path = require("path");
module.exports = {
process(sourceText, sourcePath, options) {
return {
code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`,
};
},
};
+64
View File
@@ -0,0 +1,64 @@
import type { Config } from "jest";
const config: Config = {
collectCoverage: true,
coverageDirectory: "coverage",
projects: [
{
displayName: "dom",
clearMocks: true,
moduleFileExtensions: [
"js",
"mjs",
"cjs",
"jsx",
"ts",
"tsx",
"json",
"node",
],
testEnvironment: "jsdom",
testMatch: ["**/*.test.tsx"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }],
"^.+\\.(js|jsx|mjs)$": "babel-jest",
"^.+\\.(css|scss|sass|less)$": "jest-preview/transforms/css",
"^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)":
"jest-preview/transforms/file",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/fileTransformer.ts",
},
transformIgnorePatterns: [
"/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky)/)",
],
moduleNameMapper: { "^preact(/(.*)|$)": "preact$1" },
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
},
{
displayName: "node",
clearMocks: true,
moduleFileExtensions: [
"js",
"mjs",
"cjs",
"jsx",
"ts",
"tsx",
"json",
"node",
],
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }],
"^.+\\.(js|jsx|mjs)$": "babel-jest",
},
transformIgnorePatterns: ["/node_modules/(?!(ky)/)"],
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
},
],
};
export default config;
+2381 -1212
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -27,14 +27,16 @@
"react-redux": "^9.2.0",
"react-router-dom": "^6.23.1",
"redux-first-history": "^5.2.0",
"ts-node": "^10.9.2",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "PORT=5000 rsbuild dev",
"build": "rsbuild build",
"preview": "rsbuild preview",
"test": "jest test",
"lint": "eslint src"
"test": "jest",
"lint": "eslint src",
"jest-preview": "jest-preview"
},
"eslintConfig": {
"extends": [
@@ -55,6 +57,8 @@
]
},
"devDependencies": {
"@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.4.3",
"@rsbuild/plugin-react": "^1.3.3",
"@rsbuild/plugin-svgr": "^1.2.0",
@@ -62,12 +66,15 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/jest": "^30.0.0",
"babel-jest": "^30.0.4",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"i18next-resources-for-ts": "1.4.0",
"jest": "^27.5.1",
"jest": "^30.0.4",
"jest-environment-jsdom": "^30.0.4",
"jest-preview": "^0.3.1",
"ts-jest": "^29.4.0",
"typescript": "^4.9.5"
}
}
+1 -1
View File
@@ -2,5 +2,5 @@ import React from "react";
import logo from "../../static/images/calendar.svg";
export function Loading() {
return <img src={logo} />;
return <img src={logo} alt="loading" />;
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { Auth } from "./oidcAuth";
import { Loading } from "../../components/Loading/Loading";
import { Error } from "../../components/Error/Error";
import { push } from "redux-first-history";
import { redirectTo } from "../../utils/apiUtils";
export function HandleLogin() {
const userData = useAppSelector((state) => state.user.userData);
@@ -22,7 +23,7 @@ export function HandleLogin() {
})
);
window.location.assign(loginurl.redirectTo);
redirectTo(loginurl.redirectTo);
}
};
+4 -3
View File
@@ -1,4 +1,5 @@
import * as client from "openid-client";
import { getLocation } from "../../utils/apiUtils";
export const clientConfig = {
url: (window as any).SSO_BASE_URL ?? "",
@@ -49,14 +50,14 @@ export async function Logout() {
export async function Callback(code_verifier: string, state: any) {
try {
const openIdClientConfig = await getClientConfig();
const currentUrl = new URL(window.location.href);
const currentLocation = getLocation();
console.log("Callback URL:", currentUrl.toString());
console.log("Callback URL:", currentLocation);
console.log("Code verifier:", code_verifier);
const tokenSet = await client.authorizationCodeGrant(
openIdClientConfig,
currentUrl,
new URL(currentLocation),
{
pkceCodeVerifier: code_verifier,
expectedState: state,
+17 -5
View File
@@ -1,5 +1,17 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import "@testing-library/jest-dom";
import { TextEncoder, TextDecoder } from "util";
import { clientConfig } from "./features/User/oidcAuth";
global.TextEncoder = TextEncoder;
jest.mock("openid-client", () => ({
discovery: jest.fn(),
randomPKCECodeVerifier: jest.fn(),
calculatePKCECodeChallenge: jest.fn(),
randomState: jest.fn(),
buildAuthorizationUrl: jest.fn(),
buildEndSessionUrl: jest.fn(),
authorizationCodeGrant: jest.fn(),
fetchUserInfo: jest.fn(),
}));
+4
View File
@@ -0,0 +1,4 @@
declare module "*.svg" {
const content: string;
export default content;
}
+9 -1
View File
@@ -29,9 +29,17 @@ export const api = ky.extend({
state: loginurl.state,
})
);
window.location.assign(loginurl.redirectTo);
redirectTo(loginurl.redirectTo);
}
},
],
},
});
export function redirectTo(url: URL) {
window.location.assign(url);
}
export function getLocation() {
return window.location.href;
}
+4 -10
View File
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -13,14 +9,12 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
, "__test__/App.test.tsx" ]
"include": ["src", "__test__"]
}