From cd575f25c150b0145011a5faf28957f28185c9b1 Mon Sep 17 00:00:00 2001 From: Hubert Date: Fri, 31 Oct 2025 10:07:29 +0100 Subject: [PATCH 1/3] tests for projects creation + delete hook --- .../CreateProjectDialogContainer.cy.tsx | 156 ++++++++++++++++++ .../Dialogs/CreateProjectDialogContainer.tsx | 37 ++--- .../Projects/ProjectsListItemMenu.tsx | 21 ++- src/hooks/useCreateProject.spec.ts | 111 +++++++++++++ src/hooks/useCreateProject.ts | 41 +++++ src/hooks/useDeleteProject.spec.ts | 93 +++++++++++ src/hooks/useDeleteProject.ts | 23 +++ 7 files changed, 450 insertions(+), 32 deletions(-) create mode 100644 src/components/Dialogs/CreateProjectDialogContainer.cy.tsx create mode 100644 src/hooks/useCreateProject.spec.ts create mode 100644 src/hooks/useCreateProject.ts create mode 100644 src/hooks/useDeleteProject.spec.ts create mode 100644 src/hooks/useDeleteProject.ts diff --git a/src/components/Dialogs/CreateProjectDialogContainer.cy.tsx b/src/components/Dialogs/CreateProjectDialogContainer.cy.tsx new file mode 100644 index 00000000..62ba8a18 --- /dev/null +++ b/src/components/Dialogs/CreateProjectDialogContainer.cy.tsx @@ -0,0 +1,156 @@ +import { CreateProjectDialogContainer } from './CreateProjectDialogContainer'; +import { useCreateProject, CreateProjectParams } from '../../hooks/useCreateProject'; +import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding'; + +describe('CreateProjectDialogContainer', () => { + let createProjectPayload: CreateProjectParams | null = null; + + const fakeUseCreateProject: typeof useCreateProject = () => ({ + createProject: async (data: CreateProjectParams): Promise => { + createProjectPayload = data; + }, + isLoading: false, + }); + + const fakeUseAuthOnboarding = (() => ({ + user: { + email: 'name@domain.com', + }, + })) as typeof useAuthOnboarding; + + beforeEach(() => { + createProjectPayload = null; + }); + + it('creates a project with valid data', () => { + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + const expectedPayload = { + name: 'test-project', + displayName: 'Test Project Display Name', + chargingTarget: '12345678-1234-1234-1234-123456789abc', + chargingTargetType: 'btp', + members: [ + { + name: 'name@domain.com', + roles: ['admin'], + kind: 'User', + }, + ], + }; + + // Fill in the form + cy.get('#name').find('input[id*="inner"]').type('test-project'); + cy.get('#displayName').find('input[id*="inner"]').type('Test Project Display Name'); + + // Select charging target type (should be pre-selected as 'btp') + cy.get('#chargingTargetType').click(); + cy.contains('BTP').click(); + + // Fill charging target + cy.get('#chargingTarget').find('input[id*="inner"]').type('12345678-1234-1234-1234-123456789abc'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Verify the hook was called with correct data + cy.then(() => cy.wrap(createProjectPayload).deepEqualJson(expectedPayload)); + + // Dialog should close on success + cy.wrap(setIsOpen).should('have.been.calledWith', false); + }); + + it('validates required fields', () => { + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + // Try to submit without filling required fields + cy.get('ui5-button').contains('Create').click(); + + // Should show validation errors + cy.get('#name').should('have.attr', 'value-state', 'Negative'); + cy.contains('This field is required').should('exist'); + + // Dialog should not close + cy.wrap(setIsOpen).should('not.have.been.called'); + }); + + it('validates charging target format for BTP', () => { + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + cy.get('#name').find('input[id*="inner"]').type('test-project'); + cy.get('#chargingTargetType').click(); + cy.contains('BTP').click(); + + // Invalid format + cy.get('#chargingTarget').find('input[id*="inner"]').type('invalid-format'); + cy.get('ui5-button').contains('Create').click(); + + // Should show validation error + cy.get('#chargingTarget').should('have.attr', 'value-state', 'Negative'); + + // Dialog should not close + cy.wrap(setIsOpen).should('not.have.been.called'); + }); + + it('should not close dialog when creation fails', () => { + const failingUseCreateProject: typeof useCreateProject = () => ({ + createProject: async (): Promise => { + throw new Error('Creation failed'); + }, + isLoading: false, + }); + + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + // Fill in the form + cy.get('#name').find('input[id*="inner"]').type('test-project'); + cy.get('#chargingTargetType').click(); + cy.contains('BTP').click(); + cy.get('#chargingTarget').find('input[id*="inner"]').type('12345678-1234-1234-1234-123456789abc'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Dialog should NOT close on failure + cy.wrap(setIsOpen).should('not.have.been.called'); + + // Dialog should still be visible + cy.contains('Create').should('be.visible'); + }); +}); diff --git a/src/components/Dialogs/CreateProjectDialogContainer.tsx b/src/components/Dialogs/CreateProjectDialogContainer.tsx index 11eb95f4..1b0495da 100644 --- a/src/components/Dialogs/CreateProjectDialogContainer.tsx +++ b/src/components/Dialogs/CreateProjectDialogContainer.tsx @@ -1,26 +1,26 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useApiResourceMutation } from '../../lib/api/useApiResource'; import { ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error'; import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog.tsx'; - -import { useToast } from '../../context/ToastContext.tsx'; -import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; +import { useAuthOnboarding as _useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { MemberRoles } from '../../lib/api/types/shared/members.ts'; - import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { CreateProject, CreateProjectResource, CreateProjectType } from '../../lib/api/types/crate/createProject.ts'; import { createProjectWorkspaceSchema } from '../../lib/api/validations/schemas.ts'; import { CreateDialogProps } from './CreateWorkspaceDialogContainer.tsx'; +import { useCreateProject as _useCreateProject } from '../../hooks/useCreateProject.ts'; export function CreateProjectDialogContainer({ isOpen, setIsOpen, + useCreateProject = _useCreateProject, + useAuthOnboarding = _useAuthOnboarding, }: { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; + useCreateProject?: typeof _useCreateProject; + useAuthOnboarding?: typeof _useAuthOnboarding; }) { const { t } = useTranslation(); const validationSchemaProjectWorkspace = useMemo(() => createProjectWorkspaceSchema(t), [t]); @@ -44,6 +44,9 @@ export function CreateProjectDialogContainer({ const { user } = useAuthOnboarding(); const username = user?.email; + const { createProject } = useCreateProject(); + const errorDialogRef = useRef(null); + const clearForm = useCallback(() => { resetField('name'); resetField('chargingTarget'); @@ -60,12 +63,6 @@ export function CreateProjectDialogContainer({ } }, [resetField, setValue, username, isOpen, clearForm]); - const toast = useToast(); - - const { trigger } = useApiResourceMutation(CreateProjectResource()); - - const errorDialogRef = useRef(null); - const handleProjectCreate = async ({ name, chargingTarget, @@ -74,16 +71,14 @@ export function CreateProjectDialogContainer({ members, }: OnCreatePayload): Promise => { try { - await trigger( - CreateProject(name, { - displayName: displayName, - chargingTarget: chargingTarget, - members: members, - chargingTargetType: chargingTargetType, - }), - ); + await createProject({ + name, + displayName, + chargingTarget, + chargingTargetType, + members, + }); setIsOpen(false); - toast.show(t('CreateProjectDialog.toastMessage')); return true; } catch (e) { console.error(e); diff --git a/src/components/Projects/ProjectsListItemMenu.tsx b/src/components/Projects/ProjectsListItemMenu.tsx index 7a02fae0..82e8b3ce 100644 --- a/src/components/Projects/ProjectsListItemMenu.tsx +++ b/src/components/Projects/ProjectsListItemMenu.tsx @@ -7,23 +7,25 @@ import '@ui5/webcomponents-icons/dist/accept'; import { useTranslation } from 'react-i18next'; import { DeleteConfirmationDialog } from '../Dialogs/DeleteConfirmationDialog.tsx'; -import { useToast } from '../../context/ToastContext.tsx'; -import { useApiResourceMutation } from '../../lib/api/useApiResource.ts'; -import { DeleteWorkspaceType } from '../../lib/api/types/crate/deleteWorkspace.ts'; -import { DeleteProjectResource } from '../../lib/api/types/crate/deleteProject.ts'; +import { useDeleteProject as _useDeleteProject } from '../../hooks/useDeleteProject.ts'; import { KubectlDeleteProject } from '../Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteProject.tsx'; type ProjectsListItemMenuProps = { projectName: string; + useDeleteProject?: typeof _useDeleteProject; }; -export const ProjectsListItemMenu: FC = ({ projectName }) => { +export const ProjectsListItemMenu: FC = ({ + projectName, + useDeleteProject = _useDeleteProject, +}) => { const popoverRef = useRef(null); const [open, setOpen] = useState(false); const [dialogDeleteProjectIsOpen, setDialogDeleteProjectIsOpen] = useState(false); const { t } = useTranslation(); - const toast = useToast(); - const { trigger } = useApiResourceMutation(DeleteProjectResource(projectName)); + + const { deleteProject } = useDeleteProject(projectName); + const handleOpenerClick = (e: Ui5CustomEvent) => { e.stopImmediatePropagation(); e.stopPropagation(); @@ -59,10 +61,7 @@ export const ProjectsListItemMenu: FC = ({ projectNam kubectl={} isOpen={dialogDeleteProjectIsOpen} setIsOpen={setDialogDeleteProjectIsOpen} - onDeletionConfirmed={async () => { - await trigger(); - toast.show(t('ProjectsListView.deleteConfirmationDialog')); - }} + onDeletionConfirmed={deleteProject} /> )} diff --git a/src/hooks/useCreateProject.spec.ts b/src/hooks/useCreateProject.spec.ts new file mode 100644 index 00000000..64b4e625 --- /dev/null +++ b/src/hooks/useCreateProject.spec.ts @@ -0,0 +1,111 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCreateProject } from './useCreateProject'; +import { describe, it, expect, vi, afterEach, Mock, beforeEach } from 'vitest'; +import { assertNonNullish, assertString } from '../utils/test/vitest-utils'; +import { MemberRoles } from '../lib/api/types/shared/members'; + +// Mock toast and translation +vi.mock('../context/ToastContext', () => ({ + useToast: () => ({ + show: vi.fn(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('useCreateProject', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should perform a valid create project request', async () => { + // ARRANGE + const mockProjectData = { + name: 'test-project', + displayName: 'Test Project', + chargingTarget: '12345678-1234-1234-1234-123456789abc', + chargingTargetType: 'btp', + members: [ + { + name: 'user@domain.com', + roles: [MemberRoles.admin], + kind: 'User' as const, + }, + ], + }; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({}), + } as unknown as Response); + + // ACT + const renderHookResult = renderHook(() => useCreateProject()); + const { createProject } = renderHookResult.result.current; + + await act(async () => { + await createProject(mockProjectData); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + const [url, init] = call; + assertNonNullish(init); + const { method, headers, body } = init; + + expect(url).toContain('/api/onboarding/apis/core.openmcp.cloud/v1alpha1/projects'); + expect(method).toBe('POST'); + expect(headers).toEqual( + expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-use-crate': 'true', + }), + ); + + assertString(body); + const parsedBody = JSON.parse(body); + expect(parsedBody.metadata.name).toBe('test-project'); + expect(parsedBody.metadata.annotations?.['openmcp.cloud/display-name']).toBe('Test Project'); + expect(parsedBody.metadata.labels?.['openmcp.cloud.sap/charging-target']).toBe( + '12345678-1234-1234-1234-123456789abc', + ); + expect(parsedBody.spec.members).toHaveLength(1); + expect(parsedBody.spec.members[0].name).toBe('user@domain.com'); + }); + + it('should throw error on API failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new Error('API Error')); + + const mockProjectData = { + name: 'test-project', + displayName: 'Test Project', + chargingTarget: '12345678-1234-1234-1234-123456789abc', + chargingTargetType: 'btp', + members: [], + }; + + // ACT + const renderHookResult = renderHook(() => useCreateProject()); + const { createProject } = renderHookResult.result.current; + + // ASSERT + await act(async () => { + await expect(createProject(mockProjectData)).rejects.toThrow('API Error'); + }); + }); +}); diff --git a/src/hooks/useCreateProject.ts b/src/hooks/useCreateProject.ts new file mode 100644 index 00000000..af578841 --- /dev/null +++ b/src/hooks/useCreateProject.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useApiResourceMutation } from '../lib/api/useApiResource'; +import { CreateProject, CreateProjectResource, CreateProjectType } from '../lib/api/types/crate/createProject'; +import { useToast } from '../context/ToastContext'; +import { Member } from '../lib/api/types/shared/members'; +import { useTranslation } from 'react-i18next'; + +export interface CreateProjectParams { + name: string; + displayName?: string; + chargingTarget?: string; + chargingTargetType?: string; + members: Member[]; +} + +export function useCreateProject() { + const { t } = useTranslation(); + const toast = useToast(); + + const { trigger, isMutating } = useApiResourceMutation(CreateProjectResource()); + + const createProject = useCallback( + async ({ name, displayName, chargingTarget, chargingTargetType, members }: CreateProjectParams): Promise => { + await trigger( + CreateProject(name, { + displayName, + chargingTarget, + chargingTargetType, + members, + }), + ); + toast.show(t('CreateProjectDialog.toastMessage')); + }, + [trigger, toast, t], + ); + + return { + createProject, + isLoading: isMutating, + }; +} diff --git a/src/hooks/useDeleteProject.spec.ts b/src/hooks/useDeleteProject.spec.ts new file mode 100644 index 00000000..952a0d57 --- /dev/null +++ b/src/hooks/useDeleteProject.spec.ts @@ -0,0 +1,93 @@ +import { act, renderHook } from '@testing-library/react'; +import { useDeleteProject } from './useDeleteProject'; +import { describe, it, expect, vi, afterEach, Mock, beforeEach } from 'vitest'; +import { assertNonNullish } from '../utils/test/vitest-utils'; + +// Mock toast and translation +vi.mock('../context/ToastContext', () => ({ + useToast: () => ({ + show: vi.fn(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('useDeleteProject', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should perform a valid delete project request', async () => { + // ARRANGE + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({}), + } as unknown as Response); + + // ACT + const renderHookResult = renderHook(() => useDeleteProject('test-project')); + const { deleteProject } = renderHookResult.result.current; + + await act(async () => { + await deleteProject(); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + const [url, init] = call; + assertNonNullish(init); + const { method, headers } = init; + + expect(url).toContain('/api/onboarding/apis/core.openmcp.cloud/v1alpha1/projects/test-project'); + expect(method).toBe('DELETE'); + expect(headers).toEqual( + expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-use-crate': 'true', + }), + ); + }); + + it('should throw error on API failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new Error('API Error')); + + // ACT + const renderHookResult = renderHook(() => useDeleteProject('test-project')); + const { deleteProject } = renderHookResult.result.current; + + // ASSERT + await act(async () => { + await expect(deleteProject()).rejects.toThrow('API Error'); + }); + }); + + it('should throw error on network failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new TypeError('Network error')); + + // ACT + const renderHookResult = renderHook(() => useDeleteProject('test-project')); + const { deleteProject } = renderHookResult.result.current; + + // ASSERT + await act(async () => { + await expect(deleteProject()).rejects.toThrow('Network error'); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useDeleteProject.ts b/src/hooks/useDeleteProject.ts new file mode 100644 index 00000000..42d492ae --- /dev/null +++ b/src/hooks/useDeleteProject.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; +import { useApiResourceMutation } from '../lib/api/useApiResource'; +import { DeleteProjectResource } from '../lib/api/types/crate/deleteProject'; +import { DeleteWorkspaceType } from '../lib/api/types/crate/deleteWorkspace'; +import { useToast } from '../context/ToastContext'; +import { useTranslation } from 'react-i18next'; + +export function useDeleteProject(projectName: string) { + const { t } = useTranslation(); + const toast = useToast(); + + const { trigger, isMutating } = useApiResourceMutation(DeleteProjectResource(projectName)); + + const deleteProject = useCallback(async (): Promise => { + await trigger(); + toast.show(t('ProjectsListView.deleteConfirmationDialog')); + }, [trigger, toast, t]); + + return { + deleteProject, + isDeleting: isMutating, + }; +} From 5a2e555f6e3677e751f3a63a1ae97958760f670c Mon Sep 17 00:00:00 2001 From: Hubert Date: Fri, 31 Oct 2025 11:37:56 +0100 Subject: [PATCH 2/3] removing isMutating --- src/hooks/useCreateProject.ts | 5 ++--- src/hooks/useDeleteProject.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/hooks/useCreateProject.ts b/src/hooks/useCreateProject.ts index af578841..7580afe7 100644 --- a/src/hooks/useCreateProject.ts +++ b/src/hooks/useCreateProject.ts @@ -17,7 +17,7 @@ export function useCreateProject() { const { t } = useTranslation(); const toast = useToast(); - const { trigger, isMutating } = useApiResourceMutation(CreateProjectResource()); + const { trigger } = useApiResourceMutation(CreateProjectResource()); const createProject = useCallback( async ({ name, displayName, chargingTarget, chargingTargetType, members }: CreateProjectParams): Promise => { @@ -35,7 +35,6 @@ export function useCreateProject() { ); return { - createProject, - isLoading: isMutating, + createProject }; } diff --git a/src/hooks/useDeleteProject.ts b/src/hooks/useDeleteProject.ts index 42d492ae..76093cf4 100644 --- a/src/hooks/useDeleteProject.ts +++ b/src/hooks/useDeleteProject.ts @@ -9,7 +9,7 @@ export function useDeleteProject(projectName: string) { const { t } = useTranslation(); const toast = useToast(); - const { trigger, isMutating } = useApiResourceMutation(DeleteProjectResource(projectName)); + const { trigger } = useApiResourceMutation(DeleteProjectResource(projectName)); const deleteProject = useCallback(async (): Promise => { await trigger(); @@ -17,7 +17,6 @@ export function useDeleteProject(projectName: string) { }, [trigger, toast, t]); return { - deleteProject, - isDeleting: isMutating, + deleteProject }; } From c5c6cc27b009c1570ceb62e777b9bb543c42eb11 Mon Sep 17 00:00:00 2001 From: Hubert Date: Fri, 31 Oct 2025 11:45:42 +0100 Subject: [PATCH 3/3] adding deletion project tests + lint fix --- .../Projects/ProjectsListItemMenu.cy.tsx | 41 +++++++++++++++++++ src/hooks/useCreateProject.ts | 2 +- src/hooks/useDeleteProject.ts | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/components/Projects/ProjectsListItemMenu.cy.tsx diff --git a/src/components/Projects/ProjectsListItemMenu.cy.tsx b/src/components/Projects/ProjectsListItemMenu.cy.tsx new file mode 100644 index 00000000..929f32c6 --- /dev/null +++ b/src/components/Projects/ProjectsListItemMenu.cy.tsx @@ -0,0 +1,41 @@ +import { ProjectsListItemMenu } from './ProjectsListItemMenu.tsx'; +import { useDeleteProject } from '../../hooks/useDeleteProject.ts'; +import '@ui5/webcomponents-cypress-commands'; + +describe('ProjectsListItemMenu', () => { + let deleteProjectCalled = false; + + const fakeUseDeleteProject: typeof useDeleteProject = () => ({ + deleteProject: async (): Promise => { + deleteProjectCalled = true; + }, + }); + + beforeEach(() => { + deleteProjectCalled = false; + }); + + it('deletes the project', () => { + const projectName = 'test-project'; + + cy.mount(); + + // Open overflow menu + cy.get('ui5-button[icon="overflow"]').click(); + + // Click delete option + cy.contains('Delete project').click({ force: true }); + + // Type confirmation text + cy.get('ui5-dialog[open]').find('ui5-input').typeIntoUi5Input(projectName); + + // Verify delete not called yet + cy.then(() => cy.wrap(deleteProjectCalled).should('equal', false)); + + // Click delete button + cy.get('ui5-dialog[open]').find('ui5-button').contains('Delete').click(); + + // Verify delete was called + cy.then(() => cy.wrap(deleteProjectCalled).should('equal', true)); + }); +}); diff --git a/src/hooks/useCreateProject.ts b/src/hooks/useCreateProject.ts index 7580afe7..65d1c349 100644 --- a/src/hooks/useCreateProject.ts +++ b/src/hooks/useCreateProject.ts @@ -35,6 +35,6 @@ export function useCreateProject() { ); return { - createProject + createProject, }; } diff --git a/src/hooks/useDeleteProject.ts b/src/hooks/useDeleteProject.ts index 76093cf4..ace8f8bf 100644 --- a/src/hooks/useDeleteProject.ts +++ b/src/hooks/useDeleteProject.ts @@ -17,6 +17,6 @@ export function useDeleteProject(projectName: string) { }, [trigger, toast, t]); return { - deleteProject + deleteProject, }; }