Add protected person deletion

This commit is contained in:
karilint
2026-05-25 14:04:06 +03:00
parent ee1421a5bd
commit 6f4428211c
6 changed files with 359 additions and 4 deletions
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { PersonDetails } from '@/components/Person/PersonDetails'
import { PersonDetailsType, Role } from '@/shared/types'
jest.mock('@/components/DetailView/DetailView', () => ({
DetailView: (props: { deleteFunction?: () => Promise<void> }) => {
const handleDelete = () => {
if (props.deleteFunction) void props.deleteFunction()
}
return <button onClick={handleDelete}>Delete</button>
},
}))
jest.mock('@/components/Person/Tabs/PersonTab', () => ({
PersonTab: () => <div data-testid="person-tab" />,
}))
const mockUseGetPersonDetailsQuery = jest.fn()
const mockUseGetPersonDetailsIdMutation = jest.fn()
const mockUseEditPersonMutation = jest.fn()
const mockUseDeletePersonMutation = jest.fn()
const mockUseUser = jest.fn()
const mockNotify = jest.fn()
const mockNavigate = jest.fn()
let deletePersonMock: jest.Mock
jest.mock('@/redux/personReducer', () => ({
useGetPersonDetailsQuery: (id: string, options?: unknown) => mockUseGetPersonDetailsQuery(id, options),
useGetPersonDetailsIdMutation: () => mockUseGetPersonDetailsIdMutation(),
useEditPersonMutation: () => mockUseEditPersonMutation(),
useDeletePersonMutation: () => mockUseDeletePersonMutation(),
}))
jest.mock('@/hooks/user', () => ({
useUser: () => mockUseUser(),
}))
jest.mock('@/hooks/notification', () => ({
useNotify: () => ({ notify: mockNotify }),
}))
jest.mock('react-router-dom', () => {
const actualRouterDom = jest.requireActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actualRouterDom,
useNavigate: () => mockNavigate,
}
})
const person: PersonDetailsType = {
initials: 'DEL-UI',
first_name: 'Delete',
surname: 'Ui',
full_name: 'Delete Ui',
format: null,
email: 'delete-ui@example.com',
user_id: null,
organization: 'Test organization',
country: 'Finland',
password_set: null,
used_morph: null,
used_now: null,
used_gene: null,
user: null,
now_user_group: '',
}
const renderPersonDetails = () => {
render(
<MemoryRouter initialEntries={['/person/DEL-UI']}>
<Routes>
<Route path="/person/:id" element={<PersonDetails />} />
</Routes>
</MemoryRouter>
)
}
describe('PersonDetails', () => {
beforeEach(() => {
mockUseUser.mockReturnValue({ initials: 'TEST-SU', role: Role.Admin })
mockUseGetPersonDetailsQuery.mockReturnValue({ data: person, isLoading: false, isError: false })
mockUseGetPersonDetailsIdMutation.mockReturnValue([jest.fn()])
mockUseEditPersonMutation.mockReturnValue([jest.fn(), { isLoading: false }])
deletePersonMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
mockUseDeletePersonMutation.mockReturnValue([deletePersonMock, { isLoading: false }])
mockNotify.mockReset()
mockNavigate.mockReset()
})
it('deletes the person and navigates back to the list', async () => {
renderPersonDetails()
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(deletePersonMock).toHaveBeenCalledWith(person.initials)
})
expect(mockNotify).toHaveBeenCalledWith('Deleted person successfully.')
expect(mockNavigate).toHaveBeenCalledWith('/person')
})
it('shows the backend message when deleting is blocked', async () => {
deletePersonMock.mockReturnValue({
unwrap: () =>
Promise.reject(
Object.assign(new Error('Delete blocked'), {
status: 409,
data: { message: 'Person cannot be deleted because they are still linked to database data.' },
})
),
})
renderPersonDetails()
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
'Person cannot be deleted because they are still linked to database data.',
'error'
)
})
expect(mockNavigate).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router-dom'
import {
useDeletePersonMutation,
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useGetPersonDetailsQuery,
@@ -13,11 +14,30 @@ import { validatePerson } from '@/shared/validators/person'
import { useNotify } from '@/hooks/notification'
import { useEffect } from 'react'
import { emptyPerson } from '../DetailView/common/defaultValues'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
const getDeleteErrorMessage = (error: unknown): string => {
const fetchError = error as FetchBaseQueryError
if (
fetchError &&
typeof fetchError === 'object' &&
'data' in fetchError &&
fetchError.data &&
typeof fetchError.data === 'object' &&
'message' in fetchError.data &&
typeof fetchError.data.message === 'string'
) {
return fetchError.data.message
}
return 'Could not delete person.'
}
export const PersonDetails = () => {
const { id: idFromUrl } = useParams()
const user = useUser()
const [editPersonRequest, { isLoading: mutationLoading }] = useEditPersonMutation()
const [deletePersonRequest, { isLoading: deleteLoading }] = useDeletePersonMutation()
const { notify } = useNotify()
const navigate = useNavigate()
@@ -70,10 +90,22 @@ export const PersonDetails = () => {
}
if (isError) return <div>Error loading data</div>
if (isLoading || (!data && !isNew) || mutationLoading) return <CircularProgress />
if (isLoading || (!data && !isNew) || mutationLoading || deleteLoading) return <CircularProgress />
document.title = isNew ? 'New person' : `User - ${data!.user?.user_name}`
const deleteFunction = async () => {
if (!id) return
try {
await deletePersonRequest(id).unwrap()
notify('Deleted person successfully.')
navigate('/person')
} catch (error) {
notify(getDeleteErrorMessage(error), 'error')
}
}
const tabs: TabType[] = [
{
title: 'Person',
@@ -90,6 +122,7 @@ export const PersonDetails = () => {
tabs={tabs}
data={isNew ? emptyPerson : data!}
validator={validatePerson}
deleteFunction={deleteFunction}
/>
)
}
+14 -2
View File
@@ -28,8 +28,20 @@ const personsApi = api.injectEndpoints({
}),
invalidatesTags: (result, _error, { initials }) => (result ? [{ type: 'person', id: initials }, 'persons'] : []),
}),
deletePerson: builder.mutation<void, string>({
query: initials => ({
url: `/person/${initials}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _error, initials) => [{ type: 'person', id: initials }, 'persons'],
}),
}),
})
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation, useGetPersonDetailsIdMutation } =
personsApi
export const {
useGetAllPersonsQuery,
useGetPersonDetailsQuery,
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useDeletePersonMutation,
} = personsApi