diff --git a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx new file mode 100644 index 00000000000..a470f94adcf --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx @@ -0,0 +1,219 @@ +import * as React from 'react'; +import { screen } from '@testing-library/react'; +import * as _ from 'lodash'; +import * as Router from 'react-router-dom-v5-compat'; +import { DetailsPage } from '@console/internal/components/factory'; +import { Firehose } from '@console/internal/components/utils'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testCatalogSource, testPackageManifest, dummyPackageManifest } from '../../../mocks'; +import { CatalogSourceModel, PackageManifestModel } from '../../models'; +import { + CatalogSourceDetails, + CatalogSourceDetailsPage, + CreateSubscriptionYAML, + CatalogSourceOperatorsPage, +} from '../catalog-source'; + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(), +})); + +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useParams: jest.fn(), + useLocation: jest.fn(), +})); + +jest.mock('@console/internal/components/factory', () => ({ + DetailsPage: jest.fn(() => null), + Table: jest.fn(() => null), + TableData: jest.fn(({ children }) => children), + MultiListPage: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/utils', () => ({ + ...jest.requireActual('@console/internal/components/utils'), + Firehose: jest.fn(({ children }) => { + const props = { packageManifest: { loaded: false } }; + return typeof children === 'function' ? children(props) : children; + }), + LoadingBox: jest.fn(() => 'Loading...'), + ResourceSummary: jest.fn(() => null), + SectionHeading: jest.fn(() => null), + DetailsItem: jest.fn(({ obj, path, children }) => { + if (children) return children; + if (path) { + const value = path.split('.').reduce((acc, key) => acc?.[key], obj); + return value || null; + } + return null; + }), +})); + +jest.mock('@console/internal/components/create-yaml', () => ({ + CreateYAML: jest.fn(({ template }) => template), +})); + +jest.mock('../operator-group', () => ({ + requireOperatorGroup: jest.fn((component) => component), +})); + +jest.mock('@console/shared/src/components/error', () => ({ + ErrorBoundary: jest.fn(({ children }) => children), + withFallback: jest.fn((successComponent) => (props) => successComponent(props)), +})); + +jest.mock('../package-manifest', () => ({ + PackageManifestsPage: jest.fn(() => null), +})); + +jest.mock('../registry-poll-interval-details', () => ({ + RegistryPollIntervalDetailItem: jest.fn(() => null), +})); + +jest.mock('@console/shared/src/components/layout/PaneBody', () => ({ + __esModule: true, + default: jest.fn(({ children }) => children), +})); + +jest.mock('@patternfly/react-core', () => ({ + ...jest.requireActual('@patternfly/react-core'), + Grid: jest.fn(({ children }) => children), + GridItem: jest.fn(({ children }) => children), + DescriptionList: jest.fn(({ children }) => children), +})); + +const mockDetailsPage = (DetailsPage as unknown) as jest.Mock; +const mockFirehose = (Firehose as unknown) as jest.Mock; +const mockUseK8sWatchResource = useK8sWatchResource as jest.Mock; + +describe('CatalogSourceDetails', () => { + let obj; + + beforeEach(() => { + obj = _.cloneDeep(testCatalogSource); + }); + + it('displays catalog source name and publisher', () => { + renderWithProviders( + , + ); + + expect(screen.getByText(obj.spec.displayName, { exact: false })).toBeVisible(); + expect(screen.getByText(obj.spec.publisher, { exact: false })).toBeVisible(); + }); +}); + +describe('CatalogSourceDetailsPage', () => { + beforeEach(() => { + mockUseK8sWatchResource.mockReturnValue([dummyPackageManifest, true, null]); + jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default', name: 'some-catalog' }); + mockDetailsPage.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders catalog source details page without errors', () => { + expect(() => { + renderWithProviders(); + }).not.toThrow(); + }); + + // TODO: Refactor to test user behavior instead of implementation details + it('configures DetailsPage with correct navigation and resources', () => { + renderWithProviders(); + + expect(mockDetailsPage).toHaveBeenCalledTimes(1); + const [detailsPageProps] = mockDetailsPage.mock.calls[0]; + + expect(detailsPageProps.kind).toEqual(referenceForModel(CatalogSourceModel)); + + expect(detailsPageProps.pages).toHaveLength(3); + expect(detailsPageProps.pages[0]).toMatchObject({ + nameKey: 'public~Details', + component: CatalogSourceDetails, + }); + expect(detailsPageProps.pages[1]).toMatchObject({ + nameKey: 'public~YAML', + }); + expect(detailsPageProps.pages[2]).toMatchObject({ + nameKey: 'olm~Operators', + component: CatalogSourceOperatorsPage, + }); + + expect(detailsPageProps.resources).toEqual([ + { + kind: referenceForModel(PackageManifestModel), + isList: true, + prop: 'packageManifests', + namespace: 'default', + }, + ]); + }); +}); + +describe('CreateSubscriptionYAML', () => { + beforeEach(() => { + jest.spyOn(Router, 'useParams').mockReturnValue({ + ns: 'default', + pkgName: testPackageManifest.metadata.name, + }); + jest.spyOn(Router, 'useLocation').mockReturnValue({ + ...window.location, + search: `?pkg=${testPackageManifest.metadata.name}&catalog=ocs&catalogNamespace=default`, + }); + mockFirehose.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('displays package name in the subscription YAML when loaded', () => { + mockFirehose.mockImplementationOnce((firehoseProps) => { + const childElement = firehoseProps.children; + return React.cloneElement(childElement, { + packageManifest: { loaded: true, data: testPackageManifest }, + operatorGroup: { loaded: true, data: [] }, + }); + }); + + renderWithProviders(); + + expect(screen.getByText(new RegExp(testPackageManifest.metadata.name))).toBeVisible(); + }); + + it('displays loading indicator when package manifest is not yet loaded', () => { + mockFirehose.mockImplementationOnce((firehoseProps) => { + const childElement = firehoseProps.children; + return React.cloneElement(childElement, { + packageManifest: { loaded: false }, + operatorGroup: { loaded: false }, + }); + }); + + renderWithProviders(); + + expect(screen.getByText('Loading...')).toBeVisible(); + }); + + it('displays subscription YAML with default channel information', () => { + mockFirehose.mockImplementationOnce((firehoseProps) => { + const childElement = firehoseProps.children; + return React.cloneElement(childElement, { + packageManifest: { loaded: true, data: testPackageManifest }, + operatorGroup: { loaded: true, data: [] }, + }); + }); + + renderWithProviders(); + + expect(screen.getByText(/channel:\s*alpha/)).toBeInTheDocument(); + expect(screen.getByText(/source:\s*ocs/)).toBeInTheDocument(); + expect(screen.getByText(/startingCSV:\s*testapp/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/clusterserviceversion.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/clusterserviceversion.spec.tsx new file mode 100644 index 00000000000..8b89107e99f --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/clusterserviceversion.spec.tsx @@ -0,0 +1,356 @@ +import { screen } from '@testing-library/react'; +import * as _ from 'lodash'; +import operatorLogo from '@console/internal/imgs/operator.svg'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { + testClusterServiceVersion, + testSubscription, + testPackageManifest, + testInstallPlan, + testModel, + testSubscriptions, +} from '../../../mocks'; +import { ClusterServiceVersionPhase } from '../../types'; +import { ClusterServiceVersionLogo } from '../cluster-service-version-logo'; +import { + ClusterServiceVersionTableRow, + ClusterServiceVersionTableRowProps, + CRDCard, + CRDCardProps, + CSVSubscription, + CSVSubscriptionProps, +} from '../clusterserviceversion'; + +// Mock hooks +jest.mock('@console/shared/src/hooks/useK8sModel', () => ({ + useK8sModel: () => [testModel], +})); + +jest.mock('@console/internal/components/utils/rbac', () => ({ + useAccessReview: () => true, +})); + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + ...jest.requireActual('@console/dynamic-plugin-sdk'), + useAccessReview: () => [true, false], + useAccessReviewAllowed: () => true, +})); + +jest.mock('@console/shared/src/hooks/redux-selectors', () => ({ + useActiveNamespace: jest.fn(), +})); + +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useParams: jest.fn(), + useLocation: jest.fn(), +})); + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(() => [[], true, null]), +})); + +jest.mock('../../utils/useClusterServiceVersion', () => ({ + useClusterServiceVersion: jest.fn(() => [testClusterServiceVersion, true, null]), +})); + +jest.mock('../../utils/useClusterServiceVersionPath', () => ({ + useClusterServiceVersionPath: jest.fn(() => '/test-path'), +})); + +jest.mock('@console/internal/components/utils', () => ({ + ...jest.requireActual('@console/internal/components/utils'), + AsyncComponent: ({ children }) => children || null, +})); + +jest.mock('@console/internal/components/conditions', () => ({ + Conditions: () => 'Conditions', + ConditionTypes: { ClusterServiceVersion: 'ClusterServiceVersion' }, +})); + +jest.mock('@console/internal/components/events', () => ({ + ResourceEventStream: () => 'ResourceEventStream', +})); + +jest.mock('../operand', () => ({ + ProvidedAPIsPage: () => 'ProvidedAPIsPage', + ProvidedAPIPage: () => 'ProvidedAPIPage', +})); + +jest.mock('../subscription', () => ({ + ...jest.requireActual('../subscription'), + SubscriptionDetails: () => 'SubscriptionDetails', + SubscriptionUpdates: () => 'SubscriptionUpdates', + catalogSourceForSubscription: jest.fn(), +})); + +describe('ClusterServiceVersionTableRow', () => { + let clusterServiceVersionTableRowProps: ClusterServiceVersionTableRowProps; + + beforeEach(() => { + jest.clearAllMocks(); + window.SERVER_FLAGS.copiedCSVsDisabled = false; + + clusterServiceVersionTableRowProps = { + catalogSourceMissing: false, + obj: testClusterServiceVersion, + subscription: testSubscription, + }; + }); + + it('renders component wrapped in ErrorBoundary', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(testClusterServiceVersion.spec.displayName)).toBeVisible(); + }); + + it('renders LazyActionMenu with correct context and variant', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByRole('button', { name: 'Actions' })).toBeVisible(); + }); + + it('renders clickable link with CSV logo and display name', () => { + renderWithProviders( + + + + + + +
, + ); + + const link = screen.getByRole('link', { + name: new RegExp(testClusterServiceVersion.spec.displayName), + }); + expect(link).toBeVisible(); + }); + + it('renders managed namespace', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByRole('link', { name: 'openshift-operators' })).toBeVisible(); + }); + + it('renders last updated timestamp', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByTestId('timestamp')).toBeVisible(); + }); + + it('renders status showing Succeeded phase', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(ClusterServiceVersionPhase.CSVPhaseSucceeded)).toBeVisible(); + }); + + it('renders Deleting status when CSV has deletionTimestamp', () => { + const deletingCSV = _.cloneDeepWith(testClusterServiceVersion, (v, k) => + k === 'metadata' ? { ...v, deletionTimestamp: Date.now() } : undefined, + ); + + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText('Deleting')).toBeVisible(); + }); + + it('renders links for each CRD provided by the Operator', () => { + renderWithProviders( + + + + + + +
, + ); + + testClusterServiceVersion.spec.customresourcedefinitions.owned.forEach((desc) => { + const crdLink = screen.getByRole('link', { name: new RegExp(desc.displayName || desc.kind) }); + expect(crdLink).toBeVisible(); + }); + }); +}); + +describe('ClusterServiceVersionLogo', () => { + it('renders logo image from base64 encoded string', () => { + const { provider, icon, displayName } = testClusterServiceVersion.spec; + + renderWithProviders( + , + ); + + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('src', `data:${icon[0].mediatype};base64,${icon[0].base64data}`); + }); + + it('renders fallback image when icon is invalid', () => { + const { provider, displayName } = testClusterServiceVersion.spec; + + renderWithProviders( + , + ); + + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('src', operatorLogo); + }); + + it('renders CSV display name and provider name', () => { + const { provider, icon, displayName } = testClusterServiceVersion.spec; + + renderWithProviders( + , + ); + + expect(screen.getByText(displayName)).toBeVisible(); + expect(screen.getByText(new RegExp(provider.name))).toBeVisible(); + }); +}); + +describe('CRDCard', () => { + let crdCardProps: CRDCardProps; + const crd = testClusterServiceVersion.spec.customresourcedefinitions.owned[0]; + + beforeEach(() => { + crdCardProps = { + canCreate: false, + crd, + csv: testClusterServiceVersion, + }; + }); + + it('does not render create link when canCreate is false', () => { + renderWithProviders(); + + expect(screen.queryByRole('link', { name: 'Create instance' })).not.toBeInTheDocument(); + }); +}); + +describe('CSVSubscription', () => { + let csvSubscriptionProps: CSVSubscriptionProps; + + beforeEach(() => { + csvSubscriptionProps = { + obj: testClusterServiceVersion, + customData: { + subscriptions: testSubscriptions, + subscription: undefined, + subscriptionsLoaded: true, + }, + packageManifests: [], + installPlans: [], + }; + }); + + it('renders StatusBox with EmptyMsg when subscription does not exist', () => { + renderWithProviders(); + + expect(screen.getByText('No Operator Subscription')).toBeVisible(); + expect(screen.getByText('This Operator will not receive updates.')).toBeVisible(); + }); + + it('renders SubscriptionDetails when subscription exists', () => { + const obj = _.set(_.cloneDeep(testClusterServiceVersion), 'metadata.annotations', { + 'olm.operatorNamespace': 'default', + }); + const subscription = _.set(_.cloneDeep(testSubscription), 'status', { + installedCSV: obj.metadata.name, + }); + + renderWithProviders( + , + ); + expect(screen.queryByText('No Operator Subscription')).not.toBeInTheDocument(); + }); + + it('passes matching PackageManifest when multiple exist with same name', () => { + const obj = _.set(_.cloneDeep(testClusterServiceVersion), 'metadata.annotations', { + 'olm.operatorNamespace': 'default', + }); + const subscription = _.set(_.cloneDeep(testSubscription), 'status', { + installedCSV: obj.metadata.name, + }); + const otherPkg = _.set( + _.cloneDeep(testPackageManifest), + 'status.catalogSource', + 'other-source', + ); + + renderWithProviders( + , + ); + expect(screen.queryByText('No Operator Subscription')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/install-plan.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/install-plan.spec.tsx new file mode 100644 index 00000000000..7ce08abb903 --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/install-plan.spec.tsx @@ -0,0 +1,426 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import * as _ from 'lodash'; +import * as Router from 'react-router-dom-v5-compat'; +import * as k8sResourceModule from '@console/dynamic-plugin-sdk/src/utils/k8s/k8s-resource'; +import { Table, MultiListPage, DetailsPage } from '@console/internal/components/factory'; +import { useAccessReview } from '@console/internal/components/utils'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testInstallPlan } from '../../../mocks'; +import { InstallPlanModel, ClusterServiceVersionModel, OperatorGroupModel } from '../../models'; +import { InstallPlanKind, InstallPlanApproval } from '../../types'; +import { + InstallPlanTableRow, + InstallPlansList, + InstallPlansPage, + InstallPlanDetailsPage, + InstallPlanPreview, + InstallPlanDetails, +} from '../install-plan'; + +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useParams: jest.fn(), +})); + +jest.mock('@console/internal/components/utils/rbac', () => ({ + useAccessReview: jest.fn(), + asAccessReview: jest.fn(() => ({})), +})); + +jest.mock('@console/internal/components/factory', () => ({ + ...jest.requireActual('@console/internal/components/factory'), + Table: jest.fn(() => null), + MultiListPage: jest.fn(() => null), + DetailsPage: jest.fn(() => null), +})); + +const mockTable = Table as jest.Mock; +const mockMultiListPage = MultiListPage as jest.Mock; +const mockDetailsPage = DetailsPage as jest.Mock; +const mockUseAccessReview = useAccessReview as jest.Mock; + +describe('InstallPlanTableRow', () => { + let installPlan: InstallPlanKind; + const columns = []; + + beforeEach(() => { + jest.clearAllMocks(); + installPlan = _.cloneDeep(testInstallPlan); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders install plan name with correct resource link', () => { + renderWithProviders( + + + + + + +
, + ); + + const installPlanLinks = screen.getAllByText(installPlan.metadata.name); + const installPlanLink = installPlanLinks.find((link) => + link.getAttribute('href')?.includes('InstallPlan'), + ); + expect(installPlanLink).toBeVisible(); + }); + + it('renders install plan namespace', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(installPlan.metadata.namespace)).toBeVisible(); + }); + + it('renders install plan status', () => { + renderWithProviders( + + + + + + +
, + ); + + const statusElement = screen.getByTestId('status-text'); + expect(statusElement).toHaveTextContent(installPlan.status.phase); + }); + + it('renders fallback status when status.phase is undefined', () => { + const installPlanWithoutStatus = { ...installPlan, status: null }; + + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText('Unknown')).toBeVisible(); + }); + + it('renders CSV component name', () => { + renderWithProviders( + + + + + + +
, + ); + + const csvName = installPlan.spec.clusterServiceVersionNames[0]; + const csvLinks = screen.getAllByText(csvName); + const csvLink = csvLinks.find((link) => + link.getAttribute('href')?.includes('ClusterServiceVersion'), + ); + expect(csvLink).toBeVisible(); + }); +}); + +describe('InstallPlansList', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTable.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders Table component with correct header titles', () => { + renderWithProviders(); + + expect(mockTable).toHaveBeenCalledTimes(1); + const [tableProps] = mockTable.mock.calls[0]; + + const headers = tableProps.Header({}); + const headerTitles = headers.map((header) => header.title); + + expect(headerTitles).toEqual([ + 'Name', + 'Namespace', + 'Status', + 'Components', + 'Subscriptions', + '', + ]); + }); + + it('provides custom empty message for table', () => { + renderWithProviders(); + + expect(mockTable).toHaveBeenCalledTimes(1); + const [tableProps] = mockTable.mock.calls[0]; + + expect(tableProps.EmptyMsg).toBeDefined(); + }); +}); + +describe('InstallPlansPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default' }); + mockMultiListPage.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders MultiListPage with correct configuration', () => { + renderWithProviders(); + + expect(mockMultiListPage).toHaveBeenCalledTimes(1); + const [multiListPageProps] = mockMultiListPage.mock.calls[0]; + + expect(multiListPageProps.title).toEqual('InstallPlans'); + expect(multiListPageProps.showTitle).toBe(false); + expect(multiListPageProps.ListComponent).toEqual(InstallPlansList); + }); + + it('fetches InstallPlans and OperatorGroups from correct namespace', () => { + renderWithProviders(); + + expect(mockMultiListPage).toHaveBeenCalledTimes(1); + const [multiListPageProps] = mockMultiListPage.mock.calls[0]; + + expect(multiListPageProps.resources).toEqual([ + { + kind: referenceForModel(InstallPlanModel), + namespace: 'default', + namespaced: true, + prop: 'installPlan', + }, + { + kind: referenceForModel(OperatorGroupModel), + namespace: 'default', + namespaced: true, + prop: 'operatorGroup', + }, + ]); + }); +}); + +describe('InstallPlanPreview', () => { + let installPlan: InstallPlanKind; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseAccessReview.mockReturnValue(true); + + installPlan = { + ...testInstallPlan, + status: { + ...testInstallPlan.status, + plan: [ + { + resolving: 'testoperator.v1.0.0', + status: 'Created', + resource: { + group: ClusterServiceVersionModel.apiGroup, + version: ClusterServiceVersionModel.apiVersion, + kind: ClusterServiceVersionModel.kind, + name: 'testoperator.v1.0.0', + manifest: '', + }, + }, + { + resolving: 'testoperator.v1.0.0', + status: 'Unknown', + resource: { + group: 'apiextensions.k8s.io', + version: 'v1', + kind: 'CustomResourceDefinition', + name: 'test-crds.test.com', + manifest: '', + }, + }, + ], + }, + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders empty message when status.plan is empty', () => { + const emptyPlan = { ...installPlan, status: { ...installPlan.status, plan: [] } }; + + renderWithProviders(); + + expect(screen.getByText(/no components resolved/i)).toBeVisible(); + }); + + it('renders Approve button when install plan requires approval', () => { + const manualPlan = { + ...installPlan, + spec: { + ...installPlan.spec, + approval: InstallPlanApproval.Manual, + approved: false, + }, + }; + + renderWithProviders(); + + expect(screen.getByRole('button', { name: 'Approve' })).toBeVisible(); + }); + + it('calls k8sPatch to approve install plan when Approve button is clicked', async () => { + const k8sPatchSpy = jest.spyOn(k8sResourceModule, 'k8sPatch').mockResolvedValue(installPlan); + + const manualPlan = { + ...installPlan, + spec: { + ...installPlan.spec, + approval: InstallPlanApproval.Manual, + approved: false, + }, + }; + + renderWithProviders(); + + const approveButton = screen.getByRole('button', { name: 'Approve' }); + fireEvent.click(approveButton); + + await waitFor(() => { + expect(k8sPatchSpy).toHaveBeenCalledWith( + InstallPlanModel, + manualPlan, + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: '/spec/approved', + value: true, + }), + ]), + ); + }); + }); + + it('renders Deny button when install plan requires approval', () => { + const manualPlan = { + ...installPlan, + spec: { + ...installPlan.spec, + approval: InstallPlanApproval.Manual, + approved: false, + }, + }; + + renderWithProviders(); + + expect(screen.getByRole('button', { name: 'Deny' })).toBeVisible(); + }); + + it('renders component names from install plan', () => { + renderWithProviders(); + + const resourceName = installPlan.status.plan[0].resource.name; + const elements = screen.getAllByText(resourceName); + expect(elements.length).toBeGreaterThan(0); + expect(elements[0]).toBeVisible(); + }); + + it('renders preview button for uncreated components', () => { + renderWithProviders(); + + const uncreatedStep = installPlan.status.plan.find((step) => step.status === 'Unknown'); + expect(screen.getByRole('button', { name: uncreatedStep.resource.name })).toBeVisible(); + }); +}); + +describe('InstallPlanDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders link to Components tab when install plan needs approval', () => { + mockUseAccessReview.mockReturnValue(true); + const manualPlan = { + ...testInstallPlan, + spec: { + ...testInstallPlan.spec, + approval: InstallPlanApproval.Manual, + approved: false, + }, + }; + + renderWithProviders(); + + const previewButton = screen.getByRole('button', { name: 'Preview InstallPlan' }); + expect(previewButton).toBeVisible(); + + const link = previewButton.closest('a'); + expect(link).toHaveAttribute( + 'href', + `/k8s/ns/default/${referenceForModel(InstallPlanModel)}/${ + testInstallPlan.metadata.name + }/components`, + ); + }); + + it('does not render Components link when install plan is automatic', () => { + mockUseAccessReview.mockReturnValue(true); + const automaticPlan = { + ...testInstallPlan, + spec: { + ...testInstallPlan.spec, + approval: InstallPlanApproval.Automatic, + approved: true, + }, + }; + + renderWithProviders(); + + expect(screen.queryByRole('button', { name: 'Preview InstallPlan' })).not.toBeInTheDocument(); + }); +}); + +describe('InstallPlanDetailsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(Router, 'useParams') + .mockReturnValue({ ns: 'default', name: testInstallPlan.metadata.name }); + mockDetailsPage.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders DetailsPage with three navigation tabs', () => { + renderWithProviders(); + + expect(mockDetailsPage).toHaveBeenCalledTimes(1); + const [detailsPageProps] = mockDetailsPage.mock.calls[0]; + + const pageNames = detailsPageProps.pages.map((p) => p.name || p.nameKey); + expect(pageNames).toEqual(['public~Details', 'public~YAML', 'olm~Components']); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operator-group.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/operator-group.spec.tsx similarity index 79% rename from frontend/packages/operator-lifecycle-manager/src/components/operator-group.spec.tsx rename to frontend/packages/operator-lifecycle-manager/src/components/__tests__/operator-group.spec.tsx index 2313d266a0a..b7f84ba556b 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/operator-group.spec.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/operator-group.spec.tsx @@ -1,48 +1,51 @@ -import { shallow } from 'enzyme'; +import { screen } from '@testing-library/react'; import * as _ from 'lodash'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { testOperatorGroup, testSubscription, testPackageManifest, dummyPackageManifest, -} from '../../mocks'; -import { OperatorGroupKind, SubscriptionKind, InstallModeType } from '../types'; +} from '../../../mocks'; +import { OperatorGroupKind, SubscriptionKind, InstallModeType } from '../../types'; import { requireOperatorGroup, - NoOperatorGroupMsg, supports, InstallModeSet, installedFor, subscriptionFor, -} from './operator-group'; +} from '../operator-group'; describe('requireOperatorGroup', () => { const SomeComponent = () =>
Requires OperatorGroup
; - it('renders given component if `OperatorGroups` has not loaded yet', () => { + it('renders given component if OperatorGroups has not loaded yet', () => { const WrappedComponent = requireOperatorGroup(SomeComponent); - const wrapper = shallow(); - expect(wrapper.find(SomeComponent).exists()).toBe(true); - expect(wrapper.find(NoOperatorGroupMsg).exists()).toBe(false); + renderWithProviders(); + + expect(screen.getByText('Requires OperatorGroup')).toBeVisible(); + expect(screen.queryByText('Namespace not enabled')).not.toBeInTheDocument(); }); - it('renders message if no `OperatorGroups` loaded', () => { + it('renders message if no OperatorGroups loaded', () => { const WrappedComponent = requireOperatorGroup(SomeComponent); - const wrapper = shallow(); - expect(wrapper.find(NoOperatorGroupMsg).exists()).toBe(true); - expect(wrapper.find(SomeComponent).exists()).toBe(false); + renderWithProviders(); + + expect(screen.getByText('Namespace not enabled')).toBeVisible(); + expect(screen.queryByText('Requires OperatorGroup')).not.toBeInTheDocument(); }); - it('renders given component if `OperatorGroups` loaded and present', () => { + it('renders given component if OperatorGroups loaded and present', () => { const WrappedComponent = requireOperatorGroup(SomeComponent); - const wrapper = shallow( + + renderWithProviders( , ); - expect(wrapper.find(SomeComponent).exists()).toBe(true); - expect(wrapper.find(NoOperatorGroupMsg).exists()).toBe(false); + expect(screen.getByText('Requires OperatorGroup')).toBeVisible(); + expect(screen.queryByText('Namespace not enabled')).not.toBeInTheDocument(); }); }); @@ -57,7 +60,7 @@ describe('subscriptionFor', () => { operatorGroups = []; }); - it('returns nothing if no `Subscriptions` exist for the given package', () => { + it('returns nothing if no Subscriptions exist for the given package', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; @@ -66,7 +69,7 @@ describe('subscriptionFor', () => { ).toBeUndefined(); }); - it('returns nothing if no `OperatorGroups` target the given namespace', () => { + it('returns nothing if no OperatorGroups target the given namespace', () => { subscriptions = [testSubscription]; operatorGroups = [ { ...testOperatorGroup, status: { namespaces: ['prod-a', 'prod-b'], lastUpdated: null } }, @@ -75,7 +78,7 @@ describe('subscriptionFor', () => { expect(subscriptionFor(subscriptions)(operatorGroups)(pkg)(ns)).toBeUndefined(); }); - it('returns nothing if no `Subscriptions` share the package namespace', () => { + it('returns nothing if no Subscriptions share the package namespace', () => { subscriptions = [testSubscription]; operatorGroups = [ { ...testOperatorGroup, status: { namespaces: ['prod-a', 'prod-b'], lastUpdated: null } }, @@ -86,7 +89,7 @@ describe('subscriptionFor', () => { ).toBeUndefined(); }); - it('returns nothing if no `Subscriptions` share the package catalog source', () => { + it('returns nothing if no Subscriptions share the package catalog source', () => { subscriptions = [testSubscription]; operatorGroups = [ { ...testOperatorGroup, status: { namespaces: ['prod-a', 'prod-b'], lastUpdated: null } }, @@ -97,21 +100,21 @@ describe('subscriptionFor', () => { ).toBeUndefined(); }); - it('returns nothing if checking for `all-namespaces`', () => { + it('returns nothing if checking for all-namespaces', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; expect(subscriptionFor(subscriptions)(operatorGroups)(pkg)('')).toBeUndefined(); }); - it('returns `Subscription` when it exists in the "global" `OperatorGroup`', () => { + it('returns Subscription when it exists in the global OperatorGroup', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [''], lastUpdated: null } }]; expect(subscriptionFor(subscriptions)(operatorGroups)(pkg)(ns)).toEqual(testSubscription); }); - it('returns `Subscription` when it exists in an `OperatorGroup` that targets given namespace', () => { + it('returns Subscription when it exists in an OperatorGroup that targets given namespace', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; @@ -130,14 +133,14 @@ describe('installedFor', () => { operatorGroups = []; }); - it('returns false if no `Subscriptions` exist for the given package', () => { + it('returns false if no Subscriptions exist for the given package', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; expect(installedFor(subscriptions)(operatorGroups)(dummyPackageManifest)(ns)).toBe(false); }); - it('returns false if no `OperatorGroups` target the given namespace', () => { + it('returns false if no OperatorGroups target the given namespace', () => { subscriptions = [testSubscription]; operatorGroups = [ { ...testOperatorGroup, status: { namespaces: ['prod-a', 'prod-b'], lastUpdated: null } }, @@ -146,35 +149,35 @@ describe('installedFor', () => { expect(installedFor(subscriptions)(operatorGroups)(pkg)(ns)).toBe(false); }); - it('returns false if checking for `all-namespaces`', () => { + it('returns false if checking for all-namespaces', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; expect(installedFor(subscriptions)(operatorGroups)(pkg)('')).toBe(false); }); - it('returns false if `Subscription` is in a different namespace than the given package', () => { + it('returns false if Subscription is in a different namespace than the given package', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; expect(installedFor(subscriptions)(operatorGroups)(dummyPackageManifest)(ns)).toBe(false); }); - it('returns false if `Subscription` is from a different catalog source than the given package', () => { + it('returns false if Subscription is from a different catalog source than the given package', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; expect(installedFor(subscriptions)(operatorGroups)(dummyPackageManifest)(ns)).toBe(false); }); - it('returns true if `Subscription` exists in the "global" `OperatorGroup`', () => { + it('returns true if Subscription exists in the global OperatorGroup', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [''], lastUpdated: null } }]; expect(installedFor(subscriptions)(operatorGroups)(pkg)(ns)).toBe(true); }); - it('returns true if `Subscription` exists in an `OperatorGroup` that targets given namespace', () => { + it('returns true if Subscription exists in an OperatorGroup that targets given namespace', () => { subscriptions = [testSubscription]; operatorGroups = [{ ...testOperatorGroup, status: { namespaces: [ns], lastUpdated: null } }]; diff --git a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/package-manifest.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/package-manifest.spec.tsx new file mode 100644 index 00000000000..093213ccf63 --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/package-manifest.spec.tsx @@ -0,0 +1,158 @@ +import { screen } from '@testing-library/react'; +import * as UIActions from '@console/internal/actions/ui'; +import { ResourceLink } from '@console/internal/components/utils'; +import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testPackageManifest, testCatalogSource } from '../../../mocks'; +import { ClusterServiceVersionLogo } from '../cluster-service-version-logo'; +import { + PackageManifestTableRow, + PackageManifestTableHeader, + PackageManifestTableHeaderWithCatalogSource, +} from '../package-manifest'; + +jest.mock('../cluster-service-version-logo', () => ({ + ClusterServiceVersionLogo: jest.fn(() => null), +})); + +jest.mock('@console/shared/src/components/datetime/Timestamp', () => ({ + Timestamp: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/utils', () => ({ + ...jest.requireActual('@console/internal/components/utils'), + ResourceLink: jest.fn(() => null), +})); + +const mockClusterServiceVersionLogo = ClusterServiceVersionLogo as jest.Mock; +const mockTimestamp = Timestamp as jest.Mock; +const mockResourceLink = ResourceLink as jest.Mock; + +describe('PackageManifestTableHeader', () => { + it('renders column header for package name', () => { + const headers = PackageManifestTableHeader(); + expect(headers[0].title).toEqual('Name'); + }); + + it('renders column header for latest CSV version for package in catalog', () => { + const headers = PackageManifestTableHeader(); + expect(headers[1].title).toEqual('Latest version'); + }); + + it('renders column header for creation timestamp', () => { + const headers = PackageManifestTableHeader(); + expect(headers[2].title).toEqual('Created'); + }); +}); + +describe('PackageManifestTableHeaderWithCatalogSource', () => { + it('renders column header for catalog source', () => { + const headers = PackageManifestTableHeaderWithCatalogSource(); + expect(headers[3].title).toEqual('CatalogSource'); + }); +}); + +describe('PackageManifestTableRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(UIActions, 'getActiveNamespace').mockReturnValue('default'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders column for package name and logo', () => { + const columns: any[] = []; + + renderWithProviders( + + + + + + +
, + ); + + expect(mockClusterServiceVersionLogo).toHaveBeenCalledTimes(1); + const [logoProps] = mockClusterServiceVersionLogo.mock.calls[0]; + expect(logoProps.displayName).toEqual( + testPackageManifest.status.channels[0].currentCSVDesc.displayName, + ); + }); + + it('renders column for latest CSV version for package in catalog', () => { + const columns: any[] = []; + const { + name, + currentCSVDesc: { version }, + } = testPackageManifest.status.channels[0]; + + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(`${version} (${name})`)).toBeVisible(); + }); + + it('renders column for creation timestamp', () => { + const columns: any[] = []; + const pkgManifestCreationTimestamp = testPackageManifest.metadata.creationTimestamp; + + renderWithProviders( + + + + + + +
, + ); + + expect(mockTimestamp).toHaveBeenCalledTimes(1); + const [timestampProps] = mockTimestamp.mock.calls[0]; + expect(timestampProps.timestamp).toEqual(pkgManifestCreationTimestamp); + }); + + it('renders column for catalog source for a package when no catalog source is defined', () => { + const catalogSourceName = testPackageManifest.status.catalogSource; + const columns: any[] = []; + + renderWithProviders( + + + + + + +
, + ); + + expect(mockResourceLink).toHaveBeenCalledTimes(1); + const [resourceLinkProps] = mockResourceLink.mock.calls[0]; + expect(resourceLinkProps.name).toEqual(catalogSourceName); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/subscription.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/subscription.spec.tsx new file mode 100644 index 00000000000..e7fcd0134ab --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/subscription.spec.tsx @@ -0,0 +1,353 @@ +import { screen } from '@testing-library/react'; +import * as _ from 'lodash'; +import * as Router from 'react-router-dom-v5-compat'; +import { Table, MultiListPage, DetailsPage } from '@console/internal/components/factory'; +import { ResourceLink } from '@console/internal/components/utils'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { LazyActionMenu } from '@console/shared/src'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { + testSubscription, + testSubscriptions, + testClusterServiceVersion, + testPackageManifest, +} from '../../../mocks'; +import { + SubscriptionModel, + ClusterServiceVersionModel, + PackageManifestModel, + OperatorGroupModel, + InstallPlanModel, +} from '../../models'; +import { SubscriptionState } from '../../types'; +import { + SubscriptionTableRow, + SubscriptionsList, + SubscriptionsPage, + SubscriptionDetails, + SubscriptionDetailsPage, + SubscriptionStatus, +} from '../subscription'; + +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useParams: jest.fn(), +})); + +jest.mock('@console/internal/components/utils', () => ({ + ...jest.requireActual('@console/internal/components/utils'), + ResourceLink: jest.fn(() => null), +})); + +jest.mock('@console/shared/src', () => ({ + ...jest.requireActual('@console/shared/src'), + LazyActionMenu: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/factory', () => ({ + ...jest.requireActual('@console/internal/components/factory'), + Table: jest.fn(() => null), + MultiListPage: jest.fn(() => null), + DetailsPage: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/utils/details-page', () => ({ + ...jest.requireActual('@console/internal/components/utils/details-page'), + ResourceSummary: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/conditions', () => ({ + Conditions: jest.fn(() => null), +})); + +const mockResourceLink = ResourceLink as jest.Mock; +const mockLazyActionMenu = LazyActionMenu as jest.Mock; +const mockTable = Table as jest.Mock; +const mockMultiListPage = MultiListPage as jest.Mock; +const mockDetailsPage = DetailsPage as jest.Mock; + +describe('SubscriptionTableRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders subscription name and namespace resource links', () => { + const subscription = { + ...testSubscription, + status: { installedCSV: 'testapp.v1.0.0' }, + }; + + renderWithProviders( + + + + + + +
, + ); + + expect(mockResourceLink).toHaveBeenCalledWith( + expect.objectContaining({ + kind: referenceForModel(SubscriptionModel), + name: subscription.metadata.name, + namespace: subscription.metadata.namespace, + }), + expect.anything(), + ); + + expect(mockResourceLink).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'Namespace', + name: subscription.metadata.namespace, + }), + expect.anything(), + ); + }); + + it('renders action menu with subscription context', () => { + const subscription = { + ...testSubscription, + status: { installedCSV: 'testapp.v1.0.0' }, + }; + + renderWithProviders( + + + + + + +
, + ); + + expect(mockLazyActionMenu).toHaveBeenCalledTimes(1); + const [actionMenuProps] = mockLazyActionMenu.mock.calls[0]; + expect(actionMenuProps.context).toEqual({ + [referenceForModel(SubscriptionModel)]: subscription, + }); + }); + + it('renders channel and approval strategy text', () => { + const subscription = { + ...testSubscription, + status: { installedCSV: 'testapp.v1.0.0' }, + }; + + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(subscription.spec.channel)).toBeVisible(); + expect(screen.getByText('Automatic')).toBeVisible(); + }); +}); + +describe('SubscriptionStatus', () => { + it('renders "Upgrade available" when update is available', () => { + const subscription = { + ...testSubscription, + status: { state: SubscriptionState.SubscriptionStateUpgradeAvailable }, + }; + + renderWithProviders(); + + expect(screen.getByText('Upgrade available')).toBeVisible(); + }); + + it('renders "Unknown failure" when status is unknown', () => { + const subscription = { + ...testSubscription, + status: {}, + }; + + renderWithProviders(); + + expect(screen.getByText('Unknown failure')).toBeVisible(); + }); + + it('renders "Upgrading" when update is pending', () => { + const subscription = { + ...testSubscription, + status: { state: SubscriptionState.SubscriptionStateUpgradePending }, + }; + + renderWithProviders(); + + expect(screen.getByText('Upgrading')).toBeVisible(); + }); + + it('renders "Up to date" when subscription is at latest', () => { + const subscription = { + ...testSubscription, + status: { state: SubscriptionState.SubscriptionStateAtLatest }, + }; + + renderWithProviders(); + + expect(screen.getByText('Up to date')).toBeVisible(); + }); +}); + +describe('SubscriptionsList', () => { + it('renders table with correct header titles', () => { + renderWithProviders( + , + ); + + expect(mockTable).toHaveBeenCalledTimes(1); + const [tableProps] = mockTable.mock.calls[0]; + const headerTitles = tableProps.Header().map((header) => header.title); + + expect(headerTitles).toEqual([ + 'Name', + 'Namespace', + 'Status', + 'Update channel', + 'Update approval', + '', + ]); + }); +}); + +describe('SubscriptionsPage', () => { + it('renders MultiListPage with correct configuration', () => { + renderWithProviders(); + + expect(mockMultiListPage).toHaveBeenCalledTimes(1); + const [multiListPageProps] = mockMultiListPage.mock.calls[0]; + + expect(multiListPageProps.ListComponent).toEqual(SubscriptionsList); + expect(multiListPageProps.title).toEqual('Subscriptions'); + expect(multiListPageProps.canCreate).toBe(true); + expect(multiListPageProps.createProps).toEqual({ + to: '/catalog?catalogType=operator', + }); + expect(multiListPageProps.createButtonText).toEqual('Create Subscription'); + expect(multiListPageProps.filterLabel).toEqual('Subscriptions by package'); + expect(multiListPageProps.resources).toEqual([ + { + kind: referenceForModel(SubscriptionModel), + namespace: 'default', + namespaced: true, + prop: 'subscription', + }, + { + kind: referenceForModel(OperatorGroupModel), + namespace: 'default', + namespaced: true, + prop: 'operatorGroup', + }, + ]); + }); +}); + +describe('SubscriptionDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders installed CSV resource link when installed', () => { + const obj = _.cloneDeep(testSubscription); + obj.status = { installedCSV: testClusterServiceVersion.metadata.name }; + + renderWithProviders( + , + ); + + expect(screen.getByText('Installed version')).toBeVisible(); + expect(mockResourceLink).toHaveBeenCalledWith( + expect.objectContaining({ + title: obj.status.installedCSV, + name: obj.status.installedCSV, + }), + expect.anything(), + ); + }); + + it('renders catalog source resource link', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('CatalogSource')).toBeVisible(); + expect(mockResourceLink).toHaveBeenCalledWith( + expect.objectContaining({ + name: testSubscription.spec.source, + }), + expect.anything(), + ); + }); +}); + +describe('SubscriptionDetailsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default', name: 'example-sub' }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders DetailsPage with correct configuration', () => { + renderWithProviders(); + + expect(mockDetailsPage).toHaveBeenCalledTimes(1); + const [detailsPageProps] = mockDetailsPage.mock.calls[0]; + + expect(detailsPageProps.kind).toEqual(referenceForModel(SubscriptionModel)); + expect(detailsPageProps.pages).toHaveLength(2); + expect(detailsPageProps.customActionMenu).toBeDefined(); + expect(detailsPageProps.resources).toEqual([ + { + kind: referenceForModel(PackageManifestModel), + namespace: 'default', + isList: true, + prop: 'packageManifests', + }, + { + kind: referenceForModel(InstallPlanModel), + isList: true, + namespace: 'default', + prop: 'installPlans', + }, + { + kind: referenceForModel(ClusterServiceVersionModel), + isList: true, + namespace: 'default', + prop: 'clusterServiceVersions', + }, + { + kind: referenceForModel(SubscriptionModel), + isList: true, + namespace: 'default', + prop: 'subscriptions', + }, + ]); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx deleted file mode 100644 index 8c557b267a6..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { safeLoad } from 'js-yaml'; -import * as _ from 'lodash'; -import * as Router from 'react-router-dom-v5-compat'; -import { CreateYAML, CreateYAMLProps } from '@console/internal/components/create-yaml'; -import { DetailsPage } from '@console/internal/components/factory'; -import { Firehose, LoadingBox, DetailsItem } from '@console/internal/components/utils'; -import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; -import { referenceForModel } from '@console/internal/module/k8s'; -import { ErrorBoundary } from '@console/shared/src/components/error'; -import { testCatalogSource, testPackageManifest, dummyPackageManifest } from '../../mocks'; -import { - SubscriptionModel, - CatalogSourceModel, - PackageManifestModel, - OperatorGroupModel, -} from '../models'; -import { - CatalogSourceDetails, - CatalogSourceDetailsProps, - CatalogSourceDetailsPage, - CreateSubscriptionYAML, - CatalogSourceOperatorsPage, -} from './catalog-source'; - -jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ - useK8sWatchResource: jest.fn(), -})); - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), - useLocation: jest.fn(), -})); - -describe(CatalogSourceDetails.displayName, () => { - let wrapper: ShallowWrapper; - let obj: CatalogSourceDetailsProps['obj']; - - beforeEach(() => { - obj = _.cloneDeep(testCatalogSource); - wrapper = shallow(); - }); - - it('renders name and publisher of the catalog', () => { - expect(wrapper.find(DetailsItem).at(1).props().obj.spec.displayName).toEqual( - obj.spec.displayName, - ); - - expect(wrapper.find(DetailsItem).at(2).props().obj.spec.publisher).toEqual(obj.spec.publisher); - }); -}); - -describe(CatalogSourceDetailsPage.displayName, () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - (useK8sWatchResource as jest.Mock).mockReturnValue([dummyPackageManifest, true, null]); - jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default', name: 'some-catalog' }); - wrapper = shallow(); - }); - - it('renders `DetailsPage` with correct props', () => { - expect(wrapper.find(DetailsPage).props().kind).toEqual(referenceForModel(CatalogSourceModel)); - - const detailsPage = wrapper.find(DetailsPage); - const { pages } = detailsPage.props(); - expect(pages.length).toEqual(3); - expect(pages[0].nameKey).toEqual(`public~Details`); - expect(pages[1].nameKey).toEqual(`public~YAML`); - expect(pages[2].nameKey).toEqual(`olm~Operators`); - - expect(pages[0].component).toEqual(CatalogSourceDetails); - expect(pages[2].component).toEqual(CatalogSourceOperatorsPage); - - expect(wrapper.find(DetailsPage).props().resources).toEqual([ - { - kind: referenceForModel(PackageManifestModel), - isList: true, - prop: 'packageManifests', - namespace: 'default', - }, - ]); - }); -}); - -describe(CreateSubscriptionYAML.displayName, () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - jest - .spyOn(Router, 'useParams') - .mockReturnValue({ ns: 'default', pkgName: testPackageManifest.metadata.name }); - jest.spyOn(Router, 'useLocation').mockReturnValue({ - ...window.location, - search: `?pkg=${testPackageManifest.metadata.name}&catalog=ocs&catalogNamespace=default`, - }); - wrapper = shallow(); - }); - - it('renders a `Firehose` for the `PackageManfest` specified in the URL', () => { - expect(wrapper.find(Firehose).props().resources).toEqual([ - { - kind: referenceForModel(PackageManifestModel), - isList: false, - name: testPackageManifest.metadata.name, - namespace: 'default', - prop: 'packageManifest', - }, - { - kind: referenceForModel(OperatorGroupModel), - isList: true, - namespace: 'default', - prop: 'operatorGroup', - }, - ]); - }); - - it('renders YAML editor component wrapped by an error boundary component', () => { - wrapper = wrapper.setProps({ - packageManifest: { loaded: true, data: testPackageManifest }, - } as any); - const createYAML = wrapper.find(Firehose).childAt(0).dive().dive(); - - expect(createYAML.find(ErrorBoundary).exists()).toBe(true); - expect(createYAML.find(ErrorBoundary).childAt(0).dive().find(CreateYAML).exists()).toBe(true); - }); - - it('passes example YAML templates using the package default channel', () => { - wrapper = wrapper.setProps({ - packageManifest: { loaded: true, data: testPackageManifest }, - } as any); - - const createYAML = wrapper - .find(Firehose) - .childAt(0) - .dive() - .dive() - .find(ErrorBoundary) - .childAt(0) - .dive(); - const subTemplate = safeLoad(createYAML.props().template); - - window.location.search = `?pkg=${testPackageManifest.metadata.name}&catalog=ocs&catalogNamespace=default`; - - expect(subTemplate.kind).toContain(SubscriptionModel.kind); - expect(subTemplate.spec.name).toEqual(testPackageManifest.metadata.name); - expect(subTemplate.spec.channel).toEqual(testPackageManifest.status.channels[0].name); - expect(subTemplate.spec.startingCSV).toEqual(testPackageManifest.status.channels[0].currentCSV); - expect(subTemplate.spec.source).toEqual('ocs'); - }); - - it('does not render YAML editor component if `PackageManifest` has not loaded yet', () => { - wrapper = wrapper.setProps({ packageManifest: { loaded: false } } as any); - const createYAML = wrapper.find(Firehose).childAt(0).dive().dive(); - - expect(createYAML.find(CreateYAML).exists()).toBe(false); - expect(createYAML.find(ErrorBoundary).childAt(0).dive().find(LoadingBox).exists()).toBe(true); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.spec.tsx deleted file mode 100644 index 7abfe0d9096..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.spec.tsx +++ /dev/null @@ -1,596 +0,0 @@ -import { - CardTitle, - CardBody, - CardFooter, - DescriptionListTerm, - DescriptionListDescription, -} from '@patternfly/react-core'; -import { shallow, ShallowWrapper, mount, ReactWrapper } from 'enzyme'; -import * as _ from 'lodash'; -import { Provider } from 'react-redux'; -import * as ReactRouter from 'react-router-dom-v5-compat'; -import { ActionMenuVariant } from '@console/dynamic-plugin-sdk/src/api/internal-types'; -import * as rbacModule from '@console/dynamic-plugin-sdk/src/app/components/utils/rbac'; -import { - DetailsPage, - Table, - TableProps, - ComponentProps, -} from '@console/internal/components/factory'; -import { - ScrollToTopOnMount, - SectionHeading, - resourceObjPath, - StatusBox, -} from '@console/internal/components/utils'; -import operatorLogo from '@console/internal/imgs/operator.svg'; -import { referenceForModel } from '@console/internal/module/k8s'; -import store from '@console/internal/redux'; -import LazyActionMenu from '@console/shared/src/components/actions/LazyActionMenu'; -import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; -import { ErrorBoundary } from '@console/shared/src/components/error'; -import PaneBody from '@console/shared/src/components/layout/PaneBody'; -import { useActiveNamespace } from '@console/shared/src/hooks/redux-selectors'; -import { - testClusterServiceVersion, - testSubscription, - testPackageManifest, - testCatalogSource, - testInstallPlan, - testModel, - testSubscriptions, -} from '../../mocks'; -import { ClusterServiceVersionModel } from '../models'; -import { ClusterServiceVersionKind, ClusterServiceVersionPhase } from '../types'; -import { ClusterServiceVersionLogo } from './cluster-service-version-logo'; -import { - ClusterServiceVersionDetailsPage, - ClusterServiceVersionDetails, - ClusterServiceVersionDetailsProps, - ClusterServiceVersionTableRow, - ClusterServiceVersionTableRowProps, - CRDCard, - CRDCardRow, - CSVSubscription, - CSVSubscriptionProps, - ClusterServiceVersionList, -} from './clusterserviceversion'; -import { SubscriptionUpdates, SubscriptionDetails } from './subscription'; -import { ClusterServiceVersionLogoProps, referenceForProvidedAPI } from '.'; - -jest.mock('@console/shared/src/hooks/useK8sModel', () => ({ - useK8sModel: () => [testModel], -})); - -jest.mock('@console/internal/components/utils/rbac', () => ({ - useAccessReview: () => true, -})); - -jest.mock('@console/shared/src/hooks/redux-selectors', () => { - return { - useActiveNamespace: jest.fn(), - }; -}); - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), - useLocation: jest.fn(), -})); - -describe(ClusterServiceVersionTableRow.displayName, () => { - let wrapper: ShallowWrapper; - beforeEach(() => { - window.SERVER_FLAGS.copiedCSVsDisabled = false; - wrapper = shallow( - , - ) - .childAt(0) - .shallow(); - }); - - it('renders a component wrapped in an `ErrorBoundary', () => { - wrapper = shallow( - , - ); - - expect(wrapper.find(ErrorBoundary).exists()).toBe(true); - }); - - it('renders `ResourceKebab` with actions', () => { - const col = wrapper; - - expect(col.find(LazyActionMenu).props().context).toEqual({ - 'operator-actions': { resource: testClusterServiceVersion, subscription: testSubscription }, - }); - expect(col.find(LazyActionMenu).props().variant).toEqual(ActionMenuVariant.KEBAB); - }); - - it('renders clickable column for app logo and name', () => { - const col = wrapper.childAt(0); - - expect(col.find(ReactRouter.Link).props().to).toEqual( - resourceObjPath(testClusterServiceVersion, referenceForModel(ClusterServiceVersionModel)), - ); - expect(col.find(ReactRouter.Link).find(ClusterServiceVersionLogo).exists()).toBe(true); - }); - - it('renders column for managedNamespace', () => { - const col = wrapper.childAt(1); - const managedNamespace = col.childAt(0); - expect(managedNamespace.exists()).toBeTruthy(); - }); - - it('renders column for last updated', () => { - const col = wrapper.childAt(3); - expect(col.find(Timestamp).props().timestamp).toEqual('2020-04-21T18:19:49Z'); - }); - - it('renders column for app status', () => { - const col = wrapper.childAt(2); - const statusComponent = col.childAt(0).find('ClusterServiceVersionStatus'); - expect(statusComponent.exists()).toBeTruthy(); - expect(statusComponent.render().text()).toContain(ClusterServiceVersionPhase.CSVPhaseSucceeded); - }); - - it('renders "disabling" status if CSV has `deletionTimestamp`', () => { - wrapper = wrapper.setProps({ - obj: _.cloneDeepWith(testClusterServiceVersion, (v, k) => - k === 'metadata' ? { ...v, deletionTimestamp: Date.now() } : undefined, - ), - }); - const col = wrapper.childAt(2); - - expect(col.childAt(0).find('ClusterServiceVersionStatus').render().text()).toEqual('Deleting'); - }); - - it('renders column with each CRD provided by the Operator', () => { - const col = wrapper.childAt(4); - testClusterServiceVersion.spec.customresourcedefinitions.owned.forEach((desc, i) => { - expect(col.find(ReactRouter.Link).at(i).props().title).toEqual(desc.name); - expect(col.find(ReactRouter.Link).at(i).props().to).toEqual( - `${resourceObjPath( - testClusterServiceVersion, - referenceForModel(ClusterServiceVersionModel), - )}/${referenceForProvidedAPI(desc)}`, - ); - }); - }); -}); - -describe(ClusterServiceVersionLogo.displayName, () => { - let wrapper: ReactWrapper; - - beforeEach(() => { - const { provider, icon, displayName } = testClusterServiceVersion.spec; - wrapper = mount( - , - ); - }); - - it('renders logo image from given base64 encoded image string', () => { - const image = wrapper.find('img'); - - expect(image.props().src).toEqual( - `data:${testClusterServiceVersion.spec.icon[0].mediatype};base64,${testClusterServiceVersion.spec.icon[0].base64data}`, - ); - }); - - it('renders fallback image if given icon is invalid', () => { - wrapper.setProps({ icon: null }); - const fallbackImg = wrapper.find('img'); - - expect(fallbackImg.props().src).toEqual(operatorLogo); - }); - - it('renders ClusterServiceVersion name and provider from given spec', () => { - expect(wrapper.text()).toContain(testClusterServiceVersion.spec.displayName); - expect(wrapper.text()).toContain(`by ${testClusterServiceVersion.spec.provider.name}`); - }); -}); - -describe(ClusterServiceVersionList.displayName, () => { - it('renders `List` with SingleProjectTableHeader for namespace scoped CSV', () => { - (useActiveNamespace as jest.Mock).mockImplementation(() => 'test'); - const wrapper = shallow( - , - ); - const header = wrapper.find(Table).props().Header; - expect(header.name).toEqual('SingleProjectTableHeader'); - const headerColumns = header({} as ComponentProps); - expect(headerColumns[0].title).toEqual('Name'); - expect(headerColumns[1].title).toEqual('Managed Namespaces'); - expect(headerColumns[2].title).toEqual('Status'); - expect(headerColumns[3].title).toEqual('Last updated'); - expect(headerColumns[4].title).toEqual('Provided APIs'); - expect(headerColumns[5].title).toEqual(''); - }); - it('renders `List` with AllProjectTableHeader for all-namespaces scoped CSV', () => { - (useActiveNamespace as jest.Mock).mockImplementation(() => '#ALL_NS#'); - const wrapper = shallow( - , - ); - const header = wrapper.find(Table).props().Header; - expect(header.name).toEqual('AllProjectsTableHeader'); - const headerColumns = header({} as ComponentProps); - expect(headerColumns[0].title).toEqual('Name'); - expect(headerColumns[1].title).toEqual('Namespace'); - expect(headerColumns[2].title).toEqual('Managed Namespaces'); - expect(headerColumns[3].title).toEqual('Status'); - expect(headerColumns[4].title).toEqual('Last updated'); - expect(headerColumns[5].title).toEqual('Provided APIs'); - expect(headerColumns[6].title).toEqual(''); - }); -}); - -describe(CRDCard.displayName, () => { - const crd = testClusterServiceVersion.spec.customresourcedefinitions.owned[0]; - - it('renders a card with title, body, and footer', () => { - const wrapper = shallow(); - - expect(wrapper.find(CardTitle).exists()).toBe(true); - expect(wrapper.find(CardBody).exists()).toBe(true); - expect(wrapper.find(CardFooter).exists()).toBe(true); - }); - - it('renders a link to create a new instance', () => { - const wrapper = shallow(); - - expect(wrapper.find(CardFooter).find(ReactRouter.Link).props().to).toEqual( - `/k8s/ns/${testClusterServiceVersion.metadata.namespace}/${ - ClusterServiceVersionModel.plural - }/${testClusterServiceVersion.metadata.name}/${referenceForProvidedAPI(crd)}/~new`, - ); - }); - - it('does not render link to create new instance if `props.canCreate` is false', () => { - const wrapper = shallow( - , - ); - - expect(wrapper.find(CardFooter).find(ReactRouter.Link).exists()).toBe(false); - }); -}); - -describe(ClusterServiceVersionDetails.displayName, () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow( - , - ); - }); - - it('renders `ScrollToTopOnMount` component', () => { - expect(wrapper.find(ScrollToTopOnMount).exists()).toBe(true); - }); - - it('renders row of cards for each "owned" CRD for the given `ClusterServiceVersion`', () => { - expect(wrapper.find(CRDCardRow).props().csv).toEqual(testClusterServiceVersion); - expect(wrapper.find(CRDCardRow).props().providedAPIs).toEqual( - testClusterServiceVersion.spec.customresourcedefinitions.owned, - ); - }); - - it('renders description section for ClusterServiceVersion', () => { - expect(wrapper.find(PaneBody).at(0).find(SectionHeading).at(1).props().text).toEqual( - 'Description', - ); - }); - - it('renders creation date from ClusterServiceVersion', () => { - expect(wrapper.find(Timestamp).props().timestamp).toEqual( - testClusterServiceVersion.metadata.creationTimestamp, - ); - }); - - it('renders list of maintainers from ClusterServiceVersion', () => { - const maintainers = wrapper - .findWhere((node) => node.equals(Maintainers)) - .parents() - .at(0) - .find(DescriptionListDescription) - .shallow(); - - expect(maintainers.length).toEqual(testClusterServiceVersion.spec.maintainers.length); - - testClusterServiceVersion.spec.maintainers.forEach((maintainer, i) => { - expect(maintainers.at(i).text()).toContain(maintainer.name); - expect(maintainers.at(i).find('.co-break-all').text()).toEqual(maintainer.email); - expect(maintainers.at(i).find('.co-break-all').props().href).toEqual( - `mailto:${maintainer.email}`, - ); - }); - }); - - it('renders important links from ClusterServiceVersion', () => { - const links = wrapper - .findWhere((node) => node.equals(Links)) - .parents() - .at(0) - .find(DescriptionListDescription); - - expect(links.length).toEqual(testClusterServiceVersion.spec.links.length); - }); - - it('renders empty state for unfulfilled outputs and metadata', () => { - const emptyClusterServiceVersion: ClusterServiceVersionKind = _.cloneDeep( - testClusterServiceVersion, - ); - emptyClusterServiceVersion.spec.description = ''; - emptyClusterServiceVersion.spec.provider = undefined; - emptyClusterServiceVersion.spec.links = []; - emptyClusterServiceVersion.spec.maintainers = []; - wrapper.setProps({ obj: emptyClusterServiceVersion }); - - const provider = wrapper - .findWhere((node) => node.equals(Provider)) - .parents() - .at(0) - .find(DescriptionListDescription) - .shallow(); - const links = wrapper - .findWhere((node) => node.equals(Links)) - .parents() - .at(0) - .find(DescriptionListDescription) - .shallow(); - const maintainers = wrapper - .findWhere((node) => node.equals(Maintainers)) - .parents() - .at(0) - .find(DescriptionListDescription) - .shallow(); - - expect(provider.text()).toEqual('Not available'); - expect(links.text()).toEqual('Not available'); - expect(maintainers.text()).toEqual('Not available'); - }); - - it('renders info section for ClusterServiceVersion', () => { - expect(wrapper.find(PaneBody).at(1).find(SectionHeading).props().text).toEqual( - 'ClusterServiceVersion details', - ); - }); - - it('renders conditions section for ClusterServiceVersion', () => { - expect(wrapper.find(PaneBody).at(2).find(SectionHeading).props().text).toEqual('Conditions'); - }); - - it('does not render service accounts section if empty', () => { - const emptyTestClusterServiceVersion = _.cloneDeep(testClusterServiceVersion); - emptyTestClusterServiceVersion.spec.install.spec.permissions = []; - wrapper = shallow( - , - ); - expect(emptyTestClusterServiceVersion.spec.install.spec.permissions.length).toEqual(0); - expect(wrapper.findWhere((node) => node.text() === 'Operator ServiceAccounts').length).toEqual( - 0, - ); - }); - - it('does not render duplicate service accounts', () => { - const duplicateTestClusterServiceVersion = _.cloneDeep(testClusterServiceVersion); - const permission = duplicateTestClusterServiceVersion.spec.install.spec.permissions[0]; - duplicateTestClusterServiceVersion.spec.install.spec.permissions.push(permission); - wrapper = shallow( - , - ); - expect(duplicateTestClusterServiceVersion.spec.install.spec.permissions.length).toEqual(2); - expect(wrapper.findWhere((node) => node.text() === 'Operator ServiceAccounts').length).toEqual( - 1, - ); - expect( - wrapper.find(`[data-service-account-name="${permission.serviceAccountName}"]`).length, - ).toEqual(1); - }); -}); - -describe(CSVSubscription.displayName, () => { - let wrapper: ShallowWrapper; - - it('renders `StatusBox` with correct props when Operator subscription does not exist', () => { - wrapper = shallow( - , - ); - - expect(wrapper.find(StatusBox).props().EmptyMsg).toBeDefined(); - expect(wrapper.find(StatusBox).props().loaded).toBe(true); - expect(wrapper.find(StatusBox).props().data).toBeUndefined(); - }); - - it('renders `SubscriptionDetails` with correct props when Operator subscription exists', () => { - const obj = _.set(_.cloneDeep(testClusterServiceVersion), 'metadata.annotations', { - 'olm.operatorNamespace': 'default', - }); - const subscription = _.set(_.cloneDeep(testSubscription), 'status', { - installedCSV: obj.metadata.name, - }); - - wrapper = shallow( - , - ); - - const subscriptionUpdates = wrapper - .find(StatusBox) - .find(SubscriptionDetails) - .dive() - .find(SubscriptionUpdates); - expect(subscriptionUpdates.props().obj).toEqual(subscription); - expect(subscriptionUpdates.props().installedCSV).toEqual(obj); - expect(subscriptionUpdates.props().pkg).toEqual(testPackageManifest); - }); - - it('passes the matching `PackageManifest` if there are multiple with the same `metadata.name`', () => { - const obj = _.set(_.cloneDeep(testClusterServiceVersion), 'metadata.annotations', { - 'olm.operatorNamespace': 'default', - }); - const subscription = _.set(_.cloneDeep(testSubscription), 'status', { - installedCSV: obj.metadata.name, - }); - const otherPkg = _.set( - _.cloneDeep(testPackageManifest), - 'status.catalogSource', - 'other-source', - ); - - wrapper = shallow( - , - ); - - expect( - wrapper.find(StatusBox).find(SubscriptionDetails).dive().find(SubscriptionUpdates).props() - .pkg, - ).toEqual(testPackageManifest); - }); -}); - -describe(ClusterServiceVersionDetailsPage.displayName, () => { - let wrapper: ReactWrapper; - let spyUseAccessReview; - - const name = 'example'; - const ns = 'default'; - - beforeEach(() => { - spyUseAccessReview = jest.spyOn(rbacModule, 'useAccessReview'); - spyUseAccessReview.mockReturnValue([true, false]); - - jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ name: 'example', ns: 'default' }); - jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ pathname: '' }); - - window.SERVER_FLAGS.copiedCSVsDisabled = false; - wrapper = mount( - - - - - , - ); - }); - - it('passes URL parameters to `DetailsPage`', () => { - const detailsPage = wrapper.find(DetailsPage); - - expect(detailsPage.props().namespace).toEqual(ns); - expect(detailsPage.props().name).toEqual(name); - expect(detailsPage.props().kind).toEqual(referenceForModel(ClusterServiceVersionModel)); - }); - - it('renders a `DetailsPage` with the correct subpages', () => { - const detailsPage = wrapper.find(DetailsPage); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[0].nameKey).toEqual( - 'public~Details', - ); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[0].href).toEqual(''); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[0].component).toEqual( - ClusterServiceVersionDetails, - ); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[1].nameKey).toEqual( - `public~YAML`, - ); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[1].href).toEqual('yaml'); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[2].nameKey).toEqual( - 'olm~Subscription', - ); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[2].href).toEqual('subscription'); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[3].nameKey).toEqual( - `public~Events`, - ); - expect(detailsPage.props().pagesFor(testClusterServiceVersion)[3].href).toEqual('events'); - }); - - it('includes tab for each "owned" CRD', () => { - const detailsPage = wrapper.find(DetailsPage); - - const csv = _.cloneDeep(testClusterServiceVersion); - csv.spec.customresourcedefinitions.owned = csv.spec.customresourcedefinitions.owned.concat([ - { name: 'e.example.com', kind: 'E', version: 'v1alpha1', displayName: 'E' }, - ]); - - expect(detailsPage.props().pagesFor(csv)[4].nameKey).toEqual('olm~All instances'); - expect(detailsPage.props().pagesFor(csv)[4].href).toEqual('instances'); - csv.spec.customresourcedefinitions.owned.forEach((desc, i) => { - expect(detailsPage.props().pagesFor(csv)[5 + i].name).toEqual(desc.displayName); - expect(detailsPage.props().pagesFor(csv)[5 + i].href).toEqual(referenceForProvidedAPI(desc)); - }); - }); - - it('does not include "All Instances" tab if only one "owned" CRD', () => { - const detailsPage = wrapper.find(DetailsPage); - - expect( - detailsPage - .props() - .pagesFor(testClusterServiceVersion) - .some((p) => p.name === 'All Instances'), - ).toBe(false); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/install-plan.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/install-plan.spec.tsx deleted file mode 100644 index 3b5c73b5be8..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/install-plan.spec.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { Button, Hint } from '@patternfly/react-core'; -import { shallow, ShallowWrapper } from 'enzyme'; -import * as _ from 'lodash'; -import { Link } from 'react-router-dom-v5-compat'; -import * as Router from 'react-router-dom-v5-compat'; -import * as k8sResourceModule from '@console/dynamic-plugin-sdk/src/utils/k8s/k8s-resource'; -import { - Table, - MultiListPage, - DetailsPage, - RowFunctionArgs, - ComponentProps, -} from '@console/internal/components/factory'; -import { - ResourceLink, - ResourceIcon, - ConsoleEmptyState, - useAccessReview, -} from '@console/internal/components/utils'; -import { CustomResourceDefinitionModel } from '@console/internal/models'; -import { referenceForModel, K8sResourceKind } from '@console/internal/module/k8s'; -import { testInstallPlan } from '../../mocks'; -import { InstallPlanModel, ClusterServiceVersionModel, OperatorGroupModel } from '../models'; -import { InstallPlanKind, InstallPlanApproval } from '../types'; -import { - InstallPlanTableRow, - InstallPlansList, - InstallPlansListProps, - InstallPlansPage, - InstallPlansPageProps, - InstallPlanDetailsPage, - InstallPlanPreview, - InstallPlanDetails, - InstallPlanDetailsProps, - InstallPlanHint, -} from './install-plan'; -import * as modal from './modals/installplan-preview-modal'; -import { referenceForStepResource } from '.'; -import Spy = jasmine.Spy; - -const i18nNS = 'public'; - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), -})); - -jest.mock('@console/internal/components/utils/rbac', () => ({ - useAccessReview: jest.fn(), -})); - -const useAccessReviewMock = useAccessReview as jest.Mock; - -describe('InstallPlanTableRow', () => { - let obj: InstallPlanKind; - let wrapper: ShallowWrapper; - - const updateWrapper = () => { - const rowArgs: RowFunctionArgs = { - obj, - } as any; - - wrapper = shallow(); - return wrapper; - }; - - beforeEach(() => { - obj = _.cloneDeep(testInstallPlan); - wrapper = updateWrapper(); - }); - - it('renders column for install plan name', () => { - expect(wrapper.childAt(0).find(ResourceLink).props().kind).toEqual( - referenceForModel(InstallPlanModel), - ); - expect(wrapper.childAt(0).find(ResourceLink).props().namespace).toEqual( - testInstallPlan.metadata.namespace, - ); - expect(wrapper.childAt(0).find(ResourceLink).props().name).toEqual( - testInstallPlan.metadata.name, - ); - }); - - it('renders column for install plan namespace', () => { - expect(wrapper.childAt(1).find(ResourceLink).props().kind).toEqual('Namespace'); - }); - - it('renders column for install plan status', () => { - expect(wrapper.childAt(2).render().find('[data-test="status-text"]').text()).toEqual( - testInstallPlan.status.phase, - ); - }); - - it('renders column with fallback status if `status.phase` is undefined', () => { - obj = { ..._.cloneDeep(testInstallPlan), status: null }; - wrapper = updateWrapper(); - - expect(wrapper.childAt(2).render().text()).toEqual('Unknown'); - expect(wrapper.childAt(3).find(ResourceIcon).length).toEqual(1); - expect(wrapper.childAt(3).find(ResourceIcon).at(0).props().kind).toEqual( - referenceForModel(ClusterServiceVersionModel), - ); - }); - - it('render column for install plan components list', () => { - expect(wrapper.childAt(3).find(ResourceLink).props().kind).toEqual( - referenceForModel(ClusterServiceVersionModel), - ); - expect(wrapper.childAt(3).find(ResourceLink).props().name).toEqual( - testInstallPlan.spec.clusterServiceVersionNames.toString(), - ); - expect(wrapper.childAt(3).find(ResourceLink).props().namespace).toEqual( - testInstallPlan.metadata.namespace, - ); - }); - - it('renders column for parent subscription(s) determined by `metadata.ownerReferences`', () => { - expect(wrapper.childAt(4).find(ResourceLink).length).toEqual(1); - }); -}); - -describe('InstallPlansList', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow(); - }); - - it('renders a `Table` component with the correct props', () => { - const headerTitles = wrapper - .find(Table) - .props() - .Header({} as ComponentProps) - .map((header) => header.title); - expect(headerTitles).toEqual([ - 'Name', - 'Namespace', - 'Status', - 'Components', - 'Subscriptions', - '', - ]); - }); - - it('passes custom empty message for table', () => { - const MsgComponent = wrapper.find(Table).props().EmptyMsg; - const msgWrapper = shallow(); - expect(msgWrapper.find(ConsoleEmptyState).props().title).toEqual('No InstallPlans found'); - expect(msgWrapper.find(ConsoleEmptyState).children().text()).toEqual( - 'InstallPlans are created automatically by subscriptions or manually using the CLI.', - ); - }); -}); - -describe('InstallPlansPage', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - jest.spyOn(Router, 'useParams').mockReturnValue({ - ns: 'default', - }); - wrapper = shallow(); - }); - - it('renders a `MultiListPage` with the correct props', () => { - expect(wrapper.find(MultiListPage).props().title).toEqual('InstallPlans'); - expect(wrapper.find(MultiListPage).props().showTitle).toBe(false); - expect(wrapper.find(MultiListPage).props().ListComponent).toEqual(InstallPlansList); - expect(wrapper.find(MultiListPage).props().resources).toEqual([ - { - kind: referenceForModel(InstallPlanModel), - namespace: 'default', - namespaced: true, - prop: 'installPlan', - }, - { - kind: referenceForModel(OperatorGroupModel), - namespace: 'default', - namespaced: true, - prop: 'operatorGroup', - }, - ]); - }); -}); - -describe('InstallPlanPreview', () => { - // let wrapper: ShallowWrapper; - // let obj: InstallPlanKind; - const obj: InstallPlanKind = { - ...testInstallPlan, - status: { - ...testInstallPlan.status, - plan: [ - { - resolving: 'testoperator.v1.0.0', - status: 'Created', - resource: { - group: ClusterServiceVersionModel.apiGroup, - version: ClusterServiceVersionModel.apiVersion, - kind: ClusterServiceVersionModel.kind, - name: 'testoperator.v1.0.0', - manifest: '', - }, - }, - { - resolving: 'testoperator.v1.0.0', - status: 'Unknown', - resource: { - group: CustomResourceDefinitionModel.apiGroup, - version: CustomResourceDefinitionModel.apiVersion, - kind: CustomResourceDefinitionModel.kind, - name: 'test-crds.test.com', - manifest: '', - }, - }, - ], - }, - }; - - const spyAndExpect = (spy: Spy) => (returnValue: any) => - new Promise((resolve) => - spy.and.callFake((...args) => { - resolve(args); - return returnValue; - }), - ); - - it('renders empty message if `status.plan` is not filled', () => { - const wrapper = shallow( - , - ); - expect(wrapper.find(ConsoleEmptyState).exists()).toBe(true); - }); - - it('renders button to approve install plan if requires approval', () => { - useAccessReviewMock.mockReturnValue(true); - const wrapper = shallow( - , - ); - expect( - wrapper.find(InstallPlanHint).dive().find(Hint).shallow().find(Button).at(0).render().text(), - ).toEqual('Approve'); - }); - - it('calls `k8sPatch` to set `approved: true` when button is clicked', (done) => { - jest - .spyOn(k8sResourceModule, 'k8sPatch') - .mockImplementation((_model, data) => Promise.resolve(data)); - - spyAndExpect(spyOn(k8sResourceModule, 'k8sPatch'))(Promise.resolve(testInstallPlan)) - .then(([model, installPlan]) => { - expect(model).toEqual(InstallPlanModel); - expect(jest.spyOn(k8sResourceModule, 'k8sPatch')).toHaveBeenLastCalledWith( - InstallPlanModel, - installPlan, - [ - { - op: 'replace', - path: '/spec/approved', - value: true, - }, - ], - ); - done(); - }) - .catch((err) => fail(err)); - - const wrapper = shallow( - , - ); - - wrapper.find(InstallPlanHint).dive().find(Hint).shallow().find(Button).at(0).simulate('click'); - }); - - it('renders button to deny install plan if requires approval', () => { - const wrapper = shallow( - , - ); - expect( - wrapper.find(InstallPlanHint).dive().find(Hint).shallow().find(Button).at(1).render().text(), - ).toEqual('Deny'); - }); - - it('renders section for each resolving `ClusterServiceVersion`', () => { - const wrapper = shallow(); - expect(wrapper.find('.co-m-pane__body').length).toEqual(1); - wrapper.find('.co-m-pane__body').forEach((section) => { - expect(section.find('tbody').find('tr').length).toEqual(2); - }); - }); - - it('renders link to view install plan component if it exists', () => { - const wrapper = shallow(); - const row = wrapper.find('.co-m-pane__body').find('tbody').find('tr').at(0); - - expect(row.find('td').at(0).find(ResourceLink).props().name).toEqual( - obj.status.plan[0].resource.name, - ); - }); - - it('renders link to open preview modal for install plan component if not created yet', () => { - const wrapper = shallow(); - const row = wrapper.find('.co-m-pane__body').find('tbody').find('tr').at(1); - const modalSpy = spyOn(modal, 'installPlanPreviewModal').and.returnValue(null); - - expect(row.find('td').at(0).find(ResourceIcon).props().kind).toEqual( - referenceForStepResource(obj.status.plan[1].resource), - ); - - row.find('td').at(0).find(Button).simulate('click'); - - expect(modalSpy.calls.argsFor(0)[0].stepResource).toEqual(obj.status.plan[1].resource); - }); -}); - -describe('InstallPlanDetails', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow(); - }); - - it('renders link to "Components" tab if install plan needs approval', () => { - const installPlan = _.cloneDeep(testInstallPlan); - installPlan.spec.approval = InstallPlanApproval.Manual; - installPlan.spec.approved = false; - wrapper = wrapper.setProps({ obj: installPlan }); - - expect( - wrapper.find(InstallPlanHint).dive().find(Hint).shallow().find(Link).props().to, - ).toEqual( - `/k8s/ns/default/${referenceForModel(InstallPlanModel)}/${ - testInstallPlan.metadata.name - }/components`, - ); - }); - - it('does not render link to "Components" tab if install plan does not need approval"', () => { - expect(wrapper.find(InstallPlanHint).exists()).toBe(false); - }); -}); - -describe('InstallPlanDetailsPage', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - jest - .spyOn(Router, 'useParams') - .mockReturnValue({ ns: 'default', name: testInstallPlan.metadata.name }); - wrapper = shallow(); - }); - - it('renders a `DetailsPage` with correct props', () => { - expect( - wrapper - .find(DetailsPage) - .props() - .pages.map((p) => p.name || p.nameKey), - ).toEqual([`${i18nNS}~Details`, `${i18nNS}~YAML`, 'olm~Components']); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/installplan-approval-modal.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/installplan-approval-modal.spec.tsx new file mode 100644 index 00000000000..5f0da26d30d --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/installplan-approval-modal.spec.tsx @@ -0,0 +1,154 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import * as _ from 'lodash'; +import { modelFor } from '@console/internal/module/k8s'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testSubscription, testInstallPlan } from '../../../../mocks'; +import { SubscriptionModel, InstallPlanModel } from '../../../models'; +import { InstallPlanApproval } from '../../../types'; +import { + InstallPlanApprovalModal, + InstallPlanApprovalModalProps, +} from '../installplan-approval-modal'; + +jest.mock('@console/internal/module/k8s', () => ({ + ...jest.requireActual('@console/internal/module/k8s'), + modelFor: jest.fn(), +})); + +jest.mock('@console/internal/components/factory/modal', () => ({ + ...jest.requireActual('@console/internal/components/factory/modal'), + ModalTitle: jest.fn(({ children }) => children), + ModalBody: jest.fn(({ children }) => children), + ModalSubmitFooter: jest.fn(() => null), +})); + +const mockModelFor = modelFor as jest.Mock; + +describe('InstallPlanApprovalModal', () => { + let installPlanApprovalModalProps: InstallPlanApprovalModalProps; + + beforeEach(() => { + jest.clearAllMocks(); + + installPlanApprovalModalProps = { + obj: { ...testSubscription }, + k8sUpdate: jest.fn(() => Promise.resolve()), + close: jest.fn(), + cancel: jest.fn(), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders modal with correct title', () => { + renderWithProviders(); + + expect(screen.getByText('Change update approval strategy')).toBeVisible(); + }); + + it('renders two radio buttons for approval strategies', () => { + renderWithProviders(); + + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(2); + expect(screen.getByRole('radio', { name: 'Automatic (default)' })).toBeVisible(); + expect(screen.getByRole('radio', { name: 'Manual' })).toBeVisible(); + }); + + it('pre-selects Automatic when subscription uses automatic approval', () => { + renderWithProviders(); + + const automaticRadio = screen.getByRole('radio', { name: 'Automatic (default)' }); + const manualRadio = screen.getByRole('radio', { name: 'Manual' }); + + expect(automaticRadio).toBeChecked(); + expect(manualRadio).not.toBeChecked(); + }); + + it('pre-selects Manual when install plan uses manual approval', () => { + const installPlan = _.cloneDeep(testInstallPlan); + installPlan.spec.approval = InstallPlanApproval.Manual; + + renderWithProviders( + , + ); + + const automaticRadio = screen.getByRole('radio', { name: 'Automatic (default)' }); + const manualRadio = screen.getByRole('radio', { name: 'Manual' }); + + expect(automaticRadio).not.toBeChecked(); + expect(manualRadio).toBeChecked(); + }); + + it('calls k8sUpdate with updated subscription when form is submitted', async () => { + mockModelFor.mockReturnValue(SubscriptionModel); + + renderWithProviders(); + + const manualRadio = screen.getByRole('radio', { name: 'Manual' }); + fireEvent.click(manualRadio); + + const form = screen.getByRole('radio', { name: 'Manual' }).closest('form'); + if (form) { + fireEvent.submit(form); + } + + await waitFor(() => { + expect(installPlanApprovalModalProps.k8sUpdate).toHaveBeenCalledTimes(1); + }); + + expect(installPlanApprovalModalProps.k8sUpdate).toHaveBeenCalledWith( + SubscriptionModel, + expect.objectContaining({ + spec: expect.objectContaining({ + installPlanApproval: InstallPlanApproval.Manual, + }), + }), + ); + }); + + it('calls k8sUpdate with updated install plan when form is submitted', async () => { + const installPlan = _.cloneDeep(testInstallPlan); + mockModelFor.mockReturnValue(InstallPlanModel); + + renderWithProviders( + , + ); + + const manualRadio = screen.getByRole('radio', { name: 'Manual' }); + fireEvent.click(manualRadio); + + const form = screen.getByRole('radio', { name: 'Manual' }).closest('form'); + if (form) { + fireEvent.submit(form); + } + + await waitFor(() => { + expect(installPlanApprovalModalProps.k8sUpdate).toHaveBeenCalledTimes(1); + }); + + expect(installPlanApprovalModalProps.k8sUpdate).toHaveBeenCalledWith( + InstallPlanModel, + expect.objectContaining({ + spec: expect.objectContaining({ + approval: InstallPlanApproval.Manual, + }), + }), + ); + }); + + it('calls close callback after successful submit', async () => { + renderWithProviders(); + + const form = screen.getByRole('radio', { name: 'Automatic (default)' }).closest('form'); + if (form) { + fireEvent.submit(form); + } + + await waitFor(() => { + expect(installPlanApprovalModalProps.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/subscription-channel-modal.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/subscription-channel-modal.spec.tsx new file mode 100644 index 00000000000..35d23c3a78c --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/subscription-channel-modal.spec.tsx @@ -0,0 +1,142 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import * as _ from 'lodash'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testSubscription, testPackageManifest } from '../../../../mocks'; +import { SubscriptionModel } from '../../../models'; +import { SubscriptionKind, PackageManifestKind } from '../../../types'; +import { + SubscriptionChannelModal, + SubscriptionChannelModalProps, +} from '../subscription-channel-modal'; + +describe('SubscriptionChannelModal', () => { + let subscriptionChannelModalProps: SubscriptionChannelModalProps; + let k8sUpdate: jest.Mock; + let close: jest.Mock; + let cancel: jest.Mock; + let subscription: SubscriptionKind; + let pkg: PackageManifestKind; + + beforeEach(() => { + jest.clearAllMocks(); + + k8sUpdate = jest.fn().mockResolvedValue({}); + close = jest.fn(); + cancel = jest.fn(); + subscription = _.cloneDeep(testSubscription); + pkg = _.cloneDeep(testPackageManifest); + pkg.status.defaultChannel = 'stable'; + pkg.status.channels = [ + { + name: 'stable', + currentCSV: 'testapp', + currentCSVDesc: { + displayName: 'Test App', + icon: [{ mediatype: 'image/png', base64data: '' }], + version: '0.0.1', + provider: { + name: 'CoreOS, Inc', + }, + installModes: [], + }, + }, + { + name: 'nightly', + currentCSV: 'testapp-nightly', + currentCSVDesc: { + displayName: 'Test App', + icon: [{ mediatype: 'image/png', base64data: '' }], + version: '0.0.1', + provider: { + name: 'CoreOS, Inc', + }, + installModes: [], + }, + }, + ]; + + subscriptionChannelModalProps = { + subscription, + pkg, + k8sUpdate, + close, + cancel, + }; + }); + + it('displays modal title and save button', () => { + renderWithProviders(); + + expect(screen.getByText('Change Subscription update channel')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Save' })).toBeVisible(); + }); + + it('displays radio button for each available channel', () => { + renderWithProviders(); + + const radioButtons = screen.getAllByRole('radio'); + expect(radioButtons).toHaveLength(pkg.status.channels.length); + expect(screen.getByRole('radio', { name: /stable/i })).toBeVisible(); + expect(screen.getByRole('radio', { name: /nightly/i })).toBeVisible(); + }); + + it('updates subscription when different channel is selected and form is submitted', async () => { + renderWithProviders(); + + const nightlyRadio = screen.getByRole('radio', { name: /nightly/i }); + fireEvent.click(nightlyRadio); + + const form = screen.getByRole('button', { name: 'Save' }).closest('form'); + if (form) { + fireEvent.submit(form); + } + + await waitFor(() => { + expect(k8sUpdate).toHaveBeenCalledTimes(1); + }); + + expect(k8sUpdate).toHaveBeenCalledWith( + SubscriptionModel, + expect.objectContaining({ + spec: expect.objectContaining({ + channel: 'nightly', + }), + }), + ); + }); + + it('calls close callback after successful form submission', async () => { + renderWithProviders(); + + const nightlyRadio = screen.getByRole('radio', { name: /nightly/i }); + fireEvent.click(nightlyRadio); + + const form = screen.getByRole('button', { name: 'Save' }).closest('form'); + if (form) { + fireEvent.submit(form); + } + + await waitFor(() => { + expect(close).toHaveBeenCalledTimes(1); + }); + }); + + it('disables submit button when no channel change is made', () => { + renderWithProviders(); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + }); + + it('enables submit button when channel selection changes', () => { + renderWithProviders(); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + + const nightlyRadio = screen.getByRole('radio', { name: /nightly/i }); + fireEvent.click(nightlyRadio); + + expect(saveButton).not.toBeDisabled(); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/uninstall-operator-modal.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/uninstall-operator-modal.spec.tsx new file mode 100644 index 00000000000..0f92a62a59b --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/modals/__tests__/uninstall-operator-modal.spec.tsx @@ -0,0 +1,134 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import * as _ from 'lodash'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { useAccessReview } from '@console/internal/components/utils/rbac'; +import { useOperands } from '@console/shared/src/hooks/useOperands'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testSubscription, dummyPackageManifest } from '../../../../mocks'; +import { ClusterServiceVersionModel, SubscriptionModel } from '../../../models'; +import { UninstallOperatorModal, UninstallOperatorModalProps } from '../uninstall-operator-modal'; + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(), +})); + +jest.mock('@console/internal/components/utils/rbac', () => ({ + useAccessReview: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/useOperands', () => ({ + useOperands: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key.replace(/^[^~]+~/, ''), // Remove namespace prefix (e.g., "olm~") + i18n: { language: 'en' }, + }), + withTranslation: () => (component) => component, + Trans: () => null, +})); + +jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s', () => ({ + k8sGetResource: jest.fn(), +})); + +describe(UninstallOperatorModal.name, () => { + let uninstallOperatorModalProps: UninstallOperatorModalProps; + + beforeEach(() => { + jest.clearAllMocks(); + + uninstallOperatorModalProps = { + subscription: { + ..._.cloneDeep(testSubscription), + status: { installedCSV: 'testapp.v1.0.0' }, + }, + k8sKill: jest.fn().mockResolvedValue({}), + k8sGet: jest.fn().mockResolvedValue({}), + k8sPatch: jest.fn().mockResolvedValue({}), + close: jest.fn(), + cancel: jest.fn(), + }; + + (useK8sWatchResource as jest.Mock).mockReturnValue([dummyPackageManifest, true, null]); + (useAccessReview as jest.Mock).mockReturnValue(false); + (useOperands as jest.Mock).mockReturnValue([[], true, '']); + }); + + it('displays modal title and uninstall button when rendered', () => { + renderWithProviders(); + + expect(screen.getByText('Uninstall Operator?')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Uninstall' })).toBeVisible(); + }); + + it('deletes subscription when form is submitted', async () => { + renderWithProviders(); + + fireEvent.submit(screen.getByRole('form')); + + await waitFor(() => { + expect(uninstallOperatorModalProps.k8sKill).toHaveBeenCalledTimes(2); + }); + + expect(uninstallOperatorModalProps.k8sKill).toHaveBeenCalledWith( + SubscriptionModel, + uninstallOperatorModalProps.subscription, + {}, + expect.objectContaining({ + kind: 'DeleteOptions', + apiVersion: 'v1', + propagationPolicy: 'Foreground', + }), + ); + }); + + it('deletes ClusterServiceVersion when form is submitted', async () => { + renderWithProviders(); + + fireEvent.submit(screen.getByRole('form')); + + await waitFor(() => { + expect(uninstallOperatorModalProps.k8sKill).toHaveBeenCalledTimes(2); + }); + + expect(uninstallOperatorModalProps.k8sKill).toHaveBeenCalledWith( + ClusterServiceVersionModel, + expect.objectContaining({ + metadata: expect.objectContaining({ + name: 'testapp.v1.0.0', + namespace: testSubscription.metadata.namespace, + }), + }), + {}, + expect.objectContaining({ + kind: 'DeleteOptions', + apiVersion: 'v1', + propagationPolicy: 'Foreground', + }), + ); + }); + + it('does not delete ClusterServiceVersion when installedCSV is missing from subscription', async () => { + renderWithProviders( + , + ); + + fireEvent.submit(screen.getByRole('form')); + + await waitFor(() => { + expect(uninstallOperatorModalProps.k8sKill).toHaveBeenCalledTimes(1); + }); + }); + + it('calls close callback after successful form submission', async () => { + renderWithProviders(); + + fireEvent.submit(screen.getByRole('form')); + + await waitFor(() => { + expect(uninstallOperatorModalProps.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/installplan-approval-modal.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/installplan-approval-modal.spec.tsx deleted file mode 100644 index b9969d4a94e..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/modals/installplan-approval-modal.spec.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Radio } from '@patternfly/react-core'; -import { ShallowWrapper, shallow } from 'enzyme'; -import * as _ from 'lodash'; -import { - ModalTitle, - ModalSubmitFooter, - ModalBody, -} from '@console/internal/components/factory/modal'; -import * as k8sModelsModule from '@console/internal/module/k8s/k8s-models'; -import { testSubscription, testInstallPlan } from '../../../mocks'; -import { SubscriptionModel, InstallPlanModel } from '../../models'; -import { SubscriptionKind, InstallPlanApproval } from '../../types'; -import { - InstallPlanApprovalModal, - InstallPlanApprovalModalProps, -} from './installplan-approval-modal'; -import Spy = jasmine.Spy; - -describe(InstallPlanApprovalModal.name, () => { - let wrapper: ShallowWrapper; - let k8sUpdate: Spy; - let close: Spy; - let cancel: Spy; - let subscription: SubscriptionKind; - - beforeEach(() => { - k8sUpdate = jasmine.createSpy().and.returnValue(Promise.resolve()); - close = jasmine.createSpy(); - cancel = jasmine.createSpy(); - subscription = _.cloneDeep(testSubscription); - - wrapper = shallow( - , - ); - }); - - it('renders a modal form', () => { - expect(wrapper.find('form').props().name).toEqual('form'); - expect(wrapper.find(ModalTitle).exists()).toBe(true); - expect(wrapper.find(ModalSubmitFooter).props().submitText).toEqual('Save'); - }); - - it('renders a radio button for each available approval strategy', () => { - expect(wrapper.find(ModalBody).find(Radio).length).toEqual(2); - }); - - it('pre-selects the approval strategy option that is currently being used by a subscription', () => { - expect(wrapper.find(ModalBody).find(Radio).at(0).props().isChecked).toBe(true); - }); - - it('pre-selects the approval strategy option that is currently being used by an install plan', () => { - wrapper = wrapper.setProps({ obj: _.cloneDeep(testInstallPlan) }); - expect(wrapper.find(ModalBody).find(Radio).at(0).props().isChecked).toBe(true); - }); - - it('calls `props.k8sUpdate` to update the subscription when form is submitted', () => { - spyOn(k8sModelsModule, 'modelFor').and.returnValue(SubscriptionModel); - k8sUpdate.and.callFake((modelArg, subscriptionArg) => { - expect(modelArg).toEqual(SubscriptionModel); - expect(subscriptionArg?.spec?.installPlanApproval).toEqual(InstallPlanApproval.Manual); - return Promise.resolve(); - }); - wrapper - .find(ModalBody) - .find(Radio) - .at(1) - .props() - .onChange({ target: { value: InstallPlanApproval.Manual } } as any, true); - wrapper.find('form').simulate('submit', new Event('submit')); - }); - - it('calls `props.k8sUpdate` to update the install plan when form is submitted', () => { - wrapper = wrapper.setProps({ obj: _.cloneDeep(testInstallPlan) }); - spyOn(k8sModelsModule, 'modelFor').and.returnValue(InstallPlanModel); - k8sUpdate.and.callFake((modelArg, installPlanArg) => { - expect(modelArg).toEqual(InstallPlanModel); - expect(installPlanArg?.spec?.approval).toEqual(InstallPlanApproval.Manual); - return Promise.resolve(); - }); - wrapper - .find(ModalBody) - .find(Radio) - .at(1) - .props() - .onChange({ target: { value: InstallPlanApproval.Manual } } as any, true); - wrapper.find('form').simulate('submit', new Event('submit')); - }); - - it('calls `props.close` after successful submit', (done) => { - close.and.callFake(() => { - done(); - }); - - wrapper.find('form').simulate('submit', new Event('submit')); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/subscription-channel-modal.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/subscription-channel-modal.spec.tsx deleted file mode 100644 index 008a757eead..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/modals/subscription-channel-modal.spec.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Radio } from '@patternfly/react-core'; -import { ShallowWrapper, shallow } from 'enzyme'; -import * as _ from 'lodash'; -import { - ModalTitle, - ModalSubmitFooter, - ModalBody, -} from '@console/internal/components/factory/modal'; -import { testSubscription, testPackageManifest } from '../../../mocks'; -import { SubscriptionModel } from '../../models'; -import { SubscriptionKind, PackageManifestKind } from '../../types'; -import { - SubscriptionChannelModal, - SubscriptionChannelModalProps, -} from './subscription-channel-modal'; -import Spy = jasmine.Spy; - -describe('SubscriptionChannelModal', () => { - let wrapper: ShallowWrapper; - let k8sUpdate: Spy; - let close: Spy; - let cancel: Spy; - let subscription: SubscriptionKind; - let pkg: PackageManifestKind; - - beforeEach(() => { - k8sUpdate = jasmine.createSpy('k8sUpdate').and.returnValue(Promise.resolve()); - close = jasmine.createSpy('close'); - cancel = jasmine.createSpy('cancel'); - subscription = _.cloneDeep(testSubscription); - pkg = _.cloneDeep(testPackageManifest); - pkg.status.defaultChannel = 'stable'; - pkg.status.channels = [ - { - name: 'stable', - currentCSV: 'testapp', - currentCSVDesc: { - displayName: 'Test App', - icon: [{ mediatype: 'image/png', base64data: '' }], - version: '0.0.1', - provider: { - name: 'CoreOS, Inc', - }, - installModes: [], - }, - }, - { - name: 'nightly', - currentCSV: 'testapp-nightly', - currentCSVDesc: { - displayName: 'Test App', - icon: [{ mediatype: 'image/png', base64data: '' }], - version: '0.0.1', - provider: { - name: 'CoreOS, Inc', - }, - installModes: [], - }, - }, - ]; - - wrapper = shallow( - , - ); - }); - - it('renders a modal form', () => { - expect(wrapper.find('form').props().name).toEqual('form'); - expect(wrapper.find(ModalTitle).exists()).toBe(true); - expect(wrapper.find(ModalSubmitFooter).props().submitText).toEqual('Save'); - }); - - it('renders a radio button for each available channel in the package', () => { - expect(wrapper.find(ModalBody).find(Radio).length).toEqual(pkg.status.channels.length); - }); - - it('calls `props.k8sUpdate` to update the subscription when form is submitted', (done) => { - k8sUpdate.and.callFake((model, obj) => { - expect(model).toEqual(SubscriptionModel); - expect(obj.spec.channel).toEqual('nightly'); - done(); - return Promise.resolve(); - }); - wrapper - .find(Radio) - .at(1) - .props() - .onChange({ target: { value: 'nightly' } } as any, true); - wrapper.find('form').simulate('submit', new Event('submit')); - }); - - it('calls `props.close` after successful submit', (done) => { - close.and.callFake(() => { - done(); - }); - - wrapper.find('form').simulate('submit', new Event('submit')); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.spec.tsx deleted file mode 100644 index a752a4eb3f4..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.spec.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { ReactWrapper, mount } from 'enzyme'; -import * as _ from 'lodash'; -import { act } from 'react-dom/test-utils'; -import { ModalTitle, ModalSubmitFooter } from '@console/internal/components/factory/modal'; -import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; -import { useAccessReview } from '@console/internal/components/utils/rbac'; -import { useOperands } from '@console/shared/src/hooks/useOperands'; -import { testSubscription, dummyPackageManifest } from '../../../mocks'; -import { ClusterServiceVersionModel, SubscriptionModel } from '../../models'; -import { SubscriptionKind } from '../../types'; -import { UninstallOperatorModal, UninstallOperatorModalProps } from './uninstall-operator-modal'; -import Spy = jasmine.Spy; - -jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ - useK8sWatchResource: jest.fn(), -})); - -jest.mock('@console/internal/components/utils/rbac', () => ({ - useAccessReview: jest.fn(), -})); - -jest.mock('@console/shared/src/hooks/useOperands', () => ({ - useOperands: jest.fn(), -})); - -jest.mock('react-i18next', () => { - const reactI18next = jest.requireActual('react-i18next'); - return { - ...reactI18next, - Trans: () => null, - }; -}); - -jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s', () => ({ - k8sGetResource: jest.fn(), -})); - -describe(UninstallOperatorModal.name, () => { - let wrapper: ReactWrapper; - let k8sKill: Spy; - let k8sGet: Spy; - let k8sPatch: Spy; - let close: Spy; - let cancel: Spy; - let subscription: SubscriptionKind; - - const spyAndExpect = (spy: Spy) => (returnValue: any) => - new Promise((resolve) => - spy.and.callFake((...args) => { - resolve(args); - return returnValue; - }), - ); - - beforeEach(() => { - k8sKill = jasmine.createSpy('k8sKill').and.returnValue(Promise.resolve()); - k8sGet = jasmine.createSpy('k8sGet').and.returnValue(Promise.resolve()); - k8sPatch = jasmine.createSpy('k8sPatch').and.returnValue(Promise.resolve()); - close = jasmine.createSpy('close'); - cancel = jasmine.createSpy('cancel'); - subscription = { ..._.cloneDeep(testSubscription), status: { installedCSV: 'testapp.v1.0.0' } }; - - (useK8sWatchResource as jest.Mock).mockReturnValue([dummyPackageManifest, true, null]); - (useAccessReview as jest.Mock).mockReturnValue(false); - (useOperands as jest.Mock).mockReturnValue([[], true, '']); - - // React.useEffect is not supported by Enzyme's shallow rendering, switching to mount - wrapper = mount( - , - ); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('renders a modal form', () => { - expect(wrapper.find('form').props().name).toEqual('form'); - expect(wrapper.find(ModalTitle).exists()).toBe(true); - expect(wrapper.find(ModalSubmitFooter).props().submitText).toEqual('Uninstall'); - }); - - it('calls `props.k8sKill` to delete the subscription when form is submitted', async () => { - await act(async () => { - wrapper.find('form').simulate('submit', new Event('submit')); - await spyAndExpect(close)(null); - }); - - expect(k8sKill).toHaveBeenCalledTimes(2); - expect(k8sKill.calls.argsFor(0)[0]).toEqual(SubscriptionModel); - expect(k8sKill.calls.argsFor(0)[1]).toEqual(subscription); - expect(k8sKill.calls.argsFor(0)[2]).toEqual({}); - expect(k8sKill.calls.argsFor(0)[3]).toEqual({ - kind: 'DeleteOptions', - apiVersion: 'v1', - propagationPolicy: 'Foreground', - }); - }); - - it('calls `props.k8sKill` to delete the `ClusterServiceVersion` from the subscription namespace when form is submitted', async () => { - await act(async () => { - wrapper.find('form').simulate('submit', new Event('submit')); - await spyAndExpect(close)(null); - }); - - expect(k8sKill).toHaveBeenCalledTimes(2); - expect(k8sKill.calls.argsFor(1)[0]).toEqual(ClusterServiceVersionModel); - expect(k8sKill.calls.argsFor(1)[1].metadata.namespace).toEqual( - testSubscription.metadata.namespace, - ); - expect(k8sKill.calls.argsFor(1)[1].metadata.name).toEqual('testapp.v1.0.0'); - expect(k8sKill.calls.argsFor(1)[2]).toEqual({}); - expect(k8sKill.calls.argsFor(1)[3]).toEqual({ - kind: 'DeleteOptions', - apiVersion: 'v1', - propagationPolicy: 'Foreground', - }); - }); - - it('does not call `props.k8sKill` to delete `ClusterServiceVersion` if `status.installedCSV` field missing from subscription', async () => { - wrapper = wrapper.setProps({ subscription: testSubscription }); - - await act(async () => { - wrapper.find('form').simulate('submit', new Event('submit')); - await spyAndExpect(close)(null); - }); - - expect(k8sKill).toHaveBeenCalledTimes(1); - }); - - it('adds delete options with `propagationPolicy`', async () => { - await act(async () => { - wrapper.find('form').simulate('submit', new Event('submit')); - spyAndExpect(close)(null); - }); - - expect(k8sKill).toHaveBeenCalledTimes(2); - expect(k8sKill.calls.argsFor(0)[3]).toEqual({ - kind: 'DeleteOptions', - apiVersion: 'v1', - propagationPolicy: 'Foreground', - }); - expect(k8sKill.calls.argsFor(1)[3]).toEqual({ - kind: 'DeleteOptions', - apiVersion: 'v1', - propagationPolicy: 'Foreground', - }); - }); - - it('calls `props.close` after successful submit', async () => { - await act(async () => { - wrapper.find('form').simulate('submit', new Event('submit')); - spyAndExpect(close)(null); - }); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/create-operand.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/create-operand.spec.tsx new file mode 100644 index 00000000000..2e86cd8471e --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/create-operand.spec.tsx @@ -0,0 +1,216 @@ +import * as Router from 'react-router-dom-v5-compat'; +import { CreateYAML } from '@console/internal/components/create-yaml'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { SyncedEditor } from '@console/shared/src/components/synced-editor'; +import { EditorType } from '@console/shared/src/components/synced-editor/editor-toggle'; +import { useK8sModel } from '@console/shared/src/hooks/useK8sModel'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testClusterServiceVersion, testModel, testCRD } from '../../../../mocks'; +import { CreateOperand } from '../create-operand'; +import { OperandYAML } from '../operand-yaml'; + +jest.mock('@console/shared/src/hooks/useK8sModel', () => ({ useK8sModel: jest.fn() })); + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(), +})); + +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useParams: jest.fn(), +})); + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + useActivePerspective: jest.fn(() => ['admin']), +})); + +jest.mock('@console/shared/src/components/synced-editor', () => ({ + SyncedEditor: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/create-yaml', () => ({ + CreateYAML: jest.fn(() => null), +})); + +jest.mock('@console/shared/src/components/heading/PageHeading', () => ({ + PageHeading: jest.fn(() => null), +})); + +const mockUseK8sModel = useK8sModel as jest.Mock; +const mockUseK8sWatchResource = useK8sWatchResource as jest.Mock; +const mockSyncedEditor = (SyncedEditor as unknown) as jest.Mock; +const mockCreateYAML = (CreateYAML as unknown) as jest.Mock; + +describe('CreateOperand', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default', plural: 'testresources' }); + mockUseK8sModel.mockReturnValue([testModel, true]); + mockUseK8sWatchResource.mockReturnValue([testCRD, true, undefined]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('configures SyncedEditor with YAML as initialType when initialEditorType is YAML', () => { + // Arrange + renderWithProviders( + , + ); + + // Assert + expect(mockSyncedEditor).toHaveBeenCalledTimes(1); + const [syncedEditorProps] = mockSyncedEditor.mock.calls[0]; + expect(syncedEditorProps.initialType).toEqual(EditorType.YAML); + }); + + it('configures SyncedEditor with Form as initialType when initialEditorType is Form', () => { + // Arrange + renderWithProviders( + , + ); + + // Assert + expect(mockSyncedEditor).toHaveBeenCalledTimes(1); + const [syncedEditorProps] = mockSyncedEditor.mock.calls[0]; + expect(syncedEditorProps.initialType).toEqual(EditorType.Form); + }); + + it('passes sample data to SyncedEditor when CSV contains alm-examples annotation', () => { + // Arrange + const csvWithExamples = { + ...testClusterServiceVersion, + metadata: { + ...testClusterServiceVersion.metadata, + annotations: { + ...testClusterServiceVersion.metadata.annotations, + 'alm-examples': JSON.stringify([ + { + apiVersion: 'testapp.coreos.com/v1alpha1', + kind: 'TestResource', + metadata: { name: 'example-resource' }, + spec: { size: 3 }, + }, + ]), + }, + }, + }; + + renderWithProviders( + , + ); + + // Assert + expect(mockSyncedEditor).toHaveBeenCalledTimes(1); + const [syncedEditorProps] = mockSyncedEditor.mock.calls[0]; + expect(syncedEditorProps.initialData).toMatchObject({ + kind: 'TestResource', + metadata: expect.objectContaining({ + name: 'example-resource', + }), + spec: expect.objectContaining({ + size: 3, + }), + }); + }); + + it('provides onChangeEditorType callback to SyncedEditor', () => { + // Arrange + renderWithProviders( + , + ); + + // Assert + expect(mockSyncedEditor).toHaveBeenCalledTimes(1); + const [syncedEditorProps] = mockSyncedEditor.mock.calls[0]; + expect(syncedEditorProps.onChangeEditorType).toBeDefined(); + expect(typeof syncedEditorProps.onChangeEditorType).toBe('function'); + }); +}); + +describe('OperandYAML', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders CreateYAML with hideHeader prop set to true', () => { + renderWithProviders(); + + expect(mockCreateYAML).toHaveBeenCalledTimes(1); + const [createYAMLProps] = mockCreateYAML.mock.calls[0]; + expect(createYAMLProps.hideHeader).toBe(true); + }); + + it('passes initialYAML as template prop to CreateYAML', () => { + const initialYAML = 'apiVersion: v1\nkind: Pod'; + + renderWithProviders(); + + expect(mockCreateYAML).toHaveBeenCalledTimes(1); + const [createYAMLProps] = mockCreateYAML.mock.calls[0]; + expect(createYAMLProps.template).toEqual(initialYAML); + }); + + it('defaults initialYAML to empty string when not provided', () => { + renderWithProviders(); + + expect(mockCreateYAML).toHaveBeenCalledTimes(1); + const [createYAMLProps] = mockCreateYAML.mock.calls[0]; + expect(createYAMLProps.template).toEqual(''); + }); + + it('passes onChange callback to CreateYAML', () => { + const onChange = jest.fn(); + + renderWithProviders(); + + expect(mockCreateYAML).toHaveBeenCalledTimes(1); + const [createYAMLProps] = mockCreateYAML.mock.calls[0]; + expect(createYAMLProps.onChange).toEqual(onChange); + }); + + it('passes resourceObjPath function when next prop is provided', () => { + const next = '/next-path'; + + renderWithProviders(); + + expect(mockCreateYAML).toHaveBeenCalledTimes(1); + const [createYAMLProps] = mockCreateYAML.mock.calls[0]; + expect(createYAMLProps.resourceObjPath).toBeDefined(); + expect(typeof createYAMLProps.resourceObjPath).toBe('function'); + expect(createYAMLProps.resourceObjPath()).toEqual(next); + }); + + it('does not pass resourceObjPath when next prop is not provided', () => { + renderWithProviders(); + + expect(mockCreateYAML).toHaveBeenCalledTimes(1); + const [createYAMLProps] = mockCreateYAML.mock.calls[0]; + expect(createYAMLProps.resourceObjPath).toBeUndefined(); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx new file mode 100644 index 00000000000..f9d5852c74c --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx @@ -0,0 +1,282 @@ +import { screen } from '@testing-library/react'; +import * as _ from 'lodash'; +import * as ReactRouter from 'react-router-dom-v5-compat'; +import { DetailsPage } from '@console/internal/components/factory'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { testResourceInstance, testClusterServiceVersion } from '../../../../mocks'; +import { ClusterServiceVersionModel } from '../../../models'; +import { + OperandTableRow, + OperandDetailsPage, + ProvidedAPIsPage, + ProvidedAPIPage, + OperandStatus, +} from '../index'; + +jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useParams: jest.fn(), + useLocation: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/useK8sModels', () => ({ + useK8sModels: () => [ + { + 'testapp.coreos.com~v1alpha1~TestResource': { + abbr: 'TR', + apiGroup: 'testapp.coreos.com', + apiVersion: 'v1alpha1', + crd: true, + kind: 'TestResource', + label: 'Test Resource', + labelPlural: 'Test Resources', + namespaced: true, + plural: 'testresources', + verbs: ['create'], + }, + }, + false, + null, + ], +})); + +jest.mock('@console/shared/src/hooks/useK8sModel', () => ({ + useK8sModel: () => [ + { + abbr: 'TR', + apiGroup: 'testapp.coreos.com', + apiVersion: 'v1alpha1', + crd: true, + kind: 'TestResource', + label: 'Test Resource', + labelPlural: 'Test Resources', + namespaced: true, + plural: 'testresources', + verbs: ['create'], + }, + false, + null, + ], +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => jest.fn(), +})); + +jest.mock('@console/internal/components/factory', () => ({ + ...jest.requireActual('@console/internal/components/factory'), + DetailsPage: jest.fn(() => null), +})); + +const mockDetailsPage = DetailsPage as jest.Mock; + +describe('OperandTableRow', () => { + it('renders operand name and namespace when provided', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(testResourceInstance.metadata.name)).toBeInTheDocument(); + expect(screen.getByText(testResourceInstance.metadata.namespace)).toBeInTheDocument(); + }); + + it('renders operand kind', () => { + renderWithProviders( + + + + + + +
, + ); + + expect(screen.getByText(testResourceInstance.kind)).toBeInTheDocument(); + }); +}); + +describe('OperandStatus', () => { + it('displays status when status field is present', () => { + const operand = { + status: { + status: 'Running', + state: 'Degraded', + conditions: [ + { + type: 'Failed', + status: 'True', + }, + ], + }, + }; + + renderWithProviders(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + }); + + it('displays phase when phase field is present', () => { + const operand = { + status: { + phase: 'Running', + status: 'Installed', + state: 'Degraded', + conditions: [ + { + type: 'Failed', + status: 'True', + }, + ], + }, + }; + + renderWithProviders(); + + expect(screen.getByText('Phase')).toBeInTheDocument(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + }); + + it('displays state when only state field is present', () => { + const operand = { + status: { + state: 'Running', + conditions: [ + { + type: 'Failed', + status: 'True', + }, + ], + }, + }; + + renderWithProviders(); + + expect(screen.getByText('State')).toBeInTheDocument(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + }); + + it('displays condition type when condition status is True', () => { + const operand = { + status: { + conditions: [ + { + type: 'Failed', + status: 'False', + }, + { + type: 'Running', + status: 'True', + }, + ], + }, + }; + + renderWithProviders(); + + expect(screen.getByText('Condition')).toBeInTheDocument(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + }); + + it('displays dash when no status information is available', () => { + const operand = {}; + + renderWithProviders(); + + expect(screen.getByText('-')).toBeInTheDocument(); + }); +}); + +describe('OperandDetailsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ + ns: 'default', + appName: 'testapp', + plural: 'testapp.coreos.com~v1alpha1~TestResource', + name: 'my-test-resource', + }); + + jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ + pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/testapp.coreos.com~v1alpha1~TestResource/my-test-resource`, + }); + }); + + it('renders without errors', () => { + expect(() => { + renderWithProviders(); + }).not.toThrow(); + }); + + it('configures DetailsPage with Details, YAML, Resources, and Events tabs', () => { + renderWithProviders(); + + expect(mockDetailsPage).toHaveBeenCalledTimes(1); + const [detailsPageProps] = mockDetailsPage.mock.calls[0]; + + expect(detailsPageProps.pages).toHaveLength(4); + expect(detailsPageProps.pages[0].nameKey).toEqual('public~Details'); + expect(detailsPageProps.pages[1].nameKey).toEqual('public~YAML'); + expect(detailsPageProps.pages[2].nameKey).toEqual('olm~Resources'); + expect(detailsPageProps.pages[3].nameKey).toEqual('public~Events'); + }); +}); + +describe('ProvidedAPIsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ + ns: 'default', + appName: 'testapp', + }); + + jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ + pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/instances`, + }); + }); + + it('renders create dropdown when CSV has multiple owned CRDs', () => { + const csv = _.cloneDeep(testClusterServiceVersion); + csv.spec.customresourcedefinitions.owned.push({ + name: 'foobars.testapp.coreos.com', + displayName: 'Foo Bars', + version: 'v1', + kind: 'FooBar', + }); + + renderWithProviders(); + + expect(screen.getByText('Create new')).toBeInTheDocument(); + }); +}); + +describe('ProvidedAPIPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ + ns: 'default', + appName: 'testapp', + plural: 'TestResource', + }); + + jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ + pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/TestResource`, + }); + }); + + it('renders create button with correct text for single CRD', () => { + renderWithProviders(); + + expect(screen.getByText('Create Test Resource')).toBeVisible(); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operand/create-operand.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operand/create-operand.spec.tsx deleted file mode 100644 index 09a62e88da9..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/operand/create-operand.spec.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { Alert, Button } from '@patternfly/react-core'; -import { shallow, ShallowWrapper } from 'enzyme'; -import { safeDump } from 'js-yaml'; -import * as _ from 'lodash'; -import * as Router from 'react-router-dom-v5-compat'; -import { CreateYAML } from '@console/internal/components/create-yaml'; -import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; -import { CustomResourceDefinitionModel } from '@console/internal/models'; -import * as k8s from '@console/internal/module/k8s'; -import { EditorType } from '@console/shared/src/components/synced-editor/editor-toggle'; -import { useK8sModel } from '@console/shared/src/hooks/useK8sModel'; -import { referenceForProvidedAPI } from '..'; -import { - testClusterServiceVersion, - testResourceInstance, - testModel, - testCRD, -} from '../../../mocks'; -import { CreateOperand, CreateOperandProps } from './create-operand'; -import { OperandForm, OperandFormProps } from './operand-form'; -import { OperandYAML, OperandYAMLProps } from './operand-yaml'; -import Spy = jasmine.Spy; - -jest.mock('@console/shared/src/hooks/useK8sModel', () => ({ useK8sModel: jest.fn() })); - -(useK8sModel as jest.Mock).mockImplementation(() => [testModel, false]); - -jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ - useK8sWatchResource: jest.fn(), -})); - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), -})); - -(useK8sWatchResource as jest.Mock).mockImplementation((res) => [ - res.kind === CustomResourceDefinitionModel.kind ? testCRD : testClusterServiceVersion, - true, - undefined, -]); - -xdescribe('[https://issues.redhat.com/browse/CONSOLE-2137] CreateOperand', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow( - , - ); - }); - - it('renders YAML editor by default', () => { - expect(wrapper.find(Button).childAt(0).text()).toEqual('Edit Form'); - expect(wrapper.find(OperandYAML).exists()).toBe(true); - expect(wrapper.find(OperandForm).exists()).toBe(false); - }); - - it('passes correct YAML to YAML editor', () => { - const data = _.cloneDeep(testClusterServiceVersion); - const testResourceInstanceYAML = safeDump(testResourceInstance); - data.metadata.annotations = { 'alm-examples': JSON.stringify([testResourceInstance]) }; - wrapper = wrapper.setProps({ csv: data, loaded: true, loadError: null }); - expect(wrapper.find(OperandYAML).props().initialYAML).toEqual(testResourceInstanceYAML); - }); - - it('switches to form component when button is clicked', () => { - wrapper.find(Button).simulate('click'); - - expect(wrapper.find(Button).childAt(0).text()).toEqual('Edit YAML'); - expect(wrapper.find(OperandYAML).exists()).toBe(false); - expect(wrapper.find(OperandForm).exists()).toBe(true); - }); -}); - -xdescribe('[https://issues.redhat.com/browse/CONSOLE-2136] CreateOperandForm', () => { - let wrapper: ShallowWrapper; - - const spyAndExpect = (spy: Spy) => (returnValue: any) => - new Promise((resolve) => - spy.and.callFake((...args) => { - resolve(args); - return returnValue; - }), - ); - - beforeEach(() => { - jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default' }); - wrapper = shallow( - , - ); - }); - - it('renders form', () => { - expect( - referenceForProvidedAPI(testClusterServiceVersion.spec.customresourcedefinitions.owned[0]), - ).toEqual(k8s.referenceForModel(testModel)); - - expect(wrapper.find('form').exists()).toBe(true); - }); - - it('renders input component for each field', () => { - wrapper.find('.co-dynamic-form__form-group').forEach((formGroup) => { - const descriptor = testClusterServiceVersion.spec.customresourcedefinitions.owned[0].specDescriptors.find( - (d) => d.displayName === formGroup.find('.form-label').text(), - ); - - expect(descriptor).toBeDefined(); - }); - }); - - it('renders alert to use YAML editor for full control over all operand fields', () => { - expect(wrapper.find(Alert).props().title).toEqual( - 'Note: Some fields may not be represented in this form. Please select "Edit YAML" for full control of object creation.', - ); - expect(wrapper.find(Alert).props().variant).toEqual('info'); - }); - - it('calls `k8sCreate` to create new operand if form is valid', (done) => { - spyAndExpect(spyOn(k8s, 'k8sCreate'))(Promise.resolve({})) - .then(([model, obj]: [k8s.K8sKind, k8s.K8sResourceKind]) => { - expect(model).toEqual(testModel); - expect(obj.apiVersion).toEqual(k8s.apiVersionForModel(testModel)); - expect(obj.kind).toEqual(testModel.kind); - expect(obj.metadata.name).toEqual('example'); - expect(obj.metadata.namespace).toEqual('default'); - done(); - }) - .catch((err) => fail(err)); - - wrapper.find({ type: 'submit' }).simulate('click', new Event('click')); - }); - - it('displays errors if calling `k8sCreate` fails', (done) => { - const error = { message: 'Failed to create' } as k8s.Status; - /* eslint-disable-next-line prefer-promise-reject-errors */ - spyAndExpect(spyOn(k8s, 'k8sCreate'))(Promise.reject({ json: error })) - .then( - () => new Promise((resolve) => setTimeout(() => resolve(), 10)), - ) - .then(() => { - expect(wrapper.find(Alert).at(0).props().title).toEqual(error.message); - done(); - }) - .catch((err) => fail(err)); - - wrapper.find({ type: 'submit' }).simulate('click', new Event('click')); - }); -}); - -describe(OperandYAML.displayName, () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow(); - }); - - it('renders `CreateYAML` component with correct props', () => { - expect(wrapper.find(CreateYAML).props().hideHeader).toBe(true); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operand/index.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operand/index.spec.tsx deleted file mode 100644 index e2f7f1925e9..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/operand/index.spec.tsx +++ /dev/null @@ -1,776 +0,0 @@ -import { shallow, ShallowWrapper, mount, ReactWrapper } from 'enzyme'; -import * as _ from 'lodash'; -import { Provider } from 'react-redux'; -import * as ReactRouter from 'react-router-dom-v5-compat'; -import { ListPageBody } from '@console/dynamic-plugin-sdk'; -import { Table, DetailsPage, MultiListPage } from '@console/internal/components/factory'; -import { - ListPageCreateLink, - ListPageCreateDropdown, -} from '@console/internal/components/factory/ListPage/ListPageCreate'; -import ListPageFilter from '@console/internal/components/factory/ListPage/ListPageFilter'; -import ListPageHeader from '@console/internal/components/factory/ListPage/ListPageHeader'; -import { - LabelList, - FirehoseResourcesResult, - ResourceLink, -} from '@console/internal/components/utils'; -import { referenceFor, K8sResourceKind } from '@console/internal/module/k8s'; -import * as k8sModelsModule from '@console/internal/module/k8s/k8s-models'; -import store from '@console/internal/redux'; -import * as useExtensionsModule from '@console/plugin-sdk/src/api/useExtensions'; -import { LazyActionMenu } from '@console/shared'; -import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; -import { - testCRD, - testResourceInstance, - testClusterServiceVersion, - testModel, - testConditionsDescriptor, -} from '../../../mocks'; -import { ClusterServiceVersionModel } from '../../models'; -import { DescriptorDetailsItem, DescriptorDetailsItems } from '../descriptors'; -import { Resources } from '../k8s-resource'; -import { OperandLink } from './operand-link'; -import { - OperandList, - OperandListProps, - ProvidedAPIsPage, - ProvidedAPIsPageProps, - OperandTableRowProps, - OperandTableRow, - OperandDetails, - OperandDetailsProps, - OperandDetailsPage, - ProvidedAPIPage, - ProvidedAPIPageProps, - OperandStatus, - OperandStatusProps, -} from '.'; - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), - useLocation: jest.fn(), -})); - -jest.mock('@console/shared/src/hooks/useK8sModels', () => ({ - useK8sModels: () => [ - { - 'testapp.coreos.com~v1alpha1~TestResource': { - abbr: 'TR', - apiGroup: 'testapp.coreos.com', - apiVersion: 'v1alpha1', - crd: true, - kind: 'TestResource', - label: 'Test Resource', - labelPlural: 'Test Resources', - namespaced: true, - plural: 'testresources', - verbs: ['create'], - }, - }, - false, - null, - ], -})); - -jest.mock('@console/shared/src/hooks/useK8sModel', () => { - return { - useK8sModel: (groupVersionKind) => [ - groupVersionKind === 'TestResourceRO' - ? { - abbr: 'TR', - apiGroup: 'testapp.coreos.com', - apiVersion: 'v1alpha1', - crd: true, - kind: 'TestResourceRO', - label: 'Test Resource', - labelPlural: 'Test Resources', - namespaced: true, - plural: 'testresources', - verbs: ['get'], - } - : { - abbr: 'TR', - apiGroup: 'testapp.coreos.com', - apiVersion: 'v1alpha1', - crd: true, - kind: 'TestResource', - label: 'Test Resource', - labelPlural: 'Test Resources', - namespaced: true, - plural: 'testresources', - verbs: ['create'], - }, - false, - null, - ], - }; -}); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: () => jest.fn(), -})); - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), - useLocation: jest.fn(), -})); - -const i18nNS = 'public'; - -describe(OperandTableRow.displayName, () => { - let wrapper: ReactWrapper; - - beforeEach(() => { - spyOn(useExtensionsModule, 'useExtensions').and.returnValue([]); - wrapper = mount(, { - wrappingComponent: (props) => ( - - - - ), - }); - }); - - it('renders column for resource name', () => { - const col = wrapper.childAt(0); - const link = col.find(OperandLink); - - expect(link.props().obj).toEqual(testResourceInstance); - }); - - it('renders column for resource type', () => { - const col = wrapper.childAt(1); - - expect(col.text()).toEqual(testResourceInstance.kind); - }); - it('renders column for resource namespace', () => { - const col = wrapper.childAt(2); - const link = col.find(ResourceLink); - - expect(link.props().name).toEqual(testResourceInstance.metadata.namespace); - }); - it('renders column for resource status', () => { - const col = wrapper.childAt(3); - - expect(col.find(OperandStatus).props().operand).toEqual(testResourceInstance); - }); - - it('renders column for resource labels', () => { - const col = wrapper.childAt(4); - const labelList = col.find(LabelList); - - expect(labelList.props().kind).toEqual(testResourceInstance.kind); - expect(labelList.props().labels).toEqual(testResourceInstance.metadata.labels); - }); - - it('renders column for last updated timestamp', () => { - const col = wrapper.childAt(5); - const timestamp = col.find(Timestamp); - - expect(timestamp.props().timestamp).toEqual(testResourceInstance.metadata.creationTimestamp); - }); - - it('renders a `LazyActionsMenu` for resource actions', () => { - const kebab = wrapper.find(LazyActionMenu); - expect(kebab.props().context.hasOwnProperty('operand-actions')).toBeTruthy(); - }); -}); - -describe(OperandList.displayName, () => { - let wrapper: ReactWrapper; - let resources: K8sResourceKind[]; - - beforeEach(() => { - resources = [testResourceInstance]; - spyOn(useExtensionsModule, 'useExtensions').and.returnValue([]); - wrapper = mount(, { - wrappingComponent: (props) => ( - - - - ), - }); - }); - - it('renders a `Table` of the custom resource instances of the given kind', () => { - const table: ReactWrapper = wrapper.find(Table); - - expect( - Object.keys(wrapper.props()).reduce( - (k, prop) => _.isEqual(table.prop(prop), wrapper.prop(prop)), - false, - ), - ).toBe(true); - expect(table.props().Header().length).toEqual(7); - expect(table.props().Header()[0].title).toEqual('Name'); - expect(table.props().Header()[1].title).toEqual('Kind'); - expect(table.props().Header()[2].title).toEqual('Namespace'); - expect(table.props().Header()[3].title).toEqual('Status'); - expect(table.props().Header()[4].title).toEqual('Labels'); - expect(table.props().Header()[5].title).toEqual('Last updated'); - expect(_.isFunction(table.props().Row)).toBe(true); - }); -}); - -describe(OperandDetails.displayName, () => { - let wrapper: ShallowWrapper; - let resourceDefinition: any; - - beforeEach(() => { - resourceDefinition = { - plural: testModel.plural, - annotations: testCRD.metadata.annotations, - apiVersion: testModel.apiVersion, - }; - wrapper = shallow( - , - ); - }); - - it('renders description title', () => { - const title = wrapper.find('SectionHeading').first().prop('text'); - expect(title).toEqual('Test Resource overview'); - }); - - it('renders info section', () => { - const section = wrapper.find('.co-operand-details__section.co-operand-details__section--info'); - - expect(section.exists()).toBe(true); - }); - - it('does not render filtered status fields', () => { - const crd = testClusterServiceVersion.spec.customresourcedefinitions.owned.find( - (c) => c.name === 'testresources.testapp.coreos.com', - ); - const filteredDescriptor = crd.statusDescriptors.find((sd) => sd.path === 'importantMetrics'); - const statusView = wrapper - .find(DescriptorDetailsItem) - .filterWhere((node) => node.props().descriptor === filteredDescriptor); - - expect(statusView.exists()).toBe(false); - }); - - it('does not render any spec descriptor fields if there are none defined on the `ClusterServiceVersion`', () => { - const csv = _.cloneDeep(testClusterServiceVersion); - csv.spec.customresourcedefinitions.owned = []; - wrapper = wrapper.setProps({ csv }); - - expect(wrapper.find(DescriptorDetailsItem).length).toEqual(0); - }); - - xit('[CONSOLE-2336] renders spec descriptor fields if the custom resource is `owned`', () => { - expect( - wrapper.find(DescriptorDetailsItems).last().shallow().find(DescriptorDetailsItem).length, - ).toEqual( - testClusterServiceVersion.spec.customresourcedefinitions.owned[0].specDescriptors.length, - ); - }); - - xit('[CONSOLE-2336] renders spec descriptor fields if the custom resource is `required`', () => { - const csv = _.cloneDeep(testClusterServiceVersion); - csv.spec.customresourcedefinitions.required = _.cloneDeep( - csv.spec.customresourcedefinitions.owned, - ); - csv.spec.customresourcedefinitions.owned = []; - wrapper = wrapper.setProps({ csv }); - - expect( - wrapper.find(DescriptorDetailsItems).last().shallow().find(DescriptorDetailsItem).length, - ).toEqual(csv.spec.customresourcedefinitions.required[0].specDescriptors.length); - }); - - it('renders a Condtions table', () => { - expect(wrapper.find('SectionHeading').at(1).prop('text')).toEqual('Conditions'); - - expect(wrapper.find('Conditions').prop('conditions')).toEqual( - testResourceInstance.status.conditions, - ); - }); - - it('renders a DescriptorConditions component for conditions descriptor', () => { - expect(wrapper.find('DescriptorConditions').prop('descriptor')).toEqual( - testConditionsDescriptor, - ); - expect(wrapper.find('DescriptorConditions').prop('obj')).toEqual(testResourceInstance); - expect(wrapper.find('DescriptorConditions').prop('schema')).toEqual({}); - }); -}); - -describe('ResourcesList', () => { - jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ - pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/etcd/${referenceFor( - testResourceInstance, - )}/my-etcd`, - }); - jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ - ns: 'default', - appName: 'etcd', - plural: `${referenceFor(testResourceInstance)}`, - name: 'my-etcd', - }); - it('uses the resources defined in the CSV', () => { - const wrapper = mount( - , - { - wrappingComponent: ({ children }) => ( - - {children} - - ), - }, - ); - const multiListPage = wrapper.find(MultiListPage); - expect(multiListPage.props().resources).toEqual( - testClusterServiceVersion.spec.customresourcedefinitions.owned[0].resources.map( - (resource) => ({ kind: resource.kind, namespaced: true, prop: 'Pod' }), - ), - ); - }); - - it('uses the default resources if the kind is not found in the CSV', () => { - const wrapper = mount(, { - wrappingComponent: ({ children }) => ( - - {children} - - ), - }); - const multiListPage = wrapper.find(MultiListPage); - expect(multiListPage.props().resources.length > 5).toEqual(true); - }); -}); - -describe(OperandDetailsPage.displayName, () => { - window.SERVER_FLAGS.copiedCSVsDisabled = false; - - beforeEach(() => { - jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ - ns: 'default', - appName: 'testapp', - plural: 'testapp.coreos.com~v1alpha1~TestResource', - name: 'my-test-resource', - }); - - jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ - pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/testapp.coreos.com~v1alpha1~TestResource/my-test-resource`, - }); - }); - - it('renders a `DetailsPage` with the correct subpages', () => { - const wrapper = mount( - - - - - , - ); - - const detailsPage = wrapper.find(DetailsPage); - expect(detailsPage.props().pages[0].nameKey).toEqual(`${i18nNS}~Details`); - expect(detailsPage.props().pages[0].href).toEqual(''); - expect(detailsPage.props().pages[1].nameKey).toEqual(`${i18nNS}~YAML`); - expect(detailsPage.props().pages[1].href).toEqual('yaml'); - expect(detailsPage.props().pages[2].nameKey).toEqual('olm~Resources'); - expect(detailsPage.props().pages[2].href).toEqual('resources'); - }); - - it('renders a `DetailsPage` which also watches the parent CSV', () => { - const wrapper = mount( - - - - - , - ); - expect(wrapper.find(DetailsPage).prop('resources')[0]).toEqual({ - kind: 'CustomResourceDefinition', - name: 'testresources.testapp.coreos.com', - isList: false, - prop: 'crd', - }); - }); - - it('menu actions to `DetailsPage`', () => { - const wrapper = mount( - - - - - , - ); - expect(wrapper.find(DetailsPage).prop('customActionMenu')).toBeTruthy(); - }); - - it('passes function to create breadcrumbs for resource to `DetailsPage`', () => { - const wrapper = mount( - - - - - , - ); - expect(wrapper.find(DetailsPage).props().breadcrumbsFor(null)).toEqual([ - { - name: 'Installed Operators', - path: `/k8s/ns/default/${ClusterServiceVersionModel.plural}`, - }, - { - name: 'testapp', - path: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/testapp.coreos.com~v1alpha1~TestResource`, - }, - { - name: `TestResource details`, - path: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/testapp.coreos.com~v1alpha1~TestResource/my-test-resource`, - }, - ]); - }); - - it('creates correct breadcrumbs even if `namespace`, `plural`, `appName`, and `name` URL parameters are the same', () => { - jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ - ns: 'example', - appName: 'example', - plural: 'example', - name: 'example', - }); - - jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ - pathname: `/k8s/ns/example/${ClusterServiceVersionModel.plural}/example/example/example`, - }); - - const wrapper = mount( - - - - - , - ); - - expect(wrapper.find(DetailsPage).props().breadcrumbsFor(null)).toEqual([ - { - name: 'Installed Operators', - path: `/k8s/ns/example/${ClusterServiceVersionModel.plural}`, - }, - { - name: 'example', - path: `/k8s/ns/example/${ClusterServiceVersionModel.plural}/example/example`, - }, - { - name: `example details`, - path: `/k8s/ns/example/${ClusterServiceVersionModel.plural}/example/example/example`, - }, - ]); - }); - - it('passes `flatten` function to Resources component which returns only objects with `ownerReferences` to each other or parent object', () => { - const wrapper = mount( - - - - - , - ); - const { flatten } = wrapper.find(MultiListPage).props(); - const pod = { - kind: 'Pod', - metadata: { - uid: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', - ownerReferences: [ - { - uid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - name: 'foo', - kind: 'fooKind', - apiVersion: 'fooVersion', - }, - ], - }, - }; - const deployment = { - kind: 'Deployment', - metadata: { - uid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - ownerReferences: [ - { - uid: testResourceInstance.metadata.uid, - name: 'foo', - kind: 'fooKind', - apiVersion: 'fooVersion', - }, - ], - }, - }; - const secret = { - kind: 'Secret', - metadata: { - uid: 'cccccccc-cccc-cccc-cccc-cccccccccccc', - }, - }; - const resources: FirehoseResourcesResult = { - Deployment: { - data: [deployment], - loaded: true, - loadError: undefined, - }, - Secret: { - data: [secret], - loaded: true, - loadError: undefined, - }, - Pod: { - data: [pod], - loaded: true, - loadError: undefined, - }, - }; - const data = flatten(resources); - - expect(data.map((obj) => obj.metadata.uid)).not.toContain(secret.metadata.uid); - expect(data.map((obj) => obj.metadata.uid)).toContain(pod.metadata.uid); - expect(data.map((obj) => obj.metadata.uid)).toContain(deployment.metadata.uid); - }); -}); - -describe(ProvidedAPIsPage.displayName, () => { - let wrapper: ReactWrapper; - - beforeAll(() => { - // Since crd models have not been loaded into redux state, just force return of the correct model type - spyOn(k8sModelsModule, 'modelFor').and.returnValue(testModel); - }); - - beforeEach(() => { - jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ - ns: 'default', - appName: 'testapp', - }); - - jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ - pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/instances`, - }); - - wrapper = mount( - - - - - , - ); - }); - - it('render listpage components', () => { - expect(wrapper.find(ListPageHeader).exists()).toBe(true); - expect(wrapper.find(ListPageCreateDropdown).exists()).toBe(true); - expect(wrapper.find(ListPageBody).exists()).toBe(true); - expect(wrapper.find(ListPageFilter).exists()).toBe(true); - }); - - it('render ListPageCreateDropdown with the correct text', () => { - expect(wrapper.find(ListPageCreateDropdown).children().text()).toEqual('Create new'); - }); - - it('passes `items` props and render ListPageCreateDropdown create button if app has multiple owned CRDs', () => { - const obj = _.cloneDeep(testClusterServiceVersion); - obj.spec.customresourcedefinitions.owned.push({ - name: 'foobars.testapp.coreos.com', - displayName: 'Foo Bars', - version: 'v1', - kind: 'FooBar', - }); - - wrapper = mount( - - - - - , - ); - - const listPageCreateDropdown = wrapper.find(ListPageCreateDropdown); - - expect(listPageCreateDropdown.props().items).toEqual({ - 'testapp.coreos.com~v1alpha1~TestResource': 'Test Resource', - 'testapp.coreos.com~v1~FooBar': 'Foo Bars', - }); - }); - - it('check if ListPageBody component renders the correct children', () => { - expect(wrapper.find(ListPageBody).children().length).toBe(1); - }); -}); - -describe(ProvidedAPIPage.displayName, () => { - let wrapper: ReactWrapper; - - beforeEach(() => { - jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ - ns: 'default', - appName: 'testapp', - plural: 'TestResourceRO', - }); - - jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({ - pathname: `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/TestResourceRO`, - }); - - wrapper = mount( - - - - - , - ); - }); - - it('render listpage components', () => { - expect(wrapper.find(ListPageHeader).exists()).toBe(true); - expect(wrapper.find(ListPageCreateLink).exists()).toBe(true); - expect(wrapper.find(ListPageBody).exists()).toBe(true); - expect(wrapper.find(ListPageFilter).exists()).toBe(true); - }); - - it('render ListPageCreateLink with the correct props for create button if app has single owned CRDs', () => { - expect(wrapper.find(ListPageCreateLink).props().to).toEqual( - `/k8s/ns/default/${ClusterServiceVersionModel.plural}/testapp/TestResourceRO/~new`, - ); - }); - - it('render ListPageCreateLink with the correct text', () => { - expect(wrapper.find(ListPageCreateLink).children().text()).toEqual('Create Test Resource'); - }); - - it('check if ListPageBody component renders the correct children', () => { - expect(wrapper.find(ListPageBody).children().length).toBe(1); - }); -}); - -describe('OperandStatus', () => { - let wrapper: ShallowWrapper; - - it('displays the correct status for a `status` value of `Running`', () => { - const obj = { - status: { - status: 'Running', - state: 'Degraded', - conditions: [ - { - type: 'Failed', - status: 'True', - }, - ], - }, - }; - wrapper = shallow(); - expect(wrapper.childAt(0).text()).toEqual('Status'); - expect(wrapper.childAt(3).props().title).toEqual('Running'); - }); - - it('displays the correct status for a `phase` value of `Running`', () => { - const obj = { - status: { - phase: 'Running', - status: 'Installed', - state: 'Degraded', - conditions: [ - { - type: 'Failed', - status: 'True', - }, - ], - }, - }; - wrapper = shallow(); - expect(wrapper.childAt(0).text()).toEqual('Phase'); - expect(wrapper.childAt(3).props().title).toEqual('Running'); - }); - - it('displays the correct status for a `phase` value of `Running`', () => { - const obj = { - status: { - phase: 'Running', - status: 'Installed', - state: 'Degraded', - conditions: [ - { - type: 'Failed', - status: 'True', - }, - ], - }, - }; - wrapper = shallow(); - expect(wrapper.childAt(0).text()).toEqual('Phase'); - expect(wrapper.childAt(3).props().title).toEqual('Running'); - }); - - it('displays the correct status for a `state` value of `Running`', () => { - const obj = { - status: { - state: 'Running', - conditions: [ - { - type: 'Failed', - status: 'True', - }, - ], - }, - }; - wrapper = shallow(); - expect(wrapper.childAt(0).text()).toEqual('State'); - expect(wrapper.childAt(3).props().title).toEqual('Running'); - }); - - it('displays the correct status for a condition status of `True`', () => { - const obj = { - status: { - conditions: [ - { - type: 'Failed', - status: 'False', - }, - { - type: 'Running', - status: 'True', - }, - ], - }, - }; - wrapper = shallow(); - expect(wrapper.childAt(0).text()).toEqual('Condition'); - expect(wrapper.childAt(3).props().title).toEqual('Running'); - }); - - it('displays the `-` status when no conditions are `True`', () => { - const obj = { - status: { - conditions: [ - { - type: 'Failed', - status: 'False', - }, - { - type: 'Installed', - status: 'False', - }, - ], - }, - }; - wrapper = shallow(); - expect(wrapper.text()).toEqual('-'); - }); - - it('displays the `-` for a missing status stanza', () => { - const obj = {}; - wrapper = shallow(); - expect(wrapper.text()).toEqual('-'); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx deleted file mode 100644 index ac05bce98f0..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import * as React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; -import * as UIActions from '@console/internal/actions/ui'; -import { RowFunctionArgs } from '@console/internal/components/factory'; -import { ResourceLink } from '@console/internal/components/utils'; -import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; -import { testPackageManifest, testCatalogSource } from '../../mocks'; -import { PackageManifestKind, CatalogSourceKind } from '../types'; -import { ClusterServiceVersionLogo } from './cluster-service-version-logo'; -import { - PackageManifestTableRow, - PackageManifestTableHeader, - PackageManifestTableHeaderWithCatalogSource, -} from './package-manifest'; - -describe(PackageManifestTableHeader.displayName, () => { - it('renders column header for package name', () => { - expect(PackageManifestTableHeader()[0].title).toEqual('Name'); - }); - - it('renders column header for latest CSV version for package in catalog', () => { - expect(PackageManifestTableHeader()[1].title).toEqual('Latest version'); - }); - - it('renders column header for creation timestamp', () => { - expect(PackageManifestTableHeader()[2].title).toEqual('Created'); - }); -}); - -describe(PackageManifestTableHeaderWithCatalogSource.displayName, () => { - it('renders column header for catalog source', () => { - expect(PackageManifestTableHeaderWithCatalogSource()[3].title).toEqual('CatalogSource'); - }); -}); - -describe('PackageManifestTableRow', () => { - let wrapper: ShallowWrapper - >>; - - beforeEach(() => { - jest.spyOn(UIActions, 'getActiveNamespace').mockReturnValue('default'); - - const columns: any[] = []; - wrapper = shallow( - , - ); - }); - - it('renders column for package name and logo', () => { - expect(wrapper.childAt(0).dive().find(ClusterServiceVersionLogo).props().displayName).toEqual( - testPackageManifest.status.channels[0].currentCSVDesc.displayName, - ); - }); - - it('renders column for latest CSV version for package in catalog', () => { - const { - name, - currentCSVDesc: { version }, - } = testPackageManifest.status.channels[0]; - expect(wrapper.childAt(1).dive().text()).toEqual(`${version} (${name})`); - }); - - it('renders column for creation timestamp', () => { - const pkgManifestCreationTimestamp = testPackageManifest.metadata.creationTimestamp; - expect(wrapper.childAt(2).dive().find(Timestamp).props().timestamp).toEqual( - `${pkgManifestCreationTimestamp}`, - ); - }); - - // This is to verify cataloSource column gets rendered on the Search page for PackageManifest resource - it('renders column for catalog source for a package when no catalog source is defined', () => { - const catalogSourceName = testPackageManifest.status.catalogSource; - const columns: any[] = []; - - wrapper = shallow( - , - ); - expect(wrapper.childAt(3).dive().find(ResourceLink).props().name).toEqual( - `${catalogSourceName}`, - ); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/subscription.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/subscription.spec.tsx deleted file mode 100644 index c0d599ea1e6..00000000000 --- a/frontend/packages/operator-lifecycle-manager/src/components/subscription.spec.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { Button, DescriptionListDescription, DescriptionListTerm } from '@patternfly/react-core'; -import { shallow, ShallowWrapper } from 'enzyme'; -import * as _ from 'lodash'; -import * as Router from 'react-router-dom-v5-compat'; -import { - Table, - MultiListPage, - DetailsPage, - RowFunctionArgs, -} from '@console/internal/components/factory'; -import { ResourceLink } from '@console/internal/components/utils'; -import { referenceForModel } from '@console/internal/module/k8s'; -import { LazyActionMenu } from '@console/shared/src'; -import { DescriptionListTermHelp } from '@console/shared/src/components/description-list/DescriptionListTermHelp'; -import { - testSubscription, - testSubscriptions, - testClusterServiceVersion, - testPackageManifest, -} from '../../mocks'; -import { - SubscriptionModel, - ClusterServiceVersionModel, - PackageManifestModel, - OperatorGroupModel, - InstallPlanModel, -} from '../models'; -import { SubscriptionKind, SubscriptionState } from '../types'; -import { - SubscriptionTableRow, - SubscriptionsList, - SubscriptionsListProps, - SubscriptionsPage, - SubscriptionsPageProps, - SubscriptionDetails, - SubscriptionDetailsPage, - SubscriptionDetailsProps, - SubscriptionUpdates, - SubscriptionUpdatesProps, - SubscriptionUpdatesState, - SubscriptionStatus, -} from './subscription'; - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), -})); - -describe('SubscriptionTableRow', () => { - let wrapper: ShallowWrapper; - let subscription: SubscriptionKind; - - const updateWrapper = () => { - const rowArgs: RowFunctionArgs = { - obj: subscription, - } as any; - - wrapper = shallow(); - return wrapper; - }; - - beforeEach(() => { - subscription = { - ...testSubscription, - status: { installedCSV: 'testapp.v1.0.0' }, - }; - wrapper = updateWrapper(); - }); - - it('renders column for subscription name', () => { - expect(wrapper.childAt(0).shallow().find(ResourceLink).props().name).toEqual( - subscription.metadata.name, - ); - expect(wrapper.childAt(0).shallow().find(ResourceLink).props().namespace).toEqual( - subscription.metadata.namespace, - ); - expect(wrapper.childAt(0).shallow().find(ResourceLink).props().kind).toEqual( - referenceForModel(SubscriptionModel), - ); - }); - - it('renders actions kebab', () => { - expect(wrapper.find(LazyActionMenu).props().context).toEqual({ - [referenceForModel(SubscriptionModel)]: subscription, - }); - }); - - it('renders column for namespace name', () => { - expect(wrapper.childAt(1).shallow().find(ResourceLink).props().name).toEqual( - subscription.metadata.namespace, - ); - expect(wrapper.childAt(1).shallow().find(ResourceLink).props().kind).toEqual('Namespace'); - }); - - it('renders column for subscription state when update available', () => { - subscription.status.state = SubscriptionState.SubscriptionStateUpgradeAvailable; - wrapper = updateWrapper(); - - expect(wrapper.childAt(2).find(SubscriptionStatus).shallow().text()).toContain( - 'Upgrade available', - ); - }); - - it('renders column for subscription state when unknown state', () => { - expect(wrapper.childAt(2).find(SubscriptionStatus).shallow().text()).toEqual('Unknown failure'); - }); - - it('renders column for subscription state when update in progress', () => { - subscription.status.state = SubscriptionState.SubscriptionStateUpgradePending; - wrapper = updateWrapper(); - - expect(wrapper.childAt(2).find(SubscriptionStatus).shallow().text()).toContain('Upgrading'); - }); - - it('renders column for subscription state when no updates available', () => { - subscription.status.state = SubscriptionState.SubscriptionStateAtLatest; - wrapper = updateWrapper(); - - expect(wrapper.childAt(2).find(SubscriptionStatus).shallow().text()).toContain('Up to date'); - }); - - it('renders column for current subscription channel', () => { - expect(wrapper.childAt(3).shallow().text()).toEqual(subscription.spec.channel); - }); - - it('renders column for approval strategy', () => { - expect(wrapper.childAt(4).shallow().text()).toEqual('Automatic'); - }); -}); - -describe('SubscriptionsList', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow( - , - ); - }); - - it('renders a `Table` component with correct header', () => { - const headerTitles = wrapper - .find(Table) - .props() - .Header() - .map((header) => header.title); - expect(headerTitles).toEqual([ - 'Name', - 'Namespace', - 'Status', - 'Update channel', - 'Update approval', - '', - ]); - }); -}); - -describe('SubscriptionsPage', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow(); - }); - - it('renders a `MultiListPage` component with the correct props', () => { - expect(wrapper.find(MultiListPage).props().ListComponent).toEqual(SubscriptionsList); - expect(wrapper.find(MultiListPage).props().title).toEqual('Subscriptions'); - expect(wrapper.find(MultiListPage).props().canCreate).toBe(true); - expect(wrapper.find(MultiListPage).props().createProps).toEqual({ - to: '/catalog?catalogType=operator', - }); - expect(wrapper.find(MultiListPage).props().createButtonText).toEqual('Create Subscription'); - expect(wrapper.find(MultiListPage).props().filterLabel).toEqual('Subscriptions by package'); - expect(wrapper.find(MultiListPage).props().resources).toEqual([ - { - kind: referenceForModel(SubscriptionModel), - namespace: 'default', - namespaced: true, - prop: 'subscription', - }, - { - kind: referenceForModel(OperatorGroupModel), - namespace: 'default', - namespaced: true, - prop: 'operatorGroup', - }, - ]); - }); -}); - -describe('SubscriptionUpdates', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow( - , - ); - }); - - it('renders link to configure update channel', () => { - const channel = wrapper - .findWhere((node) => - node.equals( - , - ), - ) - .parents() - .at(0) - .find(Button) - .render() - .text(); - - expect(channel).toEqual(testSubscription.spec.channel); - }); - - it('renders link to set approval strategy', () => { - const strategy = wrapper - .findWhere((node) => - node.equals( - , - ), - ) - .parents() - .at(0) - .find(Button) - .render() - .text(); - - expect(strategy).toEqual(testSubscription.spec.installPlanApproval || 'Automatic'); - }); -}); - -describe('SubscriptionDetails', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { - wrapper = shallow( - , - ); - }); - - it('renders subscription update channel and approval component', () => { - expect(wrapper.find(SubscriptionUpdates).exists()).toBe(true); - }); - - it('renders link to `ClusterServiceVersion` if installed', () => { - const obj = _.cloneDeep(testSubscription); - obj.status = { installedCSV: testClusterServiceVersion.metadata.name }; - wrapper = wrapper.setProps({ obj, clusterServiceVersions: [testClusterServiceVersion] }); - - const link = wrapper - .findWhere((node) => - node.equals(Installed version), - ) - .parents() - .at(0) - .find(DescriptionListDescription) - .find(ResourceLink) - .at(0); - - expect(link.props().title).toEqual(obj.status.installedCSV); - expect(link.props().name).toEqual(obj.status.installedCSV); - }); - - it('renders link to catalog source', () => { - const link = wrapper - .findWhere((node) => node.equals(CatalogSource)) - .parents() - .at(0) - .find(DescriptionListDescription) - .find(ResourceLink) - .at(0); - - expect(link.props().name).toEqual(testSubscription.spec.source); - }); -}); - -describe('SubscriptionDetailsPage', () => { - it('renders `DetailsPage` with correct props', () => { - jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default', name: 'example-sub' }); - const wrapper = shallow(); - - expect(wrapper.find(DetailsPage).props().kind).toEqual(referenceForModel(SubscriptionModel)); - expect(wrapper.find(DetailsPage).props().pages.length).toEqual(2); - expect(wrapper.find(DetailsPage).props().customActionMenu).toBeDefined(); - }); - - it('passes additional resources to watch', () => { - jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'default', name: 'example-sub' }); - const wrapper = shallow(); - - expect(wrapper.find(DetailsPage).props().resources).toEqual([ - { - kind: referenceForModel(PackageManifestModel), - namespace: 'default', - isList: true, - prop: 'packageManifests', - }, - { - kind: referenceForModel(InstallPlanModel), - isList: true, - namespace: 'default', - prop: 'installPlans', - }, - { - kind: referenceForModel(ClusterServiceVersionModel), - isList: true, - namespace: 'default', - prop: 'clusterServiceVersions', - }, - { - kind: referenceForModel(SubscriptionModel), - isList: true, - namespace: 'default', - prop: 'subscriptions', - }, - ]); - }); -});