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) => ) }) ); 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( ); }; 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 ' }).email).toBeTruthy(); expect(validateOnboarding({ ...base, password: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy(); expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).password).toBeTruthy(); }); });