300 lines
15 KiB
JavaScript
300 lines
15 KiB
JavaScript
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
|
|
/**
|
|
* Clients → Onboard client. The real page, hooks and validation run; only the
|
|
* HTTP functions are mocked, shaped like POST /admin/clients/onboard and
|
|
* GET /admin/clients/onboarded. The server's own gate and transaction are
|
|
* covered by doormile_backend's route tests and the live local run.
|
|
*/
|
|
|
|
// Radix's Switch measures itself; jsdom has no ResizeObserver.
|
|
beforeAll(() => {
|
|
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
|
|
});
|
|
|
|
jest.mock('lucide-react', () =>
|
|
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
|
);
|
|
|
|
jest.mock('@/api/doormile', () => ({
|
|
onboardClient: jest.fn(),
|
|
getOnboardedClients: jest.fn(),
|
|
getOnboardingCities: jest.fn(),
|
|
updateOnboardedClient: jest.fn(),
|
|
deleteOnboardedClient: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@/api/doormile/notify', () => ({
|
|
OpenToast: jest.fn(),
|
|
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
|
}));
|
|
|
|
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
|
|
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
|
|
|
|
import * as api from '@/api/doormile';
|
|
import ClientOnboarding from '@/pages/doormile/clients/ClientOnboarding';
|
|
import { canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding } from '@/lib/clientOnboarding';
|
|
|
|
const CITIES = [
|
|
{ applocationid: 1, applocationname: 'Coimbatore', status: 'Active' },
|
|
{ applocationid: 2, applocationname: 'Hyderabad', status: 'Active' },
|
|
{ applocationid: 0, applocationname: 'All', status: 'Active' },
|
|
];
|
|
|
|
const renderPage = () => {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<MemoryRouter>
|
|
<ClientOnboarding />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
const fill = (label, value) => fireEvent.change(screen.getByLabelText(label, { exact: false }), { target: { value } });
|
|
|
|
const fillValidForm = async () => {
|
|
await screen.findByRole('option', { name: 'Coimbatore' });
|
|
fill('Company name', 'Acme Foods');
|
|
fill('Contact person', 'Priya Raman');
|
|
fill('Mobile number', '98765 43210');
|
|
fill('Login email', ' Ops@Acme.Example ');
|
|
fill('Operating city', '1');
|
|
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
|
|
fill('Confirm password', 's3cure-pass');
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
|
|
jest.clearAllMocks();
|
|
api.getOnboardingCities.mockResolvedValue(CITIES);
|
|
api.getOnboardedClients.mockResolvedValue([
|
|
{ authid: 31, tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', requiredeliveryotp: false, contactname: 'Old Contact', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
|
|
]);
|
|
api.updateOnboardedClient.mockResolvedValue({ success: true, data: {} });
|
|
api.deleteOnboardedClient.mockResolvedValue({ success: true, data: {} });
|
|
});
|
|
|
|
describe('Edit and Delete on a client login', () => {
|
|
it('shows Edit and Delete on each row', async () => {
|
|
renderPage();
|
|
expect(await screen.findByRole('button', { name: 'Edit Older Client' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('sends only the fields that changed', async () => {
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
|
const dialog = await screen.findByRole('dialog');
|
|
const save = screen.getByRole('button', { name: /Save changes/ });
|
|
expect(save).toBeDisabled(); // nothing changed yet
|
|
fireEvent.change(dialog.querySelector('select'), { target: { value: 'Inactive' } });
|
|
fireEvent.change(screen.getAllByLabelText(/Mobile number/)[1], { target: { value: '9123456780' } });
|
|
fireEvent.click(save);
|
|
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' }));
|
|
});
|
|
|
|
it('resets the password only when one is entered, and checks its length', async () => {
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
|
await screen.findByRole('dialog');
|
|
fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'short' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
|
await waitFor(() => expect(document.getElementById('edit-client-password-error')).toHaveTextContent('At least 8 characters'));
|
|
expect(api.updateOnboardedClient).not.toHaveBeenCalled();
|
|
fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'new-pass-123' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
|
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { password: 'new-pass-123' }));
|
|
});
|
|
|
|
it('shows a server conflict in the dialog', async () => {
|
|
api.updateOnboardedClient.mockRejectedValue({ response: { status: 409, data: { message: 'this email already has a console login' } } });
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
|
await screen.findByRole('dialog');
|
|
fireEvent.change(screen.getAllByLabelText(/Login email/)[1], { target: { value: 'taken@x.example' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
|
expect(await screen.findByText('this email already has a console login')).toBeInTheDocument();
|
|
});
|
|
|
|
it('never calls /clients/undefined when the backend is too old to send ids', async () => {
|
|
api.getOnboardedClients.mockResolvedValue([
|
|
{ tenantid: 12, tenantname: 'Older Client', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager' },
|
|
]);
|
|
renderPage();
|
|
const edit = await screen.findByRole('button', { name: 'Edit Older Client' });
|
|
expect(edit).toBeDisabled();
|
|
expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeDisabled();
|
|
expect(screen.getByText('Edit and Delete are not available on this server yet')).toBeInTheDocument();
|
|
fireEvent.click(edit);
|
|
expect(api.updateOnboardedClient).not.toHaveBeenCalled();
|
|
expect(api.deleteOnboardedClient).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('deletes only after the company name is typed', async () => {
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Delete Older Client' }));
|
|
await screen.findByRole('dialog');
|
|
const remove = screen.getByRole('button', { name: /Remove login/ });
|
|
expect(remove).toBeDisabled();
|
|
fireEvent.change(screen.getByLabelText(/to confirm/), { target: { value: 'older client' } });
|
|
expect(remove).not.toBeDisabled();
|
|
fireEvent.click(remove);
|
|
await waitFor(() => expect(api.deleteOnboardedClient).toHaveBeenCalledWith(31));
|
|
});
|
|
});
|
|
|
|
describe('Client onboarding page', () => {
|
|
it('onboards a client with a normalised payload and shows the new login once', async () => {
|
|
api.onboardClient.mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
tenant: { tenantid: 42, tenantname: 'Acme Foods', status: 'Active' },
|
|
login: { email: 'ops@acme.example', role: 'manager', userid: 7, tenantid: 42 },
|
|
},
|
|
});
|
|
renderPage();
|
|
await fillValidForm();
|
|
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
|
|
|
|
await screen.findByText('Acme Foods is onboarded');
|
|
expect(api.onboardClient.mock.calls[0][0]).toEqual({
|
|
companyname: 'Acme Foods',
|
|
contactname: 'Priya Raman',
|
|
email: 'ops@acme.example',
|
|
phone: '9876543210',
|
|
password: 's3cure-pass',
|
|
applocationid: 1,
|
|
requiredeliveryotp: false,
|
|
});
|
|
expect(screen.getByRole('status')).toHaveTextContent('ops@acme.example');
|
|
expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown');
|
|
// The form is cleared so the password does not linger on screen.
|
|
expect(document.getElementById('onboard-password')).toHaveValue('');
|
|
});
|
|
|
|
it('lists clients that already have a console login, without the "All" pseudo-city', async () => {
|
|
renderPage();
|
|
expect(await screen.findByText('Older Client')).toBeInTheDocument();
|
|
expect(screen.getByText('a@old.example')).toBeInTheDocument();
|
|
expect(screen.queryByRole('option', { name: 'All' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('refuses an incomplete form without calling the server', async () => {
|
|
renderPage();
|
|
await screen.findByRole('option', { name: 'Coimbatore' });
|
|
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
|
|
fill('Confirm password', 'different-1');
|
|
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
|
|
|
|
expect(await screen.findByText('Enter the company name')).toBeInTheDocument();
|
|
expect(screen.getByText('The passwords do not match')).toBeInTheDocument();
|
|
expect(screen.getByText("Choose the client's operating city")).toBeInTheDocument();
|
|
expect(api.onboardClient).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('puts a server conflict under the field it is about', async () => {
|
|
api.onboardClient.mockRejectedValue({ response: { status: 409, data: { success: false, message: 'this email already has a console login' } } });
|
|
renderPage();
|
|
await fillValidForm();
|
|
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
|
|
expect(await screen.findByText('this email already has a console login')).toBeInTheDocument();
|
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('says so when the server refuses the login', async () => {
|
|
api.onboardClient.mockRejectedValue({ response: { status: 403, data: { success: false } } });
|
|
renderPage();
|
|
await fillValidForm();
|
|
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('not allowed to onboard clients');
|
|
});
|
|
|
|
it('fills both password fields when generating one', async () => {
|
|
renderPage();
|
|
await screen.findByRole('option', { name: 'Coimbatore' });
|
|
fireEvent.click(screen.getByRole('button', { name: /Generate password/ }));
|
|
const pw = document.getElementById('onboard-password').value;
|
|
expect(pw).toHaveLength(14);
|
|
expect(screen.getByLabelText('Confirm password', { exact: false })).toHaveValue(pw);
|
|
});
|
|
|
|
it.each([
|
|
['another Doormile admin', { user: { email: 'suriya@doormile.com', role: 'admin' }, isClient: false }],
|
|
['the owner email on a client login', { user: { email: 'admin@doormile.com', role: 'admin', tenantid: 7 }, isClient: true }],
|
|
['the owner email as a manager', { user: { email: 'admin@doormile.com', role: 'manager' }, isClient: false }],
|
|
])('is closed to %s, and calls nothing', async (_name, auth) => {
|
|
mockAuth = auth;
|
|
renderPage();
|
|
expect(screen.getByText('Client onboarding is restricted')).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /Onboard client/ })).not.toBeInTheDocument();
|
|
await waitFor(() => expect(api.getOnboardedClients).not.toHaveBeenCalled());
|
|
expect(api.getOnboardingCities).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Client onboarding when the city list fails', () => {
|
|
it('explains a server without the onboarding endpoints and blocks submit', async () => {
|
|
api.getOnboardingCities.mockRejectedValue({ response: { status: 404, data: {} } });
|
|
renderPage();
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert).toHaveTextContent('Client onboarding is not available right now');
|
|
expect(alert).toHaveTextContent('does not have client onboarding yet');
|
|
expect(screen.getByRole('option', { name: 'Cities could not be loaded' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Onboard client/ })).toBeDisabled();
|
|
});
|
|
|
|
it('retries on demand and recovers', async () => {
|
|
api.getOnboardingCities.mockRejectedValueOnce({ response: { status: 500, data: { message: 'failed to list cities' } } });
|
|
renderPage();
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('failed to list cities');
|
|
api.getOnboardingCities.mockResolvedValue(CITIES);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
|
expect(await screen.findByRole('option', { name: 'Coimbatore' })).toBeInTheDocument();
|
|
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
|
|
expect(screen.getByRole('button', { name: /Onboard client/ })).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
describe('client onboarding helpers', () => {
|
|
it('allows only the owner email, as Doormile staff admin, case-insensitively', () => {
|
|
expect(canOnboardClients({ email: 'Admin@Doormile.com', role: 'admin' }, false)).toBe(true);
|
|
expect(canOnboardClients({ email: 'admin@doormile.com', roleid: 1 }, false)).toBe(true);
|
|
expect(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, true)).toBe(false);
|
|
expect(canOnboardClients({ email: 'suriya@doormile.com', role: 'admin' }, false)).toBe(false);
|
|
expect(canOnboardClients(null, false)).toBe(false);
|
|
});
|
|
|
|
it('generates passwords the validation accepts', () => {
|
|
for (let i = 0; i < 50; i += 1) {
|
|
const pw = generateClientPassword();
|
|
expect(pw).toMatch(/\d/);
|
|
expect(pw).toMatch(/[a-z]/i);
|
|
const errors = validateOnboarding({
|
|
companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: pw, confirm: pw,
|
|
});
|
|
expect(errors).toEqual({});
|
|
}
|
|
});
|
|
|
|
it('explains each failure plainly', () => {
|
|
expect(onboardingUnavailableMessage({ response: { status: 404 } })).toMatch(/deployed/);
|
|
expect(onboardingUnavailableMessage({ response: { status: 403 } })).toMatch(/admin@doormile.com/);
|
|
expect(onboardingUnavailableMessage({ response: { status: 401 } })).toMatch(/Sign in again/);
|
|
expect(onboardingUnavailableMessage(new Error('Network Error'))).toMatch(/Could not reach/);
|
|
});
|
|
|
|
it('mirrors the server rules', () => {
|
|
const base = { companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: 'longenough1', confirm: 'longenough1' };
|
|
expect(validateOnboarding({ ...base, phone: '5876543210' }).phone).toBeTruthy();
|
|
expect(validateOnboarding({ ...base, email: 'Ops <a@b.co>' }).email).toBeTruthy();
|
|
expect(validateOnboarding({ ...base, password: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy();
|
|
expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).password).toBeTruthy();
|
|
});
|
|
});
|