diff --git a/cypress/support/commands.d.ts b/cypress/support/commands.d.ts new file mode 100644 index 00000000..7cb6ecb4 --- /dev/null +++ b/cypress/support/commands.d.ts @@ -0,0 +1,16 @@ +declare global { + namespace Cypress { + interface Chainable { + /** + * Deep-compares two objects after normalising them with + * JSON.stringify/parse (removes proxies, undefined, symbols …). + * + * @example + * cy.wrap(actual).deepEqualJson(expected) + */ + deepEqualJson(expected: unknown): Chainable; + } + } +} + +export {}; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 4ae0de28..06dc1d0a 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,2 +1,9 @@ import '@ui5/webcomponents-cypress-commands'; -import "../../src/utils/i18n/i18n"; \ No newline at end of file +import '../../src/utils/i18n/i18n'; + +const toPlain = (o: T): T => JSON.parse(JSON.stringify(o)); + +Cypress.Commands.add('deepEqualJson', { prevSubject: true }, (subject, expected) => { + expect(toPlain(subject)).to.deep.equal(toPlain(expected)); + return subject; +}); diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.cy.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.cy.tsx new file mode 100644 index 00000000..2b21da21 --- /dev/null +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.cy.tsx @@ -0,0 +1,81 @@ +import { CreateManagedControlPlaneWizardContainer } from './CreateManagedControlPlaneWizardContainer.tsx'; +import { useCreateManagedControlPlane } from '../../../hooks/useCreateManagedControlPlane.tsx'; +import { CreateManagedControlPlaneType } from '../../../lib/api/types/crate/createManagedControlPlane.ts'; +import { useAuthOnboarding } from '../../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; + +describe('CreateManagedControlPlaneWizardContainer', () => { + let createMutationPayload: CreateManagedControlPlaneType | null = null; + + const fakeUseCreateManagedControlPlane: typeof useCreateManagedControlPlane = () => ({ + mutate: async (data: CreateManagedControlPlaneType): Promise => { + createMutationPayload = data; + return data; + }, + }); + const fakeUseAuthOnboarding = (() => ({ + user: { + email: 'name@domain.com', + }, + })) as typeof useAuthOnboarding; + + beforeEach(() => { + createMutationPayload = null; + }); + + it('creates a Managed Control Plane', () => { + cy.mount( + {}} + />, + ); + + const expMutationPayload: CreateManagedControlPlaneType = { + apiVersion: 'core.openmcp.cloud/v1alpha1', + kind: 'ManagedControlPlane', + metadata: { + name: 'some-text', + namespace: '--ws-', + annotations: { + 'openmcp.cloud/display-name': '', + }, + labels: { + 'openmcp.cloud.sap/charging-target-type': '', + 'openmcp.cloud.sap/charging-target': '', + }, + }, + spec: { + authentication: { + enableSystemIdentityProvider: true, + }, + components: { + apiServer: { + type: 'GardenerDedicated', + }, + }, + authorization: { + roleBindings: [ + { + role: 'admin', + subjects: [ + { + kind: 'User', + name: 'openmcp:name@domain.com', + }, + ], + }, + ], + }, + }, + }; + + cy.get('#name').find(' input[id*="inner"]').type('some-text'); + cy.get('ui5-button').contains('Next').click(); // navigate to Members + cy.get('ui5-button').contains('Next').click(); // navigate to Component Selection + cy.get('ui5-button').contains('Next').click(); // navigate to Summarize + cy.get('ui5-button').contains('Create').click(); + cy.then(() => cy.wrap(createMutationPayload).deepEqualJson(expMutationPayload)); + }); +}); diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index dbcc2057..a787c434 100644 --- a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -22,7 +22,7 @@ import { import { SummarizeStep } from './SummarizeStep.tsx'; import { Trans, useTranslation } from 'react-i18next'; -import { useAuthOnboarding } from '../../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; +import { useAuthOnboarding as _useAuthOnboarding } from '../../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { ErrorDialog, ErrorDialogHandle } from '../../Shared/ErrorMessageBox.tsx'; import { CreateDialogProps } from '../../Dialogs/CreateWorkspaceDialogContainer.tsx'; import { createManagedControlPlaneSchema } from '../../../lib/api/validations/schemas.ts'; @@ -61,6 +61,7 @@ import { stringify } from 'yaml'; import { useComponentsSelectionData } from './useComponentsSelectionData.ts'; import { Infobox } from '../../Ui/Infobox/Infobox.tsx'; import styles from './CreateManagedControlPlaneWizardContainer.module.css'; +import { useCreateManagedControlPlane as _useCreateManagedControlPlane } from '../../../hooks/useCreateManagedControlPlane.tsx'; type CreateManagedControlPlaneWizardContainerProps = { isOpen: boolean; @@ -73,6 +74,8 @@ type CreateManagedControlPlaneWizardContainerProps = { initialData?: ManagedControlPlaneInterface; isOnMcpPage?: boolean; initialSection?: WizardStepType; + useCreateManagedControlPlane?: typeof _useCreateManagedControlPlane; + useAuthOnboarding?: typeof _useAuthOnboarding; }; export type WizardStepType = 'metadata' | 'members' | 'componentSelection' | 'summarize' | 'success'; @@ -90,6 +93,8 @@ export const CreateManagedControlPlaneWizardContainer: FC { const { t } = useTranslation(); const { user } = useAuthOnboarding(); @@ -216,6 +221,7 @@ export const CreateManagedControlPlaneWizardContainer: FC( CreateManagedControlPlaneResource(projectName, workspaceName), ); + const { mutate: createManagedControlPlane } = useCreateManagedControlPlane(projectName, workspaceName); const { trigger: triggerUpdate } = useApiResourceMutation( UpdateManagedControlPlaneResource(projectName, workspaceName, initialData?.metadata?.name ?? ''), undefined, @@ -250,7 +256,7 @@ export const CreateManagedControlPlaneWizardContainer: FC { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should perform a valid request', async () => { + // ARRANGE + const mockData: CreateManagedControlPlaneType = { + apiVersion: 'core.openmcp.cloud/v1alpha1', + kind: 'ManagedControlPlane', + metadata: { + name: 'name', + namespace: 'project-projectName--ws-workspaceName', + annotations: { + 'openmcp.cloud/display-name': 'display-name', + }, + labels: { + 'openmcp.cloud.sap/charging-target-type': 'BTP', + 'openmcp.cloud.sap/charging-target': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + }, + }, + spec: { + authentication: { + enableSystemIdentityProvider: true, + }, + components: { + externalSecretsOperator: { + version: '0.20.1', + }, + flux: { + version: '2.16.2', + }, + kyverno: { + version: '3.5.2', + }, + btpServiceOperator: { + version: '0.9.2', + }, + apiServer: { + type: 'GardenerDedicated', + }, + crossplane: { + version: '1.19.0', + providers: [ + { + name: 'provider-hana', + version: '0.2.0', + }, + ], + }, + }, + authorization: { + roleBindings: [ + { + role: 'admin', + subjects: [ + { + kind: 'User', + name: 'openmcp:user@domain.com', + }, + ], + }, + ], + }, + }, + }; + + const fetchMock: Mock = vi.fn(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({}), + } as unknown as Response); + global.fetch = fetchMock; + + // ACT + const renderHookResult = renderHook(() => useCreateManagedControlPlane('projectName', 'workspaceName')); + const { mutate: create } = renderHookResult.result.current; + + await act(async () => { + await create(mockData); + }); + + // 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/namespaces/projectName--ws-workspaceName/managedcontrolplanes', + ); + + 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).toEqual(mockData); + }); +}); diff --git a/src/hooks/useCreateManagedControlPlane.tsx b/src/hooks/useCreateManagedControlPlane.tsx new file mode 100644 index 00000000..b9bb3df2 --- /dev/null +++ b/src/hooks/useCreateManagedControlPlane.tsx @@ -0,0 +1,17 @@ +import { + CreateManagedControlPlaneResource, + CreateManagedControlPlaneType, +} from '../lib/api/types/crate/createManagedControlPlane.ts'; +import { useApiResourceMutation } from '../lib/api/useApiResource.ts'; + +export function useCreateManagedControlPlane(projectName: string, workspaceName: string) { + const { trigger } = useApiResourceMutation( + CreateManagedControlPlaneResource(projectName, workspaceName), + ); + + const mutate = async (data: CreateManagedControlPlaneType) => { + return trigger(data); + }; + + return { mutate }; +} diff --git a/src/utils/test/vitest-utils.ts b/src/utils/test/vitest-utils.ts new file mode 100644 index 00000000..239c1f0a --- /dev/null +++ b/src/utils/test/vitest-utils.ts @@ -0,0 +1,18 @@ +import { expect } from 'vitest'; + +/** + * Asserts that `value` is neither `null` nor `undefined` (non-nullish). + * Narrows the type, so that subsequent type assertions (!) become unnecessary. + */ +export function assertNonNullish(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); + expect(value).not.toBeNull(); +} + +/** + * Asserts that `value` is a `string`. + * Narrows the type of `value` to `string` after this call. + */ +export function assertString(value: unknown): asserts value is string { + expect(typeof value).toBe('string'); +} diff --git a/tsconfig.json b/tsconfig.json index 98078ac8..21b6e77f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,7 @@ "noFallthroughCasesInSwitch": true, "types": ["node", "cypress"] }, - "include": ["src", "cypress.d.ts"], + "include": ["src", "cypress.d.ts", "cypress/**/*.d.ts"], "references": [ { "path": "./tsconfig.node.json"