diff --git a/met-web/src/components/imageUpload/Uploader.tsx b/met-web/src/components/imageUpload/Uploader.tsx
index c29caf77a..994ba5cd0 100644
--- a/met-web/src/components/imageUpload/Uploader.tsx
+++ b/met-web/src/components/imageUpload/Uploader.tsx
@@ -46,6 +46,7 @@ const Uploader = ({ height = '10em', accept = {}, children }: UploaderProps) =>
}}
>
)}
/>
@@ -361,6 +364,9 @@ export const TenantForm = ({
maxLength={80}
title="Image Description"
instructions="An accessible description of the image"
+ inputProps={{
+ 'data-testid': 'tenant-form/image-description',
+ }}
/>
)}
/>
diff --git a/met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx b/met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx
new file mode 100644
index 000000000..f30facee7
--- /dev/null
+++ b/met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx
@@ -0,0 +1,227 @@
+import React, { ReactNode } from 'react';
+import { render, waitFor, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import * as reactRedux from 'react-redux';
+import * as reactRouter from 'react-router';
+import * as tenantService from 'services/tenantService';
+import TenantCreationPage from 'components/tenantManagement/Create';
+import { USER_ROLES } from 'services/userService/constants';
+
+const mockTenant = {
+ id: 1,
+ name: 'Tenant One',
+ title: 'Title One',
+ description: 'Description One',
+ contact_name: 'Contact One',
+ short_name: 'tenantone',
+ contact_email: 'contactone@example.com',
+ logo_url: 'https://example.com/logo.png',
+ logo_credit: 'Photographer One',
+ logo_description: 'Logo Description One',
+};
+
+jest.mock('axios');
+
+jest.mock('@mui/material', () => ({
+ ...jest.requireActual('@mui/material'),
+ Box: ({ children }: { children: ReactNode }) =>
{children}
,
+ Grid: ({ children }: { children: ReactNode }) => {children}
,
+ Skeleton: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+jest.mock('components/common/Typography/', () => ({
+ Header1: ({ children }: { children: ReactNode }) => {children}
,
+ Header2: ({ children }: { children: ReactNode }) => {children}
,
+ BodyText: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+jest.mock('components/common/Layout', () => ({
+ ResponsiveContainer: ({ children }: { children: ReactNode }) => {children}
,
+ DetailsContainer: ({ children }: { children: ReactNode }) => {children}
,
+ Detail: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(() => {
+ return {
+ roles: [USER_ROLES.SUPER_ADMIN],
+ };
+ }),
+ useDispatch: jest.fn(),
+}));
+
+const navigate = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: jest.fn(() => {
+ return { tenantShortName: mockTenant.short_name };
+ }),
+ useNavigate: jest.fn(() => navigate),
+}));
+
+jest.mock('services/tenantService', () => ({
+ getAllTenants: jest.fn(),
+ createTenant: jest.fn(),
+}));
+
+jest.mock('services/notificationService/notificationSlice', () => ({
+ openNotification: jest.fn(),
+}));
+
+let capturedNotification: any;
+jest.mock('services/notificationModalService/notificationModalSlice', () => ({
+ openNotificationModal: jest.fn((notification: any) => {
+ capturedNotification = notification;
+ }),
+}));
+
+// Mocking BreadcrumbTrail component
+jest.mock('components/common/Navigation/Breadcrumb', () => ({
+ BreadcrumbTrail: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+describe('Tenant Detail Page tests', () => {
+ const dispatch = jest.fn();
+
+ const editField = async (placeholder: string, value: string) => {
+ const field = screen.getByPlaceholderText(placeholder) as HTMLInputElement;
+ field.focus();
+ field.setSelectionRange(0, field.value.length);
+ fireEvent.change(field, { target: { value } });
+ fireEvent.blur(field); // Trigger validation
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(dispatch);
+ jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(navigate);
+ jest.spyOn(tenantService, 'getAllTenants').mockResolvedValue([mockTenant]);
+ jest.spyOn(tenantService, 'createTenant').mockResolvedValue(mockTenant);
+ render();
+ });
+
+ test('Tenant creation page is rendered', async () => {
+ await waitFor(() => {
+ expect(screen.getByText('Create Tenant Instance')).toBeVisible();
+ expect(screen.getByText('Tenant Details')).toBeVisible();
+ expect(screen.getByText('* Required fields')).toBeVisible();
+ });
+
+ // The page should be fetching the tenant data to validate the short name
+ expect(tenantService.getAllTenants).toHaveBeenCalledTimes(1);
+
+ // Check that the form isn't pre-filled
+ await waitFor(() => {
+ const fields = screen.getAllByRole('textbox');
+ expect(fields).toHaveLength(8);
+ expect(screen.getByPlaceholderText('Name')).toContainValue('');
+ expect(screen.getByPlaceholderText('Full Name')).toContainValue('');
+ expect(screen.getByPlaceholderText('Email')).toContainValue('');
+ expect(screen.getByPlaceholderText('shortname')).toContainValue('');
+ expect(screen.getByPlaceholderText('Title')).toContainValue('');
+ expect(screen.getByPlaceholderText('Description')).toContainValue('');
+ expect(screen.getByText('Drag and drop your image here.')).toBeVisible();
+ expect(screen.getByTestId('tenant-form/image-credit')).toContainValue('');
+ expect(screen.getByTestId('tenant-form/image-description')).toContainValue('');
+ });
+
+ // Check that the buttons are visible
+ expect(screen.getByText('Create Instance')).toBeVisible();
+ // Button should be disabled until form is completed
+ expect(screen.getByText('Create Instance')).toBeDisabled();
+ expect(screen.getByText('Cancel')).toBeVisible();
+ });
+
+ test('Button is enabled after form is filled', async () => {
+ await waitFor(() => {
+ editField('Name', 'New Name');
+ editField('Full Name', 'New Full Name');
+ editField('Email', 'contactone@example.com');
+ editField('shortname', 'newname');
+ editField('Title', 'New Title');
+ editField('Description', 'New Description');
+ expect(screen.getByText('Create Instance')).toBeEnabled();
+ });
+ });
+
+ test('Email throws error if invalid', async () => {
+ await waitFor(() => {
+ editField('Email', 'invalid-email');
+ expect(screen.getByText("That doesn't look like a valid email...")).toBeVisible();
+ expect(screen.getByText('Create Instance')).toBeDisabled();
+ });
+ });
+
+ test('Short name throws error if invalid', async () => {
+ await waitFor(() => {
+ editField('shortname', 'invalid shortname');
+ expect(screen.getByText('Your input contains invalid symbols')).toBeVisible();
+ expect(screen.getByText('Create Instance')).toBeDisabled();
+ });
+ });
+
+ test('Character limit is enforced on fields', async () => {
+ await waitFor(() => {
+ editField('Title', 'a'.repeat(256));
+ expect(screen.getByText('This input is too long!')).toBeVisible();
+ expect(screen.getByText('Create Instance')).toBeDisabled();
+ });
+ });
+
+ test('Unique short name is enforced', async () => {
+ await waitFor(() => {
+ editField('shortname', mockTenant.short_name);
+ expect(screen.getByText('This short name is already in use')).toBeVisible();
+ expect(screen.getByText('Create Instance')).toBeDisabled();
+ });
+ });
+
+ test('Cancel button navigates back to tenant listing page', async () => {
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(navigate).toHaveBeenCalledTimes(1);
+ expect(navigate).toHaveBeenCalledWith(`../tenantadmin`);
+ });
+ });
+
+ test('User is prompted for confirmation when navigating with unsaved changes', async () => {
+ await waitFor(() => {
+ editField('Name', 'New Name');
+ fireEvent.click(screen.getByText('Cancel'));
+ });
+ await waitFor(() => {
+ expect(capturedNotification).toBeDefined();
+ expect(capturedNotification.data.header).toBe('Unsaved Changes');
+ expect(capturedNotification.data.handleConfirm).toBeDefined();
+ });
+ capturedNotification.data.handleConfirm();
+ expect(navigate).toHaveBeenCalledTimes(1);
+ });
+
+ test('Create instance button calls createTenant action', async () => {
+ await waitFor(() => {
+ editField('Name', 'New Name');
+ editField('Full Name', 'New Full Name');
+ editField('Email', 'contactone@example.com');
+ editField('shortname', 'newname');
+ editField('Title', 'New Title');
+ editField('Description', 'New Description');
+ expect(screen.getByText('Create Instance')).toBeEnabled();
+ fireEvent.click(screen.getByText('Create Instance'));
+ const updatedTenant = {
+ name: 'New Name',
+ title: 'New Title',
+ description: 'New Description',
+ contact_name: 'New Full Name',
+ contact_email: 'contactone@example.com',
+ short_name: 'newname',
+ logo_url: '',
+ logo_credit: '',
+ logo_description: '',
+ };
+ expect(tenantService.createTenant).toHaveBeenCalledWith(updatedTenant);
+ });
+ });
+});
diff --git a/met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx b/met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx
new file mode 100644
index 000000000..148e6fee3
--- /dev/null
+++ b/met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx
@@ -0,0 +1,202 @@
+import React, { ReactNode } from 'react';
+import { render, waitFor, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import * as reactRedux from 'react-redux';
+import * as reactRouter from 'react-router';
+import * as tenantService from 'services/tenantService';
+import TenantEditPage from 'components/tenantManagement/Edit';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { USER_ROLES } from 'services/userService/constants';
+
+const mockTenant = {
+ id: 1,
+ name: 'Tenant One',
+ title: 'Title One',
+ description: 'Description One',
+ contact_name: 'Contact One',
+ short_name: 'tenantone',
+ contact_email: 'contactone@example.com',
+ logo_url: 'https://example.com/logo.png',
+ logo_credit: 'Photographer One',
+ logo_description: 'Logo Description One',
+};
+
+jest.mock('axios');
+
+jest.mock('@mui/material', () => ({
+ ...jest.requireActual('@mui/material'),
+ Box: ({ children }: { children: ReactNode }) => {children}
,
+ Grid: ({ children }: { children: ReactNode }) => {children}
,
+ Skeleton: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+jest.mock('components/common/Typography/', () => ({
+ Header1: ({ children }: { children: ReactNode }) => {children}
,
+ Header2: ({ children }: { children: ReactNode }) => {children}
,
+ BodyText: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+jest.mock('components/common/Layout', () => ({
+ ResponsiveContainer: ({ children }: { children: ReactNode }) => {children}
,
+ DetailsContainer: ({ children }: { children: ReactNode }) => {children}
,
+ Detail: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(() => {
+ return {
+ roles: [USER_ROLES.SUPER_ADMIN],
+ };
+ }),
+ useDispatch: jest.fn(),
+}));
+
+const navigate = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: jest.fn(() => {
+ return { tenantShortName: mockTenant.short_name };
+ }),
+ useNavigate: jest.fn(() => navigate),
+}));
+
+jest.mock('services/tenantService', () => ({
+ getTenant: jest.fn(),
+ getAllTenants: jest.fn(),
+ updateTenant: jest.fn(),
+}));
+
+jest.mock('services/notificationService/notificationSlice', () => ({
+ openNotification: jest.fn(),
+}));
+
+jest.mock('services/notificationModalService/notificationModalSlice', () => ({
+ openNotificationModal: jest.fn(),
+}));
+
+// Mocking BreadcrumbTrail component
+jest.mock('components/common/Navigation/Breadcrumb', () => ({
+ BreadcrumbTrail: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+describe('Tenant Detail Page tests', () => {
+ const dispatch = jest.fn();
+
+ const editField = (placeholder: string, value: string) => {
+ const field = screen.getByPlaceholderText(placeholder) as HTMLInputElement;
+ field.focus();
+ field.setSelectionRange(0, field.value.length);
+ fireEvent.change(field, { target: { value } });
+ fireEvent.blur(field); // Trigger validation
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(dispatch);
+ jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(navigate);
+ jest.spyOn(tenantService, 'getAllTenants').mockResolvedValue([mockTenant]);
+ jest.spyOn(tenantService, 'getTenant').mockResolvedValue(mockTenant);
+ render(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ test('Loader is displayed while fetching tenant data', async () => {
+ // Ensure the fetch does not resolve (force the loader to display)
+ jest.spyOn(tenantService, 'getTenant').mockReturnValue(new Promise(() => {}));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('loader')).toBeVisible();
+ expect(tenantService.getTenant).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ test('Tenant edit page is rendered', async () => {
+ await waitFor(() => {
+ expect(screen.getByText('Edit Tenant Instance')).toBeVisible();
+ expect(screen.getByText('Tenant Details')).toBeVisible();
+ expect(screen.getByText('* Required fields')).toBeVisible();
+ });
+
+ // The data should already be fetched if the header is displayed
+ expect(tenantService.getTenant).toHaveBeenCalledTimes(1);
+
+ // Check that the form is populated with the correct data
+ await waitFor(() => {
+ const fields = screen.getAllByRole('textbox');
+ expect(fields).toHaveLength(8);
+ expect(screen.getByPlaceholderText('Name')).toContainValue('Tenant One');
+ expect(screen.getByPlaceholderText('Full Name')).toContainValue('Contact One');
+ expect(screen.getByPlaceholderText('Email')).toContainValue('contactone@example.com');
+ expect(screen.getByPlaceholderText('shortname')).toContainValue('tenantone');
+ expect(screen.getByPlaceholderText('Title')).toContainValue('Title One');
+ expect(screen.getByPlaceholderText('Description')).toContainValue('Description One');
+ expect(screen.getByTestId('uploaded-image')).toHaveAttribute('src', 'https://example.com/logo.png');
+ expect(screen.getByTestId('tenant-form/image-credit')).toContainValue('Photographer One');
+ expect(screen.getByTestId('tenant-form/image-description')).toContainValue('Logo Description One');
+ });
+
+ // Check that the buttons are visible
+ expect(screen.getByText('Update')).toBeVisible();
+ expect(screen.getByText('Update')).toBeDisabled(); // Button should be disabled until form is edited
+ expect(screen.getByText('Cancel')).toBeVisible();
+ });
+
+ test('Button is enabled after form is edited', async () => {
+ await waitFor(() => {
+ editField('Name', 'New Name');
+ expect(screen.getByText('Update')).not.toBeDisabled();
+ });
+ });
+
+ test('Email throws error if invalid', async () => {
+ await waitFor(() => {
+ editField('Email', 'invalid-email');
+ expect(screen.getByText("That doesn't look like a valid email...")).toBeVisible();
+ expect(screen.getByText('Update')).toBeDisabled();
+ });
+ });
+
+ test('Short name throws error if invalid', async () => {
+ await waitFor(() => {
+ editField('shortname', 'invalid shortname');
+ expect(screen.getByText('Your input contains invalid symbols')).toBeVisible();
+ expect(screen.getByText('Update')).toBeDisabled();
+ });
+ });
+
+ test('Character limit is enforced on fields', async () => {
+ await waitFor(() => {
+ editField('Title', 'a'.repeat(256));
+ expect(screen.getByText('This input is too long!')).toBeVisible();
+ expect(screen.getByText('Update')).toBeDisabled();
+ });
+ });
+
+ test('Cancel button navigates back to tenant details page', async () => {
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(navigate).toHaveBeenCalledTimes(1);
+ expect(navigate).toHaveBeenCalledWith(`../tenantadmin/${mockTenant.short_name}/detail`);
+ });
+ });
+
+ test('Update button calls updateTenant action', async () => {
+ await waitFor(() => {
+ editField('Name', 'New Name');
+ fireEvent.click(screen.getByText('Update'));
+ expect(tenantService.updateTenant).toHaveBeenCalledTimes(1);
+ const updatedTenant = {
+ ...mockTenant,
+ name: 'New Name',
+ };
+ expect(tenantService.updateTenant).toHaveBeenCalledWith(updatedTenant, mockTenant.short_name);
+ });
+ });
+});
diff --git a/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx b/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx
index a230952c8..715dce718 100644
--- a/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx
+++ b/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx
@@ -7,6 +7,7 @@ import * as tenantService from 'services/tenantService';
import TenantDetail from '../../../../src/components/tenantManagement/Detail';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { USER_ROLES } from 'services/userService/constants';
+import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice';
const mockTenant = {
id: 1,
@@ -69,8 +70,11 @@ jest.mock('services/notificationService/notificationSlice', () => ({
openNotification: jest.fn(),
}));
+let capturedNotification: any;
jest.mock('services/notificationModalService/notificationModalSlice', () => ({
- openNotificationModal: jest.fn(),
+ openNotificationModal: jest.fn((notification: any) => {
+ capturedNotification = notification;
+ }),
}));
// Mocking BreadcrumbTrail component
@@ -107,6 +111,9 @@ describe('Tenant Detail Page tests', () => {
expect(screen.getByText('contactone@example.com')).toBeVisible();
expect(screen.getByText('Photographer One')).toBeVisible();
expect(screen.getByText('Logo Description One')).toBeVisible();
+
+ expect(screen.getByText('Edit')).toBeVisible();
+ expect(screen.getByText('Delete Tenant Instance')).toBeVisible();
});
});
@@ -124,4 +131,25 @@ describe('Tenant Detail Page tests', () => {
const loadingTexts = screen.getAllByText('Loading...');
expect(loadingTexts.length).toBeGreaterThan(0);
});
+
+ test('Delete popup works as expected', async () => {
+ render(
+
+
+ } />
+
+ ,
+ );
+
+ await waitFor(() => {
+ screen.getByText('Delete Tenant Instance').click();
+ });
+ await waitFor(() => {
+ expect(openNotificationModal).toHaveBeenCalledTimes(1);
+ expect(capturedNotification.data.header).toBe('Delete Tenant Instance?');
+ capturedNotification.data.handleConfirm();
+ // Test that the deleteTenant function was called
+ expect(tenantService.deleteTenant).toHaveBeenCalledTimes(1);
+ });
+ });
});