Skip to content

Commit cd575f2

Browse files
tests for projects creation + delete hook
1 parent 89f48a7 commit cd575f2

File tree

7 files changed

+450
-32
lines changed

7 files changed

+450
-32
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { CreateProjectDialogContainer } from './CreateProjectDialogContainer';
2+
import { useCreateProject, CreateProjectParams } from '../../hooks/useCreateProject';
3+
import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding';
4+
5+
describe('CreateProjectDialogContainer', () => {
6+
let createProjectPayload: CreateProjectParams | null = null;
7+
8+
const fakeUseCreateProject: typeof useCreateProject = () => ({
9+
createProject: async (data: CreateProjectParams): Promise<void> => {
10+
createProjectPayload = data;
11+
},
12+
isLoading: false,
13+
});
14+
15+
const fakeUseAuthOnboarding = (() => ({
16+
user: {
17+
18+
},
19+
})) as typeof useAuthOnboarding;
20+
21+
beforeEach(() => {
22+
createProjectPayload = null;
23+
});
24+
25+
it('creates a project with valid data', () => {
26+
const setIsOpen = cy.stub();
27+
28+
cy.mount(
29+
<CreateProjectDialogContainer
30+
useCreateProject={fakeUseCreateProject}
31+
useAuthOnboarding={fakeUseAuthOnboarding}
32+
isOpen={true}
33+
setIsOpen={setIsOpen}
34+
/>,
35+
);
36+
37+
const expectedPayload = {
38+
name: 'test-project',
39+
displayName: 'Test Project Display Name',
40+
chargingTarget: '12345678-1234-1234-1234-123456789abc',
41+
chargingTargetType: 'btp',
42+
members: [
43+
{
44+
45+
roles: ['admin'],
46+
kind: 'User',
47+
},
48+
],
49+
};
50+
51+
// Fill in the form
52+
cy.get('#name').find('input[id*="inner"]').type('test-project');
53+
cy.get('#displayName').find('input[id*="inner"]').type('Test Project Display Name');
54+
55+
// Select charging target type (should be pre-selected as 'btp')
56+
cy.get('#chargingTargetType').click();
57+
cy.contains('BTP').click();
58+
59+
// Fill charging target
60+
cy.get('#chargingTarget').find('input[id*="inner"]').type('12345678-1234-1234-1234-123456789abc');
61+
62+
// Submit the form
63+
cy.get('ui5-button').contains('Create').click();
64+
65+
// Verify the hook was called with correct data
66+
cy.then(() => cy.wrap(createProjectPayload).deepEqualJson(expectedPayload));
67+
68+
// Dialog should close on success
69+
cy.wrap(setIsOpen).should('have.been.calledWith', false);
70+
});
71+
72+
it('validates required fields', () => {
73+
const setIsOpen = cy.stub();
74+
75+
cy.mount(
76+
<CreateProjectDialogContainer
77+
useCreateProject={fakeUseCreateProject}
78+
useAuthOnboarding={fakeUseAuthOnboarding}
79+
isOpen={true}
80+
setIsOpen={setIsOpen}
81+
/>,
82+
);
83+
84+
// Try to submit without filling required fields
85+
cy.get('ui5-button').contains('Create').click();
86+
87+
// Should show validation errors
88+
cy.get('#name').should('have.attr', 'value-state', 'Negative');
89+
cy.contains('This field is required').should('exist');
90+
91+
// Dialog should not close
92+
cy.wrap(setIsOpen).should('not.have.been.called');
93+
});
94+
95+
it('validates charging target format for BTP', () => {
96+
const setIsOpen = cy.stub();
97+
98+
cy.mount(
99+
<CreateProjectDialogContainer
100+
useCreateProject={fakeUseCreateProject}
101+
useAuthOnboarding={fakeUseAuthOnboarding}
102+
isOpen={true}
103+
setIsOpen={setIsOpen}
104+
/>,
105+
);
106+
107+
cy.get('#name').find('input[id*="inner"]').type('test-project');
108+
cy.get('#chargingTargetType').click();
109+
cy.contains('BTP').click();
110+
111+
// Invalid format
112+
cy.get('#chargingTarget').find('input[id*="inner"]').type('invalid-format');
113+
cy.get('ui5-button').contains('Create').click();
114+
115+
// Should show validation error
116+
cy.get('#chargingTarget').should('have.attr', 'value-state', 'Negative');
117+
118+
// Dialog should not close
119+
cy.wrap(setIsOpen).should('not.have.been.called');
120+
});
121+
122+
it('should not close dialog when creation fails', () => {
123+
const failingUseCreateProject: typeof useCreateProject = () => ({
124+
createProject: async (): Promise<void> => {
125+
throw new Error('Creation failed');
126+
},
127+
isLoading: false,
128+
});
129+
130+
const setIsOpen = cy.stub();
131+
132+
cy.mount(
133+
<CreateProjectDialogContainer
134+
useCreateProject={failingUseCreateProject}
135+
useAuthOnboarding={fakeUseAuthOnboarding}
136+
isOpen={true}
137+
setIsOpen={setIsOpen}
138+
/>,
139+
);
140+
141+
// Fill in the form
142+
cy.get('#name').find('input[id*="inner"]').type('test-project');
143+
cy.get('#chargingTargetType').click();
144+
cy.contains('BTP').click();
145+
cy.get('#chargingTarget').find('input[id*="inner"]').type('12345678-1234-1234-1234-123456789abc');
146+
147+
// Submit the form
148+
cy.get('ui5-button').contains('Create').click();
149+
150+
// Dialog should NOT close on failure
151+
cy.wrap(setIsOpen).should('not.have.been.called');
152+
153+
// Dialog should still be visible
154+
cy.contains('Create').should('be.visible');
155+
});
156+
});

src/components/Dialogs/CreateProjectDialogContainer.tsx

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
import { useCallback, useEffect, useMemo, useRef } from 'react';
2-
import { useApiResourceMutation } from '../../lib/api/useApiResource';
32
import { ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx';
43
import { APIError } from '../../lib/api/error';
54
import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog.tsx';
6-
7-
import { useToast } from '../../context/ToastContext.tsx';
8-
import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx';
5+
import { useAuthOnboarding as _useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx';
96
import { MemberRoles } from '../../lib/api/types/shared/members.ts';
10-
117
import { useTranslation } from 'react-i18next';
128
import { zodResolver } from '@hookform/resolvers/zod';
139
import { useForm } from 'react-hook-form';
14-
import { CreateProject, CreateProjectResource, CreateProjectType } from '../../lib/api/types/crate/createProject.ts';
1510
import { createProjectWorkspaceSchema } from '../../lib/api/validations/schemas.ts';
1611
import { CreateDialogProps } from './CreateWorkspaceDialogContainer.tsx';
12+
import { useCreateProject as _useCreateProject } from '../../hooks/useCreateProject.ts';
1713

1814
export function CreateProjectDialogContainer({
1915
isOpen,
2016
setIsOpen,
17+
useCreateProject = _useCreateProject,
18+
useAuthOnboarding = _useAuthOnboarding,
2119
}: {
2220
isOpen: boolean;
2321
setIsOpen: (isOpen: boolean) => void;
22+
useCreateProject?: typeof _useCreateProject;
23+
useAuthOnboarding?: typeof _useAuthOnboarding;
2424
}) {
2525
const { t } = useTranslation();
2626
const validationSchemaProjectWorkspace = useMemo(() => createProjectWorkspaceSchema(t), [t]);
@@ -44,6 +44,9 @@ export function CreateProjectDialogContainer({
4444
const { user } = useAuthOnboarding();
4545

4646
const username = user?.email;
47+
const { createProject } = useCreateProject();
48+
const errorDialogRef = useRef<ErrorDialogHandle>(null);
49+
4750
const clearForm = useCallback(() => {
4851
resetField('name');
4952
resetField('chargingTarget');
@@ -60,12 +63,6 @@ export function CreateProjectDialogContainer({
6063
}
6164
}, [resetField, setValue, username, isOpen, clearForm]);
6265

63-
const toast = useToast();
64-
65-
const { trigger } = useApiResourceMutation<CreateProjectType>(CreateProjectResource());
66-
67-
const errorDialogRef = useRef<ErrorDialogHandle>(null);
68-
6966
const handleProjectCreate = async ({
7067
name,
7168
chargingTarget,
@@ -74,16 +71,14 @@ export function CreateProjectDialogContainer({
7471
members,
7572
}: OnCreatePayload): Promise<boolean> => {
7673
try {
77-
await trigger(
78-
CreateProject(name, {
79-
displayName: displayName,
80-
chargingTarget: chargingTarget,
81-
members: members,
82-
chargingTargetType: chargingTargetType,
83-
}),
84-
);
74+
await createProject({
75+
name,
76+
displayName,
77+
chargingTarget,
78+
chargingTargetType,
79+
members,
80+
});
8581
setIsOpen(false);
86-
toast.show(t('CreateProjectDialog.toastMessage'));
8782
return true;
8883
} catch (e) {
8984
console.error(e);

src/components/Projects/ProjectsListItemMenu.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,25 @@ import '@ui5/webcomponents-icons/dist/accept';
77
import { useTranslation } from 'react-i18next';
88
import { DeleteConfirmationDialog } from '../Dialogs/DeleteConfirmationDialog.tsx';
99

10-
import { useToast } from '../../context/ToastContext.tsx';
11-
import { useApiResourceMutation } from '../../lib/api/useApiResource.ts';
12-
import { DeleteWorkspaceType } from '../../lib/api/types/crate/deleteWorkspace.ts';
13-
import { DeleteProjectResource } from '../../lib/api/types/crate/deleteProject.ts';
10+
import { useDeleteProject as _useDeleteProject } from '../../hooks/useDeleteProject.ts';
1411
import { KubectlDeleteProject } from '../Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteProject.tsx';
1512

1613
type ProjectsListItemMenuProps = {
1714
projectName: string;
15+
useDeleteProject?: typeof _useDeleteProject;
1816
};
1917

20-
export const ProjectsListItemMenu: FC<ProjectsListItemMenuProps> = ({ projectName }) => {
18+
export const ProjectsListItemMenu: FC<ProjectsListItemMenuProps> = ({
19+
projectName,
20+
useDeleteProject = _useDeleteProject,
21+
}) => {
2122
const popoverRef = useRef<MenuDomRef>(null);
2223
const [open, setOpen] = useState(false);
2324
const [dialogDeleteProjectIsOpen, setDialogDeleteProjectIsOpen] = useState(false);
2425
const { t } = useTranslation();
25-
const toast = useToast();
26-
const { trigger } = useApiResourceMutation<DeleteWorkspaceType>(DeleteProjectResource(projectName));
26+
27+
const { deleteProject } = useDeleteProject(projectName);
28+
2729
const handleOpenerClick = (e: Ui5CustomEvent<ButtonDomRef, ButtonClickEventDetail>) => {
2830
e.stopImmediatePropagation();
2931
e.stopPropagation();
@@ -59,10 +61,7 @@ export const ProjectsListItemMenu: FC<ProjectsListItemMenuProps> = ({ projectNam
5961
kubectl={<KubectlDeleteProject projectName={projectName} />}
6062
isOpen={dialogDeleteProjectIsOpen}
6163
setIsOpen={setDialogDeleteProjectIsOpen}
62-
onDeletionConfirmed={async () => {
63-
await trigger();
64-
toast.show(t('ProjectsListView.deleteConfirmationDialog'));
65-
}}
64+
onDeletionConfirmed={deleteProject}
6665
/>
6766
)}
6867
</div>

src/hooks/useCreateProject.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { useCreateProject } from './useCreateProject';
3+
import { describe, it, expect, vi, afterEach, Mock, beforeEach } from 'vitest';
4+
import { assertNonNullish, assertString } from '../utils/test/vitest-utils';
5+
import { MemberRoles } from '../lib/api/types/shared/members';
6+
7+
// Mock toast and translation
8+
vi.mock('../context/ToastContext', () => ({
9+
useToast: () => ({
10+
show: vi.fn(),
11+
}),
12+
}));
13+
14+
vi.mock('react-i18next', () => ({
15+
useTranslation: () => ({
16+
t: (key: string) => key,
17+
}),
18+
}));
19+
20+
describe('useCreateProject', () => {
21+
let fetchMock: Mock<typeof fetch>;
22+
23+
beforeEach(() => {
24+
fetchMock = vi.fn();
25+
global.fetch = fetchMock;
26+
});
27+
28+
afterEach(() => {
29+
vi.clearAllMocks();
30+
});
31+
32+
it('should perform a valid create project request', async () => {
33+
// ARRANGE
34+
const mockProjectData = {
35+
name: 'test-project',
36+
displayName: 'Test Project',
37+
chargingTarget: '12345678-1234-1234-1234-123456789abc',
38+
chargingTargetType: 'btp',
39+
members: [
40+
{
41+
42+
roles: [MemberRoles.admin],
43+
kind: 'User' as const,
44+
},
45+
],
46+
};
47+
48+
fetchMock.mockResolvedValue({
49+
ok: true,
50+
status: 200,
51+
json: vi.fn().mockResolvedValue({}),
52+
} as unknown as Response);
53+
54+
// ACT
55+
const renderHookResult = renderHook(() => useCreateProject());
56+
const { createProject } = renderHookResult.result.current;
57+
58+
await act(async () => {
59+
await createProject(mockProjectData);
60+
});
61+
62+
// ASSERT
63+
expect(fetchMock).toHaveBeenCalledTimes(1);
64+
65+
const call = fetchMock.mock.calls[0];
66+
const [url, init] = call;
67+
assertNonNullish(init);
68+
const { method, headers, body } = init;
69+
70+
expect(url).toContain('/api/onboarding/apis/core.openmcp.cloud/v1alpha1/projects');
71+
expect(method).toBe('POST');
72+
expect(headers).toEqual(
73+
expect.objectContaining({
74+
'Content-Type': 'application/json',
75+
'X-use-crate': 'true',
76+
}),
77+
);
78+
79+
assertString(body);
80+
const parsedBody = JSON.parse(body);
81+
expect(parsedBody.metadata.name).toBe('test-project');
82+
expect(parsedBody.metadata.annotations?.['openmcp.cloud/display-name']).toBe('Test Project');
83+
expect(parsedBody.metadata.labels?.['openmcp.cloud.sap/charging-target']).toBe(
84+
'12345678-1234-1234-1234-123456789abc',
85+
);
86+
expect(parsedBody.spec.members).toHaveLength(1);
87+
expect(parsedBody.spec.members[0].name).toBe('[email protected]');
88+
});
89+
90+
it('should throw error on API failure', async () => {
91+
// ARRANGE
92+
fetchMock.mockRejectedValue(new Error('API Error'));
93+
94+
const mockProjectData = {
95+
name: 'test-project',
96+
displayName: 'Test Project',
97+
chargingTarget: '12345678-1234-1234-1234-123456789abc',
98+
chargingTargetType: 'btp',
99+
members: [],
100+
};
101+
102+
// ACT
103+
const renderHookResult = renderHook(() => useCreateProject());
104+
const { createProject } = renderHookResult.result.current;
105+
106+
// ASSERT
107+
await act(async () => {
108+
await expect(createProject(mockProjectData)).rejects.toThrow('API Error');
109+
});
110+
});
111+
});

0 commit comments

Comments
 (0)