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); + }); + }); });