Files
doormilxpress_astryx/tests/integration/clientOnboarding.test.jsx

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