diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md new file mode 100644 index 000000000..03963ec3f --- /dev/null +++ b/TESTING_GUIDE.md @@ -0,0 +1,96 @@ +# Frontend Plugin Testing Guide + +## Testing Utilities + +### `renderInTestApp` (from `@backstage/test-utils`) + +The **recommended default** for rendering components in tests. Provides full Backstage context: theme, routing, API registry, and error boundaries. + +Use for: + +- Page-level components that depend on routing or Backstage APIs +- Components that use Backstage UI components (`Progress`, `Table`, `EmptyState`, etc.) which internally call hooks like `useTranslationRef()` + +```tsx +import { renderInTestApp } from '@backstage/test-utils'; + +await renderInTestApp(); +``` + +### `EntityProvider` (from `@backstage/plugin-catalog-react`) + +Wrap components that call `useEntity()` to provide entity context. Use alongside `renderInTestApp`. + +```tsx +import { EntityProvider } from '@backstage/plugin-catalog-react'; + +await renderInTestApp( + + + , +); +``` + +### `TestApiProvider` (from `@backstage/test-utils`) + +For standalone scenarios where you need specific API mocks but not the full app context (no routing needed). Prefer `renderInTestApp` when possible. + +### Plain `render()` (from `@testing-library/react`) + +For simple components with no Backstage context dependencies (no Backstage hooks or components used internally). + +```tsx +import { render } from '@testing-library/react'; + +render(); +``` + +## What to Mock + +**Do mock:** + +- Your own hooks (`../../hooks`) +- Sibling/child components (to isolate the unit under test) +- External service plugins (`@openchoreo/backstage-plugin-react`) + +**Do NOT mock:** + +- `@backstage/core-components` — use `renderInTestApp` instead, which provides the required context. Mocking these components is [documented as broken](https://github.com/backstage/backstage/issues/20713) and strips away real rendering behavior. +- `@backstage/plugin-catalog-react` — use `EntityProvider` to supply entity context. +- MUI components — they work without any special context. + +## Quick Reference + +| Component type | Render with | Entity context | +| ---------------------------------- | ----------------- | -------------------------------------- | +| Page (uses routing + Backstage UI) | `renderInTestApp` | `EntityProvider` if uses `useEntity()` | +| Component using Backstage UI only | `renderInTestApp` | No | +| Pure component (MUI / HTML only) | `render` | No | + +## Test Structure + +```tsx +// 1. Imports +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; + +// 2. Mock own hooks and siblings only +jest.mock('../../hooks', () => ({ ... })); +jest.mock('./ChildComponent', () => ({ ... })); + +// 3. Render helper +function renderPage() { + return renderInTestApp( + + + , + ); +} + +// 4. Tests (always async with renderInTestApp) +it('renders content', async () => { + await renderPage(); + expect(screen.getByText('Hello')).toBeInTheDocument(); +}); +``` diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index fcb19570d..49258f127 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -14,7 +14,8 @@ "clean": "backstage-cli package clean" }, "dependencies": { - "@backstage/backend-test-utils": "1.9.0" + "@backstage/backend-test-utils": "1.9.0", + "@backstage/catalog-model": "1.7.5" }, "devDependencies": { "@backstage/cli": "0.34.3" diff --git a/packages/test-utils/src/frontend/entityFixtures.ts b/packages/test-utils/src/frontend/entityFixtures.ts new file mode 100644 index 000000000..61c72fbe1 --- /dev/null +++ b/packages/test-utils/src/frontend/entityFixtures.ts @@ -0,0 +1,62 @@ +import type { Entity } from '@backstage/catalog-model'; + +/** + * Creates a minimal Backstage Component entity for tests. + * Override any field via the `overrides` parameter. + */ +export function mockComponentEntity( + overrides: { + name?: string; + namespace?: string; + annotations?: Record; + tags?: string[]; + type?: string; + description?: string; + } = {}, +): Entity { + const name = overrides.name ?? 'test-component'; + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + namespace: overrides.namespace ?? 'default', + description: overrides.description, + annotations: { + 'openchoreo.io/namespace': 'test-ns', + ...overrides.annotations, + }, + tags: overrides.tags ?? ['service'], + }, + spec: { + type: overrides.type ?? 'service', + }, + }; +} + +/** + * Creates a minimal Backstage System entity (project) for tests. + * Override any field via the `overrides` parameter. + */ +export function mockSystemEntity( + overrides: { + name?: string; + namespace?: string; + annotations?: Record; + } = {}, +): Entity { + const name = overrides.name ?? 'test-project'; + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name, + namespace: overrides.namespace ?? 'default', + annotations: { + 'openchoreo.io/namespace': 'test-ns', + ...overrides.annotations, + }, + }, + spec: {}, + }; +} diff --git a/packages/test-utils/src/frontend/index.ts b/packages/test-utils/src/frontend/index.ts new file mode 100644 index 000000000..98eeca3c0 --- /dev/null +++ b/packages/test-utils/src/frontend/index.ts @@ -0,0 +1,5 @@ +export { + createMockOpenChoreoClient, + type MockOpenChoreoClient, +} from './mockOpenChoreoClient'; +export { mockComponentEntity, mockSystemEntity } from './entityFixtures'; diff --git a/packages/test-utils/src/frontend/mockOpenChoreoClient.ts b/packages/test-utils/src/frontend/mockOpenChoreoClient.ts new file mode 100644 index 000000000..18dfb6747 --- /dev/null +++ b/packages/test-utils/src/frontend/mockOpenChoreoClient.ts @@ -0,0 +1,44 @@ +/** + * A fully-mocked OpenChoreoClientApi backed by a Proxy. + * Any method accessed on this object is automatically a `jest.fn()`, + * so it never goes stale when new methods are added to the interface. + * + * Use with `TestApiProvider` to inject into components under test. + * + * @example + * ```tsx + * import { TestApiProvider } from '@backstage/test-utils'; + * import { openChoreoClientApiRef } from '@openchoreo/backstage-plugin'; + * import { createMockOpenChoreoClient } from '@openchoreo/test-utils'; + * + * const mockClient = createMockOpenChoreoClient(); + * mockClient.getEnvironments.mockResolvedValue([...]); + * + * render( + * + * + * + * ); + * ``` + */ +export type MockOpenChoreoClient = Record; + +export function createMockOpenChoreoClient( + overrides: Partial> = {}, +): MockOpenChoreoClient { + const mocks: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (value) { + mocks[key] = value; + } + } + + return new Proxy(mocks, { + get(target, prop: string) { + if (!(prop in target)) { + target[prop] = jest.fn(); + } + return target[prop]; + }, + }) as MockOpenChoreoClient; +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index fd341cf3e..5376e4881 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -9,3 +9,9 @@ export { buildReadyCondition, buildNotReadyCondition, } from './fixtures'; +export { + createMockOpenChoreoClient, + type MockOpenChoreoClient, + mockComponentEntity, + mockSystemEntity, +} from './frontend'; diff --git a/plugins/openchoreo-ci/package.json b/plugins/openchoreo-ci/package.json index b6613c419..d73f5b2e0 100644 --- a/plugins/openchoreo-ci/package.json +++ b/plugins/openchoreo-ci/package.json @@ -53,6 +53,7 @@ "@backstage/core-app-api": "1.19.0", "@backstage/dev-utils": "1.1.14", "@backstage/test-utils": "1.7.11", + "@openchoreo/test-utils": "workspace:^", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", "@testing-library/user-event": "14.6.1", diff --git a/plugins/openchoreo-ci/src/components/BuildStatusChip/BuildStatusChip.test.tsx b/plugins/openchoreo-ci/src/components/BuildStatusChip/BuildStatusChip.test.tsx new file mode 100644 index 000000000..70769db59 --- /dev/null +++ b/plugins/openchoreo-ci/src/components/BuildStatusChip/BuildStatusChip.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import { BuildStatusChip } from './BuildStatusChip'; + +// ---- Tests ---- + +describe('BuildStatusChip', () => { + it('renders success chip for "Succeeded" status', () => { + render(); + expect(screen.getByText('Succeeded')).toBeInTheDocument(); + }); + + it('renders success chip for "Completed" status', () => { + render(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('renders error chip for "Failed" status', () => { + render(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + }); + + it('renders error chip for "Error" status', () => { + render(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('renders running chip for "Running" status', () => { + render(); + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + it('renders running chip for "InProgress" status', () => { + render(); + expect(screen.getByText('InProgress')).toBeInTheDocument(); + }); + + it('renders pending chip for "Pending" status', () => { + render(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + + it('renders pending chip for "Queued" status', () => { + render(); + expect(screen.getByText('Queued')).toBeInTheDocument(); + }); + + it('renders "Unknown" when status is undefined', () => { + render(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('renders fallback chip for unrecognized status', () => { + render(); + expect(screen.getByText('SomeOtherStatus')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/BuildWithCommitDialog/BuildWithCommitDialog.test.tsx b/plugins/openchoreo-ci/src/components/BuildWithCommitDialog/BuildWithCommitDialog.test.tsx new file mode 100644 index 000000000..d6fb212c9 --- /dev/null +++ b/plugins/openchoreo-ci/src/components/BuildWithCommitDialog/BuildWithCommitDialog.test.tsx @@ -0,0 +1,155 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BuildWithCommitDialog } from './BuildWithCommitDialog'; + +// ---- Helpers ---- + +function renderDialog( + overrides: Partial> = {}, +) { + const defaultProps = { + open: true, + onClose: jest.fn(), + onTrigger: jest.fn().mockResolvedValue(undefined), + isLoading: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('BuildWithCommitDialog', () => { + it('renders dialog title and input when open', () => { + renderDialog(); + + expect(screen.getByText('Build with Specific Commit')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + renderDialog({ open: false }); + + expect( + screen.queryByText('Build with Specific Commit'), + ).not.toBeInTheDocument(); + }); + + it('disables trigger button when input is empty', () => { + renderDialog(); + + expect( + screen.getByRole('button', { name: /trigger workflow/i }), + ).toBeDisabled(); + }); + + it('shows validation error for non-hex characters', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByRole('textbox'), 'xyz12345'); + + expect( + screen.getByText(/must contain only hexadecimal characters/), + ).toBeInTheDocument(); + }); + + it('shows validation error for SHA shorter than 7 characters', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByRole('textbox'), 'abc12'); + + expect( + screen.getByText(/must be at least 7 characters long/), + ).toBeInTheDocument(); + }); + + it('shows validation error for SHA longer than 40 characters', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByRole('textbox'), 'a'.repeat(41)); + + expect(screen.getByText(/cannot exceed 40 characters/)).toBeInTheDocument(); + }); + + it('enables trigger button for valid SHA', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByRole('textbox'), 'abc1234'); + + expect( + screen.getByRole('button', { name: /trigger workflow/i }), + ).toBeEnabled(); + }); + + it('calls onTrigger with trimmed SHA and closes on success', async () => { + const user = userEvent.setup(); + const onTrigger = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); + + renderDialog({ onTrigger, onClose }); + + await user.type(screen.getByRole('textbox'), 'abc1234def'); + await user.click(screen.getByRole('button', { name: /trigger workflow/i })); + + expect(onTrigger).toHaveBeenCalledWith('abc1234def'); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows error when onTrigger rejects', async () => { + const user = userEvent.setup(); + const onTrigger = jest.fn().mockRejectedValue(new Error('Build failed')); + + renderDialog({ onTrigger }); + + await user.type(screen.getByRole('textbox'), 'abc1234'); + await user.click(screen.getByRole('button', { name: /trigger workflow/i })); + + await waitFor(() => { + expect(screen.getByText('Build failed')).toBeInTheDocument(); + }); + }); + + it('shows "Triggering..." and disables buttons when loading', () => { + renderDialog({ isLoading: true }); + + expect(screen.getByRole('button', { name: /triggering/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + }); + + it('calls onClose when Cancel is clicked', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + renderDialog({ onClose }); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(onClose).toHaveBeenCalled(); + }); + + it('shows required error when triggering with empty input', async () => { + const user = userEvent.setup(); + // Need a valid-looking SHA first, then clear it to trigger the empty check + renderDialog(); + + // Directly click trigger without typing + // The button is disabled when empty, so let's test by entering then clearing + const input = screen.getByRole('textbox'); + await user.type(input, 'abc1234'); + await user.clear(input); + + // Button should be disabled again + expect( + screen.getByRole('button', { name: /trigger workflow/i }), + ).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/OverviewTab/OverviewTab.test.tsx b/plugins/openchoreo-ci/src/components/OverviewTab/OverviewTab.test.tsx new file mode 100644 index 000000000..eb740f6f2 --- /dev/null +++ b/plugins/openchoreo-ci/src/components/OverviewTab/OverviewTab.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import { OverviewTab } from './OverviewTab'; + +// ---- Mocks ---- + +jest.mock('../WorkflowDetailsRenderer', () => ({ + WorkflowDetailsRenderer: ({ data }: any) => ( +
{JSON.stringify(data)}
+ ), +})); + +// ---- Tests ---- + +describe('OverviewTab', () => { + it('shows empty state when workflow is null', () => { + render(); + + expect( + screen.getByText('No workflow details available for this component.'), + ).toBeInTheDocument(); + }); + + it('shows empty state when workflow is undefined', () => { + render(); + + expect( + screen.getByText('No workflow details available for this component.'), + ).toBeInTheDocument(); + }); + + it('displays workflow name', () => { + render( + , + ); + + expect(screen.getByText('Workflow Name:')).toBeInTheDocument(); + expect(screen.getByText('build-and-deploy')).toBeInTheDocument(); + }); + + it('renders WorkflowDetailsRenderer when parameters exist', () => { + const parameters = { image: 'node:18', timeout: '30m' }; + + render(); + + expect(screen.getByTestId('workflow-details-renderer')).toBeInTheDocument(); + }); + + it('does not render WorkflowDetailsRenderer when parameters are empty', () => { + render(); + + expect( + screen.queryByTestId('workflow-details-renderer'), + ).not.toBeInTheDocument(); + }); + + it('does not render WorkflowDetailsRenderer when parameters are undefined', () => { + render(); + + expect( + screen.queryByTestId('workflow-details-renderer'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/RunMetadataContent/RunMetadataContent.test.tsx b/plugins/openchoreo-ci/src/components/RunMetadataContent/RunMetadataContent.test.tsx new file mode 100644 index 000000000..f0ad77b41 --- /dev/null +++ b/plugins/openchoreo-ci/src/components/RunMetadataContent/RunMetadataContent.test.tsx @@ -0,0 +1,195 @@ +import { render, screen } from '@testing-library/react'; +import { RunMetadataContent } from './RunMetadataContent'; +import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; + +// ---- Mocks ---- + +const mockUseWorkflowRun = jest.fn(); +jest.mock('../../hooks', () => ({ + useWorkflowRun: (name: string) => mockUseWorkflowRun(name), +})); + +jest.mock('../BuildStatusChip', () => ({ + BuildStatusChip: ({ status }: { status: string }) => ( + {status} + ), +})); + +jest.mock('../../utils/schemaExtensions', () => ({ + extractGitFieldValues: (_params: any, _mapping: any) => ({}), +})); + +// ---- Helpers ---- + +const baseBuild: ModelsBuild = { + name: 'build-42', + uuid: 'uuid-42', + componentName: 'api-service', + projectName: 'my-project', + namespaceName: 'dev-ns', + status: 'Succeeded', + createdAt: '2024-06-01T10:00:00Z', + commit: 'abc1234567890', +}; + +function renderContent( + overrides: Partial> = {}, +) { + const defaultProps = { + build: baseBuild, + ...overrides, + }; + + return render(); +} + +// ---- Tests ---- + +describe('RunMetadataContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows loading spinner when loading', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: null, + loading: true, + error: null, + }); + + renderContent(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows error message when fetch fails', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: null, + loading: false, + error: new Error('Network failure'), + }); + + renderContent(); + + expect( + screen.getByText(/Failed to load workflow run details: Network failure/), + ).toBeInTheDocument(); + }); + + it('displays build name and status when loaded', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: { ...baseBuild }, + loading: false, + error: null, + }); + + renderContent(); + + expect(screen.getByText('Build Information')).toBeInTheDocument(); + expect(screen.getByText('build-42')).toBeInTheDocument(); + expect(screen.getByTestId('build-status-chip')).toHaveTextContent( + 'Succeeded', + ); + }); + + it('displays timestamps section', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: { + ...baseBuild, + startedAt: '2024-06-01T10:01:00Z', + }, + loading: false, + error: null, + }); + + renderContent(); + + expect(screen.getByText('Timestamps')).toBeInTheDocument(); + expect(screen.getByText('Created:')).toBeInTheDocument(); + expect(screen.getByText('Started:')).toBeInTheDocument(); + }); + + it('shows completed and duration for terminal runs', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: { + ...baseBuild, + startedAt: '2024-06-01T10:00:00Z', + completedAt: '2024-06-01T10:05:30Z', + }, + loading: false, + error: null, + }); + + renderContent(); + + expect(screen.getByText('Completed:')).toBeInTheDocument(); + expect(screen.getByText('Duration:')).toBeInTheDocument(); + expect(screen.getByText('5m 30s')).toBeInTheDocument(); + }); + + it('shows workload pending message for non-terminal runs', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: { + ...baseBuild, + status: 'Running', + }, + loading: false, + error: null, + }); + + renderContent({ build: { ...baseBuild, status: 'Running' } }); + + expect( + screen.getByText( + 'Workload details will be available once the workflow run completes.', + ), + ).toBeInTheDocument(); + }); + + it('shows workload not found for terminal run without workloadCr', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: { + ...baseBuild, + completedAt: '2024-06-01T10:05:00Z', + }, + loading: false, + error: null, + }); + + renderContent(); + + expect( + screen.getByText('Workload details are not found in the workflow run.'), + ).toBeInTheDocument(); + }); + + it('shows workload image when available in workloadCr', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: { + ...baseBuild, + completedAt: '2024-06-01T10:05:00Z', + workloadCr: JSON.stringify({ + spec: { container: { image: 'registry.io/app:v1.0' } }, + }), + }, + loading: false, + error: null, + }); + + renderContent(); + + expect(screen.getByText('registry.io/app:v1.0')).toBeInTheDocument(); + }); + + it('falls back to build data when workflowRun is null', () => { + mockUseWorkflowRun.mockReturnValue({ + workflowRun: null, + loading: false, + error: null, + }); + + renderContent(); + + expect(screen.getByText('build-42')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx new file mode 100644 index 000000000..724e9a002 --- /dev/null +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -0,0 +1,183 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RunsTab } from './RunsTab'; +import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; + +// ---- Mocks ---- + +jest.mock('../BuildStatusChip', () => ({ + BuildStatusChip: ({ status }: { status: string }) => ( + {status} + ), +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + formatRelativeTime: (ts: string) => `relative(${ts})`, +})); + +jest.mock('../../utils/schemaExtensions', () => ({ + extractGitFieldValues: (_params: any, _mapping: any) => ({}), +})); + +jest.mock('../../hooks', () => ({ + formatRetentionDuration: (ttl: string) => `formatted(${ttl})`, +})); + +jest.mock('@backstage/core-components', () => ({ + Table: ({ title, data, columns, emptyContent, onRowClick }: any) => ( +
+
{title}
+ {data.length === 0 ? ( +
{emptyContent}
+ ) : ( +
+ {data.map((row: any, i: number) => ( +
onRowClick?.(undefined, row)} + > + {row.name} + {columns.map((col: any, j: number) => + col.render ? ( + + {col.render(row)} + + ) : null, + )} +
+ ))} +
+ )} +
+ ), +})); + +// ---- Helpers ---- + +const builds: ModelsBuild[] = [ + { + name: 'build-2', + uuid: 'uuid-2', + componentName: 'api-service', + projectName: 'my-project', + namespaceName: 'dev-ns', + status: 'Succeeded', + createdAt: '2024-06-02T10:00:00Z', + commit: 'abc1234', + }, + { + name: 'build-1', + uuid: 'uuid-1', + componentName: 'api-service', + projectName: 'my-project', + namespaceName: 'dev-ns', + status: 'Failed', + createdAt: '2024-06-01T10:00:00Z', + commit: 'def5678', + }, +]; + +function renderTab( + overrides: Partial> = {}, +) { + const defaultProps = { + builds, + loading: false, + isRefreshing: false, + onRefresh: jest.fn(), + onRowClick: jest.fn(), + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('RunsTab', () => { + it('renders table with "Workflow Runs" title', () => { + renderTab(); + + expect(screen.getByText('Workflow Runs')).toBeInTheDocument(); + }); + + it('renders rows for each build', () => { + renderTab(); + + expect(screen.getByTestId('row-build-2')).toBeInTheDocument(); + expect(screen.getByTestId('row-build-1')).toBeInTheDocument(); + }); + + it('sorts builds by createdAt descending (newest first)', () => { + renderTab(); + + const rows = screen.getByTestId('table-rows'); + const rowTexts = rows.textContent; + // build-2 (newer) should appear before build-1 + expect(rowTexts?.indexOf('build-2')).toBeLessThan( + rowTexts?.indexOf('build-1') ?? Infinity, + ); + }); + + it('calls onRowClick when a row is clicked', async () => { + const user = userEvent.setup(); + const onRowClick = jest.fn(); + + renderTab({ onRowClick }); + + await user.click(screen.getByTestId('row-build-2')); + + expect(onRowClick).toHaveBeenCalledWith( + expect.objectContaining({ name: 'build-2' }), + ); + }); + + it('shows empty content when no builds', () => { + renderTab({ builds: [] }); + + expect(screen.getByText('No workflow runs found')).toBeInTheDocument(); + expect( + screen.getByText('Trigger a workflow to see runs appear here'), + ).toBeInTheDocument(); + }); + + it('shows retention info in empty content when retentionTtl is provided', () => { + renderTab({ builds: [], retentionTtl: '24h' }); + + expect( + screen.getByText(/automatically removed after formatted\(24h\)/), + ).toBeInTheDocument(); + }); + + it('shows retention tooltip when retentionTtl is provided', () => { + renderTab({ retentionTtl: '48h' }); + + expect(screen.getByLabelText('Retention period info')).toBeInTheDocument(); + }); + + it('renders refresh button', () => { + renderTab(); + + expect(screen.getByTitle('Refresh builds')).toBeInTheDocument(); + }); + + it('disables refresh button when refreshing', () => { + renderTab({ isRefreshing: true }); + + expect(screen.getByTitle('Refreshing...')).toBeDisabled(); + }); + + it('calls onRefresh when refresh button is clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + + renderTab({ onRefresh }); + + await user.click(screen.getByTitle('Refresh builds')); + + expect(onRefresh).toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/WorkflowConfigPage/EditWorkflowConfigs/ChangesPreview.test.tsx b/plugins/openchoreo-ci/src/components/WorkflowConfigPage/EditWorkflowConfigs/ChangesPreview.test.tsx new file mode 100644 index 000000000..d924600fc --- /dev/null +++ b/plugins/openchoreo-ci/src/components/WorkflowConfigPage/EditWorkflowConfigs/ChangesPreview.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import { ChangesPreview } from './ChangesPreview'; +import type { Change } from '@openchoreo/backstage-plugin-react'; + +// ---- Tests ---- + +describe('ChangesPreview', () => { + it('shows correct count for single change', () => { + const changes: Change[] = [ + { type: 'new', path: 'image', newValue: 'node:18' }, + ]; + + render(); + + expect(screen.getByText('Confirm Changes (1 change)')).toBeInTheDocument(); + }); + + it('shows correct count for multiple changes', () => { + const changes: Change[] = [ + { type: 'new', path: 'image', newValue: 'node:18' }, + { type: 'removed', path: 'debug', oldValue: true }, + ]; + + render(); + + expect(screen.getByText('Confirm Changes (2 changes)')).toBeInTheDocument(); + }); + + it('renders new change with [New] label and value', () => { + const changes: Change[] = [ + { type: 'new', path: 'timeout', newValue: '30m' }, + ]; + + render(); + + expect(screen.getByText(/timeout/)).toBeInTheDocument(); + expect(screen.getByText('[New]')).toBeInTheDocument(); + expect(screen.getByText(/30m/)).toBeInTheDocument(); + }); + + it('renders modified change with old → new values', () => { + const changes: Change[] = [ + { type: 'modified', path: 'replicas', oldValue: 1, newValue: 3 }, + ]; + + render(); + + expect(screen.getByText(/replicas/)).toBeInTheDocument(); + // The modified line contains "1 → 3" + expect(screen.getByText(/1 →/)).toBeInTheDocument(); + }); + + it('renders removed change with [Removed] label', () => { + const changes: Change[] = [ + { type: 'removed', path: 'debug', oldValue: true }, + ]; + + render(); + + expect(screen.getByText(/debug/)).toBeInTheDocument(); + expect(screen.getByText('[Removed]')).toBeInTheDocument(); + }); + + it('formats null values as "null"', () => { + const changes: Change[] = [{ type: 'new', path: 'field', newValue: null }]; + + render(); + + expect(screen.getByText(/null/)).toBeInTheDocument(); + }); + + it('formats object values as JSON', () => { + const changes: Change[] = [ + { type: 'new', path: 'config', newValue: { key: 'val' } }, + ]; + + render(); + + expect(screen.getByText(/{"key":"val"}/)).toBeInTheDocument(); + }); + + it('shows footer text about updating workflow config', () => { + render(); + + expect( + screen.getByText( + 'This will update the workflow configuration for the component.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/WorkflowDetailsRenderer/WorkflowDetailsRenderer.test.tsx b/plugins/openchoreo-ci/src/components/WorkflowDetailsRenderer/WorkflowDetailsRenderer.test.tsx new file mode 100644 index 000000000..fdf1a3327 --- /dev/null +++ b/plugins/openchoreo-ci/src/components/WorkflowDetailsRenderer/WorkflowDetailsRenderer.test.tsx @@ -0,0 +1,111 @@ +import { render, screen } from '@testing-library/react'; +import { WorkflowDetailsRenderer } from './WorkflowDetailsRenderer'; + +// ---- Tests ---- + +describe('WorkflowDetailsRenderer', () => { + it('renders "Not specified" for null data', () => { + render(); + + expect(screen.getByText('Not specified')).toBeInTheDocument(); + }); + + it('renders "Not specified" for undefined data', () => { + render(); + + expect(screen.getByText('Not specified')).toBeInTheDocument(); + }); + + it('renders "Not specified" for empty string', () => { + render(); + + expect(screen.getByText('Not specified')).toBeInTheDocument(); + }); + + it('renders string value as text', () => { + render(); + + expect(screen.getByText('hello world')).toBeInTheDocument(); + }); + + it('renders number value as text', () => { + render(); + + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('renders boolean value as text', () => { + render(); + + expect(screen.getByText('true')).toBeInTheDocument(); + }); + + it('renders URL as a link', () => { + render(); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders path values as code', () => { + render(); + + expect(screen.getByText('/usr/local/bin')).toBeInTheDocument(); + expect(screen.getByText('/usr/local/bin').tagName).toBe('CODE'); + }); + + it('renders "Empty list" for empty array', () => { + render(); + + expect(screen.getByText('Empty list')).toBeInTheDocument(); + }); + + it('renders primitive array inline', () => { + render(); + + expect(screen.getByText('[ "a", "b", "c" ]')).toBeInTheDocument(); + }); + + it('renders "Empty object" for empty object', () => { + render(); + + expect(screen.getByText('Empty object')).toBeInTheDocument(); + }); + + it('renders object keys in title case', () => { + render(); + + expect(screen.getByText('My Property:')).toBeInTheDocument(); + expect(screen.getByText('value')).toBeInTheDocument(); + }); + + it('renders name/value array as compact table', () => { + const data = [ + { name: 'FOO', value: 'bar' }, + { name: 'BAZ', value: 'qux' }, + ]; + + render(); + + expect(screen.getByText('FOO')).toBeInTheDocument(); + expect(screen.getByText('bar')).toBeInTheDocument(); + expect(screen.getByText('BAZ')).toBeInTheDocument(); + expect(screen.getByText('qux')).toBeInTheDocument(); + }); + + it('renders nested objects with section titles at top level', () => { + render( + , + ); + + expect(screen.getByText('Build Config')).toBeInTheDocument(); + expect(screen.getByText('node:18')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.test.tsx b/plugins/openchoreo-ci/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.test.tsx new file mode 100644 index 000000000..eed037a2d --- /dev/null +++ b/plugins/openchoreo-ci/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.test.tsx @@ -0,0 +1,170 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage'; +import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; + +// ---- Mocks ---- + +jest.mock('@openchoreo/backstage-design-system', () => ({ + VerticalTabNav: ({ tabs, onChange, children }: any) => ( +
+ {tabs.map((t: any) => ( + + ))} +
{children}
+
+ ), +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + formatRelativeTime: (ts: string) => `relative(${ts})`, + useUrlSyncedTab: ({ defaultTab }: any) => { + const [tab, setTab] = require('react').useState(defaultTab); + return [tab, setTab]; + }, + DetailPageLayout: ({ title, subtitle, onBack, children }: any) => ( +
+ {title} +
{subtitle}
+ + {children} +
+ ), +})); + +jest.mock('../BuildStatusChip', () => ({ + BuildStatusChip: ({ status }: { status: string }) => ( + {status} + ), +})); + +jest.mock('../BuildLogs', () => ({ + LogsContent: ({ build }: any) => ( +
{build.name}
+ ), +})); + +jest.mock('../BuildEvents', () => ({ + EventsContent: ({ build }: any) => ( +
{build.name}
+ ), +})); + +jest.mock('../RunMetadataContent', () => ({ + RunMetadataContent: ({ build }: any) => ( +
{build.name}
+ ), +})); + +// ---- Helpers ---- + +const defaultRun: ModelsBuild = { + name: 'build-42', + uuid: 'uuid-42', + componentName: 'api-service', + projectName: 'my-project', + namespaceName: 'dev-ns', + status: 'Succeeded', + createdAt: '2024-06-01T10:00:00Z', +}; + +function renderPage( + overrides: Partial> = {}, +) { + const defaultProps = { + run: defaultRun, + onBack: jest.fn(), + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('WorkflowRunDetailsPage', () => { + it('displays run name as title', () => { + renderPage(); + + expect(screen.getByTestId('page-title')).toHaveTextContent('build-42'); + }); + + it('displays build status chip', () => { + renderPage(); + + expect(screen.getByTestId('build-status-chip')).toHaveTextContent( + 'Succeeded', + ); + }); + + it('displays relative time', () => { + renderPage(); + + expect( + screen.getByText('relative(2024-06-01T10:00:00Z)'), + ).toBeInTheDocument(); + }); + + it('renders Logs, Events, and Details tabs', () => { + renderPage(); + + expect(screen.getByTestId('tab-logs')).toBeInTheDocument(); + expect(screen.getByTestId('tab-events')).toBeInTheDocument(); + expect(screen.getByTestId('tab-details')).toBeInTheDocument(); + }); + + it('shows Logs content by default', () => { + renderPage(); + + expect(screen.getByTestId('logs-content')).toBeInTheDocument(); + }); + + it('switches to Events tab when clicked', async () => { + const user = userEvent.setup(); + + renderPage(); + + await user.click(screen.getByTestId('tab-events')); + + expect(screen.getByTestId('events-content')).toBeInTheDocument(); + expect(screen.queryByTestId('logs-content')).not.toBeInTheDocument(); + }); + + it('switches to Details tab when clicked', async () => { + const user = userEvent.setup(); + + renderPage(); + + await user.click(screen.getByTestId('tab-details')); + + expect(screen.getByTestId('run-metadata-content')).toBeInTheDocument(); + expect(screen.queryByTestId('logs-content')).not.toBeInTheDocument(); + }); + + it('calls onBack when back button is clicked', async () => { + const user = userEvent.setup(); + const onBack = jest.fn(); + + renderPage({ onBack }); + + await user.click(screen.getByTestId('back-button')); + + expect(onBack).toHaveBeenCalled(); + }); + + it('shows "Workflow Run" as title when run has no name', () => { + renderPage({ run: { ...defaultRun, name: undefined } as any }); + + expect(screen.getByTestId('page-title')).toHaveTextContent('Workflow Run'); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx b/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx index 76e3f3197..3829a46df 100644 --- a/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx +++ b/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx @@ -1,5 +1,10 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { TestApiProvider } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { mockComponentEntity } from '@openchoreo/test-utils'; +import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; import { Workflows } from './Workflows'; // ---- Mocks ---- @@ -24,26 +29,6 @@ jest.mock('../../hooks', () => ({ useWorkflowRetention: () => undefined, })); -// Stable entity reference so the useEffect dependency on `entity` does not -// trigger infinite re-renders. -const mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'test-component', - namespace: 'default', - annotations: { - 'openchoreo.io/namespace': 'test-ns', - }, - }, - spec: { type: 'service' }, -}; - -// Mock @backstage/plugin-catalog-react -jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: () => ({ entity: mockEntity }), -})); - // Stable mock objects so useApi does not produce new references each call const mockCiClient = { fetchWorkflowSchema: jest.fn().mockResolvedValue({ success: true }), @@ -55,18 +40,6 @@ const mockFetchApi = { fetch: jest.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), }; -// Mock @backstage/core-plugin-api -jest.mock('@backstage/core-plugin-api', () => ({ - useApi: (ref: { id: string }) => { - if (ref.id === 'mock-ci-client') return mockCiClient; - if (ref.id === 'discovery') return mockDiscoveryApi; - if (ref.id === 'fetch') return mockFetchApi; - return {}; - }, - discoveryApiRef: { id: 'discovery' }, - fetchApiRef: { id: 'fetch' }, -})); - // Mock @backstage/core-components jest.mock('@backstage/core-components', () => ({ Progress: () =>
Loading...
, @@ -114,11 +87,6 @@ jest.mock('@openchoreo/backstage-plugin-common', () => ({ filterEmptyObjectProperties: (obj: any) => obj, })); -// Mock openChoreoCiClientApiRef -jest.mock('../../api/OpenChoreoCiClientApi', () => ({ - openChoreoCiClientApiRef: { id: 'mock-ci-client' }, -})); - // Mock schemaExtensions utils jest.mock('../../utils/schemaExtensions', () => ({ walkSchemaForGitFields: () => ({}), @@ -169,8 +137,24 @@ jest.mock('../BuildWithParamsDialog', () => ({ // ---- Helpers ---- +// Stable entity reference so the useEffect dependency on `entity` does not +// trigger infinite re-renders. +const testEntity = mockComponentEntity(); + function renderWithRouter(ui: React.ReactElement) { - return render({ui}); + return render( + + + {ui} + + , + ); } const defaultRoutingState = { diff --git a/plugins/openchoreo-ci/src/utils/schemaExtensions.test.ts b/plugins/openchoreo-ci/src/utils/schemaExtensions.test.ts new file mode 100644 index 000000000..cb1d1d384 --- /dev/null +++ b/plugins/openchoreo-ci/src/utils/schemaExtensions.test.ts @@ -0,0 +1,218 @@ +import { + getNestedValue, + setNestedValue, + walkSchemaForGitFields, + extractGitFieldValues, +} from './schemaExtensions'; + +// ---- Tests ---- + +describe('getNestedValue', () => { + it('returns top-level value', () => { + expect(getNestedValue({ foo: 'bar' }, 'foo')).toBe('bar'); + }); + + it('returns nested value', () => { + expect(getNestedValue({ a: { b: { c: 42 } } }, 'a.b.c')).toBe(42); + }); + + it('returns undefined for missing path', () => { + expect(getNestedValue({ a: 1 }, 'b')).toBeUndefined(); + }); + + it('returns undefined when intermediate is null', () => { + expect(getNestedValue({ a: null }, 'a.b')).toBeUndefined(); + }); + + it('returns undefined when intermediate is a primitive', () => { + expect(getNestedValue({ a: 'string' }, 'a.b')).toBeUndefined(); + }); + + it('throws on __proto__ path segment', () => { + expect(() => getNestedValue({}, '__proto__')).toThrow( + 'Unsafe path segment', + ); + }); + + it('throws on constructor path segment', () => { + expect(() => getNestedValue({}, 'a.constructor')).toThrow( + 'Unsafe path segment', + ); + }); + + it('throws on prototype path segment', () => { + expect(() => getNestedValue({}, 'prototype.x')).toThrow( + 'Unsafe path segment', + ); + }); +}); + +describe('setNestedValue', () => { + it('sets top-level value', () => { + const obj: Record = {}; + setNestedValue(obj, 'foo', 'bar'); + expect(obj.foo).toBe('bar'); + }); + + it('sets deeply nested value, creating intermediates', () => { + const obj: Record = {}; + setNestedValue(obj, 'a.b.c', 42); + expect(obj.a.b.c).toBe(42); + }); + + it('overwrites existing value', () => { + const obj: Record = { a: { b: 1 } }; + setNestedValue(obj, 'a.b', 2); + expect(obj.a.b).toBe(2); + }); + + it('replaces primitive intermediate with object', () => { + const obj: Record = { a: 'string' }; + setNestedValue(obj, 'a.b', 1); + expect(obj.a.b).toBe(1); + }); + + it('throws on unsafe path segment', () => { + expect(() => setNestedValue({}, '__proto__.polluted', true)).toThrow( + 'Unsafe path segment', + ); + }); +}); + +describe('walkSchemaForGitFields', () => { + it('returns empty mapping for schema without extensions', () => { + const properties = { + name: { type: 'string' }, + count: { type: 'integer' }, + }; + expect(walkSchemaForGitFields(properties, '')).toEqual({}); + }); + + it('detects repo URL extension at top level', () => { + const properties = { + repoUrl: { + type: 'string', + 'x-openchoreo-component-parameter-repository-url': true, + }, + }; + expect(walkSchemaForGitFields(properties, '')).toEqual({ + repoUrl: 'repoUrl', + }); + }); + + it('detects nested extensions with prefix', () => { + const properties = { + repository: { + type: 'object', + properties: { + url: { + type: 'string', + 'x-openchoreo-component-parameter-repository-url': true, + }, + revision: { + type: 'object', + properties: { + branch: { + type: 'string', + 'x-openchoreo-component-parameter-repository-branch': true, + }, + commit: { + type: 'string', + 'x-openchoreo-component-parameter-repository-commit': true, + }, + }, + }, + }, + }, + }; + + const mapping = walkSchemaForGitFields(properties, ''); + expect(mapping).toEqual({ + repoUrl: 'repository.url', + branch: 'repository.revision.branch', + commit: 'repository.revision.commit', + }); + }); + + it('skips null/non-object property schemas', () => { + const properties = { + bad: null, + good: { + type: 'string', + 'x-openchoreo-component-parameter-repository-url': true, + }, + }; + expect(walkSchemaForGitFields(properties as any, '')).toEqual({ + repoUrl: 'good', + }); + }); + + it('applies prefix correctly', () => { + const properties = { + url: { + type: 'string', + 'x-openchoreo-component-parameter-repository-url': true, + }, + }; + expect(walkSchemaForGitFields(properties, 'spec')).toEqual({ + repoUrl: 'spec.url', + }); + }); +}); + +describe('extractGitFieldValues', () => { + it('returns empty object when parameters is null', () => { + expect(extractGitFieldValues(null, { repoUrl: 'url' })).toEqual({}); + }); + + it('returns empty object when parameters is undefined', () => { + expect(extractGitFieldValues(undefined, { repoUrl: 'url' })).toEqual({}); + }); + + it('returns empty object when mapping is empty', () => { + expect(extractGitFieldValues({ url: 'http://example.com' }, {})).toEqual( + {}, + ); + }); + + it('extracts values from flat parameters', () => { + const params = { url: 'http://example.com', branch: 'main' }; + const mapping = { repoUrl: 'url', branch: 'branch' }; + expect(extractGitFieldValues(params, mapping)).toEqual({ + repoUrl: 'http://example.com', + branch: 'main', + }); + }); + + it('extracts values from nested parameters', () => { + const params = { + repo: { url: 'http://example.com', rev: { commit: 'abc123' } }, + }; + const mapping = { repoUrl: 'repo.url', commit: 'repo.rev.commit' }; + expect(extractGitFieldValues(params, mapping)).toEqual({ + repoUrl: 'http://example.com', + commit: 'abc123', + }); + }); + + it('skips undefined/null/empty values', () => { + const params = { url: '', branch: null, commit: undefined, path: '/app' }; + const mapping = { + repoUrl: 'url', + branch: 'branch', + commit: 'commit', + appPath: 'path', + }; + expect(extractGitFieldValues(params, mapping)).toEqual({ + appPath: '/app', + }); + }); + + it('converts non-string values to strings', () => { + const params = { count: 42 }; + const mapping = { repoUrl: 'count' }; + expect(extractGitFieldValues(params, mapping)).toEqual({ + repoUrl: '42', + }); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Alerts/AlertRow.test.tsx b/plugins/openchoreo-observability/src/components/Alerts/AlertRow.test.tsx new file mode 100644 index 000000000..a2e189c46 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Alerts/AlertRow.test.tsx @@ -0,0 +1,218 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Table, TableBody } from '@material-ui/core'; +import { AlertRow } from './AlertRow'; +import { AlertSummary } from '../../types'; + +// ---- Helpers ---- + +const sampleAlert: AlertSummary = { + alertId: 'alert-123', + timestamp: '2024-06-01T10:00:00.000Z', + ruleName: 'High CPU Alert', + ruleDescription: 'CPU usage exceeded 90% for 5 minutes', + severity: 'critical', + sourceType: 'metric', + sourceMetric: 'cpu_usage_percent', + alertValue: '95.2%', + projectName: 'my-project', + componentName: 'api-service', + environmentName: 'production', + namespaceName: 'prod-ns', + notificationChannels: ['slack-alerts', 'pagerduty'], + incidentEnabled: true, +}; + +function renderRow( + overrides: Partial> = {}, +) { + const defaultProps = { + alert: sampleAlert, + environmentName: 'production', + projectName: 'my-project', + componentName: 'api-service', + namespaceName: 'prod-ns', + onViewIncident: jest.fn(), + }; + + return { + ...render( + + + + +
, + ), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('AlertRow', () => { + it('renders alert timestamp', () => { + renderRow(); + + expect( + screen.getByText(new Date('2024-06-01T10:00:00.000Z').toLocaleString()), + ).toBeInTheDocument(); + }); + + it('renders rule name', () => { + renderRow(); + + expect(screen.getByText('High CPU Alert')).toBeInTheDocument(); + }); + + it('renders severity chip in uppercase', () => { + renderRow(); + + expect(screen.getByText('CRITICAL')).toBeInTheDocument(); + }); + + it('renders WARNING severity', () => { + renderRow({ alert: { ...sampleAlert, severity: 'warning' } }); + + expect(screen.getByText('WARNING')).toBeInTheDocument(); + }); + + it('renders INFO severity', () => { + renderRow({ alert: { ...sampleAlert, severity: 'info' } }); + + expect(screen.getByText('INFO')).toBeInTheDocument(); + }); + + it('renders source type', () => { + renderRow(); + + expect(screen.getByText('metric')).toBeInTheDocument(); + }); + + it('renders alert value', () => { + renderRow(); + + expect(screen.getByText('95.2%')).toBeInTheDocument(); + }); + + it('shows "View incident" button when incidentEnabled', () => { + renderRow(); + + expect(screen.getByText('View incident')).toBeInTheDocument(); + }); + + it('does not show "View incident" when incidentEnabled is false', () => { + renderRow({ + alert: { ...sampleAlert, incidentEnabled: false }, + }); + + expect(screen.queryByText('View incident')).not.toBeInTheDocument(); + }); + + it('expands to show alert details on row click', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU Alert')); + + expect(screen.getByText('Alert details')).toBeInTheDocument(); + expect(screen.getByText('Alert ID:')).toBeInTheDocument(); + expect(screen.getByText('alert-123')).toBeInTheDocument(); + expect(screen.getByText('Project:')).toBeInTheDocument(); + expect(screen.getByText('Component:')).toBeInTheDocument(); + expect(screen.getByText('Environment:')).toBeInTheDocument(); + expect(screen.getByText('Namespace:')).toBeInTheDocument(); + expect(screen.getByText('Source Type:')).toBeInTheDocument(); + }); + + it('shows metric field for metric source type when expanded', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU Alert')); + + expect(screen.getByText('Metric:')).toBeInTheDocument(); + expect(screen.getByText('cpu_usage_percent')).toBeInTheDocument(); + }); + + it('shows log query field for log source type when expanded', async () => { + const user = userEvent.setup(); + renderRow({ + alert: { + ...sampleAlert, + sourceType: 'log', + sourceQuery: 'level=ERROR', + sourceMetric: undefined, + }, + }); + + await user.click(screen.getByText('High CPU Alert')); + + expect(screen.getByText('Log Query:')).toBeInTheDocument(); + expect(screen.getByText('level=ERROR')).toBeInTheDocument(); + }); + + it('shows notification channels when expanded', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU Alert')); + + expect(screen.getByText('Notification Channels:')).toBeInTheDocument(); + expect(screen.getByText('slack-alerts, pagerduty')).toBeInTheDocument(); + }); + + it('shows rule description when expanded', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU Alert')); + + expect(screen.getByText('Rule description')).toBeInTheDocument(); + expect( + screen.getByText('CPU usage exceeded 90% for 5 minutes'), + ).toBeInTheDocument(); + }); + + it('collapses on second click', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU Alert')); + expect(screen.getByText('Alert details')).toBeInTheDocument(); + + await user.click(screen.getByText('High CPU Alert')); + expect(screen.queryByText('Alert details')).not.toBeInTheDocument(); + }); + + it('calls onViewIncident when View incident is clicked in expanded view', async () => { + const user = userEvent.setup(); + const onViewIncident = jest.fn(); + renderRow({ onViewIncident }); + + // Expand the row first to get the outlined "View incident" button (not the hover one) + await user.click(screen.getByText('High CPU Alert')); + + // Click the expanded view's "View incident" button (outlined variant in details) + const detailsSection = screen.getByText('Alert details').closest('tr')!; + const { getByRole } = within(detailsSection); + await user.click(getByRole('button', { name: /view incident/i })); + + expect(onViewIncident).toHaveBeenCalledWith(sampleAlert); + }); + + it('falls back to dash for missing fields', () => { + renderRow({ + alert: { + alertId: 'a1', + timestamp: undefined, + ruleName: undefined, + severity: undefined, + sourceType: undefined, + alertValue: undefined, + }, + }); + + // Missing timestamp shows dash + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Alerts/AlertsActions.test.tsx b/plugins/openchoreo-observability/src/components/Alerts/AlertsActions.test.tsx new file mode 100644 index 000000000..2bebd9776 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Alerts/AlertsActions.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AlertsActions } from './AlertsActions'; +import { AlertsFilters } from './types'; + +// ---- Helpers ---- + +const baseFilters: AlertsFilters = { + environmentId: 'env-1', + timeRange: '1h', + sortOrder: 'desc', +}; + +function renderActions( + overrides: Partial> = {}, +) { + const defaultProps = { + totalCount: 15, + disabled: false, + onRefresh: jest.fn(), + filters: baseFilters, + onFiltersChange: jest.fn(), + lastUpdated: new Date('2024-06-01T10:00:00Z'), + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('AlertsActions', () => { + it('displays total alerts count', () => { + renderActions(); + + expect(screen.getByText('Total alerts: 15')).toBeInTheDocument(); + }); + + it('displays last updated time', () => { + renderActions(); + + expect(screen.getByText(/Last updated at:/)).toBeInTheDocument(); + }); + + it('shows "Newest First" when sort order is desc', () => { + renderActions(); + + expect( + screen.getByRole('button', { name: /newest first/i }), + ).toBeInTheDocument(); + }); + + it('shows "Oldest First" when sort order is asc', () => { + renderActions({ + filters: { ...baseFilters, sortOrder: 'asc' }, + }); + + expect( + screen.getByRole('button', { name: /oldest first/i }), + ).toBeInTheDocument(); + }); + + it('toggles sort order on click', async () => { + const user = userEvent.setup(); + const onFiltersChange = jest.fn(); + renderActions({ onFiltersChange }); + + await user.click(screen.getByRole('button', { name: /newest first/i })); + + expect(onFiltersChange).toHaveBeenCalledWith({ sortOrder: 'asc' }); + }); + + it('calls onRefresh when Refresh is clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + renderActions({ onRefresh }); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + expect(onRefresh).toHaveBeenCalled(); + }); + + it('disables buttons when disabled', () => { + renderActions({ disabled: true }); + + expect( + screen.getByRole('button', { name: /newest first/i }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /refresh/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Alerts/AlertsFilter.test.tsx b/plugins/openchoreo-observability/src/components/Alerts/AlertsFilter.test.tsx new file mode 100644 index 000000000..7bb2e230a --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Alerts/AlertsFilter.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AlertsFilter } from './AlertsFilter'; +import { AlertsFilters } from './types'; + +// ---- Helpers ---- + +const environments = [ + { id: 'dev', name: 'Development', resourceName: 'development' }, + { id: 'stg', name: 'Staging', resourceName: 'staging' }, +]; + +const baseFilters: AlertsFilters = { + environmentId: 'dev', + timeRange: '1h', + sortOrder: 'desc', + severity: [], + searchQuery: '', +}; + +function renderFilter( + overrides: Partial> = {}, +) { + const defaultProps = { + filters: baseFilters, + onFiltersChange: jest.fn(), + environments, + environmentsLoading: false, + disabled: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('AlertsFilter', () => { + it('renders search input', () => { + renderFilter(); + + expect(screen.getByPlaceholderText('Search alerts...')).toBeInTheDocument(); + }); + + it('renders severity selector', () => { + renderFilter(); + + // MUI outlined Select renders label text twice + expect(screen.getAllByText('Severity').length).toBeGreaterThanOrEqual(1); + }); + + it('renders environment selector', () => { + renderFilter(); + + expect(screen.getAllByText('Environment').length).toBeGreaterThanOrEqual(1); + }); + + it('renders time range selector', () => { + renderFilter(); + + expect(screen.getAllByText('Time Range').length).toBeGreaterThanOrEqual(1); + }); + + it('disables controls when disabled', () => { + renderFilter({ disabled: true }); + + expect(screen.getByPlaceholderText('Search alerts...')).toBeDisabled(); + }); + + it('allows typing in search field', async () => { + const user = userEvent.setup(); + renderFilter(); + + const input = screen.getByPlaceholderText('Search alerts...'); + await user.type(input, 'cpu'); + + expect(input).toHaveValue('cpu'); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Alerts/AlertsTable.test.tsx b/plugins/openchoreo-observability/src/components/Alerts/AlertsTable.test.tsx new file mode 100644 index 000000000..d8a788134 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Alerts/AlertsTable.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { AlertsTable } from './AlertsTable'; +import { AlertSummary } from '../../types'; + +// ---- Helpers ---- + +const alerts: AlertSummary[] = [ + { + alertId: 'alert-1', + timestamp: '2024-06-01T10:00:00Z', + ruleName: 'High Memory', + severity: 'warning', + sourceType: 'metric', + alertValue: '85%', + }, + { + alertId: 'alert-2', + timestamp: '2024-06-01T10:05:00Z', + ruleName: 'Error Rate Spike', + severity: 'critical', + sourceType: 'log', + alertValue: '12/min', + }, +]; + +function renderTable( + overrides: Partial> = {}, +) { + const defaultProps = { + alerts, + loading: false, + environmentName: 'development', + projectName: 'my-project', + componentName: 'api-service', + namespaceName: 'dev-ns', + }; + + return render(); +} + +// ---- Tests ---- + +describe('AlertsTable', () => { + it('renders table headers', () => { + renderTable(); + + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.getByText('Rule')).toBeInTheDocument(); + expect(screen.getByText('Severity')).toBeInTheDocument(); + expect(screen.getByText('Source')).toBeInTheDocument(); + expect(screen.getByText('Value')).toBeInTheDocument(); + }); + + it('renders alert rows', () => { + renderTable(); + + expect(screen.getByText('High Memory')).toBeInTheDocument(); + expect(screen.getByText('Error Rate Spike')).toBeInTheDocument(); + }); + + it('shows empty state when no alerts and not loading', () => { + renderTable({ alerts: [] }); + + expect(screen.getByText('No alerts found')).toBeInTheDocument(); + expect( + screen.getByText( + 'No alerts match the current filters in the selected time range.', + ), + ).toBeInTheDocument(); + }); + + it('does not show empty state when loading', () => { + renderTable({ alerts: [], loading: true }); + + expect(screen.queryByText('No alerts found')).not.toBeInTheDocument(); + }); + + it('shows loading skeletons when loading with no alerts', () => { + renderTable({ alerts: [], loading: true }); + + const skeletons = document.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('shows spinner when loading with existing alerts', () => { + renderTable({ loading: true }); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders severity chips', () => { + renderTable(); + + expect(screen.getByText('WARNING')).toBeInTheDocument(); + expect(screen.getByText('CRITICAL')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx new file mode 100644 index 000000000..2e624e1b1 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx @@ -0,0 +1,304 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { ObservabilityAlertsPage } from './ObservabilityAlertsPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseAlertsPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useAlertsPermission: () => mockUseAlertsPermission(), +})); + +const mockUseGetNamespaceAndProjectByEntity = jest.fn(); +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseComponentAlerts = jest.fn(); +const mockUseUrlFiltersForAlerts = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetNamespaceAndProjectByEntity: (...args: any[]) => + mockUseGetNamespaceAndProjectByEntity(...args), + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useComponentAlerts: (...args: any[]) => mockUseComponentAlerts(...args), + useUrlFiltersForAlerts: (...args: any[]) => + mockUseUrlFiltersForAlerts(...args), +})); + +jest.mock('./AlertsFilter', () => ({ + AlertsFilter: ({ environments, filters }: any) => ( +
+ {environments.length} + {filters.environmentId} +
+ ), +})); + +jest.mock('./AlertsTable', () => ({ + AlertsTable: ({ alerts, loading }: any) => ( +
+ {alerts.length} + {String(loading)} + {alerts.map((a: any) => ( +
+ {a.ruleName} +
+ ))} +
+ ), +})); + +jest.mock('./AlertsActions', () => ({ + AlertsActions: ({ totalCount, onRefresh }: any) => ( +
+ {totalCount} + +
+ ), +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'api-service', + namespace: 'default', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + 'openchoreo.io/component': 'api-service', + }, + }, + spec: { owner: 'team-a', system: 'my-project' }, +}; + +const defaultFilters = { + environmentId: 'env-dev', + timeRange: '1h', + sortOrder: 'desc' as const, + severity: [], + searchQuery: '', +}; + +function renderPage() { + return renderInTestApp( + + + , + ); +} + +function setupDefaultMocks() { + mockUseAlertsPermission.mockReturnValue({ + canViewAlerts: true, + loading: false, + deniedTooltip: '', + }); + + mockUseGetNamespaceAndProjectByEntity.mockReturnValue({ + namespace: 'dev-ns', + project: 'my-project', + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [{ name: 'development', displayName: 'Development' }], + loading: false, + error: null, + }); + + mockUseUrlFiltersForAlerts.mockReturnValue({ + filters: defaultFilters, + updateFilters: jest.fn(), + }); + + mockUseComponentAlerts.mockReturnValue({ + alerts: [ + { + alertId: 'a1', + ruleName: 'High CPU', + severity: 'critical', + timestamp: '2024-06-01T10:00:00Z', + }, + { + alertId: 'a2', + ruleName: 'Memory Warning', + severity: 'warning', + timestamp: '2024-06-01T10:05:00Z', + }, + ], + loading: false, + error: null, + fetchAlerts: jest.fn(), + refresh: jest.fn(), + }); +} + +// ---- Tests ---- + +describe('ObservabilityAlertsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseAlertsPermission.mockReturnValue({ + canViewAlerts: false, + loading: true, + deniedTooltip: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows permission denied when user lacks access', async () => { + mockUseAlertsPermission.mockReturnValue({ + canViewAlerts: false, + loading: false, + deniedTooltip: 'No alert access', + }); + + await renderPage(); + + expect(screen.getByText('Permission Denied')).toBeInTheDocument(); + }); + + it('renders filter, actions, and table when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('alerts-filter')).toBeInTheDocument(); + expect(screen.getByTestId('alerts-actions')).toBeInTheDocument(); + expect(screen.getByTestId('alerts-table')).toBeInTheDocument(); + }); + + it('passes environments to filter', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('1'); + }); + + it('renders alert entries in the table', async () => { + await renderPage(); + + expect(screen.getByTestId('alert-count')).toHaveTextContent('2'); + expect(screen.getByText('High CPU')).toBeInTheDocument(); + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + }); + + it('shows total count in actions', async () => { + await renderPage(); + + expect(screen.getByTestId('total-count')).toHaveTextContent('2'); + }); + + it('shows environment error', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: 'Failed to fetch environments', + }); + + await renderPage(); + + expect( + screen.getByText('Failed to fetch environments'), + ).toBeInTheDocument(); + }); + + it('shows alerts error with Retry button', async () => { + mockUseComponentAlerts.mockReturnValue({ + alerts: [], + loading: false, + error: 'Connection timed out', + fetchAlerts: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByText('Connection timed out')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('shows info message when observability is disabled', async () => { + mockUseComponentAlerts.mockReturnValue({ + alerts: [], + loading: false, + error: 'Observability is not enabled for this component', + fetchAlerts: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this component. Please enable observability to view alerts.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('shows no environments alert when none found', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: null, + }); + + mockUseUrlFiltersForAlerts.mockReturnValue({ + filters: { ...defaultFilters, environmentId: '' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'No environments found. Make sure your component is properly configured.', + ), + ).toBeInTheDocument(); + }); + + it('does not render actions/table when no environment selected', async () => { + mockUseUrlFiltersForAlerts.mockReturnValue({ + filters: { ...defaultFilters, environmentId: '' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect(screen.queryByTestId('alerts-actions')).not.toBeInTheDocument(); + expect(screen.queryByTestId('alerts-table')).not.toBeInTheDocument(); + }); + + it('filters alerts by severity client-side', async () => { + mockUseUrlFiltersForAlerts.mockReturnValue({ + filters: { ...defaultFilters, severity: ['critical'] }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + // Only the critical alert should pass through + expect(screen.getByTestId('alert-count')).toHaveTextContent('1'); + expect(screen.getByText('High CPU')).toBeInTheDocument(); + }); + + it('filters alerts by search query client-side', async () => { + mockUseUrlFiltersForAlerts.mockReturnValue({ + filters: { ...defaultFilters, searchQuery: 'memory' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByTestId('alert-count')).toHaveTextContent('1'); + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Incidents/IncidentRow.test.tsx b/plugins/openchoreo-observability/src/components/Incidents/IncidentRow.test.tsx new file mode 100644 index 000000000..576b3a992 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Incidents/IncidentRow.test.tsx @@ -0,0 +1,174 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Table, TableBody } from '@material-ui/core'; +import { IncidentRow } from './IncidentRow'; +import { IncidentSummary } from '../../types'; + +// ---- Helpers ---- + +const sampleIncident: IncidentSummary = { + incidentId: 'inc-12345678abcdef', + alertId: 'alert-001', + status: 'active', + description: 'High CPU usage on api-service', + triggeredAt: '2024-06-01T10:00:00.000Z', + componentName: 'api-service', + projectName: 'my-project', + environmentName: 'production', + namespaceName: 'prod-ns', + incidentTriggerAiRca: true, + notes: 'Escalated to on-call', +}; + +function renderRow( + overrides: Partial> = {}, +) { + const defaultProps = { + incident: sampleIncident, + namespaceName: 'prod-ns', + projectName: 'my-project', + environmentName: 'production', + onViewRCA: jest.fn(), + onAcknowledge: jest.fn(), + onResolve: jest.fn(), + }; + + return { + ...render( + + + + +
, + ), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('IncidentRow', () => { + it('renders incident timestamp', () => { + renderRow(); + + expect( + screen.getByText(new Date('2024-06-01T10:00:00.000Z').toLocaleString()), + ).toBeInTheDocument(); + }); + + it('renders truncated incident ID', () => { + renderRow(); + + expect(screen.getByText('inc-1234…')).toBeInTheDocument(); + }); + + it('renders status chip in uppercase', () => { + renderRow(); + + expect(screen.getByText('ACTIVE')).toBeInTheDocument(); + }); + + it('renders ACKNOWLEDGED status', () => { + renderRow({ + incident: { ...sampleIncident, status: 'acknowledged' }, + }); + + expect(screen.getByText('ACKNOWLEDGED')).toBeInTheDocument(); + }); + + it('renders RESOLVED status', () => { + renderRow({ + incident: { ...sampleIncident, status: 'resolved' }, + }); + + expect(screen.getByText('RESOLVED')).toBeInTheDocument(); + }); + + it('renders description', () => { + renderRow(); + + expect( + screen.getByText('High CPU usage on api-service'), + ).toBeInTheDocument(); + }); + + it('renders component name', () => { + renderRow(); + + expect(screen.getByText('api-service')).toBeInTheDocument(); + }); + + it('shows View RCA button when incidentTriggerAiRca is true', () => { + renderRow(); + + expect(screen.getAllByText('View RCA').length).toBeGreaterThanOrEqual(1); + }); + + it('does not show View RCA when incidentTriggerAiRca is false', () => { + renderRow({ + incident: { ...sampleIncident, incidentTriggerAiRca: false }, + }); + + expect(screen.queryByText('View RCA')).not.toBeInTheDocument(); + }); + + it('expands to show incident details on row click', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU usage on api-service')); + + expect(screen.getByText('Incident details')).toBeInTheDocument(); + expect(screen.getByText('Incident ID:')).toBeInTheDocument(); + expect(screen.getByText('Alert ID:')).toBeInTheDocument(); + expect(screen.getByText('Project:')).toBeInTheDocument(); + expect(screen.getByText('Component:')).toBeInTheDocument(); + expect(screen.getByText('Environment:')).toBeInTheDocument(); + expect(screen.getByText('Namespace:')).toBeInTheDocument(); + }); + + it('shows notes when expanded', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU usage on api-service')); + + expect(screen.getByText('Notes')).toBeInTheDocument(); + expect(screen.getByText('Escalated to on-call')).toBeInTheDocument(); + }); + + it('shows Acknowledge button for active incidents when expanded', async () => { + const user = userEvent.setup(); + renderRow(); + + await user.click(screen.getByText('High CPU usage on api-service')); + + expect(screen.getByText('Acknowledge')).toBeInTheDocument(); + }); + + it('shows Resolve button for acknowledged incidents when expanded', async () => { + const user = userEvent.setup(); + renderRow({ + incident: { ...sampleIncident, status: 'acknowledged' }, + }); + + await user.click(screen.getByText('High CPU usage on api-service')); + + expect(screen.getByText('Resolve')).toBeInTheDocument(); + }); + + it('falls back to dash for missing fields', () => { + renderRow({ + incident: { + incidentId: 'i1', + alertId: 'a1', + status: 'active', + triggeredAt: undefined, + description: undefined, + componentName: undefined, + }, + }); + + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Incidents/IncidentsActions.test.tsx b/plugins/openchoreo-observability/src/components/Incidents/IncidentsActions.test.tsx new file mode 100644 index 000000000..2404c28b9 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Incidents/IncidentsActions.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IncidentsActions } from './IncidentsActions'; +import { IncidentsFilters } from './types'; + +// ---- Helpers ---- + +const baseFilters: IncidentsFilters = { + environmentId: 'dev', + timeRange: '1h', +}; + +function renderActions( + overrides: Partial> = {}, +) { + const defaultProps = { + totalCount: 5, + disabled: false, + onRefresh: jest.fn(), + filters: baseFilters, + onFiltersChange: jest.fn(), + lastUpdated: new Date('2024-06-01T10:00:00Z'), + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('IncidentsActions', () => { + it('displays total incidents count', () => { + renderActions(); + + expect(screen.getByText('Total incidents: 5')).toBeInTheDocument(); + }); + + it('displays last updated text', () => { + renderActions(); + + expect(screen.getByText(/Last updated at:/)).toBeInTheDocument(); + }); + + it('shows sort button defaulting to Newest First', () => { + renderActions(); + + expect(screen.getByText('Newest First')).toBeInTheDocument(); + }); + + it('toggles sort order on click', async () => { + const user = userEvent.setup(); + const onFiltersChange = jest.fn(); + renderActions({ onFiltersChange }); + + await user.click(screen.getByText('Newest First')); + + expect(onFiltersChange).toHaveBeenCalledWith({ sortOrder: 'asc' }); + }); + + it('shows Oldest First when sortOrder is asc', () => { + renderActions({ + filters: { ...baseFilters, sortOrder: 'asc' }, + }); + + expect(screen.getByText('Oldest First')).toBeInTheDocument(); + }); + + it('calls onRefresh when Refresh is clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + renderActions({ onRefresh }); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + expect(onRefresh).toHaveBeenCalled(); + }); + + it('disables buttons when disabled', () => { + renderActions({ disabled: true }); + + expect(screen.getByRole('button', { name: /refresh/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Incidents/IncidentsFilter.test.tsx b/plugins/openchoreo-observability/src/components/Incidents/IncidentsFilter.test.tsx new file mode 100644 index 000000000..aec0caee9 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Incidents/IncidentsFilter.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import { IncidentsFilter } from './IncidentsFilter'; +import { IncidentsFilters } from './types'; + +// ---- Helpers ---- + +const environments = [ + { id: 'dev', name: 'Development', resourceName: 'development' }, + { id: 'stg', name: 'Staging', resourceName: 'staging' }, +]; + +const components = [ + { name: 'api-svc', displayName: 'API Service' }, + { name: 'web-app', displayName: 'Web App' }, +]; + +const baseFilters: IncidentsFilters = { + environmentId: 'dev', + timeRange: '1h', +}; + +function renderFilters( + overrides: Partial> = {}, +) { + const defaultProps = { + filters: baseFilters, + onFiltersChange: jest.fn(), + environments, + environmentsLoading: false, + components, + componentsLoading: false, + disabled: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('IncidentsFilter', () => { + it('renders search input', () => { + renderFilters(); + + expect( + screen.getByPlaceholderText('Search incidents...'), + ).toBeInTheDocument(); + }); + + it('renders components selector when components exist', () => { + renderFilters(); + + expect(screen.getAllByText('Components').length).toBeGreaterThanOrEqual(1); + }); + + it('does not render components selector when no components', () => { + renderFilters({ components: [] }); + + expect(screen.queryByText('Components')).not.toBeInTheDocument(); + }); + + it('renders status selector', () => { + renderFilters(); + + expect(screen.getAllByText('Status').length).toBeGreaterThanOrEqual(1); + }); + + it('renders environment selector', () => { + renderFilters(); + + expect(screen.getAllByText('Environment').length).toBeGreaterThanOrEqual(1); + }); + + it('renders time range selector', () => { + renderFilters(); + + expect(screen.getAllByText('Time Range').length).toBeGreaterThanOrEqual(1); + }); + + it('disables controls when disabled', () => { + renderFilters({ disabled: true }); + + const selects = document.querySelectorAll('.Mui-disabled'); + expect(selects.length).toBeGreaterThan(0); + }); + + it('shows skeleton while environments are loading', () => { + renderFilters({ environmentsLoading: true }); + + expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Incidents/IncidentsTable.test.tsx b/plugins/openchoreo-observability/src/components/Incidents/IncidentsTable.test.tsx new file mode 100644 index 000000000..1a58181f6 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Incidents/IncidentsTable.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import { IncidentsTable } from './IncidentsTable'; +import { IncidentSummary } from '../../types'; + +// ---- Mock IncidentRow ---- +jest.mock('./IncidentRow', () => ({ + IncidentRow: ({ incident }: any) => ( + + {incident.description} + + ), +})); + +// ---- Helpers ---- + +const sampleIncident: IncidentSummary = { + incidentId: 'inc-001', + alertId: 'alert-001', + status: 'active', + description: 'High CPU on api-service', + triggeredAt: '2024-06-01T10:00:00Z', + componentName: 'api-service', +}; + +function renderTable( + overrides: Partial> = {}, +) { + const defaultProps = { + incidents: [sampleIncident], + loading: false, + namespaceName: 'dev-ns', + projectName: 'my-project', + }; + + return render(); +} + +// ---- Tests ---- + +describe('IncidentsTable', () => { + it('renders table headers', () => { + renderTable(); + + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.getByText('Incident ID')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Component')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + }); + + it('renders incident rows', () => { + renderTable(); + + expect(screen.getByTestId('incident-row-inc-001')).toBeInTheDocument(); + }); + + it('shows empty state when no incidents', () => { + renderTable({ incidents: [] }); + + expect(screen.getByText('No incidents found')).toBeInTheDocument(); + expect( + screen.getByText( + 'No incidents match the current filters in the selected time range.', + ), + ).toBeInTheDocument(); + }); + + it('shows loading skeletons when loading', () => { + renderTable({ loading: true, incidents: [] }); + + // 5 skeleton rows × 6 cells each + const skeletons = document.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBe(30); + }); + + it('does not show empty state when loading', () => { + renderTable({ loading: true, incidents: [] }); + + expect(screen.queryByText('No incidents found')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.test.tsx b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.test.tsx new file mode 100644 index 000000000..c2afe89a3 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.test.tsx @@ -0,0 +1,277 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { ObservabilityProjectIncidentsPage } from './ObservabilityProjectIncidentsPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseIncidentsPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useIncidentsPermission: () => mockUseIncidentsPermission(), +})); + +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseGetComponentsByProject = jest.fn(); +const mockUseProjectIncidents = jest.fn(); +const mockUseUrlFiltersForIncidents = jest.fn(); +const mockUseUpdateIncident = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useGetComponentsByProject: (...args: any[]) => + mockUseGetComponentsByProject(...args), + useProjectIncidents: (...args: any[]) => mockUseProjectIncidents(...args), + useUrlFiltersForIncidents: (...args: any[]) => + mockUseUrlFiltersForIncidents(...args), + useUpdateIncident: () => mockUseUpdateIncident(), +})); + +jest.mock('./IncidentsFilter', () => ({ + IncidentsFilter: ({ environments, filters }: any) => ( +
+ {environments.length} + {filters.timeRange} +
+ ), +})); + +jest.mock('./IncidentsActions', () => ({ + IncidentsActions: ({ totalCount, onRefresh, disabled }: any) => ( +
+ {totalCount} + +
+ ), +})); + +jest.mock('./IncidentsTable', () => ({ + IncidentsTable: ({ incidents, loading }: any) => ( +
+ {incidents.length} + {loading && } +
+ ), +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-project', + namespace: 'default', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + }, + }, + spec: { owner: 'team-a' }, +}; + +const defaultEnvironment = { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', +}; + +function setupDefaultMocks() { + mockUseIncidentsPermission.mockReturnValue({ + canViewIncidents: true, + loading: false, + deniedTooltip: '', + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [defaultEnvironment], + loading: false, + error: null, + }); + + mockUseGetComponentsByProject.mockReturnValue({ + components: [{ name: 'api-svc', displayName: 'API Service' }], + loading: false, + error: null, + }); + + mockUseUrlFiltersForIncidents.mockReturnValue({ + filters: { + environmentId: 'development', + timeRange: '1h', + }, + updateFilters: jest.fn(), + }); + + mockUseProjectIncidents.mockReturnValue({ + incidents: [], + loading: false, + error: null, + fetchIncidents: jest.fn(), + refresh: jest.fn(), + }); + + mockUseUpdateIncident.mockReturnValue({ + updateIncident: jest.fn(), + }); +} + +function renderPage() { + return renderInTestApp( + + + , + ); +} + +// ---- Tests ---- + +describe('ObservabilityProjectIncidentsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseIncidentsPermission.mockReturnValue({ + canViewIncidents: false, + loading: true, + deniedTooltip: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows permission denied when user lacks permission', async () => { + mockUseIncidentsPermission.mockReturnValue({ + canViewIncidents: false, + loading: false, + deniedTooltip: 'No incidents access', + }); + + await renderPage(); + + expect(screen.getByText('Permission Denied')).toBeInTheDocument(); + }); + + it('renders filter, actions, and table when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('incidents-filter')).toBeInTheDocument(); + expect(screen.getByTestId('incidents-actions')).toBeInTheDocument(); + expect(screen.getByTestId('incidents-table')).toBeInTheDocument(); + }); + + it('passes environments to filter', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('1'); + }); + + it('shows info message when observability is disabled', async () => { + mockUseProjectIncidents.mockReturnValue({ + incidents: [], + loading: false, + error: 'Observability is not enabled for this project', + fetchIncidents: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this project. Please enable observability to view incidents.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('shows incidents error with Retry button', async () => { + mockUseProjectIncidents.mockReturnValue({ + incidents: [], + loading: false, + error: 'Incidents query failed', + fetchIncidents: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByText('Incidents query failed')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('shows no environments alert', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: null, + }); + + mockUseUrlFiltersForIncidents.mockReturnValue({ + filters: { + environmentId: '', + timeRange: '1h', + }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText('No environments found for this project.'), + ).toBeInTheDocument(); + }); + + it('does not render actions/table when no environment selected', async () => { + mockUseUrlFiltersForIncidents.mockReturnValue({ + filters: { + environmentId: '', + timeRange: '1h', + }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByTestId('incidents-filter')).toBeInTheDocument(); + expect(screen.queryByTestId('incidents-actions')).not.toBeInTheDocument(); + expect(screen.queryByTestId('incidents-table')).not.toBeInTheDocument(); + }); + + it('renders environments error as observability disabled info', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: 'Observability is not enabled for this project', + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this project. Please enable observability to view incidents.', + ), + ).toBeInTheDocument(); + }); + + it('renders components error', async () => { + mockUseGetComponentsByProject.mockReturnValue({ + components: [], + loading: false, + error: 'Failed to fetch components', + }); + + await renderPage(); + + expect(screen.getByText('Failed to fetch components')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Metrics/MetricsActions.test.tsx b/plugins/openchoreo-observability/src/components/Metrics/MetricsActions.test.tsx new file mode 100644 index 000000000..0ee860f85 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Metrics/MetricsActions.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MetricsActions } from './MetricsActions'; + +// ---- Tests ---- + +describe('MetricsActions', () => { + it('displays last updated text', () => { + render(); + + expect(screen.getByText(/Last updated at:/)).toBeInTheDocument(); + }); + + it('shows Refresh button', () => { + render(); + + expect( + screen.getByRole('button', { name: /refresh/i }), + ).toBeInTheDocument(); + }); + + it('calls onRefresh when clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + expect(onRefresh).toHaveBeenCalled(); + }); + + it('disables Refresh button when disabled', () => { + render(); + + expect(screen.getByRole('button', { name: /refresh/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Metrics/MetricsFilters.test.tsx b/plugins/openchoreo-observability/src/components/Metrics/MetricsFilters.test.tsx new file mode 100644 index 000000000..ddf09c16f --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Metrics/MetricsFilters.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { MetricsFilters } from './MetricsFilters'; +import { Environment, Filters } from '../../types'; + +// ---- Helpers ---- + +const environments: Environment[] = [ + { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', + }, + { + uid: 'env-2', + name: 'staging', + namespace: 'stg-ns', + displayName: 'Staging', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', + }, +]; + +const baseFilters: Filters = { + environment: environments[0], + timeRange: '1h', +}; + +function renderFilters( + overrides: Partial> = {}, +) { + const defaultProps = { + filters: baseFilters, + onFiltersChange: jest.fn(), + environments, + disabled: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('MetricsFilters', () => { + it('renders environment selector', () => { + renderFilters(); + + // MUI outlined Select renders label text twice + expect(screen.getAllByText('Environment').length).toBeGreaterThanOrEqual(1); + }); + + it('renders time range selector', () => { + renderFilters(); + + expect(screen.getAllByText('Time Range').length).toBeGreaterThanOrEqual(1); + }); + + it('disables controls when disabled', () => { + renderFilters({ disabled: true }); + + // Verify both selects are disabled via MUI's disabled class + const disabledSelects = document.querySelectorAll( + '.MuiInputBase-root.Mui-disabled', + ); + expect(disabledSelects.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx new file mode 100644 index 000000000..3e58b8386 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx @@ -0,0 +1,266 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { ObservabilityMetricsPage } from './ObservabilityMetricsPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseMetricsPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useMetricsPermission: () => mockUseMetricsPermission(), + ForbiddenState: ({ message }: any) => ( +
{message}
+ ), +})); + +const mockUseGetNamespaceAndProjectByEntity = jest.fn(); +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseMetrics = jest.fn(); +const mockUseUrlFilters = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetNamespaceAndProjectByEntity: (...args: any[]) => + mockUseGetNamespaceAndProjectByEntity(...args), + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useMetrics: (...args: any[]) => mockUseMetrics(...args), + useUrlFilters: (...args: any[]) => mockUseUrlFilters(...args), +})); + +jest.mock('./MetricsFilters', () => ({ + MetricsFilters: ({ environments, filters }: any) => ( +
+ {environments.length} + {filters.timeRange} +
+ ), +})); + +jest.mock('./MetricGraphByComponent', () => ({ + MetricGraphByComponent: ({ usageType }: any) => ( +
{usageType} chart
+ ), +})); + +jest.mock('./MetricsActions', () => ({ + MetricsActions: ({ onRefresh, disabled }: any) => ( +
+ +
+ ), +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'api-service', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + 'openchoreo.io/component': 'api-service', + }, + }, + spec: { owner: 'team-a' }, +}; + +const defaultEnvironment = { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', +}; + +function renderPage() { + return renderInTestApp( + + + , + ); +} + +function setupDefaultMocks() { + mockUseMetricsPermission.mockReturnValue({ + canViewMetrics: true, + loading: false, + deniedTooltip: '', + permissionName: '', + }); + + mockUseGetNamespaceAndProjectByEntity.mockReturnValue({ + namespace: 'dev-ns', + project: 'my-project', + error: null, + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [defaultEnvironment], + loading: false, + error: null, + }); + + mockUseUrlFilters.mockReturnValue({ + filters: { + environment: defaultEnvironment, + timeRange: '1h', + }, + updateFilters: jest.fn(), + }); + + mockUseMetrics.mockReturnValue({ + metrics: { + cpuUsage: { cpuUsage: [], cpuRequests: [], cpuLimits: [] }, + memoryUsage: { + memoryUsage: [], + memoryRequests: [], + memoryLimits: [], + }, + networkThroughput: { + requestCount: [], + successfulRequestCount: [], + unsuccessfulRequestCount: [], + }, + networkLatency: { + meanLatency: [], + latencyP50: [], + latencyP90: [], + latencyP99: [], + }, + }, + loading: false, + error: null, + fetchMetrics: jest.fn(), + refresh: jest.fn(), + }); +} + +// ---- Tests ---- + +describe('ObservabilityMetricsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseMetricsPermission.mockReturnValue({ + canViewMetrics: false, + loading: true, + deniedTooltip: '', + permissionName: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows forbidden state when user lacks permission', async () => { + mockUseMetricsPermission.mockReturnValue({ + canViewMetrics: false, + loading: false, + deniedTooltip: 'No metrics access', + permissionName: 'openchoreo.metrics.view', + }); + + await renderPage(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect(screen.getByText('No metrics access')).toBeInTheDocument(); + }); + + it('renders filters and actions when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('metrics-filters')).toBeInTheDocument(); + expect(screen.getByTestId('metrics-actions')).toBeInTheDocument(); + }); + + it('renders all four metric cards', async () => { + await renderPage(); + + expect(screen.getByText('CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory Usage')).toBeInTheDocument(); + expect(screen.getByText('Network Throughput')).toBeInTheDocument(); + expect(screen.getByText('Network Latency')).toBeInTheDocument(); + }); + + it('renders metric graph components', async () => { + await renderPage(); + + expect(screen.getByTestId('graph-cpu')).toBeInTheDocument(); + expect(screen.getByTestId('graph-memory')).toBeInTheDocument(); + expect(screen.getByTestId('graph-networkThroughput')).toBeInTheDocument(); + expect(screen.getByTestId('graph-networkLatency')).toBeInTheDocument(); + }); + + it('passes environments to filters', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('1'); + }); + + it('shows progress when loading', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [defaultEnvironment], + loading: true, + error: null, + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows metrics error with Retry button', async () => { + mockUseMetrics.mockReturnValue({ + metrics: null, + loading: false, + error: 'Metrics query failed', + fetchMetrics: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByText('Metrics query failed')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('shows info message when observability is disabled', async () => { + mockUseMetrics.mockReturnValue({ + metrics: null, + loading: false, + error: 'Observability is not enabled for this component', + fetchMetrics: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this component in this environment. Please enable observability to view metrics.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('renders nothing for namespace error', async () => { + mockUseGetNamespaceAndProjectByEntity.mockReturnValue({ + namespace: null, + project: null, + error: 'Namespace not found', + }); + + await renderPage(); + + expect(screen.queryByTestId('metrics-filters')).not.toBeInTheDocument(); + expect(screen.queryByTestId('metrics-actions')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Metrics/utils.test.ts b/plugins/openchoreo-observability/src/components/Metrics/utils.test.ts new file mode 100644 index 000000000..03bb0d1d3 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Metrics/utils.test.ts @@ -0,0 +1,204 @@ +import { + formatAxisTime, + formatMetricValue, + parseTimeRange, + transformMetricsData, + getMetricConfigs, + getLineOpacity, + formatMetricName, +} from './utils'; + +// ---- Tests ---- + +describe('formatAxisTime', () => { + it('shows time format for ranges <= 1 day', () => { + const ts = new Date('2024-06-01T14:30:45Z').getTime(); + const result = formatAxisTime(ts, 0.5); + + // Should be in HH:MM:SS format (local time) + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + it('shows date format for ranges > 1 day', () => { + const ts = new Date('2024-06-01T14:30:45Z').getTime(); + const result = formatAxisTime(ts, 7); + + // Should be in yyyy/mm/dd format + expect(result).toMatch(/^\d{4}\/\d{2}\/\d{2}$/); + }); +}); + +describe('formatMetricValue', () => { + it('returns "0" for zero value', () => { + expect(formatMetricValue(0, 'cpu')).toBe('0'); + }); + + it('formats CPU in mCPU for small values', () => { + expect(formatMetricValue(0.05, 'cpu')).toBe('50.00 mCPU'); + }); + + it('formats CPU in uCPU for very small values', () => { + expect(formatMetricValue(0.0005, 'cpu')).toBe('500.00 uCPU'); + }); + + it('formats CPU cores for values > 1', () => { + expect(formatMetricValue(2.5, 'cpu')).toBe('2.5000'); + }); + + it('formats memory in MB', () => { + expect(formatMetricValue(5000000, 'memory')).toBe('5.00 MB'); + }); + + it('formats memory in KB', () => { + expect(formatMetricValue(5000, 'memory')).toBe('5.00 KB'); + }); + + it('formats memory in bytes', () => { + expect(formatMetricValue(500, 'memory')).toBe('500.00 B'); + }); + + it('formats network throughput', () => { + expect(formatMetricValue(42.5, 'networkThroughput')).toBe('42.50 req/s'); + }); + + it('formats network latency in seconds', () => { + expect(formatMetricValue(2.5, 'networkLatency')).toBe('2.50 s'); + }); + + it('formats network latency in ms', () => { + expect(formatMetricValue(0.05, 'networkLatency')).toBe('50.00 ms'); + }); + + it('formats network latency in us', () => { + expect(formatMetricValue(0.0005, 'networkLatency')).toBe('500.00 us'); + }); +}); + +describe('parseTimeRange', () => { + it('parses minutes', () => { + expect(parseTimeRange('10m')).toBe(10 * 60 * 1000); + }); + + it('parses hours', () => { + expect(parseTimeRange('1h')).toBe(60 * 60 * 1000); + }); + + it('parses days', () => { + expect(parseTimeRange('7d')).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('returns null for undefined', () => { + expect(parseTimeRange(undefined)).toBeNull(); + }); + + it('returns null for unknown unit', () => { + expect(parseTimeRange('5x')).toBeNull(); + }); +}); + +describe('transformMetricsData', () => { + it('transforms metrics data into Recharts format', () => { + const data = { + cpuUsage: [ + { timestamp: '2024-06-01T10:00:00Z', value: 0.5 }, + { timestamp: '2024-06-01T10:01:00Z', value: 0.6 }, + ], + cpuRequests: [{ timestamp: '2024-06-01T10:00:00Z', value: 0.25 }], + cpuLimits: [], + }; + + const result = transformMetricsData(data); + + expect(result).toHaveLength(2); + expect(result[0].cpuUsage).toBe(0.5); + expect(result[0].cpuRequests).toBe(0.25); + expect(result[1].cpuUsage).toBe(0.6); + }); + + it('returns empty array for empty metrics', () => { + const result = transformMetricsData({} as any); + + expect(result).toEqual([]); + }); + + it('sorts by timestamp ascending', () => { + const data = { + cpuUsage: [ + { timestamp: '2024-06-01T10:02:00Z', value: 0.3 }, + { timestamp: '2024-06-01T10:00:00Z', value: 0.1 }, + ], + }; + + const result = transformMetricsData(data as any); + + expect(result[0].cpuUsage).toBe(0.1); + expect(result[1].cpuUsage).toBe(0.3); + }); +}); + +describe('getMetricConfigs', () => { + it('returns CPU metric configs', () => { + const configs = getMetricConfigs('cpu'); + + expect(configs.usage.key).toBe('cpuUsage'); + expect(configs.requests.key).toBe('cpuRequests'); + expect(configs.limits.key).toBe('cpuLimits'); + }); + + it('returns memory metric configs', () => { + const configs = getMetricConfigs('memory'); + + expect(configs.usage.key).toBe('memoryUsage'); + expect(configs.requests.key).toBe('memoryRequests'); + expect(configs.limits.key).toBe('memoryLimits'); + }); + + it('returns network throughput configs', () => { + const configs = getMetricConfigs('networkThroughput'); + + expect(configs.totalRequests.key).toBe('requestCount'); + expect(configs.successfulRequests.key).toBe('successfulRequestCount'); + expect(configs.unsuccessfulRequests.key).toBe('unsuccessfulRequestCount'); + }); + + it('returns network latency configs', () => { + const configs = getMetricConfigs('networkLatency'); + + expect(configs.meanLatency.key).toBe('meanLatency'); + expect(configs.p50Latency.key).toBe('latencyP50'); + expect(configs.p90Latency.key).toBe('latencyP90'); + expect(configs.p99Latency.key).toBe('latencyP99'); + }); +}); + +describe('getLineOpacity', () => { + it('returns 1 when no key is hovered', () => { + expect(getLineOpacity('cpuUsage')).toBe(1); + }); + + it('returns 1 when the hovered key matches', () => { + expect(getLineOpacity('cpuUsage', 'cpuUsage')).toBe(1); + }); + + it('returns 0.5 when a different key is hovered', () => { + expect(getLineOpacity('cpuUsage', 'cpuRequests')).toBe(0.5); + }); +}); + +describe('formatMetricName', () => { + it('formats camelCase to title case', () => { + expect(formatMetricName('memoryUsage')).toBe('Memory Usage'); + }); + + it('capitalizes CPU properly', () => { + expect(formatMetricName('cpuUsage')).toBe('CPU Usage'); + }); + + it('formats cpuRequests', () => { + expect(formatMetricName('cpuRequests')).toBe('CPU Requests'); + }); + + it('formats latencyP99', () => { + expect(formatMetricName('latencyP99')).toBe('Latency P99'); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RCA/RCAActions.test.tsx b/plugins/openchoreo-observability/src/components/RCA/RCAActions.test.tsx new file mode 100644 index 000000000..1d9b7d9a9 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RCA/RCAActions.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RCAActions } from './RCAActions'; + +// ---- Tests ---- + +describe('RCAActions', () => { + it('displays total reports count', () => { + render( + , + ); + + expect(screen.getByText('Total reports: 10')).toBeInTheDocument(); + }); + + it('shows "No reports data" when totalCount is undefined', () => { + render( + , + ); + + expect(screen.getByText('No reports data')).toBeInTheDocument(); + }); + + it('shows Refresh button', () => { + render( + , + ); + + expect( + screen.getByRole('button', { name: /refresh/i }), + ).toBeInTheDocument(); + }); + + it('calls onRefresh when clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + expect(onRefresh).toHaveBeenCalled(); + }); + + it('disables Refresh button when disabled', () => { + render(); + + expect(screen.getByRole('button', { name: /refresh/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RCA/RCAFilters.test.tsx b/plugins/openchoreo-observability/src/components/RCA/RCAFilters.test.tsx new file mode 100644 index 000000000..0af841dcb --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RCA/RCAFilters.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react'; +import { RCAFilters } from './RCAFilters'; +import { Filters, Environment } from '../../types'; + +// ---- Helpers ---- + +const environments: Environment[] = [ + { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', + }, + { + uid: 'env-2', + name: 'staging', + namespace: 'stg-ns', + displayName: 'Staging', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', + }, +]; + +const baseFilters: Filters = { + environment: environments[0], + timeRange: '1h', +}; + +function renderFilters( + overrides: Partial> = {}, +) { + const defaultProps = { + filters: baseFilters, + onFiltersChange: jest.fn(), + environments, + environmentsLoading: false, + disabled: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('RCAFilters', () => { + it('renders search input', () => { + renderFilters(); + + expect( + screen.getByPlaceholderText('Search RCA reports...'), + ).toBeInTheDocument(); + }); + + it('renders environment selector', () => { + renderFilters(); + + expect(screen.getAllByText('Environment').length).toBeGreaterThanOrEqual(1); + }); + + it('renders status selector', () => { + renderFilters(); + + expect(screen.getAllByText('Status').length).toBeGreaterThanOrEqual(1); + }); + + it('renders time range selector', () => { + renderFilters(); + + expect(screen.getAllByText('Time Range').length).toBeGreaterThanOrEqual(1); + }); + + it('disables controls when disabled', () => { + renderFilters({ disabled: true }); + + const selects = document.querySelectorAll('.Mui-disabled'); + expect(selects.length).toBeGreaterThan(0); + }); + + it('shows skeleton while environments are loading', () => { + renderFilters({ environmentsLoading: true }); + + expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx b/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx new file mode 100644 index 000000000..2a96f5a08 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx @@ -0,0 +1,267 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { RCAPage } from './RCAPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseRcaPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useRcaPermission: () => mockUseRcaPermission(), + ForbiddenState: ({ message }: any) => ( +
{message}
+ ), +})); + +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseUrlFilters = jest.fn(); +const mockUseRCAReports = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useUrlFilters: (...args: any[]) => mockUseUrlFilters(...args), + useRCAReports: (...args: any[]) => mockUseRCAReports(...args), +})); + +jest.mock('./RCAFilters', () => ({ + RCAFilters: ({ environments, filters }: any) => ( +
+ {environments.length} + {filters.timeRange} +
+ ), +})); + +jest.mock('./RCAActions', () => ({ + RCAActions: ({ totalCount, onRefresh, disabled }: any) => ( +
+ {totalCount} + +
+ ), +})); + +jest.mock('./RCATable', () => ({ + RCATable: ({ reports, loading }: any) => ( +
+ {reports.length} + {loading && } +
+ ), +})); + +jest.mock('./RCAReport', () => ({ + RCAReport: () =>
, +})); + +jest.mock('./RCAReport/EntityLinkContext', () => ({ + EntityLinkContext: { + Provider: ({ children }: any) => <>{children}, + }, +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-project', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + }, + }, + spec: { owner: 'team-a' }, +}; + +const defaultEnvironment = { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', +}; + +function setupDefaultMocks() { + mockUseRcaPermission.mockReturnValue({ + canViewRca: true, + loading: false, + deniedTooltip: '', + permissionName: '', + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [defaultEnvironment], + loading: false, + error: null, + }); + + mockUseUrlFilters.mockReturnValue({ + filters: { + environment: defaultEnvironment, + timeRange: '1h', + }, + updateFilters: jest.fn(), + }); + + mockUseRCAReports.mockReturnValue({ + reports: [], + loading: false, + error: null, + refresh: jest.fn(), + }); +} + +function renderPage() { + return renderInTestApp( + + + , + { routeEntries: ['/'] }, + ); +} + +// ---- Tests ---- + +describe('RCAPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseRcaPermission.mockReturnValue({ + canViewRca: false, + loading: true, + deniedTooltip: '', + permissionName: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows forbidden state when user lacks permission', async () => { + mockUseRcaPermission.mockReturnValue({ + canViewRca: false, + loading: false, + deniedTooltip: 'No RCA access', + permissionName: 'openchoreo.rca.view', + }); + + await renderPage(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect(screen.getByText('No RCA access')).toBeInTheDocument(); + }); + + it('renders filters, actions, and table when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('rca-filters')).toBeInTheDocument(); + expect(screen.getByTestId('rca-actions')).toBeInTheDocument(); + expect(screen.getByTestId('rca-table')).toBeInTheDocument(); + }); + + it('passes environments to filters', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('1'); + }); + + it('passes filtered reports count to actions', async () => { + mockUseRCAReports.mockReturnValue({ + reports: [ + { reportId: 'r1', summary: 'Report 1', status: 'completed' }, + { reportId: 'r2', summary: 'Report 2', status: 'pending' }, + ], + loading: false, + error: null, + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByTestId('total-count')).toHaveTextContent('2'); + }); + + it('shows progress when reports are loading', async () => { + mockUseRCAReports.mockReturnValue({ + reports: [], + loading: true, + error: null, + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows info message when observability is disabled', async () => { + mockUseRCAReports.mockReturnValue({ + reports: [], + loading: false, + error: 'Observability is not enabled for this environment', + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this environment. Please enable observability and enable the AI RCA agent.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('shows info message when RCA service is not configured', async () => { + mockUseRCAReports.mockReturnValue({ + reports: [], + loading: false, + error: 'RCA service is not configured', + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'AI RCA is not configured. Please enable it to view RCA reports.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('shows reports error with Retry button', async () => { + mockUseRCAReports.mockReturnValue({ + reports: [], + loading: false, + error: 'RCA query failed', + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByText('RCA query failed')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('renders nothing for environments error', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: 'Environment error', + }); + + await renderPage(); + + expect(screen.queryByTestId('rca-filters')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RCA/RCATable.test.tsx b/plugins/openchoreo-observability/src/components/RCA/RCATable.test.tsx new file mode 100644 index 000000000..d98c33d31 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RCA/RCATable.test.tsx @@ -0,0 +1,105 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { RCATable } from './RCATable'; + +// ---- Mock dependencies (own sibling components only) ---- + +jest.mock('@openchoreo/backstage-design-system', () => ({ + StatusBadge: ({ label }: any) => ( + {label} + ), +})); + +jest.mock('./RCAReport/FormattedText', () => ({ + FormattedText: ({ text }: any) => {text}, +})); + +// ---- Helpers ---- + +const sampleReports = [ + { + reportId: 'r1', + alertId: 'alert-001', + timestamp: '2024-06-01T10:00:00Z', + summary: 'CPU spike caused by memory leak in api-service', + status: 'completed' as const, + }, + { + reportId: 'r2', + alertId: 'alert-002', + timestamp: '2024-06-01T11:00:00Z', + summary: '', + status: 'pending' as const, + }, + { + reportId: 'r3', + alertId: 'alert-003', + timestamp: '2024-06-01T12:00:00Z', + summary: 'Database connection pool exhausted', + status: 'failed' as const, + }, +]; + +function renderTable( + overrides: Partial> = {}, +) { + const defaultProps = { + reports: sampleReports, + loading: false, + ...overrides, + }; + + return renderInTestApp(); +} + +// ---- Tests ---- + +describe('RCATable', () => { + it('renders table with report data', async () => { + await renderTable(); + + expect( + screen.getByText('CPU spike caused by memory leak in api-service'), + ).toBeInTheDocument(); + }); + + it('shows "Generating RCA report..." for pending reports', async () => { + await renderTable(); + + expect(screen.getByText('Generating RCA report...')).toBeInTheDocument(); + }); + + it('shows status badges', async () => { + await renderTable(); + + const badges = screen.getAllByTestId('status-badge'); + expect(badges.length).toBe(3); + expect(badges[0]).toHaveTextContent('Available'); + expect(badges[1]).toHaveTextContent('Pending'); + expect(badges[2]).toHaveTextContent('Failed'); + }); + + it('shows view report button for completed reports', async () => { + await renderTable(); + + expect(screen.getByLabelText('view report')).toBeInTheDocument(); + }); + + it('shows disabled rerun button for failed reports', async () => { + await renderTable(); + + const rerunBtn = screen.getByLabelText('rerun report'); + expect(rerunBtn).toBeDisabled(); + }); + + it('shows empty state when no reports', async () => { + await renderTable({ reports: [] }); + + expect(screen.getByText('No RCA reports found')).toBeInTheDocument(); + expect( + screen.getByText( + 'Try adjusting your filters or time range to see more reports.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/LogEntry.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogEntry.test.tsx new file mode 100644 index 000000000..e425d6985 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogEntry.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Table, TableBody } from '@material-ui/core'; +import { LogEntry } from './LogEntry'; +import { LogEntryField } from './types'; +import { LogEntry as LogEntryType } from './types'; + +// ---- Helpers ---- + +const allFields = [ + LogEntryField.Timestamp, + LogEntryField.LogLevel, + LogEntryField.Log, +]; + +const sampleLog: LogEntryType = { + timestamp: '2024-06-01T10:00:00.000Z', + log: 'Server started on port 8080', + level: 'INFO', + metadata: { + componentName: 'api-service', + componentUid: 'comp-uid-1', + projectName: 'my-project', + projectUid: 'proj-uid-1', + environmentName: 'development', + environmentUid: 'env-uid-1', + podName: 'api-service-abc123', + podNamespace: 'ns-dev', + namespaceName: 'ns-dev', + containerName: 'main', + }, +}; + +function renderLogEntry( + overrides: Partial> = {}, +) { + const defaultProps = { + log: sampleLog, + selectedFields: allFields, + environmentName: 'development', + projectName: 'my-project', + componentName: 'api-service', + }; + + return render( + + + + +
, + ); +} + +// ---- Tests ---- + +describe('LogEntry', () => { + it('renders timestamp when Timestamp field is selected', () => { + renderLogEntry(); + + // Renders formatted timestamp + expect( + screen.getByText(new Date('2024-06-01T10:00:00.000Z').toLocaleString()), + ).toBeInTheDocument(); + }); + + it('renders log level chip', () => { + renderLogEntry(); + + expect(screen.getByText('INFO')).toBeInTheDocument(); + }); + + it('renders log message', () => { + renderLogEntry(); + + expect(screen.getByText('Server started on port 8080')).toBeInTheDocument(); + }); + + it('renders only selected fields', () => { + renderLogEntry({ + selectedFields: [LogEntryField.Log], + }); + + expect(screen.getByText('Server started on port 8080')).toBeInTheDocument(); + // Timestamp should not be rendered + expect( + screen.queryByText(new Date('2024-06-01T10:00:00.000Z').toLocaleString()), + ).not.toBeInTheDocument(); + }); + + it('renders component name when ComponentName field is selected', () => { + renderLogEntry({ + selectedFields: [...allFields, LogEntryField.ComponentName], + }); + + expect(screen.getByText('api-service')).toBeInTheDocument(); + }); + + it('expands to show metadata on row click', async () => { + const user = userEvent.setup(); + renderLogEntry(); + + // Click the row to expand + await user.click(screen.getByText('Server started on port 8080')); + + // Should show metadata section + expect(screen.getByText('Full Log Message')).toBeInTheDocument(); + expect(screen.getByText('Metadata')).toBeInTheDocument(); + expect(screen.getByText('Environment Name:')).toBeInTheDocument(); + expect(screen.getByText('Pod Name:')).toBeInTheDocument(); + expect(screen.getByText('api-service-abc123')).toBeInTheDocument(); + expect(screen.getByText('Container:')).toBeInTheDocument(); + expect(screen.getByText('main')).toBeInTheDocument(); + }); + + it('collapses metadata on second click', async () => { + const user = userEvent.setup(); + renderLogEntry(); + + // Expand - click the first occurrence (the row cell) + await user.click(screen.getAllByText('Server started on port 8080')[0]); + expect(screen.getByText('Full Log Message')).toBeInTheDocument(); + + // Collapse - click the first occurrence again + await user.click(screen.getAllByText('Server started on port 8080')[0]); + expect(screen.queryByText('Full Log Message')).not.toBeInTheDocument(); + }); + + it('renders ERROR level chip', () => { + renderLogEntry({ + log: { ...sampleLog, level: 'ERROR' }, + }); + + expect(screen.getByText('ERROR')).toBeInTheDocument(); + }); + + it('renders WARN level chip', () => { + renderLogEntry({ + log: { ...sampleLog, level: 'WARN' }, + }); + + expect(screen.getByText('WARN')).toBeInTheDocument(); + }); + + it('renders DEBUG level chip', () => { + renderLogEntry({ + log: { ...sampleLog, level: 'DEBUG' }, + }); + + expect(screen.getByText('DEBUG')).toBeInTheDocument(); + }); + + it('shows copy button on log cell', () => { + renderLogEntry(); + + expect(screen.getByTitle('Copy log message')).toBeInTheDocument(); + }); + + it('falls back to prop values when metadata is missing', async () => { + const user = userEvent.setup(); + renderLogEntry({ + log: { + ...sampleLog, + metadata: undefined, + }, + environmentName: 'staging', + projectName: 'fallback-project', + componentName: 'fallback-component', + }); + + // Expand to see metadata + await user.click(screen.getByText('Server started on port 8080')); + + expect(screen.getByText('staging')).toBeInTheDocument(); + expect(screen.getByText('fallback-project')).toBeInTheDocument(); + expect(screen.getByText('fallback-component')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsActions.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsActions.test.tsx new file mode 100644 index 000000000..e5d23c826 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsActions.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LogsActions } from './LogsActions'; +import { LogEntryField, RuntimeLogsFilters } from './types'; + +// ---- Helpers ---- + +const baseFilters: RuntimeLogsFilters = { + logLevel: [], + selectedFields: [ + LogEntryField.Timestamp, + LogEntryField.LogLevel, + LogEntryField.Log, + ], + environmentId: 'env-1', + timeRange: '1h', + sortOrder: 'desc', + isLive: false, +}; + +function renderActions( + overrides: Partial> = {}, +) { + const defaultProps = { + totalCount: 42, + disabled: false, + onRefresh: jest.fn(), + filters: baseFilters, + onFiltersChange: jest.fn(), + lastUpdated: new Date('2024-06-01T10:00:00Z'), + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('LogsActions', () => { + it('displays total log count', () => { + renderActions(); + + expect(screen.getByText('Total logs: 42')).toBeInTheDocument(); + }); + + it('displays last updated time', () => { + renderActions(); + + expect(screen.getByText(/Last updated at:/)).toBeInTheDocument(); + }); + + it('shows "Newest First" when sort order is desc', () => { + renderActions(); + + expect( + screen.getByRole('button', { name: /newest first/i }), + ).toBeInTheDocument(); + }); + + it('shows "Oldest First" when sort order is asc', () => { + renderActions({ + filters: { ...baseFilters, sortOrder: 'asc' }, + }); + + expect( + screen.getByRole('button', { name: /oldest first/i }), + ).toBeInTheDocument(); + }); + + it('toggles sort order on click', async () => { + const user = userEvent.setup(); + const onFiltersChange = jest.fn(); + renderActions({ onFiltersChange }); + + await user.click(screen.getByRole('button', { name: /newest first/i })); + + expect(onFiltersChange).toHaveBeenCalledWith({ sortOrder: 'asc' }); + }); + + it('shows Live button', () => { + renderActions(); + + expect(screen.getByRole('button', { name: /live/i })).toBeInTheDocument(); + }); + + it('toggles live mode on click', async () => { + const user = userEvent.setup(); + const onFiltersChange = jest.fn(); + renderActions({ onFiltersChange }); + + await user.click(screen.getByRole('button', { name: /live/i })); + + expect(onFiltersChange).toHaveBeenCalledWith({ isLive: true }); + }); + + it('shows Refresh button', () => { + renderActions(); + + expect( + screen.getByRole('button', { name: /refresh/i }), + ).toBeInTheDocument(); + }); + + it('calls onRefresh when Refresh is clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + renderActions({ onRefresh }); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + expect(onRefresh).toHaveBeenCalled(); + }); + + it('disables all buttons when disabled', () => { + renderActions({ disabled: true }); + + expect( + screen.getByRole('button', { name: /newest first/i }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /live/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /refresh/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsFilter.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsFilter.test.tsx new file mode 100644 index 000000000..8e814b041 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsFilter.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LogsFilter } from './LogsFilter'; +import { LogEntryField, RuntimeLogsFilters } from './types'; + +// ---- Helpers ---- + +const environments = [ + { id: 'dev', name: 'Development', resourceName: 'development' }, + { id: 'stg', name: 'Staging', resourceName: 'staging' }, +]; + +const components = [ + { name: 'svc-a', displayName: 'Service A' }, + { name: 'svc-b', displayName: 'Service B' }, +]; + +const baseFilters: RuntimeLogsFilters = { + logLevel: [], + selectedFields: [ + LogEntryField.Timestamp, + LogEntryField.LogLevel, + LogEntryField.Log, + ], + environmentId: 'dev', + timeRange: '1h', + sortOrder: 'desc', + searchQuery: '', + isLive: false, +}; + +function renderFilter( + overrides: Partial> = {}, +) { + const defaultProps = { + filters: baseFilters, + onFiltersChange: jest.fn(), + environments, + environmentsLoading: false, + disabled: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('LogsFilter', () => { + it('renders search input', () => { + renderFilter(); + + expect(screen.getByPlaceholderText('Search Logs...')).toBeInTheDocument(); + }); + + it('renders environment selector', () => { + renderFilter(); + + // MUI outlined Select renders label text twice (InputLabel + outlined label) + expect(screen.getAllByText('Environment').length).toBeGreaterThanOrEqual(1); + }); + + it('renders time range selector', () => { + renderFilter(); + + expect(screen.getAllByText('Time Range').length).toBeGreaterThanOrEqual(1); + }); + + it('renders log levels selector', () => { + renderFilter(); + + expect(screen.getAllByText('Log Levels').length).toBeGreaterThanOrEqual(1); + }); + + it('shows components filter when components are provided', () => { + renderFilter({ components }); + + expect(screen.getAllByText('Components').length).toBeGreaterThanOrEqual(1); + }); + + it('does not show components filter when no components', () => { + renderFilter({ components: [] }); + + expect(screen.queryByText('Components')).not.toBeInTheDocument(); + }); + + it('shows selected fields filter when no components (component-level)', () => { + renderFilter({ components: [] }); + + expect( + screen.getAllByText('Selected Fields').length, + ).toBeGreaterThanOrEqual(1); + }); + + it('does not show selected fields when components are present (project-level)', () => { + renderFilter({ components }); + + expect(screen.queryByText('Selected Fields')).not.toBeInTheDocument(); + }); + + it('disables all controls when disabled', () => { + renderFilter({ disabled: true }); + + expect(screen.getByPlaceholderText('Search Logs...')).toBeDisabled(); + }); + + it('allows typing in search field', async () => { + const user = userEvent.setup(); + renderFilter(); + + const searchInput = screen.getByPlaceholderText('Search Logs...'); + await user.type(searchInput, 'error'); + + expect(searchInput).toHaveValue('error'); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsTable.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsTable.test.tsx new file mode 100644 index 000000000..a322c7d54 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/LogsTable.test.tsx @@ -0,0 +1,147 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { LogsTable } from './LogsTable'; +import { LogEntryField } from './types'; +import { LogEntry as LogEntryType } from './types'; + +// ---- Helpers ---- + +const allFields = [ + LogEntryField.Timestamp, + LogEntryField.LogLevel, + LogEntryField.Log, +]; + +const sampleLogs: LogEntryType[] = [ + { + timestamp: '2024-06-01T10:00:00.000Z', + log: 'First log message', + level: 'INFO', + metadata: { + componentName: 'svc-a', + componentUid: '', + projectName: 'proj', + projectUid: '', + environmentName: 'dev', + environmentUid: '', + podName: 'pod-1', + podNamespace: 'ns', + namespaceName: 'ns', + containerName: 'main', + }, + }, + { + timestamp: '2024-06-01T10:01:00.000Z', + log: 'Second log message', + level: 'ERROR', + metadata: { + componentName: 'svc-b', + componentUid: '', + projectName: 'proj', + projectUid: '', + environmentName: 'dev', + environmentUid: '', + podName: 'pod-2', + podNamespace: 'ns', + namespaceName: 'ns', + containerName: 'main', + }, + }, +]; + +function renderTable( + overrides: Partial> = {}, +) { + const loadingRef = createRef(); + const defaultProps = { + selectedFields: allFields, + logs: sampleLogs, + loading: false, + hasMore: false, + loadingRef, + environmentName: 'development', + projectName: 'my-project', + }; + + return render(); +} + +// ---- Tests ---- + +describe('LogsTable', () => { + it('renders column headers based on selected fields', () => { + renderTable(); + + expect(screen.getByText('Timestamp')).toBeInTheDocument(); + expect(screen.getByText('LogLevel')).toBeInTheDocument(); + expect(screen.getByText('Log')).toBeInTheDocument(); + }); + + it('renders ComponentName header when field is selected', () => { + renderTable({ + selectedFields: [...allFields, LogEntryField.ComponentName], + }); + + expect(screen.getByText('Component Name')).toBeInTheDocument(); + }); + + it('renders log entries', () => { + renderTable(); + + expect(screen.getByText('First log message')).toBeInTheDocument(); + expect(screen.getByText('Second log message')).toBeInTheDocument(); + }); + + it('shows empty state when no logs and not loading', () => { + renderTable({ logs: [] }); + + expect(screen.getByText('No logs found')).toBeInTheDocument(); + expect( + screen.getByText( + 'Try adjusting your filters or time range to see more logs.', + ), + ).toBeInTheDocument(); + }); + + it('shows loading skeletons when loading with no logs', () => { + renderTable({ logs: [], loading: true }); + + // Should not show empty state + expect(screen.queryByText('No logs found')).not.toBeInTheDocument(); + }); + + it('does not show empty state when loading', () => { + renderTable({ logs: [], loading: true }); + + expect(screen.queryByText('No logs found')).not.toBeInTheDocument(); + }); + + it('shows "Loading more logs..." when hasMore and loading', () => { + renderTable({ hasMore: true, loading: true }); + + expect(screen.getByText('Loading more logs...')).toBeInTheDocument(); + }); + + it('shows "Scroll to load more" when hasMore and not loading', () => { + renderTable({ hasMore: true, loading: false }); + + expect(screen.getByText('Scroll to load more logs')).toBeInTheDocument(); + }); + + it('does not show pagination indicator when hasMore is false', () => { + renderTable({ hasMore: false }); + + expect(screen.queryByText('Loading more logs...')).not.toBeInTheDocument(); + expect( + screen.queryByText('Scroll to load more logs'), + ).not.toBeInTheDocument(); + }); + + it('renders only selected field columns', () => { + renderTable({ selectedFields: [LogEntryField.Log] }); + + expect(screen.getByText('Log')).toBeInTheDocument(); + expect(screen.queryByText('Timestamp')).not.toBeInTheDocument(); + expect(screen.queryByText('LogLevel')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.test.tsx new file mode 100644 index 000000000..86a496a94 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.test.tsx @@ -0,0 +1,317 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { ObservabilityProjectRuntimeLogsPage } from './ObservabilityProjectRuntimeLogsPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseLogsPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useLogsPermission: () => mockUseLogsPermission(), + useInfiniteScroll: () => ({ loadingRef: { current: null } }), + ForbiddenState: ({ message }: any) => ( +
{message}
+ ), +})); + +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseGetComponentsByProject = jest.fn(); +const mockUseProjectRuntimeLogs = jest.fn(); +const mockUseUrlFiltersForRuntimeLogs = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useGetComponentsByProject: (...args: any[]) => + mockUseGetComponentsByProject(...args), + useProjectRuntimeLogs: (...args: any[]) => mockUseProjectRuntimeLogs(...args), + useUrlFiltersForRuntimeLogs: (...args: any[]) => + mockUseUrlFiltersForRuntimeLogs(...args), +})); + +jest.mock('./LogsFilter', () => ({ + LogsFilter: ({ environments, components, filters }: any) => ( +
+ {environments.length} + {components?.length ?? 0} + {filters.environmentId} +
+ ), +})); + +jest.mock('./LogsTable', () => ({ + LogsTable: ({ logs, loading, selectedFields }: any) => ( +
+ {logs.length} + {String(loading)} + {selectedFields.length} + {logs.map((log: any, i: number) => ( +
+ {log.log} +
+ ))} +
+ ), +})); + +jest.mock('./LogsActions', () => ({ + LogsActions: ({ totalCount, onRefresh }: any) => ( +
+ {totalCount} + +
+ ), +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-project', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + }, + }, + spec: { owner: 'team-a' }, +}; + +const defaultFilters = { + environmentId: 'env-dev', + logLevel: [], + selectedFields: ['Timestamp', 'LogLevel', 'Log'], + timeRange: '1h', + sortOrder: 'desc' as const, + searchQuery: '', + isLive: false, + componentIds: [], +}; + +function renderPage() { + return renderInTestApp( + + + , + ); +} + +function setupDefaultMocks() { + mockUseLogsPermission.mockReturnValue({ + canViewLogs: true, + loading: false, + deniedTooltip: '', + permissionName: '', + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [{ name: 'development', displayName: 'Development' }], + loading: false, + error: null, + }); + + mockUseGetComponentsByProject.mockReturnValue({ + components: [ + { name: 'svc-a', displayName: 'Service A' }, + { name: 'svc-b', displayName: 'Service B' }, + ], + loading: false, + error: null, + }); + + mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ + filters: defaultFilters, + updateFilters: jest.fn(), + }); + + mockUseProjectRuntimeLogs.mockReturnValue({ + logs: [ + { + timestamp: '2024-06-01T10:00:00.000Z', + log: 'Project log entry 1', + level: 'INFO', + metadata: { componentName: 'svc-a' }, + }, + { + timestamp: '2024-06-01T10:01:00.000Z', + log: 'Project log entry 2', + level: 'ERROR', + metadata: { componentName: 'svc-b' }, + }, + ], + loading: false, + error: null, + totalCount: 2, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + }); +} + +// ---- Tests ---- + +describe('ObservabilityProjectRuntimeLogsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseLogsPermission.mockReturnValue({ + canViewLogs: false, + loading: true, + deniedTooltip: '', + permissionName: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows forbidden state when user lacks permission', async () => { + mockUseLogsPermission.mockReturnValue({ + canViewLogs: false, + loading: false, + deniedTooltip: 'No access to project logs', + permissionName: 'openchoreo.logs.view', + }); + + await renderPage(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect(screen.getByText('No access to project logs')).toBeInTheDocument(); + }); + + it('renders filter, actions, and table when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('logs-filter')).toBeInTheDocument(); + expect(screen.getByTestId('logs-actions')).toBeInTheDocument(); + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + }); + + it('passes environments to filter', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('1'); + }); + + it('passes components to filter', async () => { + await renderPage(); + + expect(screen.getByTestId('component-count')).toHaveTextContent('2'); + }); + + it('renders project-level log entries', async () => { + await renderPage(); + + expect(screen.getByTestId('log-count')).toHaveTextContent('2'); + expect(screen.getByText('Project log entry 1')).toBeInTheDocument(); + expect(screen.getByText('Project log entry 2')).toBeInTheDocument(); + }); + + it('shows total count in actions', async () => { + await renderPage(); + + expect(screen.getByTestId('total-count')).toHaveTextContent('2'); + }); + + it('shows environment error', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: 'Environment fetch failed', + }); + + await renderPage(); + + expect(screen.getByText('Environment fetch failed')).toBeInTheDocument(); + }); + + it('shows components error', async () => { + mockUseGetComponentsByProject.mockReturnValue({ + components: [], + loading: false, + error: 'Components fetch failed', + }); + + await renderPage(); + + expect(screen.getByText('Components fetch failed')).toBeInTheDocument(); + }); + + it('shows logs error message', async () => { + mockUseProjectRuntimeLogs.mockReturnValue({ + logs: [], + loading: false, + error: 'Log query failed', + totalCount: 0, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByText('Log query failed')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('shows info message when observability is not enabled', async () => { + mockUseProjectRuntimeLogs.mockReturnValue({ + logs: [], + loading: false, + error: 'Observability is not enabled for this project', + totalCount: 0, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this project in this environment. Please enable observability to view runtime logs.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('shows no environments alert when none found', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: null, + }); + + mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ + filters: { ...defaultFilters, environmentId: '' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText('No environments found for this project.'), + ).toBeInTheDocument(); + }); + + it('does not render actions/table when no environment selected', async () => { + mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ + filters: { ...defaultFilters, environmentId: '' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect(screen.queryByTestId('logs-actions')).not.toBeInTheDocument(); + expect(screen.queryByTestId('logs-table')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.test.tsx new file mode 100644 index 000000000..bf4dcbdb6 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.test.tsx @@ -0,0 +1,334 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { ObservabilityRuntimeLogsPage } from './ObservabilityRuntimeLogsPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseLogsPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useLogsPermission: () => mockUseLogsPermission(), + useInfiniteScroll: () => ({ loadingRef: { current: null } }), + ForbiddenState: ({ message }: any) => ( +
{message}
+ ), +})); + +const mockUseGetNamespaceAndProjectByEntity = jest.fn(); +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseRuntimeLogs = jest.fn(); +const mockUseUrlFiltersForRuntimeLogs = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetNamespaceAndProjectByEntity: (...args: any[]) => + mockUseGetNamespaceAndProjectByEntity(...args), + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useRuntimeLogs: (...args: any[]) => mockUseRuntimeLogs(...args), + useUrlFiltersForRuntimeLogs: (...args: any[]) => + mockUseUrlFiltersForRuntimeLogs(...args), +})); + +jest.mock('./LogsFilter', () => ({ + LogsFilter: ({ environments, filters }: any) => ( +
+ {environments.length} + {filters.environmentId} +
+ ), +})); + +jest.mock('./LogsTable', () => ({ + LogsTable: ({ logs, loading }: any) => ( +
+ {logs.length} + {String(loading)} + {logs.map((log: any, i: number) => ( +
+ {log.log} +
+ ))} +
+ ), +})); + +jest.mock('./LogsActions', () => ({ + LogsActions: ({ totalCount, onRefresh }: any) => ( +
+ {totalCount} + +
+ ), +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'api-service', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + 'openchoreo.io/component': 'api-service', + }, + }, + spec: { owner: 'team-a' }, +}; + +const defaultFilters = { + environmentId: 'env-dev', + logLevel: [], + selectedFields: ['Timestamp', 'LogLevel', 'Log'], + timeRange: '1h', + sortOrder: 'desc' as const, + searchQuery: '', + isLive: false, +}; + +function renderPage() { + return renderInTestApp( + + + , + ); +} + +function setupDefaultMocks() { + mockUseLogsPermission.mockReturnValue({ + canViewLogs: true, + loading: false, + deniedTooltip: '', + permissionName: '', + }); + + mockUseGetNamespaceAndProjectByEntity.mockReturnValue({ + namespace: 'dev-ns', + project: 'my-project', + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [ + { name: 'development', displayName: 'Development' }, + { name: 'staging', displayName: 'Staging' }, + ], + loading: false, + error: null, + }); + + mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ + filters: defaultFilters, + updateFilters: jest.fn(), + }); + + mockUseRuntimeLogs.mockReturnValue({ + logs: [ + { + timestamp: '2024-06-01T10:00:00.000Z', + log: 'Server started', + level: 'INFO', + }, + ], + loading: false, + error: null, + totalCount: 1, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + componentId: 'comp-1', + projectId: 'proj-1', + }); +} + +// ---- Tests ---- + +describe('ObservabilityRuntimeLogsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseLogsPermission.mockReturnValue({ + canViewLogs: false, + loading: true, + deniedTooltip: '', + permissionName: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows forbidden state when user lacks permission', async () => { + mockUseLogsPermission.mockReturnValue({ + canViewLogs: false, + loading: false, + deniedTooltip: 'You cannot view logs', + permissionName: 'openchoreo.logs.view', + }); + + await renderPage(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect(screen.getByText('You cannot view logs')).toBeInTheDocument(); + }); + + it('renders logs filter and table when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('logs-filter')).toBeInTheDocument(); + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + expect(screen.getByTestId('logs-actions')).toBeInTheDocument(); + }); + + it('passes environments to LogsFilter', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('2'); + }); + + it('renders log entries in the table', async () => { + await renderPage(); + + expect(screen.getByTestId('log-count')).toHaveTextContent('1'); + expect(screen.getByText('Server started')).toBeInTheDocument(); + }); + + it('shows total count in actions', async () => { + await renderPage(); + + expect(screen.getByTestId('total-count')).toHaveTextContent('1'); + }); + + it('shows environment error when environments fail to load', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: 'Failed to fetch environments', + }); + + await renderPage(); + + expect( + screen.getByText('Failed to fetch environments'), + ).toBeInTheDocument(); + }); + + it('shows logs error message', async () => { + mockUseRuntimeLogs.mockReturnValue({ + logs: [], + loading: false, + error: 'Connection timed out', + totalCount: 0, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + componentId: 'comp-1', + projectId: 'proj-1', + }); + + await renderPage(); + + expect(screen.getByText('Connection timed out')).toBeInTheDocument(); + }); + + it('shows info alert when observability is not enabled', async () => { + mockUseRuntimeLogs.mockReturnValue({ + logs: [], + loading: false, + error: 'Observability is not enabled for this component', + totalCount: 0, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + componentId: 'comp-1', + projectId: 'proj-1', + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this component in this environment. Please enable observability to view runtime logs.', + ), + ).toBeInTheDocument(); + }); + + it('shows no environments alert when none found', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: null, + }); + + mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ + filters: { ...defaultFilters, environmentId: '' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'No environments found. Make sure your component is properly configured.', + ), + ).toBeInTheDocument(); + }); + + it('does not render actions/table when no environment selected', async () => { + mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ + filters: { ...defaultFilters, environmentId: '' }, + updateFilters: jest.fn(), + }); + + await renderPage(); + + expect(screen.queryByTestId('logs-actions')).not.toBeInTheDocument(); + expect(screen.queryByTestId('logs-table')).not.toBeInTheDocument(); + }); + + it('shows Retry button for non-observability errors', async () => { + mockUseRuntimeLogs.mockReturnValue({ + logs: [], + loading: false, + error: 'Network error', + totalCount: 0, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + componentId: 'comp-1', + projectId: 'proj-1', + }); + + await renderPage(); + + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('does not show Retry button for observability disabled error', async () => { + mockUseRuntimeLogs.mockReturnValue({ + logs: [], + loading: false, + error: 'Observability is not enabled', + totalCount: 0, + hasMore: false, + fetchLogs: jest.fn(), + loadMore: jest.fn(), + refresh: jest.fn(), + componentId: 'comp-1', + projectId: 'proj-1', + }); + + await renderPage(); + + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.test.tsx b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.test.tsx new file mode 100644 index 000000000..ad2c2467b --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.test.tsx @@ -0,0 +1,287 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { ObservabilityTracesPage } from './ObservabilityTracesPage'; + +// ---- Mocks (own hooks and child components only) ---- + +const mockUseTracesPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useTracesPermission: () => mockUseTracesPermission(), + ForbiddenState: ({ message }: any) => ( +
{message}
+ ), +})); + +const mockUseGetEnvironmentsByNamespace = jest.fn(); +const mockUseGetComponentsByProject = jest.fn(); +const mockUseTraces = jest.fn(); +const mockUseUrlFilters = jest.fn(); + +jest.mock('../../hooks', () => ({ + useGetEnvironmentsByNamespace: (...args: any[]) => + mockUseGetEnvironmentsByNamespace(...args), + useGetComponentsByProject: (...args: any[]) => + mockUseGetComponentsByProject(...args), + useTraces: (...args: any[]) => mockUseTraces(...args), + useUrlFilters: (...args: any[]) => mockUseUrlFilters(...args), +})); + +jest.mock('../../hooks/useTraceSpans', () => ({ + useTraceSpans: () => ({ + fetchSpans: jest.fn(), + getSpans: jest.fn(), + isLoading: jest.fn().mockReturnValue(false), + getError: jest.fn(), + }), +})); + +jest.mock('../../hooks/useSpanDetails', () => ({ + useSpanDetails: () => ({ + fetchSpanDetails: jest.fn(), + getDetails: jest.fn(), + isLoading: jest.fn().mockReturnValue(false), + getError: jest.fn(), + }), +})); + +jest.mock('./TracesFilters', () => ({ + TracesFilters: ({ environments, filters }: any) => ( +
+ {environments.length} + {filters.timeRange} +
+ ), +})); + +jest.mock('./TracesActions', () => ({ + TracesActions: ({ totalCount, onRefresh, disabled }: any) => ( +
+ {totalCount} + +
+ ), +})); + +jest.mock('./TracesTable', () => ({ + TracesTable: ({ traces, loading }: any) => ( +
+ {traces.length} + {loading && } +
+ ), +})); + +// ---- Helpers ---- + +const defaultEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-project', + annotations: { + 'openchoreo.io/namespace': 'dev-ns', + }, + }, + spec: { owner: 'team-a' }, +}; + +const defaultEnvironment = { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', +}; + +function setupDefaultMocks() { + mockUseTracesPermission.mockReturnValue({ + canViewTraces: true, + loading: false, + deniedTooltip: '', + permissionName: '', + }); + + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [defaultEnvironment], + loading: false, + error: null, + }); + + mockUseGetComponentsByProject.mockReturnValue({ + components: [{ name: 'api-svc', displayName: 'API Service' }], + loading: false, + error: null, + }); + + mockUseUrlFilters.mockReturnValue({ + filters: { + environment: defaultEnvironment, + timeRange: '1h', + }, + updateFilters: jest.fn(), + }); + + mockUseTraces.mockReturnValue({ + traces: [], + total: 0, + loading: false, + error: null, + refresh: jest.fn(), + }); +} + +function renderPage() { + return renderInTestApp( + + + , + ); +} + +// ---- Tests ---- + +describe('ObservabilityTracesPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('shows progress while checking permissions', async () => { + mockUseTracesPermission.mockReturnValue({ + canViewTraces: false, + loading: true, + deniedTooltip: '', + permissionName: '', + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows forbidden state when user lacks permission', async () => { + mockUseTracesPermission.mockReturnValue({ + canViewTraces: false, + loading: false, + deniedTooltip: 'No traces access', + permissionName: 'openchoreo.traces.view', + }); + + await renderPage(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect(screen.getByText('No traces access')).toBeInTheDocument(); + }); + + it('renders filters, actions, and table when permitted', async () => { + await renderPage(); + + expect(screen.getByTestId('traces-filters')).toBeInTheDocument(); + expect(screen.getByTestId('traces-actions')).toBeInTheDocument(); + expect(screen.getByTestId('traces-table')).toBeInTheDocument(); + }); + + it('passes environments to filters', async () => { + await renderPage(); + + expect(screen.getByTestId('env-count')).toHaveTextContent('1'); + }); + + it('passes total count to actions', async () => { + mockUseTraces.mockReturnValue({ + traces: [ + { + traceId: 't1', + startTime: '2024-01-01', + endTime: '2024-01-01', + durationNs: 1000, + spanCount: 3, + }, + ], + total: 42, + loading: false, + error: null, + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByTestId('total-count')).toHaveTextContent('42'); + }); + + it('shows progress when traces are loading', async () => { + mockUseTraces.mockReturnValue({ + traces: [], + total: 0, + loading: true, + error: null, + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows traces error with Retry button', async () => { + mockUseTraces.mockReturnValue({ + traces: [], + total: 0, + loading: false, + error: 'Traces query failed', + refresh: jest.fn(), + }); + + await renderPage(); + + expect(screen.getByText('Traces query failed')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('shows info message when observability is disabled', async () => { + mockUseTraces.mockReturnValue({ + traces: [], + total: 0, + loading: false, + error: 'Observability is not enabled for this component', + refresh: jest.fn(), + }); + + await renderPage(); + + expect( + screen.getByText( + 'Observability is not enabled for this project in this environment. Please enable observability to view traces', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('renders nothing for environments error', async () => { + mockUseGetEnvironmentsByNamespace.mockReturnValue({ + environments: [], + loading: false, + error: 'Environment error', + }); + + await renderPage(); + + expect(screen.queryByTestId('traces-filters')).not.toBeInTheDocument(); + }); + + it('renders nothing for components error', async () => { + mockUseGetComponentsByProject.mockReturnValue({ + components: [], + loading: false, + error: 'Components error', + }); + + await renderPage(); + + expect(screen.queryByTestId('traces-filters')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Traces/TracesActions.test.tsx b/plugins/openchoreo-observability/src/components/Traces/TracesActions.test.tsx new file mode 100644 index 000000000..3820d3521 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Traces/TracesActions.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TracesActions } from './TracesActions'; + +// ---- Tests ---- + +describe('TracesActions', () => { + it('displays total traces count', () => { + render( + , + ); + + expect(screen.getByText('Total traces: 42')).toBeInTheDocument(); + }); + + it('displays last updated text', () => { + render( + , + ); + + expect(screen.getByText(/Last updated at:/)).toBeInTheDocument(); + }); + + it('shows Refresh button', () => { + render( + , + ); + + expect( + screen.getByRole('button', { name: /refresh/i }), + ).toBeInTheDocument(); + }); + + it('calls onRefresh when clicked', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /refresh/i })); + + expect(onRefresh).toHaveBeenCalled(); + }); + + it('disables Refresh button when disabled', () => { + render(); + + expect(screen.getByRole('button', { name: /refresh/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Traces/TracesFilters.test.tsx b/plugins/openchoreo-observability/src/components/Traces/TracesFilters.test.tsx new file mode 100644 index 000000000..2d347c091 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Traces/TracesFilters.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react'; +import { TracesFilters } from './TracesFilters'; +import { Environment, Filters } from '../../types'; + +// ---- Helpers ---- + +const environments: Environment[] = [ + { + uid: 'env-1', + name: 'development', + namespace: 'dev-ns', + displayName: 'Development', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', + }, + { + uid: 'env-2', + name: 'staging', + namespace: 'stg-ns', + displayName: 'Staging', + isProduction: false, + createdAt: '2024-01-01T00:00:00Z', + }, +]; + +const components = [ + { name: 'api-svc', uid: 'c1', displayName: 'API Service' }, + { name: 'web-app', uid: 'c2', displayName: 'Web App' }, +]; + +const baseFilters: Filters = { + environment: environments[0], + timeRange: '1h', +}; + +function renderFilters( + overrides: Partial> = {}, +) { + const defaultProps = { + filters: baseFilters, + onFiltersChange: jest.fn(), + environments, + environmentsLoading: false, + components, + componentsLoading: false, + disabled: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('TracesFilters', () => { + it('renders search trace ID field', () => { + renderFilters(); + + expect( + screen.getByPlaceholderText('Enter Trace ID to search'), + ).toBeInTheDocument(); + }); + + it('renders components selector', () => { + renderFilters(); + + expect(screen.getAllByText('Components').length).toBeGreaterThanOrEqual(1); + }); + + it('renders environment selector', () => { + renderFilters(); + + expect(screen.getAllByText('Environment').length).toBeGreaterThanOrEqual(1); + }); + + it('renders time range selector', () => { + renderFilters(); + + expect(screen.getAllByText('Time Range').length).toBeGreaterThanOrEqual(1); + }); + + it('disables controls when disabled', () => { + renderFilters({ disabled: true }); + + const selects = document.querySelectorAll('.Mui-disabled'); + expect(selects.length).toBeGreaterThan(0); + }); + + it('shows skeleton while components are loading', () => { + renderFilters({ componentsLoading: true }); + + // Skeleton replaces the select dropdown + expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); + + it('shows skeleton while environments are loading', () => { + renderFilters({ environmentsLoading: true }); + + expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Traces/TracesTable.test.tsx b/plugins/openchoreo-observability/src/components/Traces/TracesTable.test.tsx new file mode 100644 index 000000000..79d177c3d --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Traces/TracesTable.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TracesTable } from './TracesTable'; +import { Trace } from '../../types'; + +// ---- Helpers ---- + +const sampleTrace: Trace = { + traceId: 'abc12345def67890', + traceName: 'GET /api/users', + startTime: '2024-06-01T10:00:00.000Z', + endTime: '2024-06-01T10:00:01.500Z', + durationNs: 1500000000, + spanCount: 5, +}; + +const mockTraceSpans = { + fetchSpans: jest.fn(), + getSpans: jest.fn().mockReturnValue(undefined), + isLoading: jest.fn().mockReturnValue(false), + getError: jest.fn().mockReturnValue(undefined), +}; + +const mockSpanDetails = { + fetchSpanDetails: jest.fn(), + getDetails: jest.fn().mockReturnValue(undefined), + isLoading: jest.fn().mockReturnValue(false), + getError: jest.fn().mockReturnValue(undefined), +}; + +function renderTable( + overrides: { + traces?: Trace[]; + loading?: boolean; + } = {}, +) { + const defaultProps = { + traces: [sampleTrace], + traceSpans: mockTraceSpans, + spanDetails: mockSpanDetails, + loading: false, + ...overrides, + }; + + return render(); +} + +// ---- Tests ---- + +describe('TracesTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table headers', () => { + renderTable(); + + expect(screen.getByText('Trace Name')).toBeInTheDocument(); + expect(screen.getByText('Start Time')).toBeInTheDocument(); + expect(screen.getByText('End Time')).toBeInTheDocument(); + expect(screen.getByText('Duration')).toBeInTheDocument(); + expect(screen.getByText('Number of Spans')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + + it('renders trace data', () => { + renderTable(); + + expect(screen.getByText('GET /api/users')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('shows empty state when no traces', () => { + renderTable({ traces: [] }); + + expect(screen.getByText('No traces found')).toBeInTheDocument(); + expect( + screen.getByText( + 'Try adjusting your filters or time range to see more traces.', + ), + ).toBeInTheDocument(); + }); + + it('does not show empty state when loading', () => { + renderTable({ traces: [], loading: true }); + + expect(screen.queryByText('No traces found')).not.toBeInTheDocument(); + }); + + it('truncates long trace IDs when no trace name', () => { + renderTable({ + traces: [ + { + ...sampleTrace, + traceName: undefined, + }, + ], + }); + + expect(screen.getByText('abc12345...')).toBeInTheDocument(); + }); + + it('calls fetchSpans on row expansion', async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByText('GET /api/users')); + + expect(mockTraceSpans.fetchSpans).toHaveBeenCalledWith('abc12345def67890'); + }); + + it('shows span error when expanded with error', async () => { + const user = userEvent.setup(); + mockTraceSpans.getError.mockReturnValue('Network error'); + renderTable(); + + await user.click(screen.getByText('GET /api/users')); + + expect( + screen.getByText('Failed to load spans: Network error'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Traces/utils.test.ts b/plugins/openchoreo-observability/src/components/Traces/utils.test.ts new file mode 100644 index 000000000..f7a5e6e3e --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Traces/utils.test.ts @@ -0,0 +1,118 @@ +import { + parseRfc3339NanoToNanoseconds, + nanosecondsToRfc3339Nano, + formatDuration, + formatTime, + formatTimeFromString, + calculateTimeRange, +} from './utils'; + +// ---- Tests ---- + +describe('parseRfc3339NanoToNanoseconds', () => { + it('converts RFC3339 timestamp to nanoseconds', () => { + const result = parseRfc3339NanoToNanoseconds( + '2024-06-01T10:00:00.000000000Z', + ); + const expectedMs = new Date('2024-06-01T10:00:00Z').getTime(); + expect(result).toBe(expectedMs * 1_000_000); + }); + + it('handles fractional seconds', () => { + const result = parseRfc3339NanoToNanoseconds( + '2024-06-01T10:00:00.123456789Z', + ); + const expectedMs = new Date('2024-06-01T10:00:00.123Z').getTime(); + expect(result).toBe(expectedMs * 1_000_000 + 456789); + }); + + it('pads short fractional parts', () => { + const result = parseRfc3339NanoToNanoseconds('2024-06-01T10:00:00.5Z'); + const expectedMs = new Date('2024-06-01T10:00:00.500Z').getTime(); + expect(result).toBe(expectedMs * 1_000_000); + }); +}); + +describe('nanosecondsToRfc3339Nano', () => { + it('converts nanoseconds to RFC3339 string', () => { + const ms = new Date('2024-06-01T10:00:00Z').getTime(); + const ns = ms * 1_000_000; + const result = nanosecondsToRfc3339Nano(ns); + expect(result).toMatch(/2024-06-01T10:00:00/); + expect(result).toMatch(/Z$/); + }); +}); + +describe('formatDuration', () => { + it('formats nanoseconds', () => { + expect(formatDuration(500)).toBe('500ns'); + }); + + it('formats microseconds', () => { + expect(formatDuration(5000)).toBe('5.000μs'); + }); + + it('formats milliseconds', () => { + expect(formatDuration(5000000)).toBe('5.000ms'); + }); + + it('formats seconds', () => { + expect(formatDuration(1500000000)).toBe('1.500s'); + }); + + it('handles zero', () => { + expect(formatDuration(0)).toBe('0ns'); + }); +}); + +describe('formatTime', () => { + it('converts nanoseconds to ISO string', () => { + const ms = new Date('2024-06-01T10:00:00Z').getTime(); + const ns = ms * 1_000_000; + expect(formatTime(ns)).toBe('2024-06-01T10:00:00.000Z'); + }); +}); + +describe('formatTimeFromString', () => { + it('returns string as-is if it ends with Z', () => { + expect(formatTimeFromString('2024-06-01T10:00:00Z')).toBe( + '2024-06-01T10:00:00Z', + ); + }); + + it('appends Z if missing', () => { + expect(formatTimeFromString('2024-06-01T10:00:00')).toBe( + '2024-06-01T10:00:00Z', + ); + }); +}); + +describe('calculateTimeRange', () => { + it('returns start and end time for 10m', () => { + const result = calculateTimeRange('10m'); + const diff = + new Date(result.endTime).getTime() - new Date(result.startTime).getTime(); + expect(diff).toBe(10 * 60 * 1000); + }); + + it('returns start and end time for 1h', () => { + const result = calculateTimeRange('1h'); + const diff = + new Date(result.endTime).getTime() - new Date(result.startTime).getTime(); + expect(diff).toBe(60 * 60 * 1000); + }); + + it('returns start and end time for 7d', () => { + const result = calculateTimeRange('7d'); + const diff = + new Date(result.endTime).getTime() - new Date(result.startTime).getTime(); + expect(diff).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('defaults to 1h for unknown range', () => { + const result = calculateTimeRange('unknown'); + const diff = + new Date(result.endTime).getTime() - new Date(result.startTime).getTime(); + expect(diff).toBe(60 * 60 * 1000); + }); +}); diff --git a/plugins/openchoreo/package.json b/plugins/openchoreo/package.json index 194985c37..6a5450926 100644 --- a/plugins/openchoreo/package.json +++ b/plugins/openchoreo/package.json @@ -74,6 +74,7 @@ "@backstage/core-app-api": "1.19.0", "@backstage/dev-utils": "1.1.14", "@backstage/test-utils": "1.7.11", + "@openchoreo/test-utils": "workspace:^", "@testing-library/dom": "9.3.4", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", diff --git a/plugins/openchoreo/src/api/OpenChoreoClient.ts b/plugins/openchoreo/src/api/OpenChoreoClient.ts index 255c0bf9b..494b8274c 100644 --- a/plugins/openchoreo/src/api/OpenChoreoClient.ts +++ b/plugins/openchoreo/src/api/OpenChoreoClient.ts @@ -550,6 +550,7 @@ export class OpenChoreoClient implements OpenChoreoClientApi { uid?: string; deletionTimestamp?: string; parameters?: Record; + autoDeploy?: boolean; }> { const metadata = extractEntityMetadata(entity); diff --git a/plugins/openchoreo/src/api/OpenChoreoClientApi.ts b/plugins/openchoreo/src/api/OpenChoreoClientApi.ts index 4a4a6714a..98889128c 100644 --- a/plugins/openchoreo/src/api/OpenChoreoClientApi.ts +++ b/plugins/openchoreo/src/api/OpenChoreoClientApi.ts @@ -497,6 +497,7 @@ export interface OpenChoreoClientApi { uid?: string; deletionTimestamp?: string; parameters?: Record; + autoDeploy?: boolean; }>; /** Get project details (including UID and deletionTimestamp) */ diff --git a/plugins/openchoreo/src/components/AccessControl/AccessControlPage.test.tsx b/plugins/openchoreo/src/components/AccessControl/AccessControlPage.test.tsx new file mode 100644 index 000000000..748b3931e --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/AccessControlPage.test.tsx @@ -0,0 +1,195 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AccessControlContent } from './AccessControlPage'; + +// ---- Mocks ---- + +jest.mock('@openchoreo/backstage-design-system', () => ({ + VerticalTabNav: ({ tabs, children }: any) => ( +
+ {tabs.map((t: any) => ( + + {t.label} + + ))} + {children} +
+ ), +})); + +jest.mock('@backstage/core-components', () => ({ + Progress: () =>
Loading...
, + WarningPanel: ({ title, children }: any) => ( +
+ {children} +
+ ), +})); + +const mockUseRolePermissions = jest.fn(); +const mockUseClusterRolePermissions = jest.fn(); +const mockUseRoleMappingPermissions = jest.fn(); +const mockUseClusterRoleMappingPermissions = jest.fn(); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useRolePermissions: () => mockUseRolePermissions(), + useClusterRolePermissions: () => mockUseClusterRolePermissions(), + useRoleMappingPermissions: () => mockUseRoleMappingPermissions(), + useClusterRoleMappingPermissions: () => + mockUseClusterRoleMappingPermissions(), +})); + +const mockUseClusterRoles = jest.fn(); +jest.mock('./hooks', () => ({ + useClusterRoles: () => mockUseClusterRoles(), +})); + +jest.mock('./RolesTab', () => ({ + RolesTab: () =>
RolesTab
, +})); + +jest.mock('./MappingsTab', () => ({ + MappingsTab: () =>
MappingsTab
, +})); + +jest.mock('./ActionsTab', () => ({ + ActionsTab: () =>
ActionsTab
, +})); + +// ---- Helpers ---- + +const grantedPerm = { canView: true, loading: false }; +const deniedPerm = { canView: false, loading: false }; +const loadingPerm = { canView: false, loading: true }; + +function setAllPermissions( + overrides: { + nsRoles?: typeof grantedPerm; + clusterRoles?: typeof grantedPerm; + nsMappings?: typeof grantedPerm; + clusterMappings?: typeof grantedPerm; + } = {}, +) { + mockUseRolePermissions.mockReturnValue(overrides.nsRoles ?? grantedPerm); + mockUseClusterRolePermissions.mockReturnValue( + overrides.clusterRoles ?? grantedPerm, + ); + mockUseRoleMappingPermissions.mockReturnValue( + overrides.nsMappings ?? grantedPerm, + ); + mockUseClusterRoleMappingPermissions.mockReturnValue( + overrides.clusterMappings ?? grantedPerm, + ); +} + +function renderContent() { + return render( + + + , + ); +} + +// ---- Tests ---- + +describe('AccessControlContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseClusterRoles.mockReturnValue({ + roles: [], + loading: false, + error: null, + }); + setAllPermissions(); + }); + + it('shows progress when permissions are loading', () => { + setAllPermissions({ nsRoles: loadingPerm }); + + renderContent(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows authorization disabled warning when authz is disabled', () => { + mockUseClusterRoles.mockReturnValue({ + roles: [], + loading: false, + error: new Error('authorization is disabled'), + }); + + renderContent(); + + expect(screen.getByTestId('warning-panel')).toHaveAttribute( + 'data-title', + 'Authorization is Disabled', + ); + expect( + screen.getByText(/Policy management operations are not available/), + ).toBeInTheDocument(); + }); + + it('shows Roles tab when user has role view permissions', () => { + renderContent(); + + expect(screen.getByTestId('tab-roles')).toHaveTextContent('Roles'); + }); + + it('shows Role Bindings tab when user has mapping permissions', () => { + renderContent(); + + expect(screen.getByTestId('tab-mappings')).toHaveTextContent( + 'Role Bindings', + ); + }); + + it('always shows Actions tab', () => { + setAllPermissions({ + nsRoles: deniedPerm, + clusterRoles: deniedPerm, + nsMappings: deniedPerm, + clusterMappings: deniedPerm, + }); + + renderContent(); + + expect(screen.getByTestId('tab-actions')).toHaveTextContent('Actions'); + }); + + it('hides Roles tab when user has no role view permissions', () => { + setAllPermissions({ + nsRoles: deniedPerm, + clusterRoles: deniedPerm, + }); + + renderContent(); + + expect(screen.queryByTestId('tab-roles')).not.toBeInTheDocument(); + }); + + it('hides Role Bindings tab when user has no mapping permissions', () => { + setAllPermissions({ + nsMappings: deniedPerm, + clusterMappings: deniedPerm, + }); + + renderContent(); + + expect(screen.queryByTestId('tab-mappings')).not.toBeInTheDocument(); + }); + + it('shows only Actions tab when all permissions denied', () => { + setAllPermissions({ + nsRoles: deniedPerm, + clusterRoles: deniedPerm, + nsMappings: deniedPerm, + clusterMappings: deniedPerm, + }); + + renderContent(); + + expect(screen.queryByTestId('tab-roles')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tab-mappings')).not.toBeInTheDocument(); + expect(screen.getByTestId('tab-actions')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/MappingsTab/MappingsTab.test.tsx b/plugins/openchoreo/src/components/AccessControl/MappingsTab/MappingsTab.test.tsx new file mode 100644 index 000000000..9a313c97c --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/MappingsTab/MappingsTab.test.tsx @@ -0,0 +1,128 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { MappingsTab } from './MappingsTab'; + +// ---- Mocks ---- + +jest.mock('../ScopeDropdown', () => ({ + ScopeDropdown: ({ value, onChange, clusterLabel, namespaceLabel }: any) => ( +
+ {value} + + +
+ ), +})); + +jest.mock('./ClusterRoleBindingsContent', () => ({ + ClusterRoleBindingsContent: () => ( +
ClusterRoleBindingsContent
+ ), +})); + +jest.mock('./NamespaceRoleBindingsContent', () => ({ + NamespaceRoleBindingsContent: ({ selectedNamespace }: any) => ( +
+ NamespaceRoleBindingsContent:{selectedNamespace} +
+ ), +})); + +jest.mock('../RolesTab/NamespaceSelector', () => ({ + NamespaceSelector: ({ value, onChange }: any) => ( + + ), +})); + +// ---- Helpers ---- + +function renderTab() { + return render( + + + , + ); +} + +// ---- Tests ---- + +describe('MappingsTab', () => { + it('defaults to cluster scope with ClusterRoleBindingsContent visible', () => { + renderTab(); + + expect(screen.getByTestId('cluster-bindings-content')).toBeInTheDocument(); + expect( + screen.queryByTestId('namespace-bindings-content'), + ).not.toBeInTheDocument(); + }); + + it('does not show namespace selector in cluster scope', () => { + renderTab(); + + expect(screen.queryByTestId('namespace-selector')).not.toBeInTheDocument(); + }); + + it('shows scope dropdown with correct labels', () => { + renderTab(); + + expect(screen.getByText('Cluster Role Bindings')).toBeInTheDocument(); + expect(screen.getByText('Namespace Role Bindings')).toBeInTheDocument(); + }); + + it('switches to namespace scope and shows NamespaceRoleBindingsContent', async () => { + const user = userEvent.setup(); + + renderTab(); + + await user.click(screen.getByTestId('switch-to-namespace')); + + expect( + screen.getByTestId('namespace-bindings-content'), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('cluster-bindings-content'), + ).not.toBeInTheDocument(); + }); + + it('shows namespace selector when in namespace scope', async () => { + const user = userEvent.setup(); + + renderTab(); + + await user.click(screen.getByTestId('switch-to-namespace')); + + expect(screen.getByTestId('namespace-selector')).toBeInTheDocument(); + }); + + it('switches back to cluster scope', async () => { + const user = userEvent.setup(); + + renderTab(); + + await user.click(screen.getByTestId('switch-to-namespace')); + expect( + screen.getByTestId('namespace-bindings-content'), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId('switch-to-cluster')); + expect(screen.getByTestId('cluster-bindings-content')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.test.tsx b/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.test.tsx new file mode 100644 index 000000000..d8c2ab0d0 --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.test.tsx @@ -0,0 +1,287 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TestApiProvider } from '@backstage/test-utils'; +import { createMockOpenChoreoClient } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { ClusterRolesContent } from './ClusterRolesContent'; + +// ---- Mocks ---- + +const mockUseClusterRolePermissions = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useClusterRolePermissions: () => mockUseClusterRolePermissions(), + ForbiddenState: ({ message, onRetry }: any) => ( +
+ {message} + {onRetry && ( + + )} +
+ ), +})); + +jest.mock('../../../utils/errorUtils', () => ({ + isForbiddenError: (err: any) => err?.message?.includes('403'), +})); + +const mockFetchRoles = jest.fn(); +const mockAddRole = jest.fn(); +const mockUpdateRole = jest.fn(); +const mockDeleteRole = jest.fn(); +const mockUseClusterRoles = jest.fn(); + +jest.mock('../hooks', () => ({ + useClusterRoles: () => mockUseClusterRoles(), +})); + +jest.mock('../../../hooks', () => ({ + useNotification: () => ({ + notification: null, + showSuccess: jest.fn(), + showError: jest.fn(), + }), +})); + +jest.mock('../../Environments/components', () => ({ + NotificationBanner: () => null, +})); + +jest.mock('@backstage/core-components', () => ({ + Progress: () =>
Loading...
, + ResponseErrorPanel: ({ error }: any) => ( +
{error.message}
+ ), +})); + +jest.mock('./RolesTable', () => ({ + RolesTable: ({ roles, onEdit, onDelete }: any) => ( +
+ {roles.map((r: any) => ( +
+ {r.name} + + +
+ ))} +
+ ), +})); + +jest.mock('./RoleDialog', () => ({ + RoleDialog: ({ open, onClose, onSave, editingRole }: any) => + open ? ( +
+ {editingRole ? 'edit' : 'create'} + + +
+ ) : null, +})); + +// ---- Helpers ---- + +const mockClient = createMockOpenChoreoClient(); + +const grantedPermissions = { + canView: true, + canCreate: true, + canUpdate: true, + canDelete: true, + loading: false, + createDeniedTooltip: '', + updateDeniedTooltip: '', + deleteDeniedTooltip: '', +}; + +function createActionsRef() { + const container = document.createElement('div'); + document.body.appendChild(container); + return { current: container }; +} + +function renderContent(actionsRef?: React.RefObject) { + const ref = actionsRef ?? createActionsRef(); + return render( + + + , + ); +} + +// ---- Tests ---- + +describe('ClusterRolesContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseClusterRolePermissions.mockReturnValue(grantedPermissions); + mockUseClusterRoles.mockReturnValue({ + roles: [{ name: 'admin', actions: ['read', 'write'] }], + loading: false, + error: null, + fetchRoles: mockFetchRoles, + addRole: mockAddRole, + updateRole: mockUpdateRole, + deleteRole: mockDeleteRole, + }); + }); + + it('shows progress when loading', () => { + mockUseClusterRoles.mockReturnValue({ + roles: [], + loading: true, + error: null, + fetchRoles: mockFetchRoles, + addRole: mockAddRole, + updateRole: mockUpdateRole, + deleteRole: mockDeleteRole, + }); + + renderContent(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows progress when permissions are loading', () => { + mockUseClusterRolePermissions.mockReturnValue({ + ...grantedPermissions, + loading: true, + }); + + renderContent(); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows forbidden state for 403 error', () => { + mockUseClusterRoles.mockReturnValue({ + roles: [], + loading: false, + error: new Error('403 Forbidden'), + fetchRoles: mockFetchRoles, + addRole: mockAddRole, + updateRole: mockUpdateRole, + deleteRole: mockDeleteRole, + }); + + renderContent(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect( + screen.getByText('You do not have permission to view cluster roles.'), + ).toBeInTheDocument(); + }); + + it('shows error panel for non-forbidden errors', () => { + mockUseClusterRoles.mockReturnValue({ + roles: [], + loading: false, + error: new Error('Network error'), + fetchRoles: mockFetchRoles, + addRole: mockAddRole, + updateRole: mockUpdateRole, + deleteRole: mockDeleteRole, + }); + + renderContent(); + + expect(screen.getByTestId('error-panel')).toBeInTheDocument(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + + it('shows forbidden state when canView is false', () => { + mockUseClusterRolePermissions.mockReturnValue({ + ...grantedPermissions, + canView: false, + }); + + renderContent(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + }); + + it('renders roles table with roles when loaded', () => { + renderContent(); + + expect(screen.getByTestId('roles-table')).toBeInTheDocument(); + expect(screen.getByTestId('role-admin')).toBeInTheDocument(); + }); + + it('renders New Cluster Role button in actions portal', () => { + renderContent(); + + expect( + screen.getByRole('button', { name: /new cluster role/i }), + ).toBeInTheDocument(); + }); + + it('disables create button when canCreate is false', () => { + mockUseClusterRolePermissions.mockReturnValue({ + ...grantedPermissions, + canCreate: false, + }); + + renderContent(); + + expect( + screen.getByRole('button', { name: /new cluster role/i }), + ).toBeDisabled(); + }); + + it('opens create dialog when New Cluster Role is clicked', async () => { + const user = userEvent.setup(); + + renderContent(); + + await user.click(screen.getByRole('button', { name: /new cluster role/i })); + + expect(screen.getByTestId('role-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-mode')).toHaveTextContent('create'); + }); + + it('opens edit dialog when edit is clicked on a role', async () => { + const user = userEvent.setup(); + + renderContent(); + + await user.click(screen.getByTestId('edit-admin')); + + expect(screen.getByTestId('role-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-mode')).toHaveTextContent('edit'); + }); + + it('calls deleteRole when delete is clicked on a role', async () => { + const user = userEvent.setup(); + + renderContent(); + + await user.click(screen.getByTestId('delete-admin')); + + await waitFor(() => { + expect(mockDeleteRole).toHaveBeenCalledWith('admin'); + }); + }); + + it('renders refresh button in actions portal', () => { + renderContent(); + + expect(screen.getByTitle('Refresh')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/RolesTab/RolesTab.test.tsx b/plugins/openchoreo/src/components/AccessControl/RolesTab/RolesTab.test.tsx new file mode 100644 index 000000000..9da8f4289 --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/RolesTab/RolesTab.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { RolesTab } from './RolesTab'; + +// ---- Mocks ---- + +jest.mock('../ScopeDropdown', () => ({ + ScopeDropdown: ({ value, onChange, clusterLabel, namespaceLabel }: any) => ( +
+ {value} + + +
+ ), +})); + +jest.mock('./ClusterRolesContent', () => ({ + ClusterRolesContent: () => ( +
ClusterRolesContent
+ ), +})); + +jest.mock('./NamespaceRolesContent', () => ({ + NamespaceRolesContent: ({ selectedNamespace }: any) => ( +
+ NamespaceRolesContent:{selectedNamespace} +
+ ), +})); + +jest.mock('./NamespaceSelector', () => ({ + NamespaceSelector: ({ value, onChange }: any) => ( + + ), +})); + +// ---- Helpers ---- + +function renderTab() { + return render( + + + , + ); +} + +// ---- Tests ---- + +describe('RolesTab', () => { + it('defaults to cluster scope with ClusterRolesContent visible', () => { + renderTab(); + + expect(screen.getByTestId('cluster-roles-content')).toBeInTheDocument(); + expect( + screen.queryByTestId('namespace-roles-content'), + ).not.toBeInTheDocument(); + }); + + it('does not show namespace selector in cluster scope', () => { + renderTab(); + + expect(screen.queryByTestId('namespace-selector')).not.toBeInTheDocument(); + }); + + it('shows scope dropdown with correct labels', () => { + renderTab(); + + expect(screen.getByText('Cluster Roles')).toBeInTheDocument(); + expect(screen.getByText('Namespace Roles')).toBeInTheDocument(); + }); + + it('switches to namespace scope and shows NamespaceRolesContent', async () => { + const user = userEvent.setup(); + + renderTab(); + + await user.click(screen.getByTestId('switch-to-namespace')); + + expect(screen.getByTestId('namespace-roles-content')).toBeInTheDocument(); + expect( + screen.queryByTestId('cluster-roles-content'), + ).not.toBeInTheDocument(); + }); + + it('shows namespace selector when in namespace scope', async () => { + const user = userEvent.setup(); + + renderTab(); + + await user.click(screen.getByTestId('switch-to-namespace')); + + expect(screen.getByTestId('namespace-selector')).toBeInTheDocument(); + }); + + it('switches back to cluster scope', async () => { + const user = userEvent.setup(); + + renderTab(); + + await user.click(screen.getByTestId('switch-to-namespace')); + expect(screen.getByTestId('namespace-roles-content')).toBeInTheDocument(); + + await user.click(screen.getByTestId('switch-to-cluster')); + expect(screen.getByTestId('cluster-roles-content')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.test.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.test.tsx new file mode 100644 index 000000000..f25ba5f9c --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.test.tsx @@ -0,0 +1,309 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { TestApiProvider } from '@backstage/test-utils'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { createMockOpenChoreoClient } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { + useDeleteEntityMenuItems, + type DeletePermissionInfo, +} from './useDeleteEntityMenuItems'; +import type { Entity } from '@backstage/catalog-model'; + +// ---- Mocks ---- + +jest.mock('../utils', () => ({ + isMarkedForDeletion: jest.fn().mockReturnValue(false), +})); + +jest.mock('../../ResourceDefinition/utils', () => ({ + isSupportedKind: (kind: string) => + ['componenttype', 'traittype', 'workflow'].includes(kind), + mapKindToApiKind: (kind: string) => kind, +})); + +jest.mock('../../../utils/errorUtils', () => ({ + isForbiddenError: (err: any) => err?.message?.includes('403'), + getErrorMessage: (err: any) => + err instanceof Error ? err.message : String(err), +})); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +// ---- Helpers ---- + +const mockClient = createMockOpenChoreoClient(); +const mockAlertApi = { post: jest.fn() }; + +function makeEntity(kind: string, name: string): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind, + metadata: { + name, + namespace: 'default', + annotations: { + 'openchoreo.io/namespace': 'test-ns', + }, + }, + spec: {}, + }; +} + +/** + * Wrapper component that renders the hook's menu item and dialog. + */ +function TestHarness({ + entity, + deletePermission, +}: { + entity: Entity; + deletePermission?: DeletePermissionInfo; +}) { + const { extraMenuItems, DeleteConfirmationDialog } = useDeleteEntityMenuItems( + entity, + deletePermission, + ); + + return ( +
+ {extraMenuItems.map(item => ( + + ))} + {extraMenuItems.length === 0 && ( + No items + )} + +
+ ); +} + +function renderHarness( + entity: Entity, + deletePermission?: DeletePermissionInfo, +) { + return render( + + + + + , + ); +} + +// ---- Tests ---- + +describe('useDeleteEntityMenuItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset isMarkedForDeletion since clearAllMocks doesn't reset mockReturnValue + const { isMarkedForDeletion } = require('../utils'); + isMarkedForDeletion.mockReturnValue(false); + }); + + it('returns "Delete Component" for Component entity', () => { + renderHarness(makeEntity('Component', 'my-service')); + + expect(screen.getByTestId('menu-item')).toHaveTextContent( + 'Delete Component', + ); + }); + + it('returns "Delete Project" for System entity', () => { + renderHarness(makeEntity('System', 'my-project')); + + expect(screen.getByTestId('menu-item')).toHaveTextContent('Delete Project'); + }); + + it('returns "Delete Namespace" for Domain entity', () => { + renderHarness(makeEntity('Domain', 'my-ns')); + + expect(screen.getByTestId('menu-item')).toHaveTextContent( + 'Delete Namespace', + ); + }); + + it('returns empty items for unsupported entity kind', () => { + renderHarness(makeEntity('API', 'my-api')); + + expect(screen.getByTestId('no-menu-items')).toBeInTheDocument(); + }); + + it('returns empty items when already marked for deletion', () => { + const { isMarkedForDeletion } = require('../utils'); + isMarkedForDeletion.mockReturnValue(true); + + renderHarness(makeEntity('Component', 'deleting-service')); + + expect(screen.getByTestId('no-menu-items')).toBeInTheDocument(); + }); + + it('returns empty items when deletePermission is loading', () => { + renderHarness(makeEntity('Component', 'my-service'), { + canDelete: false, + loading: true, + deniedTooltip: '', + }); + + expect(screen.getByTestId('no-menu-items')).toBeInTheDocument(); + }); + + it('returns disabled item with tooltip when permission denied', () => { + renderHarness(makeEntity('Component', 'my-service'), { + canDelete: false, + loading: false, + deniedTooltip: 'No delete permission', + }); + + const item = screen.getByTestId('menu-item'); + expect(item).toBeDisabled(); + expect(item).toHaveAttribute('title', 'No delete permission'); + }); + + it('opens confirmation dialog when menu item is clicked', async () => { + const user = userEvent.setup(); + + renderHarness(makeEntity('Component', 'my-service')); + + await user.click(screen.getByTestId('menu-item')); + + // Dialog title is inside an h4 + expect( + screen.getByRole('heading', { name: /delete component/i }), + ).toBeInTheDocument(); + expect(screen.getByText(/my-service/)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /^delete$/i }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('shows cascade warning for project deletion', async () => { + const user = userEvent.setup(); + + renderHarness(makeEntity('System', 'my-project')); + + await user.click(screen.getByTestId('menu-item')); + + expect( + screen.getByText( + /All components within this project will also be deleted/, + ), + ).toBeInTheDocument(); + }); + + it('shows cascade warning for namespace deletion', async () => { + const user = userEvent.setup(); + + renderHarness(makeEntity('Domain', 'my-ns')); + + await user.click(screen.getByTestId('menu-item')); + + expect( + screen.getByText(/All projects and components within this namespace/), + ).toBeInTheDocument(); + }); + + it('calls deleteComponent on confirm and navigates to /catalog', async () => { + const user = userEvent.setup(); + mockClient.deleteComponent.mockResolvedValue(undefined); + + renderHarness(makeEntity('Component', 'my-service')); + + await user.click(screen.getByTestId('menu-item')); + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + await waitFor(() => { + expect(mockClient.deleteComponent).toHaveBeenCalled(); + expect(mockAlertApi.post).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Component "my-service" has been marked for deletion', + severity: 'success', + }), + ); + expect(mockNavigate).toHaveBeenCalledWith('/catalog'); + }); + }); + + it('calls deleteProject on confirm for System entity', async () => { + const user = userEvent.setup(); + mockClient.deleteProject.mockResolvedValue(undefined); + + renderHarness(makeEntity('System', 'my-project')); + + await user.click(screen.getByTestId('menu-item')); + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + await waitFor(() => { + expect(mockClient.deleteProject).toHaveBeenCalled(); + }); + }); + + it('shows permission error when delete returns 403', async () => { + const user = userEvent.setup(); + mockClient.deleteComponent.mockRejectedValue(new Error('403 Forbidden')); + + renderHarness(makeEntity('Component', 'my-service')); + + await user.click(screen.getByTestId('menu-item')); + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + await waitFor(() => { + expect( + screen.getByText(/You do not have permission to delete this resource/), + ).toBeInTheDocument(); + }); + }); + + it('shows error message when delete fails with non-403 error', async () => { + const user = userEvent.setup(); + mockClient.deleteComponent.mockRejectedValue(new Error('Network timeout')); + + renderHarness(makeEntity('Component', 'my-service')); + + await user.click(screen.getByTestId('menu-item')); + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + await waitFor(() => { + expect(screen.getByText(/Network timeout/)).toBeInTheDocument(); + }); + }); + + it('closes dialog on cancel without API call', async () => { + const user = userEvent.setup(); + + renderHarness(makeEntity('Component', 'my-service')); + + await user.click(screen.getByTestId('menu-item')); + expect( + screen.getByRole('heading', { name: /delete component/i }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect( + screen.queryByRole('heading', { name: /delete component/i }), + ).not.toBeInTheDocument(); + }); + expect(mockClient.deleteComponent).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/Environments.test.tsx b/plugins/openchoreo/src/components/Environments/Environments.test.tsx index ab4a0e0d5..9b8e49957 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.test.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.test.tsx @@ -1,14 +1,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockComponentEntity } from '@openchoreo/test-utils'; import { Environments } from './Environments'; // ---- Mocks ---- -// Mock styles (no-op) -jest.mock('./styles', () => ({ - useEnvironmentsStyles: jest.fn(), -})); - // Mock useNotification jest.mock('../../hooks', () => ({ useNotification: () => ({ @@ -38,40 +35,6 @@ jest.mock('./hooks', () => ({ }), })); -// Mock useAutoDeployUpdate -jest.mock('./hooks/useAutoDeployUpdate', () => ({ - useAutoDeployUpdate: () => ({ - updateAutoDeploy: jest.fn(), - isUpdating: false, - error: null, - }), -})); - -// Mock @backstage/plugin-catalog-react -jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: () => ({ - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'test-component', - namespace: 'default', - annotations: {}, - tags: ['service'], - }, - spec: { type: 'service' }, - }, - }), -})); - -// Mock @backstage/core-plugin-api -jest.mock('@backstage/core-plugin-api', () => ({ - useApi: () => ({ - getComponentDetails: jest.fn().mockResolvedValue({}), - fetchEnvironmentInfo: jest.fn().mockResolvedValue([]), - }), -})); - // Mock @backstage/core-components jest.mock('@backstage/core-components', () => ({ Progress: () =>
Loading...
, @@ -107,15 +70,16 @@ jest.mock('./components', () => ({ NotificationBanner: () => null, })); -// Mock openChoreoClientApiRef -jest.mock('../../api/OpenChoreoClientApi', () => ({ - openChoreoClientApiRef: { id: 'mock' }, -})); - // ---- Helpers ---- +const testEntity = mockComponentEntity(); + function renderWithRouter(ui: React.ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } // ---- Tests ---- diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx index 32304d12f..92dc3f9fc 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.tsx @@ -1,8 +1,7 @@ -import { useCallback, useState, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Progress } from '@backstage/core-components'; import { Box } from '@material-ui/core'; -import { useApi } from '@backstage/core-plugin-api'; import { useNotification } from '../../hooks'; import { @@ -11,13 +10,11 @@ import { useEnvironmentPolling, useEnvironmentRouting, } from './hooks'; -import { useAutoDeployUpdate } from './hooks/useAutoDeployUpdate'; import type { PendingAction } from './types'; import { useEnvironmentsStyles } from './styles'; import { EnvironmentsRouter } from './EnvironmentsRouter'; import { EnvironmentsProvider } from './EnvironmentsContext'; import { NotificationBanner } from './components'; -import { openChoreoClientApiRef } from '../../api/OpenChoreoClientApi'; import { ForbiddenState, useReleaseBindingPermission, @@ -29,7 +26,6 @@ export const Environments = () => { useEnvironmentsStyles(); const { entity } = useEntity(); - const client = useApi(openChoreoClientApiRef); // Routing const { navigateToList } = useEnvironmentRouting(); @@ -45,30 +41,9 @@ export const Environments = () => { const { canViewBindings, loading: bindingsPermissionLoading } = useReleaseBindingPermission(); - // Auto deploy state - const [autoDeploy, setAutoDeploy] = useState(undefined); - const { updateAutoDeploy, isUpdating: autoDeployUpdating } = - useAutoDeployUpdate(entity); - // Notifications const notification = useNotification(); - // Fetch component details to get autoDeploy value - useEffect(() => { - const fetchComponentData = async () => { - try { - const componentData = await client.getComponentDetails(entity); - if (componentData && 'autoDeploy' in componentData) { - setAutoDeploy((componentData as any).autoDeploy); - } - } catch (err) { - // Silently fail - autoDeploy will remain undefined - } - }; - - fetchComponentData(); - }, [entity, client]); - // Polling for pending deployments useEnvironmentPolling(isPending, refetch); @@ -84,22 +59,6 @@ export const Environments = () => { [entity], ); - // Handler for auto deploy toggle change - const handleAutoDeployChange = useCallback( - async (newAutoDeploy: boolean) => { - const success = await updateAutoDeploy(newAutoDeploy); - if (success) { - setAutoDeploy(newAutoDeploy); - notification.showSuccess( - `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`, - ); - } else { - notification.showError('Failed to update auto deploy setting'); - } - }, - [updateAutoDeploy, notification], - ); - // Handler for when overrides are saved with a pending action // The release binding was already created/updated by updateReleaseBinding // in EnvironmentOverridesPage — just show success and refresh @@ -132,9 +91,6 @@ export const Environments = () => { refetch, lowestEnvironment: environments[0]?.name?.toLowerCase() || 'development', isWorkloadEditorSupported, - autoDeploy, - autoDeployUpdating, - onAutoDeployChange: handleAutoDeployChange, onPendingActionComplete: handlePendingActionComplete, canViewEnvironments, environmentReadPermissionLoading, @@ -147,9 +103,6 @@ export const Environments = () => { loading, refetch, isWorkloadEditorSupported, - autoDeploy, - autoDeployUpdating, - handleAutoDeployChange, handlePendingActionComplete, canViewEnvironments, environmentReadPermissionLoading, diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx index a751b7b92..47d37ae58 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx @@ -15,12 +15,6 @@ interface EnvironmentsContextValue { lowestEnvironment: string; /** Whether workload editor is supported for this component */ isWorkloadEditorSupported: boolean; - /** Auto deploy setting */ - autoDeploy: boolean | undefined; - /** Whether auto deploy is being updated */ - autoDeployUpdating: boolean; - /** Handler for auto deploy toggle */ - onAutoDeployChange: (enabled: boolean) => Promise; /** Handler for completing a pending action (deploy/promote) */ onPendingActionComplete: (action: PendingAction) => Promise; /** Whether the user has permission to view environments */ diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx index 3e532e831..aa61529cf 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockComponentEntity } from '@openchoreo/test-utils'; import { EnvironmentsList } from './EnvironmentsList'; import type { Environment } from './hooks'; import type { EnvironmentCardProps, SetupCardProps } from './types'; @@ -7,21 +9,10 @@ import type { EnvironmentCardProps, SetupCardProps } from './types'; // ---- Captured props for child components ---- let capturedEnvironmentCardProps: Map; -let capturedSetupCardProps: SetupCardProps | undefined; - -// ---- Mock: styles ---- -jest.mock('./styles', () => ({ - useEnvironmentsListStyles: () => ({ - cardGrid: 'cardGrid', - cardItem: 'cardItem', - }), -})); - // ---- Mock: EnvironmentCard & SetupCard ---- jest.mock('./components', () => ({ NotificationBanner: () => null, - SetupCard: (props: SetupCardProps) => { - capturedSetupCardProps = props; + SetupCard: (_props: SetupCardProps) => { return
; }, EnvironmentCard: (props: EnvironmentCardProps) => { @@ -30,23 +21,6 @@ jest.mock('./components', () => ({ }, })); -// ---- Mock: @backstage/plugin-catalog-react ---- -jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: () => ({ - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'test-component', - namespace: 'default', - annotations: {}, - tags: ['service'], - }, - spec: { type: 'service' }, - }, - }), -})); - // ---- Mock: @openchoreo/backstage-plugin-react (EmptyState, ForbiddenState) ---- jest.mock('@openchoreo/backstage-plugin-react', () => ({ EmptyState: (props: { title: string; description: string }) => ( @@ -79,9 +53,6 @@ interface MockContextValue { refetch: jest.Mock; lowestEnvironment: string; isWorkloadEditorSupported: boolean; - autoDeploy: boolean | undefined; - autoDeployUpdating: boolean; - onAutoDeployChange: jest.Mock; onPendingActionComplete: jest.Mock; canViewEnvironments: boolean; environmentReadPermissionLoading: boolean; @@ -98,9 +69,6 @@ const defaultMockContext = (): MockContextValue => ({ refetch: jest.fn(), lowestEnvironment: 'development', isWorkloadEditorSupported: true, - autoDeploy: true, - autoDeployUpdating: false, - onAutoDeployChange: jest.fn(), onPendingActionComplete: jest.fn(), canViewEnvironments: true, environmentReadPermissionLoading: false, @@ -171,8 +139,14 @@ jest.mock('../../utils/errorUtils', () => ({ // ---- Helpers ---- +const testEntity = mockComponentEntity(); + function renderWithRouter(ui: React.ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } function makeEnv( @@ -197,7 +171,6 @@ describe('EnvironmentsList', () => { jest.clearAllMocks(); mockContextValue = defaultMockContext(); capturedEnvironmentCardProps = new Map(); - capturedSetupCardProps = undefined; }); // 1. Empty state when no environments + canViewEnvironments: true @@ -377,22 +350,7 @@ describe('EnvironmentsList', () => { }); }); - // 10. SetupCard receives autoDeploy and onAutoDeployChange - it('passes autoDeploy and onAutoDeployChange to SetupCard', () => { - const mockOnAutoDeployChange = jest.fn(); - mockContextValue.autoDeploy = true; - mockContextValue.onAutoDeployChange = mockOnAutoDeployChange; - - renderWithRouter(); - - expect(capturedSetupCardProps).toBeDefined(); - expect(capturedSetupCardProps!.autoDeploy).toBe(true); - expect(capturedSetupCardProps!.onAutoDeployChange).toBe( - mockOnAutoDeployChange, - ); - }); - - // 11. Settings gear: invoking onOpenOverrides calls navigateToOverrides + // 10. Settings gear: invoking onOpenOverrides calls navigateToOverrides it('calls navigateToOverrides with environment name when onOpenOverrides is invoked', () => { const envs = [ makeEnv({ diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx index 6627a929a..3d260d7bc 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx @@ -32,9 +32,6 @@ export const EnvironmentsList = () => { loading, refetch, isWorkloadEditorSupported, - autoDeploy, - autoDeployUpdating, - onAutoDeployChange, canViewEnvironments, environmentReadPermissionLoading, canViewBindings, @@ -126,9 +123,6 @@ export const EnvironmentsList = () => { environmentsExist={environments.length > 0} isWorkloadEditorSupported={isWorkloadEditorSupported} onConfigureWorkload={handleOpenWorkloadConfig} - autoDeploy={autoDeploy} - onAutoDeployChange={onAutoDeployChange} - autoDeployUpdating={autoDeployUpdating} /> diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentActions.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentActions.test.tsx new file mode 100644 index 000000000..7dbeb254f --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentActions.test.tsx @@ -0,0 +1,247 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EnvironmentActions } from './EnvironmentActions'; +import type { EnvironmentActionsProps, ItemActionTracker } from '../types'; + +// ---- Mocks ---- + +const mockUseDeployPermission = jest.fn(); +const mockUseUndeployPermission = jest.fn(); +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useDeployPermission: () => mockUseDeployPermission(), + useUndeployPermission: () => mockUseUndeployPermission(), +})); + +// ---- Helpers ---- + +function createTracker( + overrides: Partial = {}, +): ItemActionTracker { + return { + isActive: jest.fn().mockReturnValue(false), + withTracking: jest.fn((_item: string, fn: () => Promise) => fn()), + activeItems: new Set(), + startAction: jest.fn(), + endAction: jest.fn(), + ...overrides, + } as unknown as ItemActionTracker; +} + +function renderActions(overrides: Partial = {}) { + const defaultProps: EnvironmentActionsProps = { + environmentName: 'development', + deploymentStatus: 'Ready', + onPromote: jest.fn(), + onSuspend: jest.fn(), + onRedeploy: jest.fn(), + isAlreadyPromoted: jest.fn().mockReturnValue(false), + promotionTracker: createTracker(), + suspendTracker: createTracker(), + ...overrides, + }; + + return { + ...render(), + props: defaultProps, + }; +} + +const grantedDeploy = { + canDeploy: true, + loading: false, + deniedTooltip: '', +}; + +const deniedDeploy = { + canDeploy: false, + loading: false, + deniedTooltip: 'You do not have permission to deploy', +}; + +const grantedUndeploy = { + canUndeploy: true, + loading: false, + deniedTooltip: '', +}; + +const deniedUndeploy = { + canUndeploy: false, + loading: false, + deniedTooltip: 'You do not have permission to undeploy', +}; + +// ---- Tests ---- + +describe('EnvironmentActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDeployPermission.mockReturnValue(grantedDeploy); + mockUseUndeployPermission.mockReturnValue(grantedUndeploy); + }); + + it('renders nothing when no promotion targets and no binding', () => { + const { container } = renderActions({ + promotionTargets: undefined, + bindingName: undefined, + }); + + expect(container.firstChild).toBeNull(); + }); + + it('renders promote button for single target with permission', () => { + renderActions({ + promotionTargets: [{ name: 'staging' }], + }); + + const btn = screen.getByRole('button', { name: /promote/i }); + expect(btn).toBeEnabled(); + expect(btn).toHaveTextContent('Promote'); + }); + + it('calls onPromote with correct target on click', async () => { + const user = userEvent.setup(); + const onPromote = jest.fn(); + + renderActions({ + promotionTargets: [{ name: 'staging', resourceName: 'staging-res' }], + onPromote, + }); + + await user.click(screen.getByRole('button', { name: /promote/i })); + expect(onPromote).toHaveBeenCalledWith('staging-res'); + }); + + it('shows "Promoted" and disables button when already promoted', () => { + renderActions({ + promotionTargets: [{ name: 'staging' }], + isAlreadyPromoted: jest.fn().mockReturnValue(true), + }); + + const btn = screen.getByRole('button', { name: /promoted/i }); + expect(btn).toBeDisabled(); + expect(btn).toHaveTextContent('Promoted'); + }); + + it('shows "Promoting..." when promotion is in progress', () => { + renderActions({ + promotionTargets: [{ name: 'staging' }], + promotionTracker: createTracker({ + isActive: jest.fn().mockReturnValue(true), + }), + }); + + const btn = screen.getByRole('button', { name: /promoting/i }); + expect(btn).toBeDisabled(); + }); + + it('disables promote button when user lacks deploy permission', () => { + mockUseDeployPermission.mockReturnValue(deniedDeploy); + + renderActions({ + promotionTargets: [{ name: 'staging' }], + }); + + expect(screen.getByRole('button', { name: /promote/i })).toBeDisabled(); + }); + + it('shows Undeploy button when has binding and not undeployed', () => { + renderActions({ + bindingName: 'my-binding', + statusReason: undefined, + }); + + const btn = screen.getByRole('button', { name: /undeploy/i }); + expect(btn).toBeEnabled(); + }); + + it('calls onSuspend when Undeploy is clicked', async () => { + const user = userEvent.setup(); + const onSuspend = jest.fn(); + + renderActions({ + bindingName: 'my-binding', + onSuspend, + }); + + await user.click(screen.getByRole('button', { name: /undeploy/i })); + expect(onSuspend).toHaveBeenCalled(); + }); + + it('shows Redeploy button when resources are undeployed', () => { + renderActions({ + bindingName: 'my-binding', + statusReason: 'ResourcesUndeployed', + }); + + const btn = screen.getByRole('button', { name: /redeploy/i }); + expect(btn).toBeEnabled(); + }); + + it('calls onRedeploy when Redeploy is clicked', async () => { + const user = userEvent.setup(); + const onRedeploy = jest.fn(); + + renderActions({ + bindingName: 'my-binding', + statusReason: 'ResourcesUndeployed', + onRedeploy, + }); + + await user.click(screen.getByRole('button', { name: /redeploy/i })); + expect(onRedeploy).toHaveBeenCalled(); + }); + + it('disables undeploy/redeploy when user lacks permission', () => { + mockUseUndeployPermission.mockReturnValue(deniedUndeploy); + + renderActions({ + bindingName: 'my-binding', + }); + + expect(screen.getByRole('button', { name: /undeploy/i })).toBeDisabled(); + }); + + it('renders stacked buttons for multiple promotion targets', () => { + renderActions({ + promotionTargets: [ + { name: 'staging', resourceName: 'staging-res' }, + { name: 'production', resourceName: 'production-res' }, + ], + }); + + expect( + screen.getByRole('button', { name: /promote to staging/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /promote to production/i }), + ).toBeInTheDocument(); + }); + + it('calls onPromote with correct target for multi-target', async () => { + const user = userEvent.setup(); + const onPromote = jest.fn(); + + renderActions({ + promotionTargets: [ + { name: 'staging', resourceName: 'staging-res' }, + { name: 'production', resourceName: 'prod-res' }, + ], + onPromote, + }); + + await user.click( + screen.getByRole('button', { name: /promote to production/i }), + ); + expect(onPromote).toHaveBeenCalledWith('prod-res'); + }); + + it('shows approval required text for target requiring approval', () => { + renderActions({ + promotionTargets: [{ name: 'production', requiresApproval: true }], + }); + + expect(screen.getByRole('button')).toHaveTextContent( + 'Promote (Approval Required)', + ); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentCard.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentCard.test.tsx new file mode 100644 index 000000000..9f23ee9b8 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentCard.test.tsx @@ -0,0 +1,143 @@ +import { render, screen } from '@testing-library/react'; +import { EnvironmentCard } from './EnvironmentCard'; +import type { EnvironmentCardProps, ItemActionTracker } from '../types'; + +// ---- Mocks ---- + +jest.mock('./LoadingSkeleton', () => ({ + LoadingSkeleton: ({ variant }: { variant: string }) => ( +
+ ), +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ForbiddenState: ({ message }: { message: string }) => ( +
{message}
+ ), + useDeployPermission: () => ({ + canDeploy: true, + loading: false, + deniedTooltip: '', + }), + useUndeployPermission: () => ({ + canUndeploy: true, + loading: false, + deniedTooltip: '', + }), + formatRelativeTime: (ts: string) => `relative(${ts})`, +})); + +jest.mock('@openchoreo/backstage-design-system', () => ({ + Card: ({ children, ...rest }: any) => ( +
+ {children} +
+ ), + StatusBadge: ({ status }: { status: string }) => ( + {status} + ), +})); + +jest.mock('./InvokeUrlsDialog', () => ({ + InvokeUrlsDialog: () => null, +})); + +jest.mock('./IncidentsBanner', () => ({ + IncidentsBanner: () => null, +})); + +// ---- Helpers ---- + +function createTracker( + overrides: Partial = {}, +): ItemActionTracker { + return { + isActive: jest.fn().mockReturnValue(false), + withTracking: jest.fn((_item: string, fn: () => Promise) => fn()), + activeItems: new Set(), + startAction: jest.fn(), + endAction: jest.fn(), + ...overrides, + } as unknown as ItemActionTracker; +} + +function renderCard(overrides: Partial = {}) { + const defaultProps: EnvironmentCardProps = { + environmentName: 'development', + deployment: { status: 'Ready' }, + endpoints: [], + isRefreshing: false, + isAlreadyPromoted: jest.fn().mockReturnValue(false), + actionTrackers: { + promotionTracker: createTracker(), + suspendTracker: createTracker(), + }, + onRefresh: jest.fn(), + onOpenOverrides: jest.fn(), + onOpenReleaseDetails: jest.fn(), + onPromote: jest.fn(), + onSuspend: jest.fn(), + onRedeploy: jest.fn(), + ...overrides, + }; + + return render(); +} + +// ---- Tests ---- + +describe('EnvironmentCard', () => { + it('shows loading skeleton when isRefreshing is true', () => { + renderCard({ isRefreshing: true }); + + expect(screen.getByTestId('loading-skeleton-card')).toBeInTheDocument(); + expect(screen.queryByText('Deployment Status:')).not.toBeInTheDocument(); + }); + + it('shows forbidden state when canViewBindings is false', () => { + renderCard({ + canViewBindings: false, + bindingsPermissionLoading: false, + }); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect( + screen.getByText('You do not have permission to view release bindings.'), + ).toBeInTheDocument(); + }); + + it('renders header and content in normal state', () => { + renderCard({ + environmentName: 'production', + deployment: { status: 'Ready', releaseName: 'release-1' }, + }); + + // Header: environment name + expect(screen.getByText('production')).toBeInTheDocument(); + // Content: deployment status + expect(screen.getByText('Deployment Status:')).toBeInTheDocument(); + }); + + it('passes hasOverrides=true to header when hasComponentTypeOverrides', () => { + renderCard({ + hasComponentTypeOverrides: true, + deployment: { releaseName: 'release-1' }, + }); + + // Settings icon should be present (header renders it when hasReleaseName is true) + expect( + screen.getByTitle('Configure environment overrides'), + ).toBeInTheDocument(); + }); + + it('does not show forbidden state while permissions are loading', () => { + renderCard({ + canViewBindings: false, + bindingsPermissionLoading: true, + }); + + // Should render content, not forbidden state + expect(screen.queryByTestId('forbidden-state')).not.toBeInTheDocument(); + expect(screen.getByText('Deployment Status:')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentCardContent.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentCardContent.test.tsx new file mode 100644 index 000000000..c2bec09e1 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentCardContent.test.tsx @@ -0,0 +1,193 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EnvironmentCardContent } from './EnvironmentCardContent'; +import type { EnvironmentCardContentProps } from '../types'; + +// ---- Mocks ---- + +jest.mock('@openchoreo/backstage-design-system', () => ({ + StatusBadge: ({ status }: { status: string }) => ( + {status} + ), +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + formatRelativeTime: (ts: string) => `relative(${ts})`, +})); + +jest.mock('./InvokeUrlsDialog', () => ({ + InvokeUrlsDialog: ({ open, endpoints }: any) => + open ? ( +
{endpoints.length} endpoint(s)
+ ) : null, +})); + +jest.mock('./IncidentsBanner', () => ({ + IncidentsBanner: ({ count, environmentName }: any) => ( +
+ {count} incidents in {environmentName} +
+ ), +})); + +// ---- Helpers ---- + +function renderContent(overrides: Partial = {}) { + const defaultProps: EnvironmentCardContentProps = { + status: 'Ready', + endpoints: [], + onOpenReleaseDetails: jest.fn(), + ...overrides, + }; + + return { + ...render(), + props: defaultProps, + }; +} + +// ---- Tests ---- + +describe('EnvironmentCardContent', () => { + it('shows deployed time when lastDeployed is provided', () => { + renderContent({ lastDeployed: '2024-01-01T00:00:00Z' }); + + expect(screen.getByText('Deployed')).toBeInTheDocument(); + expect( + screen.getByText('relative(2024-01-01T00:00:00Z)'), + ).toBeInTheDocument(); + }); + + it('does not show deployed section when lastDeployed is absent', () => { + renderContent({ lastDeployed: undefined }); + + expect(screen.queryByText('Deployed')).not.toBeInTheDocument(); + }); + + it('shows "active" status badge for Ready status', () => { + renderContent({ status: 'Ready' }); + + expect(screen.getByTestId('status-badge')).toHaveTextContent('active'); + }); + + it('shows "pending" status badge for NotReady status', () => { + renderContent({ status: 'NotReady' }); + + expect(screen.getByTestId('status-badge')).toHaveTextContent('pending'); + }); + + it('shows "failed" status badge for Failed status', () => { + renderContent({ status: 'Failed' }); + + expect(screen.getByTestId('status-badge')).toHaveTextContent('failed'); + }); + + it('shows "undeployed" status badge for ResourcesUndeployed reason', () => { + renderContent({ + status: 'Ready', + statusReason: 'ResourcesUndeployed', + }); + + expect(screen.getByTestId('status-badge')).toHaveTextContent('undeployed'); + }); + + it('shows View K8s Artifacts button when releaseName exists', () => { + renderContent({ releaseName: 'release-1' }); + + expect( + screen.getByRole('button', { name: /view k8s artifacts/i }), + ).toBeInTheDocument(); + }); + + it('calls onOpenReleaseDetails when View K8s Artifacts is clicked', async () => { + const user = userEvent.setup(); + const onOpenReleaseDetails = jest.fn(); + + renderContent({ releaseName: 'release-1', onOpenReleaseDetails }); + + await user.click( + screen.getByRole('button', { name: /view k8s artifacts/i }), + ); + expect(onOpenReleaseDetails).toHaveBeenCalled(); + }); + + it('does not show artifacts button when releaseName is absent', () => { + renderContent({ releaseName: undefined }); + + expect( + screen.queryByRole('button', { name: /view k8s artifacts/i }), + ).not.toBeInTheDocument(); + }); + + it('shows Endpoint URLs with count badge when Ready with endpoints', () => { + renderContent({ + status: 'Ready', + endpoints: [ + { url: 'https://api.example.com' }, + { url: 'https://web.example.com' }, + ] as any, + }); + + expect(screen.getByText('Endpoint URLs')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('opens InvokeUrlsDialog when eye icon is clicked', async () => { + const user = userEvent.setup(); + + renderContent({ + status: 'Ready', + endpoints: [{ url: 'https://api.example.com' }] as any, + }); + + await user.click(screen.getByLabelText('Show endpoint URLs')); + + expect(screen.getByTestId('invoke-urls-dialog')).toBeInTheDocument(); + expect(screen.getByText('1 endpoint(s)')).toBeInTheDocument(); + }); + + it('does not show endpoint URLs section when not Ready', () => { + renderContent({ + status: 'NotReady', + endpoints: [{ url: 'https://api.example.com' }] as any, + }); + + expect(screen.queryByText('Endpoint URLs')).not.toBeInTheDocument(); + }); + + it('shows IncidentsBanner when there are active incidents', () => { + renderContent({ + status: 'Ready', + activeIncidentCount: 3, + environmentName: 'production', + }); + + expect(screen.getByTestId('incidents-banner')).toBeInTheDocument(); + expect(screen.getByText('3 incidents in production')).toBeInTheDocument(); + }); + + it('does not show IncidentsBanner when activeIncidentCount is 0', () => { + renderContent({ + status: 'Ready', + activeIncidentCount: 0, + environmentName: 'production', + }); + + expect(screen.queryByTestId('incidents-banner')).not.toBeInTheDocument(); + }); + + it('shows image when provided', () => { + renderContent({ image: 'registry.io/my-service:v1.0.0' }); + + expect(screen.getByText('Image')).toBeInTheDocument(); + expect( + screen.getByText('registry.io/my-service:v1.0.0'), + ).toBeInTheDocument(); + }); + + it('does not show image section when image is absent', () => { + renderContent({ image: undefined }); + + expect(screen.queryByText('Image')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx new file mode 100644 index 000000000..2d6b000a7 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx @@ -0,0 +1,245 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { TestApiProvider } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + createMockOpenChoreoClient, + mockComponentEntity, +} from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { SetupCard } from './SetupCard'; + +// ---- Mocks ---- + +jest.mock('./LoadingSkeleton', () => ({ + LoadingSkeleton: ({ variant }: { variant: string }) => ( +
+ ), +})); + +jest.mock('../Workload/WorkloadButton', () => ({ + WorkloadButton: ({ onConfigureWorkload }: any) => ( + + ), +})); + +const mockUpdateAutoDeploy = jest.fn(); +jest.mock('../hooks/useAutoDeployUpdate', () => ({ + useAutoDeployUpdate: () => ({ + updateAutoDeploy: mockUpdateAutoDeploy, + isUpdating: false, + error: null, + }), +})); + +const mockShowSuccess = jest.fn(); +const mockShowError = jest.fn(); +jest.mock('../../../hooks', () => ({ + useNotification: () => ({ + notification: null, + showSuccess: mockShowSuccess, + showError: mockShowError, + hide: jest.fn(), + }), +})); + +// ---- Helpers ---- + +const mockClient = createMockOpenChoreoClient(); +const testEntity = mockComponentEntity(); + +function renderSetupCard( + props: Partial> = {}, +) { + const defaultProps = { + loading: false, + environmentsExist: true, + isWorkloadEditorSupported: false, + onConfigureWorkload: jest.fn(), + }; + + return render( + + + + + + + , + ); +} + +// ---- Tests ---- + +describe('SetupCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockClient.getComponentDetails.mockResolvedValue({}); + }); + + it('shows loading skeleton when loading with no environments', () => { + renderSetupCard({ loading: true, environmentsExist: false }); + + expect(screen.getByTestId('loading-skeleton-setup')).toBeInTheDocument(); + expect(screen.queryByText('Auto Deploy')).not.toBeInTheDocument(); + }); + + it('shows content when loaded', () => { + renderSetupCard(); + + expect(screen.getByText('Set up')).toBeInTheDocument(); + expect( + screen.getByText('Manage deployment configuration and settings'), + ).toBeInTheDocument(); + expect(screen.getByText('Auto Deploy')).toBeInTheDocument(); + }); + + it('fetches and displays autoDeploy=true from component details', async () => { + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); + + renderSetupCard(); + + await waitFor(() => { + const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); + expect(switchEl).toBeChecked(); + }); + }); + + it('fetches and displays autoDeploy=false from component details', async () => { + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); + + renderSetupCard(); + + await waitFor(() => { + const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); + expect(switchEl).not.toBeChecked(); + }); + }); + + it('switch defaults to unchecked when autoDeploy is undefined', () => { + mockClient.getComponentDetails.mockResolvedValue({}); + + renderSetupCard(); + + const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); + expect(switchEl).not.toBeChecked(); + }); + + it('opens confirmation dialog when toggle is clicked', async () => { + const user = userEvent.setup(); + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); + + renderSetupCard(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).not.toBeChecked(); + }); + + await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); + + expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); + expect(screen.getByText('Confirm')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls updateAutoDeploy on confirm and shows success notification', async () => { + const user = userEvent.setup(); + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); + mockUpdateAutoDeploy.mockResolvedValue(true); + + renderSetupCard(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).not.toBeChecked(); + }); + + await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); + await user.click(screen.getByText('Confirm')); + + expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(mockShowSuccess).toHaveBeenCalledWith( + 'Auto deploy enabled successfully', + ); + }); + }); + + it('shows error notification when updateAutoDeploy fails', async () => { + const user = userEvent.setup(); + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); + mockUpdateAutoDeploy.mockResolvedValue(false); + + renderSetupCard(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).toBeChecked(); + }); + + await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); + await user.click(screen.getByText('Confirm')); + + expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(false); + await waitFor(() => { + expect(mockShowError).toHaveBeenCalledWith( + 'Failed to update auto deploy setting', + ); + }); + }); + + it('closes dialog on cancel without calling updateAutoDeploy', async () => { + const user = userEvent.setup(); + renderSetupCard(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).toBeEnabled(); + }); + + await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); + expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); + + await user.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(screen.queryByText('Enable Auto Deploy?')).not.toBeInTheDocument(); + }); + expect(mockUpdateAutoDeploy).not.toHaveBeenCalled(); + }); + + it('shows WorkloadButton when isWorkloadEditorSupported is true', () => { + renderSetupCard({ isWorkloadEditorSupported: true }); + + expect(screen.getByTestId('workload-button')).toBeInTheDocument(); + }); + + it('hides WorkloadButton when isWorkloadEditorSupported is false', () => { + renderSetupCard({ isWorkloadEditorSupported: false }); + + expect(screen.queryByTestId('workload-button')).not.toBeInTheDocument(); + }); + + it('silently handles getComponentDetails failure', async () => { + mockClient.getComponentDetails.mockRejectedValue( + new Error('Network error'), + ); + + renderSetupCard(); + + // Should render normally with switch unchecked (default) + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).not.toBeChecked(); + }); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx index 3f0cf8a15..ba299e52a 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Box, Typography, @@ -9,11 +9,16 @@ import { } from '@material-ui/core'; import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { useSetupCardStyles } from '../styles'; import { SetupCardProps } from '../types'; import { LoadingSkeleton } from './LoadingSkeleton'; import { WorkloadButton } from '../Workload/WorkloadButton'; import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; +import { useNotification } from '../../../hooks'; /** * Setup card showing workload deployment options and auto deploy toggle. @@ -24,14 +29,60 @@ export const SetupCard = ({ environmentsExist, isWorkloadEditorSupported, onConfigureWorkload, - autoDeploy, - onAutoDeployChange, - autoDeployUpdating, }: SetupCardProps) => { const classes = useSetupCardStyles(); + const { entity } = useEntity(); + const client = useApi(openChoreoClientApiRef); + const notification = useNotification(); + const { updateAutoDeploy, isUpdating: autoDeployUpdating } = + useAutoDeployUpdate(entity); + + const [autoDeploy, setAutoDeploy] = useState(undefined); + const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); + // Fetch component details to get autoDeploy value + useEffect(() => { + let cancelled = false; + setAutoDeployLoaded(false); + + const fetchComponentData = async () => { + try { + const componentData = await client.getComponentDetails(entity); + if (!cancelled && componentData?.autoDeploy !== undefined) { + setAutoDeploy(componentData.autoDeploy); + } + } catch { + // Auto-deploy state will remain undefined and switch stays disabled + return; + } + if (!cancelled) { + setAutoDeployLoaded(true); + } + }; + + fetchComponentData(); + return () => { + cancelled = true; + }; + }, [entity, client]); + + const handleAutoDeployChange = useCallback( + async (newAutoDeploy: boolean) => { + const success = await updateAutoDeploy(newAutoDeploy); + if (success) { + setAutoDeploy(newAutoDeploy); + notification.showSuccess( + `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`, + ); + } else { + notification.showError('Failed to update auto deploy setting'); + } + }, + [updateAutoDeploy, notification], + ); + const handleToggleChange = (event: React.ChangeEvent) => { const newValue = event.target.checked; setPendingAutoDeployValue(newValue); @@ -39,7 +90,7 @@ export const SetupCard = ({ }; const handleConfirm = () => { - onAutoDeployChange(pendingAutoDeployValue); + handleAutoDeployChange(pendingAutoDeployValue); setShowConfirmDialog(false); }; @@ -76,7 +127,7 @@ export const SetupCard = ({ onChange={handleToggleChange} name="autoDeploy" color="primary" - disabled={autoDeployUpdating} + disabled={!autoDeployLoaded || autoDeployUpdating} /> } label={Auto Deploy} diff --git a/plugins/openchoreo/src/components/Environments/types.ts b/plugins/openchoreo/src/components/Environments/types.ts index d22f35d35..c5e8dd7f0 100644 --- a/plugins/openchoreo/src/components/Environments/types.ts +++ b/plugins/openchoreo/src/components/Environments/types.ts @@ -65,9 +65,6 @@ export interface SetupCardProps { environmentsExist: boolean; isWorkloadEditorSupported: boolean; onConfigureWorkload: () => void; - autoDeploy?: boolean; - onAutoDeployChange: (autoDeploy: boolean) => void; - autoDeployUpdating: boolean; } /** diff --git a/plugins/openchoreo/src/components/GitSecrets/CreateSecretDialog.test.tsx b/plugins/openchoreo/src/components/GitSecrets/CreateSecretDialog.test.tsx new file mode 100644 index 000000000..13194ffb9 --- /dev/null +++ b/plugins/openchoreo/src/components/GitSecrets/CreateSecretDialog.test.tsx @@ -0,0 +1,281 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CreateSecretDialog, WorkflowPlaneOption } from './CreateSecretDialog'; + +// ---- Helpers ---- + +const planes: WorkflowPlaneOption[] = [ + { name: 'default-plane', kind: 'WorkflowPlane' }, + { name: 'shared-plane', kind: 'ClusterWorkflowPlane' }, +]; + +function renderDialog( + overrides: Partial> = {}, +) { + const defaultProps = { + open: true, + onClose: jest.fn(), + onSubmit: jest.fn().mockResolvedValue(undefined), + namespaceName: 'test-ns', + existingSecretNames: [] as string[], + workflowPlanes: planes, + workflowPlanesLoading: false, + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('CreateSecretDialog', () => { + it('renders dialog title and namespace info when open', () => { + renderDialog(); + + expect(screen.getByText('Create Git Secret')).toBeInTheDocument(); + expect(screen.getByText('test-ns')).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + renderDialog({ open: false }); + + expect(screen.queryByText('Create Git Secret')).not.toBeInTheDocument(); + }); + + it('defaults to Basic Authentication type', () => { + renderDialog(); + + const basicRadio = screen.getByLabelText('Basic Authentication'); + expect(basicRadio).toBeChecked(); + }); + + it('shows basic auth fields by default', () => { + renderDialog(); + + expect( + screen.getByText('Username for git authentication.'), + ).toBeInTheDocument(); + expect( + screen.getByText(/Your git provider password or access token/), + ).toBeInTheDocument(); + }); + + it('switches to SSH auth fields when SSH radio is selected', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByLabelText('SSH Authentication')); + + expect( + screen.getByText('SSH key identifier for git authentication.'), + ).toBeInTheDocument(); + expect( + screen.getByText(/Drag and drop file with your private SSH key/), + ).toBeInTheDocument(); + }); + + it('disables Create button when secret name is empty', () => { + renderDialog(); + + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('disables Create button when basic auth token is empty', async () => { + const user = userEvent.setup(); + renderDialog(); + + // Type a secret name but leave token empty + const inputs = screen.getAllByRole('textbox'); + // First textbox is Secret Name + await user.type(inputs[0], 'my-secret'); + + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('enables Create button when name and token are filled', async () => { + const user = userEvent.setup(); + renderDialog(); + + const inputs = screen.getAllByRole('textbox'); + // Secret Name + await user.type(inputs[0], 'my-secret'); + // Username (optional) + // Password or Token - it's a password field, not a textbox + // Need to find the password input differently + const passwordInput = document.querySelector( + 'input[type="password"]', + ) as HTMLInputElement; + await user.type(passwordInput, 'my-token'); + + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled(); + }); + + it('shows error for duplicate secret name on submit', async () => { + const user = userEvent.setup(); + renderDialog({ existingSecretNames: ['existing-secret'] }); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'existing-secret'); + + const passwordInput = document.querySelector( + 'input[type="password"]', + ) as HTMLInputElement; + await user.type(passwordInput, 'token'); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect( + screen.getByText(/A secret with this name already exists/), + ).toBeInTheDocument(); + }); + + it('shows error for invalid secret name format', async () => { + const user = userEvent.setup(); + renderDialog(); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'invalid_name!'); + + const passwordInput = document.querySelector( + 'input[type="password"]', + ) as HTMLInputElement; + await user.type(passwordInput, 'token'); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect( + screen.getByText(/must consist of lowercase alphanumeric characters/), + ).toBeInTheDocument(); + }); + + it('keeps Create button disabled when name is filled but token is empty', async () => { + const user = userEvent.setup(); + renderDialog(); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'my-secret'); + + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('calls onSubmit with correct args and closes on success', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); + + renderDialog({ onSubmit, onClose }); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'my-secret'); + + // Type username + await user.type(inputs[1], 'myuser'); + + const passwordInput = document.querySelector( + 'input[type="password"]', + ) as HTMLInputElement; + await user.type(passwordInput, 'my-token'); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + 'my-secret', + 'basic-auth', + 'my-token', + 'myuser', + undefined, + 'WorkflowPlane', + 'default-plane', + ); + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows error when onSubmit rejects', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn().mockRejectedValue(new Error('Create failed')); + + renderDialog({ onSubmit }); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'my-secret'); + + const passwordInput = document.querySelector( + 'input[type="password"]', + ) as HTMLInputElement; + await user.type(passwordInput, 'token'); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Create failed')).toBeInTheDocument(); + }); + }); + + it('shows "Creating..." and disables buttons when loading', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn().mockReturnValue(new Promise(() => {})); // never resolves + + renderDialog({ onSubmit }); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'my-secret'); + + const passwordInput = document.querySelector( + 'input[type="password"]', + ) as HTMLInputElement; + await user.type(passwordInput, 'token'); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + }); + + it('calls onClose when Cancel is clicked', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + renderDialog({ onClose }); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(onClose).toHaveBeenCalled(); + }); + + it('shows SSH key validation error for invalid key format', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByLabelText('SSH Authentication')); + + const inputs = screen.getAllByRole('textbox'); + // Secret Name + await user.type(inputs[0], 'ssh-secret'); + // SSH Private Key textarea + const sshKeyTextarea = inputs[inputs.length - 1]; + await user.type(sshKeyTextarea, 'not-a-valid-key'); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect(screen.getByText(/Invalid SSH key format/)).toBeInTheDocument(); + }); + + it('keeps Create button disabled when SSH auth selected but key is empty', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByLabelText('SSH Authentication')); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[0], 'ssh-secret'); + + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo/src/components/GitSecrets/GitSecretsPage.test.tsx b/plugins/openchoreo/src/components/GitSecrets/GitSecretsPage.test.tsx new file mode 100644 index 000000000..cfeb6d5c1 --- /dev/null +++ b/plugins/openchoreo/src/components/GitSecrets/GitSecretsPage.test.tsx @@ -0,0 +1,239 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TestApiProvider } from '@backstage/test-utils'; +import { createMockOpenChoreoClient } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../api/OpenChoreoClientApi'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { GitSecretsContent } from './GitSecretsPage'; + +// ---- Mocks ---- + +jest.mock('@backstage/core-components', () => ({ + Page: ({ children }: any) =>
{children}
, + Header: () => null, + Content: ({ children }: any) =>
{children}
, + WarningPanel: ({ title, children }: any) => ( +
+ {children} +
+ ), +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ForbiddenState: ({ message, onRetry }: any) => ( +
+ {message} + {onRetry && } +
+ ), +})); + +const mockUseGitSecrets = jest.fn(); +jest.mock('./hooks/useGitSecrets', () => ({ + useGitSecrets: (ns: string) => mockUseGitSecrets(ns), +})); + +jest.mock('./SecretsTable', () => ({ + SecretsTable: ({ secrets, loading, onDelete, namespaceName }: any) => ( +
+ {namespaceName} + {String(loading)} + {secrets.map((s: any) => ( +
+ {s.name} + +
+ ))} +
+ ), +})); + +jest.mock('./CreateSecretDialog', () => ({ + CreateSecretDialog: ({ open, onClose, onSubmit }: any) => + open ? ( +
+ + +
+ ) : null, +})); + +// ---- Helpers ---- + +const mockClient = createMockOpenChoreoClient(); +const mockCatalogApi = { + getEntities: jest.fn(), +}; + +const defaultSecretsHook = { + secrets: [], + loading: false, + error: null, + isForbidden: false, + createSecret: jest.fn(), + deleteSecret: jest.fn(), + fetchSecrets: jest.fn(), +}; + +function renderContent() { + return render( + + + , + ); +} + +// ---- Tests ---- + +describe('GitSecretsContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockClient.listNamespaces.mockResolvedValue([ + { name: 'alpha-ns', displayName: 'Alpha' }, + { name: 'beta-ns', displayName: 'Beta' }, + ]); + mockCatalogApi.getEntities.mockResolvedValue({ items: [] }); + mockUseGitSecrets.mockReturnValue(defaultSecretsHook); + }); + + it('shows "Select a namespace" prompt initially before namespaces load', () => { + mockClient.listNamespaces.mockReturnValue(new Promise(() => {})); // never resolves + + renderContent(); + + expect( + screen.getByText('Select a namespace to manage git secrets'), + ).toBeInTheDocument(); + }); + + it('auto-selects first namespace after loading', async () => { + renderContent(); + + await waitFor(() => { + expect(mockUseGitSecrets).toHaveBeenCalledWith('alpha-ns'); + }); + }); + + it('renders namespace selector', async () => { + renderContent(); + + await waitFor(() => { + expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); + }); + }); + + it('shows secrets table when namespace is selected', async () => { + mockUseGitSecrets.mockReturnValue({ + ...defaultSecretsHook, + secrets: [{ name: 'my-secret' }], + }); + + renderContent(); + + await waitFor(() => { + expect(screen.getByTestId('secrets-table')).toBeInTheDocument(); + }); + expect(screen.getByTestId('secret-my-secret')).toBeInTheDocument(); + }); + + it('shows forbidden state when secrets access is forbidden', async () => { + mockUseGitSecrets.mockReturnValue({ + ...defaultSecretsHook, + error: new Error('403 Forbidden'), + isForbidden: true, + }); + + renderContent(); + + await waitFor(() => { + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + }); + expect( + screen.getByText('You do not have permission to view git secrets.'), + ).toBeInTheDocument(); + }); + + it('shows error warning for non-forbidden secrets errors', async () => { + mockUseGitSecrets.mockReturnValue({ + ...defaultSecretsHook, + error: new Error('Network failure'), + isForbidden: false, + }); + + renderContent(); + + await waitFor(() => { + expect(screen.getByText('Network failure')).toBeInTheDocument(); + }); + }); + + it('shows namespace loading error', async () => { + mockClient.listNamespaces.mockRejectedValue( + new Error('Failed to fetch namespaces'), + ); + + renderContent(); + + await waitFor(() => { + expect( + screen.getByText('Failed to fetch namespaces'), + ).toBeInTheDocument(); + }); + }); + + it('disables Create Secret button when no namespace selected', () => { + mockClient.listNamespaces.mockReturnValue(new Promise(() => {})); + + renderContent(); + + expect( + screen.getByRole('button', { name: /create secret/i }), + ).toBeDisabled(); + }); + + it('enables Create Secret button when namespace is selected', async () => { + renderContent(); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /create secret/i }), + ).toBeEnabled(); + }); + }); + + it('opens create dialog when Create Secret is clicked', async () => { + const user = userEvent.setup(); + + renderContent(); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /create secret/i }), + ).toBeEnabled(); + }); + + await user.click(screen.getByRole('button', { name: /create secret/i })); + + expect(screen.getByTestId('create-dialog')).toBeInTheDocument(); + }); + + it('disables Refresh button when no namespace selected', () => { + mockClient.listNamespaces.mockReturnValue(new Promise(() => {})); + + renderContent(); + + expect(screen.getByTitle('Refresh')).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo/src/components/GitSecrets/SecretsTable.test.tsx b/plugins/openchoreo/src/components/GitSecrets/SecretsTable.test.tsx new file mode 100644 index 000000000..cb07a7396 --- /dev/null +++ b/plugins/openchoreo/src/components/GitSecrets/SecretsTable.test.tsx @@ -0,0 +1,198 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SecretsTable } from './SecretsTable'; +import { GitSecret } from '../../api/OpenChoreoClientApi'; + +// ---- Helpers ---- + +const secrets: GitSecret[] = [ + { + name: 'repo-token', + namespace: 'test-ns', + workflowPlaneName: 'default-plane', + workflowPlaneKind: 'WorkflowPlane', + }, + { + name: 'deploy-key', + namespace: 'test-ns', + workflowPlaneName: 'shared-plane', + workflowPlaneKind: 'ClusterWorkflowPlane', + }, +]; + +function renderTable( + overrides: Partial> = {}, +) { + const defaultProps = { + secrets, + loading: false, + onDelete: jest.fn().mockResolvedValue(undefined), + namespaceName: 'test-ns', + }; + + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +// ---- Tests ---- + +describe('SecretsTable', () => { + it('shows progress bar when loading', () => { + renderTable({ loading: true }); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('shows empty state when no secrets', () => { + renderTable({ secrets: [] }); + + expect(screen.getByText('No git secrets in test-ns')).toBeInTheDocument(); + expect( + screen.getByText( + 'Create a git secret to access private repositories during builds.', + ), + ).toBeInTheDocument(); + }); + + it('renders secret names in the table', () => { + renderTable(); + + expect(screen.getByText('repo-token')).toBeInTheDocument(); + expect(screen.getByText('deploy-key')).toBeInTheDocument(); + }); + + it('renders workflow plane names', () => { + renderTable(); + + expect(screen.getByText('default-plane')).toBeInTheDocument(); + expect(screen.getByText('shared-plane')).toBeInTheDocument(); + }); + + it('shows Cluster chip for ClusterWorkflowPlane secrets', () => { + renderTable(); + + expect(screen.getByText('Cluster')).toBeInTheDocument(); + }); + + it('shows search field when secrets exist', () => { + renderTable(); + + expect(screen.getByLabelText('Search secrets')).toBeInTheDocument(); + }); + + it('does not show search field when no secrets', () => { + renderTable({ secrets: [] }); + + expect(screen.queryByLabelText('Search secrets')).not.toBeInTheDocument(); + }); + + it('filters secrets by search query', async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByLabelText('Search secrets'), 'repo'); + + expect(screen.getByText('repo-token')).toBeInTheDocument(); + expect(screen.queryByText('deploy-key')).not.toBeInTheDocument(); + }); + + it('shows no match message when search has no results', async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByLabelText('Search secrets'), 'nonexistent'); + + expect( + screen.getByText('No secrets match your search'), + ).toBeInTheDocument(); + }); + + it('opens delete confirmation dialog when delete icon is clicked', async () => { + const user = userEvent.setup(); + renderTable(); + + const deleteButtons = screen.getAllByTitle('Delete'); + await user.click(deleteButtons[0]); + + expect(screen.getByText('Delete Git Secret')).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete the git secret/), + ).toBeInTheDocument(); + // "repo-token" appears both in the table row and the dialog's tag + expect(screen.getAllByText('repo-token').length).toBeGreaterThanOrEqual(2); + }); + + it('calls onDelete and closes dialog on confirm', async () => { + const user = userEvent.setup(); + const onDelete = jest.fn().mockResolvedValue(undefined); + renderTable({ onDelete }); + + const deleteButtons = screen.getAllByTitle('Delete'); + await user.click(deleteButtons[0]); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalledWith('repo-token'); + }); + + await waitFor(() => { + expect(screen.queryByText('Delete Git Secret')).not.toBeInTheDocument(); + }); + }); + + it('shows error when delete fails', async () => { + const user = userEvent.setup(); + const onDelete = jest.fn().mockRejectedValue(new Error('Delete failed')); + renderTable({ onDelete }); + + const deleteButtons = screen.getAllByTitle('Delete'); + await user.click(deleteButtons[0]); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => { + expect(screen.getByText('Delete failed')).toBeInTheDocument(); + }); + }); + + it('closes delete dialog on cancel', async () => { + const user = userEvent.setup(); + renderTable(); + + const deleteButtons = screen.getAllByTitle('Delete'); + await user.click(deleteButtons[0]); + + expect(screen.getByText('Delete Git Secret')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => { + expect(screen.queryByText('Delete Git Secret')).not.toBeInTheDocument(); + }); + }); + + it('shows "Deleting..." and disables buttons during deletion', async () => { + const user = userEvent.setup(); + const onDelete = jest.fn().mockReturnValue(new Promise(() => {})); // never resolves + renderTable({ onDelete }); + + const deleteButtons = screen.getAllByTitle('Delete'); + await user.click(deleteButtons[0]); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByRole('button', { name: /deleting/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + }); + + it('renders table headers', () => { + renderTable(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Workflow Plane')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/GitSecrets/hooks/useGitSecrets.test.tsx b/plugins/openchoreo/src/components/GitSecrets/hooks/useGitSecrets.test.tsx new file mode 100644 index 000000000..4bb532fa0 --- /dev/null +++ b/plugins/openchoreo/src/components/GitSecrets/hooks/useGitSecrets.test.tsx @@ -0,0 +1,179 @@ +import { renderHook, act } from '@testing-library/react'; +import { useGitSecrets } from './useGitSecrets'; + +// ---- Mocks ---- + +const mockClient = { + listGitSecrets: jest.fn(), + createGitSecret: jest.fn(), + deleteGitSecret: jest.fn(), +}; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: () => mockClient, +})); + +// ---- Tests ---- + +describe('useGitSecrets', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockClient.listGitSecrets.mockResolvedValue({ items: [] }); + }); + + it('fetches secrets on mount', async () => { + renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + expect(mockClient.listGitSecrets).toHaveBeenCalledWith('test-ns'); + }); + + it('returns secrets from the API', async () => { + const items = [ + { name: 'secret-1', namespace: 'test-ns' }, + { name: 'secret-2', namespace: 'test-ns' }, + ]; + mockClient.listGitSecrets.mockResolvedValue({ items }); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + expect(result.current.secrets).toEqual(items); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('sets loading to true while fetching', () => { + mockClient.listGitSecrets.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + expect(result.current.loading).toBe(true); + }); + + it('sets error when fetch fails', async () => { + mockClient.listGitSecrets.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + expect(result.current.error).toEqual(new Error('Network error')); + expect(result.current.secrets).toEqual([]); + }); + + it('returns empty secrets when namespace is empty', () => { + const { result } = renderHook(() => useGitSecrets('')); + + expect(result.current.secrets).toEqual([]); + expect(mockClient.listGitSecrets).not.toHaveBeenCalled(); + }); + + it('creates a secret and refreshes the list', async () => { + const newSecret = { name: 'new-secret', namespace: 'test-ns' }; + mockClient.createGitSecret.mockResolvedValue(newSecret); + mockClient.listGitSecrets.mockResolvedValue({ + items: [newSecret], + }); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + await act(async () => { + const created = await result.current.createSecret( + 'new-secret', + 'basic-auth', + 'token123', + 'user', + ); + expect(created).toEqual(newSecret); + }); + + expect(mockClient.createGitSecret).toHaveBeenCalledWith( + 'test-ns', + 'new-secret', + 'basic-auth', + 'token123', + 'user', + undefined, + undefined, + undefined, + ); + + // Verify list was refreshed after create (initial load + refresh) + expect(mockClient.listGitSecrets).toHaveBeenCalledTimes(2); + }); + + it('deletes a secret and refreshes the list', async () => { + mockClient.deleteGitSecret.mockResolvedValue(undefined); + mockClient.listGitSecrets + .mockResolvedValueOnce({ + items: [{ name: 'to-delete', namespace: 'test-ns' }], + }) + .mockResolvedValueOnce({ items: [] }); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + await act(async () => { + await result.current.deleteSecret('to-delete'); + }); + + expect(mockClient.deleteGitSecret).toHaveBeenCalledWith( + 'test-ns', + 'to-delete', + ); + + // Verify list was refreshed after delete (initial load + refresh) + expect(mockClient.listGitSecrets).toHaveBeenCalledTimes(2); + }); + + it('refetches secrets when namespace changes', async () => { + mockClient.listGitSecrets.mockResolvedValue({ items: [] }); + + const { rerender } = renderHook(({ ns }) => useGitSecrets(ns), { + initialProps: { ns: 'ns-a' }, + }); + + await act(async () => {}); + + expect(mockClient.listGitSecrets).toHaveBeenCalledWith('ns-a'); + + rerender({ ns: 'ns-b' }); + + await act(async () => {}); + + expect(mockClient.listGitSecrets).toHaveBeenCalledWith('ns-b'); + }); + + it('sets isForbidden to false for non-403 errors', async () => { + mockClient.listGitSecrets.mockRejectedValue(new Error('Server error')); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + expect(result.current.isForbidden).toBe(false); + }); + + it('sets isForbidden to true for 403 errors', async () => { + const { ResponseError } = jest.requireActual('@backstage/errors'); + const forbiddenError = new ResponseError({ + statusCode: 403, + statusText: 'Forbidden', + data: { error: { name: 'NotAllowedError', message: 'Forbidden' } }, + }); + mockClient.listGitSecrets.mockRejectedValue(forbiddenError); + + const { result } = renderHook(() => useGitSecrets('test-ns')); + + await act(async () => {}); + + expect(result.current.isForbidden).toBe(true); + }); +}); diff --git a/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx b/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx index d3fa74737..f34cedb48 100644 --- a/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx +++ b/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockSystemEntity } from '@openchoreo/test-utils'; import { DeploymentPipelineCard } from './DeploymentPipelineCard'; // ---- Mocks ---- @@ -10,24 +12,6 @@ jest.mock('../hooks', () => ({ useDeploymentPipeline: () => mockUseDeploymentPipeline(), })); -// Mock useEntity -jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: () => ({ - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'System', - metadata: { - name: 'test-project', - namespace: 'default', - annotations: { - 'openchoreo.io/namespace': 'test-ns', - }, - }, - spec: {}, - }, - }), -})); - // Mock permission hook const mockUseProjectUpdatePermission = jest.fn(); jest.mock('@openchoreo/backstage-plugin-react', () => ({ @@ -61,22 +45,6 @@ jest.mock('./ChangePipelineDialog', () => ({ ChangePipelineDialog: () => null, })); -// Mock styles -jest.mock('./styles', () => ({ - useProjectOverviewCardStyles: () => ({ - card: 'card', - cardHeader: 'cardHeader', - cardTitle: 'cardTitle', - content: 'content', - pipelineInfo: 'pipelineInfo', - infoRow: 'infoRow', - infoLabel: 'infoLabel', - infoValue: 'infoValue', - disabledState: 'disabledState', - disabledIcon: 'disabledIcon', - }), -})); - // Mock error utils jest.mock('../../../utils/errorUtils', () => ({ isForbiddenError: (err: any) => @@ -87,8 +55,14 @@ jest.mock('../../../utils/errorUtils', () => ({ // ---- Helpers ---- +const testEntity = mockSystemEntity({ name: 'test-project' }); + function renderWithRouter(ui: React.ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } // ---- Tests ---- diff --git a/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx b/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx index 9fe071ac3..f7a729ca9 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx @@ -1,27 +1,11 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockSystemEntity } from '@openchoreo/test-utils'; import { ProjectComponentsCard } from './ProjectComponentsCard'; // ---- Mocks ---- -// Mock styles -jest.mock('./styles', () => ({ - useProjectComponentsCardStyles: () => ({ - cardWrapper: 'cardWrapper', - deploymentStatus: 'deploymentStatus', - chipContainer: 'chipContainer', - environmentChip: 'environmentChip', - statusIconReady: 'statusIconReady', - statusIconWarning: 'statusIconWarning', - statusIconError: 'statusIconError', - statusIconDefault: 'statusIconDefault', - buildStatus: 'buildStatus', - tooltipBuildName: 'tooltipBuildName', - moreChip: 'moreChip', - createComponentButton: 'createComponentButton', - }), -})); - // Mock project hooks const mockUseComponentsWithDeployment = jest.fn(); const mockUseEnvironments = jest.fn(); @@ -33,26 +17,9 @@ jest.mock('../hooks', () => ({ useDeploymentPipeline: () => mockUseDeploymentPipeline(), })); -// Mock @backstage/plugin-catalog-react -jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: () => ({ - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'System', - metadata: { - name: 'test-project', - namespace: 'default', - annotations: { - 'openchoreo.io/namespace': 'test-ns', - }, - }, - spec: {}, - }, - }), -})); - -// Mock @backstage/core-plugin-api +// Mock @backstage/core-plugin-api (useApp is not provided by TestApiProvider) jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), useApp: () => ({ getSystemIcon: () => () => , }), @@ -136,8 +103,14 @@ jest.mock('./BuildStatusCell', () => ({ // ---- Helpers ---- +const testEntity = mockSystemEntity({ name: 'test-project' }); + function renderWithRouter(ui: React.ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } const defaultPermissions = () => { diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/WorkflowsOverviewCard.test.tsx b/plugins/openchoreo/src/components/Workflows/OverviewCard/WorkflowsOverviewCard.test.tsx new file mode 100644 index 000000000..e69d38aed --- /dev/null +++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/WorkflowsOverviewCard.test.tsx @@ -0,0 +1,239 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { WorkflowsOverviewCard } from './WorkflowsOverviewCard'; + +// ---- Mocks ---- + +jest.mock('@openchoreo/backstage-design-system', () => ({ + Card: ({ children, ...rest }: any) => ( +
+ {children} +
+ ), +})); + +jest.mock('@backstage/core-components', () => ({ + Link: ({ children, to }: any) => {children}, +})); + +jest.mock('../BuildStatusChip', () => ({ + BuildStatusChip: ({ status }: { status: string }) => ( + {status} + ), +})); + +const mockUseBuildPermission = jest.fn(); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + useBuildPermission: () => mockUseBuildPermission(), + ForbiddenState: ({ message }: any) => ( +
{message}
+ ), + formatRelativeTime: (ts: string) => `relative(${ts})`, +})); + +const mockUseWorkflowsSummary = jest.fn(); +jest.mock('./useWorkflowsSummary', () => ({ + useWorkflowsSummary: () => mockUseWorkflowsSummary(), +})); + +// ---- Helpers ---- + +const defaultPermission = { + canBuild: true, + canView: true, + viewBuildDeniedTooltip: '', + triggerLoading: false, + viewLoading: false, + triggerBuildDeniedTooltip: '', +}; + +function renderCard() { + return render( + + + , + ); +} + +// ---- Tests ---- + +describe('WorkflowsOverviewCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseBuildPermission.mockReturnValue(defaultPermission); + }); + + it('shows forbidden state when view permission is denied', () => { + mockUseBuildPermission.mockReturnValue({ + ...defaultPermission, + canView: false, + viewLoading: false, + viewBuildDeniedTooltip: 'No permission to view builds', + }); + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: false, + latestBuild: null, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect(screen.getByTestId('forbidden-state')).toBeInTheDocument(); + expect( + screen.getByText('No permission to view builds'), + ).toBeInTheDocument(); + }); + + it('shows loading skeleton when loading', () => { + mockUseWorkflowsSummary.mockReturnValue({ + loading: true, + error: null, + hasWorkflows: false, + latestBuild: null, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect(screen.getByTestId('ds-card')).toBeInTheDocument(); + expect(screen.queryByText('Workflows')).not.toBeInTheDocument(); + }); + + it('shows error state when there is an error', () => { + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: new Error('fetch failed'), + hasWorkflows: false, + latestBuild: null, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect( + screen.getByText('Failed to load workflow data'), + ).toBeInTheDocument(); + }); + + it('shows workflows not enabled state', () => { + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: false, + latestBuild: null, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect( + screen.getByText('Workflows not enabled for this component'), + ).toBeInTheDocument(); + }); + + it('shows no builds yet with Build Now button', () => { + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: true, + latestBuild: null, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect(screen.getByText('No builds yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /build now/i })).toBeEnabled(); + expect(screen.getByText('View All')).toBeInTheDocument(); + }); + + it('shows latest build info with status chip', () => { + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: true, + latestBuild: { + name: 'build-42', + status: 'Succeeded', + createdAt: '2024-06-01T10:00:00Z', + commit: 'abcdef1234567890', + }, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect(screen.getByText('Latest Build')).toBeInTheDocument(); + expect(screen.getByText('build-42')).toBeInTheDocument(); + expect(screen.getByTestId('build-status-chip')).toHaveTextContent( + 'Succeeded', + ); + expect( + screen.getByText('relative(2024-06-01T10:00:00Z)'), + ).toBeInTheDocument(); + expect(screen.getByText('abcdef12')).toBeInTheDocument(); // first 8 chars + }); + + it('disables Build Now when build permission denied', () => { + mockUseBuildPermission.mockReturnValue({ + ...defaultPermission, + canBuild: false, + }); + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: true, + latestBuild: null, + triggeringBuild: false, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect(screen.getByRole('button', { name: /build now/i })).toBeDisabled(); + }); + + it('calls triggerBuild when Build Now is clicked', async () => { + const user = userEvent.setup(); + const triggerBuild = jest.fn(); + + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: true, + latestBuild: null, + triggeringBuild: false, + triggerBuild, + }); + + renderCard(); + + await user.click(screen.getByRole('button', { name: /build now/i })); + expect(triggerBuild).toHaveBeenCalled(); + }); + + it('shows Building... when build is in progress', () => { + mockUseWorkflowsSummary.mockReturnValue({ + loading: false, + error: null, + hasWorkflows: true, + latestBuild: null, + triggeringBuild: true, + triggerBuild: jest.fn(), + }); + + renderCard(); + + expect(screen.getByRole('button', { name: /building/i })).toBeDisabled(); + }); +}); diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts index 41476525f..b14c4a7dd 100644 --- a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts +++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts @@ -90,6 +90,7 @@ export function useWorkflowsSummary() { namespaceName: run.namespaceName, status: run.status, createdAt: run.createdAt, + commit: run.commit, })); const sortedBuilds = [...runs].sort( (a, b) => diff --git a/yarn.lock b/yarn.lock index a7f9f5082..dbfcc87c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10711,6 +10711,7 @@ __metadata: "@openchoreo/backstage-design-system": "workspace:^" "@openchoreo/backstage-plugin-common": "workspace:^" "@openchoreo/backstage-plugin-react": "workspace:^" + "@openchoreo/test-utils": "workspace:^" "@rjsf/core": "npm:5.24.13" "@rjsf/material-ui": "npm:5.24.13" "@rjsf/utils": "npm:5.24.13" @@ -11021,6 +11022,7 @@ __metadata: "@openchoreo/backstage-design-system": "workspace:^" "@openchoreo/backstage-plugin-common": "workspace:^" "@openchoreo/backstage-plugin-react": "workspace:^" + "@openchoreo/test-utils": "workspace:^" "@rjsf/core": "npm:5.24.13" "@rjsf/material-ui": "npm:5.24.13" "@rjsf/utils": "npm:5.24.13" @@ -11093,6 +11095,7 @@ __metadata: resolution: "@openchoreo/test-utils@workspace:packages/test-utils" dependencies: "@backstage/backend-test-utils": "npm:1.9.0" + "@backstage/catalog-model": "npm:1.7.5" "@backstage/cli": "npm:0.34.3" languageName: unknown linkType: soft