From fc07f79950adfd0739b7b2daf23533fea4fc3dd9 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 27 Jan 2025 17:29:57 +0100 Subject: [PATCH] refactor: moved methods to the right folder, added more interfaces --- phpmyfaq/admin/assets/src/api/user.ts | 33 +++++- phpmyfaq/admin/assets/src/interfaces/index.ts | 2 + .../assets/src/interfaces/userAutocomplete.ts | 4 + .../admin/assets/src/interfaces/userData.ts | 12 ++ .../assets/src/user/autocomplete.test.ts | 74 +++++++++++++ .../admin/assets/src/user/autocomplete.ts | 8 +- .../admin/assets/src/user/user-list.test.ts | 54 +++++++++ phpmyfaq/admin/assets/src/user/user-list.ts | 65 ++++------- phpmyfaq/admin/assets/src/user/users.ts | 103 ++++++++---------- .../Administration/Api/UserController.php | 27 +++-- tsconfig.json | 6 +- 11 files changed, 259 insertions(+), 129 deletions(-) create mode 100644 phpmyfaq/admin/assets/src/interfaces/userAutocomplete.ts create mode 100644 phpmyfaq/admin/assets/src/interfaces/userData.ts create mode 100644 phpmyfaq/admin/assets/src/user/autocomplete.test.ts create mode 100644 phpmyfaq/admin/assets/src/user/user-list.test.ts diff --git a/phpmyfaq/admin/assets/src/api/user.ts b/phpmyfaq/admin/assets/src/api/user.ts index b3b1e6c2a3..4b922a9f43 100644 --- a/phpmyfaq/admin/assets/src/api/user.ts +++ b/phpmyfaq/admin/assets/src/api/user.ts @@ -14,8 +14,9 @@ */ import { Response } from '../interfaces'; +import { UserData } from '../interfaces/userData'; -export const fetchUsers = async (userName: string): Promise => { +export const fetchUsers = async (userName: string): Promise => { try { const response = await fetch(`./api/user/users?filter=${userName}`, { method: 'GET', @@ -33,7 +34,7 @@ export const fetchUsers = async (userName: string): Promise => { +export const fetchUserData = async (userId: string): Promise => { try { const response = await fetch(`./api/user/data/${userId}`, { method: 'GET', @@ -51,7 +52,7 @@ export const fetchUserData = async (userId: string): Promise => { +export const fetchUserRights = async (userId: string): Promise => { try { const response = await fetch(`./api/user/permissions/${userId}`, { method: 'GET', @@ -69,7 +70,7 @@ export const fetchUserRights = async (userId: string): Promise => { +export const fetchAllUsers = async (): Promise => { try { const response = await fetch('./api/user/users', { method: 'GET', @@ -114,7 +115,7 @@ export const overwritePassword = async ( } }; -export const postUserData = async (url: string = '', data: Record = {}): Promise => { +export const postUserData = async (url: string = '', data: Record = {}): Promise => { try { const response = await fetch(url, { method: 'POST', @@ -133,7 +134,27 @@ export const postUserData = async (url: string = '', data: Record = } }; -export const deleteUser = async (userId: string, csrfToken: string): Promise => { +export const activateUser = async (userId: string, csrfToken: string): Promise => { + try { + const response = await fetch('./api/user/activate', { + method: 'POST', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + csrfToken: csrfToken, + userId: userId, + }), + }); + + return await response.json(); + } catch (error) { + throw error; + } +}; + +export const deleteUser = async (userId: string, csrfToken: string): Promise => { try { const response = await fetch('./api/user/delete', { method: 'DELETE', diff --git a/phpmyfaq/admin/assets/src/interfaces/index.ts b/phpmyfaq/admin/assets/src/interfaces/index.ts index 51fbb0062a..fcf09874c7 100644 --- a/phpmyfaq/admin/assets/src/interfaces/index.ts +++ b/phpmyfaq/admin/assets/src/interfaces/index.ts @@ -2,3 +2,5 @@ export * from './elasticsearch'; export * from './instance'; export * from './response'; export * from './stopWord'; +export * from './userAutocomplete'; +export * from './userData'; diff --git a/phpmyfaq/admin/assets/src/interfaces/userAutocomplete.ts b/phpmyfaq/admin/assets/src/interfaces/userAutocomplete.ts new file mode 100644 index 0000000000..ef9c218401 --- /dev/null +++ b/phpmyfaq/admin/assets/src/interfaces/userAutocomplete.ts @@ -0,0 +1,4 @@ +export interface UserAutocomplete { + label: string; + value: string; +} diff --git a/phpmyfaq/admin/assets/src/interfaces/userData.ts b/phpmyfaq/admin/assets/src/interfaces/userData.ts new file mode 100644 index 0000000000..d5d031ac29 --- /dev/null +++ b/phpmyfaq/admin/assets/src/interfaces/userData.ts @@ -0,0 +1,12 @@ +export interface UserData { + userId: string; + login: string; + displayName: string; + email: string; + status: string; + lastModified: string; + authSource: string; + twoFactorEnabled: string; + isSuperadmin: string; + json(): Promise; +} diff --git a/phpmyfaq/admin/assets/src/user/autocomplete.test.ts b/phpmyfaq/admin/assets/src/user/autocomplete.test.ts new file mode 100644 index 0000000000..86ccfe1ac1 --- /dev/null +++ b/phpmyfaq/admin/assets/src/user/autocomplete.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import autocomplete from 'autocompleter'; +import { updateUser } from './users'; +import { fetchUsers } from '../api'; +import { addElement } from '../../../../assets/src/utils'; +import './autocomplete'; // Ensure the event listener is registered + +vi.mock('autocompleter', () => ({ + __esModule: true, + default: vi.fn(), +})); + +vi.mock('./users', () => ({ + updateUser: vi.fn(), +})); + +vi.mock('../api', () => ({ + fetchUsers: vi.fn(), +})); + +vi.mock('../../../../assets/src/utils', () => ({ + addElement: vi.fn(() => document.createElement('div')), +})); + +describe('User Autocomplete', () => { + beforeEach(() => { + document.body.innerHTML = ` + + `; + }); + + test('should initialize autocomplete on DOMContentLoaded', () => { + const mockAutocomplete = vi.fn(); + (autocomplete as unknown as vi.Mock).mockImplementation(mockAutocomplete); + + document.dispatchEvent(new Event('DOMContentLoaded')); + + expect(mockAutocomplete).toHaveBeenCalled(); + }); + + test('should call updateUser on item select', async () => { + const mockItem = { label: 'John Doe', value: '1' }; + const input = document.getElementById('pmf-user-list-autocomplete') as HTMLInputElement; + + const onSelect = (autocomplete as unknown as vi.Mock).mock.calls[0][0].onSelect; + await onSelect(mockItem, input); + + expect(updateUser).toHaveBeenCalledWith('1'); + }); + + test('should fetch and filter users', async () => { + const mockUsers = [{ label: 'John Doe', value: '1' }]; + (fetchUsers as unknown as vi.Mock).mockResolvedValue(mockUsers); + + const fetch = (autocomplete as unknown as vi.Mock).mock.calls[0][0].fetch; + const callback = vi.fn(); + await fetch('john', callback); + + expect(fetchUsers).toHaveBeenCalledWith('john'); + expect(callback).toHaveBeenCalledWith(mockUsers); + }); + + test('should render user suggestions', () => { + const mockItem = { label: 'John Doe', value: '1' }; + const render = (autocomplete as unknown as vi.Mock).mock.calls[0][0].render; + const result = render(mockItem, 'john'); + + expect(addElement).toHaveBeenCalledWith('div', { + classList: 'pmf-user-list-result border', + innerHTML: 'John Doe', + }); + expect(result).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/phpmyfaq/admin/assets/src/user/autocomplete.ts b/phpmyfaq/admin/assets/src/user/autocomplete.ts index 7cf8f877d0..b791f0ef09 100644 --- a/phpmyfaq/admin/assets/src/user/autocomplete.ts +++ b/phpmyfaq/admin/assets/src/user/autocomplete.ts @@ -17,13 +17,9 @@ import autocomplete, { AutocompleteItem } from 'autocompleter'; import { updateUser } from './users'; import { fetchUsers } from '../api'; import { addElement } from '../../../../assets/src/utils'; +import { UserAutocomplete } from '../interfaces'; -interface User { - label: string; - value: string; -} - -type UserSuggestion = User & AutocompleteItem; +type UserSuggestion = UserAutocomplete & AutocompleteItem; document.addEventListener('DOMContentLoaded', () => { const autoComplete = document.getElementById('pmf-user-list-autocomplete') as HTMLInputElement; diff --git a/phpmyfaq/admin/assets/src/user/user-list.test.ts b/phpmyfaq/admin/assets/src/user/user-list.test.ts new file mode 100644 index 0000000000..523651a253 --- /dev/null +++ b/phpmyfaq/admin/assets/src/user/user-list.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { activateUser, deleteUser, overwritePassword, postUserData } from '../api'; + +global.fetch = vi.fn(); + +describe('User API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should overwrite password', async () => { + const mockResponse = { success: true }; + (fetch as vi.Mock).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const response = await overwritePassword('csrfToken', 'userId', 'newPassword', 'passwordRepeat'); + expect(fetch).toHaveBeenCalledWith('./api/user/overwrite-password', expect.any(Object)); + expect(response).toEqual(mockResponse); + }); + + it('should post user data', async () => { + const mockResponse = { success: true }; + (fetch as vi.Mock).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const response = await postUserData('url', { key: 'value' }); + expect(fetch).toHaveBeenCalledWith('url', expect.any(Object)); + expect(response).toEqual(mockResponse); + }); + + it('should activate user', async () => { + const mockResponse = { success: true }; + (fetch as vi.Mock).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const response = await activateUser('userId', 'csrfToken'); + expect(fetch).toHaveBeenCalledWith('./api/user/activate', expect.any(Object)); + expect(response).toEqual(mockResponse); + }); + + it('should delete user', async () => { + const mockResponse = { success: true }; + (fetch as vi.Mock).mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const response = await deleteUser('userId', 'csrfToken'); + expect(fetch).toHaveBeenCalledWith('./api/user/delete', expect.any(Object)); + expect(response).toEqual(mockResponse); + }); +}); diff --git a/phpmyfaq/admin/assets/src/user/user-list.ts b/phpmyfaq/admin/assets/src/user/user-list.ts index ac6f9cdc6a..367ff892fa 100644 --- a/phpmyfaq/admin/assets/src/user/user-list.ts +++ b/phpmyfaq/admin/assets/src/user/user-list.ts @@ -1,8 +1,6 @@ /** * Functions for handling user management * - * @todo move fetch() functionality to api functions - * * This Source Code Form is subject to the terms of the Mozilla Public License, * v. 2.0. If a copy of the MPL was not distributed with this file, You can * obtain one at https://mozilla.org/MPL/2.0/. @@ -16,41 +14,9 @@ */ import { addElement, pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; -import { deleteUser } from '../api'; +import { activateUser, deleteUser } from '../api'; import { Modal } from 'bootstrap'; - -const activateUser = async (userId: string, csrfToken: string): Promise => { - try { - const response = await fetch('./api/user/activate', { - method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - csrfToken: csrfToken, - userId: userId, - }), - }); - - if (response.status === 200) { - await response.json(); - const icon = document.querySelector(`.icon_user_id_${userId}`) as HTMLElement; - icon.classList.remove('bi-ban'); - icon.classList.add('bi-check-circle-o'); - const button = document.getElementById(`btn_activate_user_id_${userId}`) as HTMLElement; - button.remove(); - } else { - throw new Error('Network response was not ok.'); - } - } catch (error) { - const message = document.getElementById('pmf-user-message') as HTMLElement; - message.insertAdjacentElement( - 'afterend', - addElement('div', { classList: 'alert alert-danger', innerText: (error as Error).message }) - ); - } -}; +import { Response } from '../interfaces'; export const handleUserList = (): void => { const activateButtons = document.querySelectorAll('.btn-activate-user'); @@ -65,7 +31,21 @@ export const handleUserList = (): void => { const csrfToken = target.getAttribute('data-csrf-token')!; const userId = target.getAttribute('data-user-id')!; - await activateUser(userId, csrfToken); + const response = (await activateUser(userId, csrfToken)) as unknown as Response; + + if (typeof response.success === 'string') { + const icon = document.querySelector(`.icon_user_id_${userId}`) as HTMLElement; + icon.classList.remove('bi-ban'); + icon.classList.add('bi-check-circle-o'); + const button = document.getElementById(`btn_activate_user_id_${userId}`) as HTMLElement; + button.remove(); + } else { + const message = document.getElementById('pmf-user-message') as HTMLElement; + message.insertAdjacentElement( + 'afterend', + addElement('div', { classList: 'alert alert-danger', innerText: response.error }) + ); + } }); }); } @@ -94,15 +74,14 @@ export const handleUserList = (): void => { if (source.value === 'user-list') { const userId = (document.getElementById('pmf-user-id-delete') as HTMLInputElement).value; const csrfToken = (document.getElementById('csrf-token-delete-user') as HTMLInputElement).value; - const response = await deleteUser(userId, csrfToken); - const json = await response.json(); - if (json.success) { - pushNotification(json.success); + const response = (await deleteUser(userId, csrfToken)) as unknown as Response; + if (response.success) { + pushNotification(response.success); const row = document.getElementById('row_user_id_' + userId) as HTMLElement; row.remove(); } - if (json.error) { - pushErrorNotification(json.error); + if (response.error) { + pushErrorNotification(response.error); } } }); diff --git a/phpmyfaq/admin/assets/src/user/users.ts b/phpmyfaq/admin/assets/src/user/users.ts index d3e2e10d88..3b5c5ea202 100644 --- a/phpmyfaq/admin/assets/src/user/users.ts +++ b/phpmyfaq/admin/assets/src/user/users.ts @@ -1,8 +1,6 @@ /** * JavaScript functions for user frontend * - * @todo move fetch() functionality to api functions - * * This Source Code Form is subject to the terms of the Mozilla Public License, * v. 2.0. If a copy of the MPL was not distributed with this file, You can * obtain one at https://mozilla.org/MPL/2.0/. @@ -16,8 +14,9 @@ */ import { Modal } from 'bootstrap'; -import { fetchUserData, fetchUserRights, deleteUser, postUserData } from '../api'; +import { fetchUserData, fetchUserRights, deleteUser, postUserData, overwritePassword } from '../api'; import { capitalize, pushErrorNotification, pushNotification } from '../../../../assets/src/utils'; +import { Response, UserData } from '../interfaces'; /** * Updates the current loaded user @@ -33,20 +32,20 @@ export const updateUser = async (userId: string): Promise => { * @param {string} userId */ const setUserData = async (userId: string): Promise => { - const userData = await fetchUserData(userId); + const userData = (await fetchUserData(userId)) as unknown as UserData; - updateInput('current_user_id', userData.user_id); + updateInput('current_user_id', userData.userId); updateInput('pmf-user-list-autocomplete', userData.login); - updateInput('last_modified', userData.last_modified); - updateInput('update_user_id', userData.user_id); - updateInput('modal_user_id', userData.user_id); - updateInput('auth_source', capitalize(userData.auth_source)); + updateInput('last_modified', userData.lastModified); + updateInput('update_user_id', userData.userId); + updateInput('modal_user_id', userData.userId); + updateInput('auth_source', capitalize(userData.authSource)); updateInput('user_status', userData.status); - updateInput('display_name', userData.display_name); + updateInput('display_name', userData.displayName); updateInput('email', userData.email); - updateInput('overwrite_twofactor', userData.twofactor_enabled); + updateInput('overwrite_twofactor', userData.twoFactorEnabled); - if (userData.is_superadmin) { + if (userData.isSuperadmin) { const superAdmin = document.getElementById('is_superadmin') as HTMLInputElement; superAdmin.setAttribute('checked', 'checked'); document.querySelectorAll('.permission').forEach((checkbox) => { @@ -62,7 +61,7 @@ const setUserData = async (userId: string): Promise => { superAdmin.removeAttribute('checked'); } - if (userData.twofactor_enabled === '1') { + if (userData.twoFactorEnabled) { const twoFactorEnabled = document.getElementById('overwrite_twofactor') as HTMLInputElement; twoFactorEnabled.setAttribute('checked', 'checked'); twoFactorEnabled.removeAttribute('disabled'); @@ -136,7 +135,6 @@ export const handleUsers = async (): Promise => { const modalBackdrop = document.getElementsByClassName('modal-backdrop fade show') as HTMLCollectionOf; const addUser = document.getElementById('pmf-add-user-action') as HTMLButtonElement; const addUserForm = document.getElementById('pmf-add-user-form') as HTMLFormElement; - const addUserError = document.getElementById('pmf-add-user-error-message') as HTMLElement; const passwordToggle = document.getElementById('add_user_automatic_password') as HTMLInputElement; const passwordInputs = document.getElementById('add_user_show_password_inputs') as HTMLElement; const isSuperAdmin = document.getElementById('is_superadmin') as HTMLInputElement; @@ -185,7 +183,7 @@ export const handleUsers = async (): Promise => { } if (addUser) { - addUser.addEventListener('click', (event) => { + addUser.addEventListener('click', async (event) => { event.preventDefault(); const csrf = (document.getElementById('add_user_csrf') as HTMLInputElement).value; const userName = (document.getElementById('add_user_name') as HTMLInputElement).value; @@ -194,12 +192,11 @@ export const handleUsers = async (): Promise => { const email = (document.getElementById('add_user_email') as HTMLInputElement).value; const password = (document.getElementById('add_user_password') as HTMLInputElement).value; const passwordConfirm = (document.getElementById('add_user_password_confirm') as HTMLInputElement).value; - let isSuperAdmin = document.querySelector('#add_user_is_superadmin') as HTMLInputElement; + const superAdmin = document.querySelector('#add_user_is_superadmin') as HTMLInputElement; - if (isSuperAdmin) { - isSuperAdmin = isSuperAdmin.checked; - } else { - isSuperAdmin = false; + let isSuperAdmin: boolean = false; + if (superAdmin) { + isSuperAdmin = superAdmin.checked as boolean; } addUserForm.classList.add('was-validated'); @@ -215,32 +212,23 @@ export const handleUsers = async (): Promise => { isSuperAdmin, }; - postUserData('./api/user/add', userData) - .then(async (response) => { - if (response.ok) { - return response.json(); - } - if (response.status === 400) { - const json = await response.json(); - json.forEach((item: string) => { - pushErrorNotification(item); - }); - } - throw new Error('Network response was not ok: ', { cause: { response } }); - }) - .then((response) => { + try { + const response = (await postUserData('./api/user/add', userData)) as unknown as Response; + if (typeof response.success === 'string') { modal.style.display = 'none'; modal.classList.remove('show'); - modalBackdrop[0].parentNode.removeChild(modalBackdrop[0]); + modalBackdrop[0].parentNode?.removeChild(modalBackdrop[0]); pushNotification(response.success); setTimeout(() => { location.reload(); }, 1500); - }) - .catch(async (error) => { - console.error('Error adding user: ' + error); - throw error; - }); + } else { + pushErrorNotification(response.error as string); + } + } catch (error) { + console.error('Error adding user: ', error); + throw error; + } }); } @@ -267,7 +255,7 @@ export const handleUsers = async (): Promise => { const newPassword = (document.getElementById('npass') as HTMLInputElement).value; const passwordRepeat = (document.getElementById('bpass') as HTMLInputElement).value; - const response = await overwritePassword(csrf, userId, newPassword, passwordRepeat); + const response = (await overwritePassword(csrf, userId, newPassword, passwordRepeat)) as unknown as Response; if (response.success) { pushNotification(response.success); modal.hide(); @@ -301,14 +289,13 @@ export const handleUsers = async (): Promise => { if (source.value === 'users') { const userId = (document.getElementById('pmf-user-id-delete') as HTMLInputElement).value; const csrfToken = (document.getElementById('csrf-token-delete-user') as HTMLInputElement).value; - const response = await deleteUser(userId, csrfToken); - const json = await response.json(); - if (json.success) { - pushNotification(json.success); + const response = (await deleteUser(userId, csrfToken)) as unknown as Response; + if (response.success) { + pushNotification(response.success); await clearUserForm(); } - if (json.error) { - pushErrorNotification(json.error); + if (response.error) { + pushErrorNotification(response.error); } } }); @@ -331,13 +318,12 @@ export const handleUsers = async (): Promise => { userId: userId, }; - const response = await postUserData('./api/user/edit', userData); - const json = await response.json(); - if (json.success) { - pushNotification(json.success); + const response = (await postUserData('./api/user/edit', userData)) as unknown as Response; + if (response.success) { + pushNotification(response.success); } - if (json.error) { - pushErrorNotification(json.error); + if (response.error) { + pushErrorNotification(response.error); } await updateUser(userId); }); @@ -359,13 +345,12 @@ export const handleUsers = async (): Promise => { userId: userId, userRights: rightData, }; - const response = await postUserData('./api/user/update-rights', data); - const json = await response.json(); - if (json.success) { - pushNotification(json.success); + const response = (await postUserData('./api/user/update-rights', data)) as unknown as Response; + if (response.success) { + pushNotification(response.success); } - if (json.error) { - pushErrorNotification(json.error); + if (response.error) { + pushErrorNotification(response.error); } await updateUser(userId); }); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php index e28af602e2..aef26a829b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php @@ -139,30 +139,33 @@ public function csvExport(): Response } /** - * @throws Exception + * @throws Exception|\Exception */ #[Route('admin/api/user/data')] public function userData(Request $request): JsonResponse { $this->userHasUserPermission(); - $user = CurrentUser::getCurrentUser($this->configuration); + $user = $this->container->get('phpmyfaq.user.current_user'); $user->getUserById($request->get('userId'), true); - $userdata = $user->userdata->get('*'); - - if (is_array($userdata)) { - $userdata['status'] = $user->getStatus(); - $userdata['login'] = Strings::htmlentities($user->getLogin(), ENT_COMPAT); - $userdata['display_name'] = Strings::htmlentities($userdata['display_name'], ENT_COMPAT); - $userdata['is_superadmin'] = $user->isSuperAdmin(); - $userdata['auth_source'] = $user->getUserAuthSource(); + $userData = $user->userdata->get('*'); + if (is_array($userData)) { + $userData['userId'] = $user->getUserId(); + $userData['status'] = $user->getStatus(); + $userData['login'] = $user->getLogin(); + $userData['displayName'] = $userData['display_name']; + $userData['isSuperadmin'] = $user->isSuperAdmin(); + $userData['authSource'] = $user->getUserAuthSource(); + $userData['isVisible'] = $userData['is_visible']; + $userData['twoFactorEnabled'] = $userData['twofactor_enabled']; + $userData['lastModified'] = $userData['last_modified']; } else { - $userdata = []; + $userData = []; } - return $this->json($userdata, Response::HTTP_OK); + return $this->json($userData, Response::HTTP_OK); } /** diff --git a/tsconfig.json b/tsconfig.json index f5cda8038a..67d51c066d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ESNext", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "types": ["vite/client", "vitest"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "vitest", "vitest/globals"], "skipLibCheck": true, "moduleResolution": "bundler",